Initial commit: 구마모토 여행 계획 사이트

This commit is contained in:
Hyungi Ahn
2025-11-09 09:11:06 +09:00
commit c07e1bc95e
25 changed files with 4623 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
README.md
.env
.env.local
.DS_Store
*.log

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the app
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# 구마모토 여행 계획 사이트
2025년 2월 17일 ~ 2월 20일 구마모토 여행을 위한 가족 공유 여행 계획 사이트입니다.
## 기능
- 📅 **여행 일정 관리**: 날짜별 일정을 추가하고 관리할 수 있습니다
- 🗾 **관광지 정보**: 구마모토 주요 관광지 정보를 확인할 수 있습니다
- 💰 **예산 관리**: 항목별 예산을 설정하고 환율을 적용해 원화로 확인할 수 있습니다
-**체크리스트**: 준비물, 쇼핑 목록, 방문할 곳 등을 체크리스트로 관리합니다
## 시작하기
### 설치
```bash
npm install
```
### 개발 서버 실행
```bash
npm run dev
```
브라우저에서 `http://localhost:5173`을 열어 확인하세요.
### 빌드
```bash
npm run build
```
## 기술 스택
- React 18
- TypeScript
- Vite
- Tailwind CSS
- date-fns
## 사용 방법
1. **일정 추가**: 각 날짜 옆의 "+ 일정 추가" 버튼을 클릭하여 일정을 추가합니다
2. **예산 설정**: 예산 관리 섹션에서 각 항목을 클릭하여 예산을 입력합니다
3. **체크리스트**: 체크리스트에 항목을 추가하고 완료 시 체크박스를 클릭합니다
## 공유하기
이 사이트는 로컬 스토리지를 사용하여 데이터를 저장합니다. 가족과 공유하려면:
1. 개발 서버를 실행한 후 네트워크 IP로 접근하거나
2. 빌드 후 정적 호스팅 서비스(Vercel, Netlify 등)에 배포하세요

15
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
web:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
- /app/node_modules
ports:
- "5173:5173"
command: sh -c "npm install && npm run dev -- --host"
container_name: kumamoto-travel-planner-dev
restart: unless-stopped

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:80"
container_name: kumamoto-travel-planner
restart: unless-stopped

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>구마모토 여행 계획 - 2025년 2월</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

3120
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "kumamoto-travel-planner",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

146
src/App.tsx Normal file
View File

@@ -0,0 +1,146 @@
import { useState } from 'react'
import Header from './components/Header'
import Timeline from './components/Timeline'
import Attractions, { attractions } from './components/Attractions'
import Map from './components/Map'
import Budget from './components/Budget'
import Checklist from './components/Checklist'
import { TravelPlan } from './types'
function App() {
const [travelPlan, setTravelPlan] = useState<TravelPlan>({
startDate: new Date('2025-02-17'),
endDate: new Date('2025-02-20'),
schedule: [
{
date: new Date('2025-02-18'),
activities: [
{
id: '1',
time: '09:00',
title: '렌트카 픽업',
description: '렌트카로 이동',
type: 'transport',
},
{
id: '2',
time: '10:00',
title: '기쿠치협곡',
description: '렌트카로 이동',
location: '기쿠치시',
type: 'attraction',
},
{
id: '3',
time: '12:00',
title: '쿠사센리',
description: '렌트카로 이동',
location: '아소시',
type: 'attraction',
},
{
id: '4',
time: '14:00',
title: '아소산',
description: '렌트카로 이동',
location: '아소시',
type: 'attraction',
},
{
id: '5',
time: '16:00',
title: '사라카와수원',
description: '렌트카로 이동',
location: '구마모토시',
type: 'attraction',
},
],
},
{
date: new Date('2025-02-19'),
activities: [
{
id: '6',
time: '09:00',
title: '구마모토성',
description: '대중교통으로 이동',
location: '구마모토시 주오구',
type: 'attraction',
},
{
id: '7',
time: '11:30',
title: '사쿠라노바바',
description: '대중교통으로 이동',
location: '구마모토시',
type: 'attraction',
},
{
id: '8',
time: '14:00',
title: '스이젠지조주엔',
description: '대중교통으로 이동',
location: '구마모토시 주오구',
type: 'attraction',
},
{
id: '9',
time: '16:00',
title: '시모토리아케이드',
description: '대중교통으로 이동',
location: '구마모토시',
type: 'attraction',
},
],
},
],
budget: {
total: 0,
accommodation: 0,
food: 0,
transportation: 0,
shopping: 0,
activities: 0,
},
checklist: [],
})
return (
<div className="min-h-screen bg-kumamoto-light">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-8 relative">
<div className="relative rounded-lg overflow-hidden mb-4 h-64">
<img
src="https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=1920&q=80"
alt="구마모토 풍경"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
<h1 className="text-4xl font-bold mb-2"> </h1>
<p className="text-lg opacity-90">
2025 2 17 ~ 2 20 (4)
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Map attractions={attractions} />
<Timeline travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
<Attractions />
</div>
<div className="space-y-6">
<Budget travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
<Checklist travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
</div>
</div>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,232 @@
import { Attraction } from '../types'
const attractions: Attraction[] = [
{
id: '1',
name: 'Kumamoto Castle',
nameKo: '구마모토성',
description:
'일본 3대 명성 중 하나로, 400년의 역사를 가진 웅장한 성입니다. 2016년 지진으로 일부 손상되었지만 복구 작업이 진행 중입니다.',
location: '구마모토시 주오구 혼마루 1-1',
estimatedTime: '2-3시간',
admissionFee: 500,
category: 'castle',
imageUrl: 'https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=800&q=80',
coordinates: { lat: 32.8061, lng: 130.7058 },
},
{
id: '2',
name: 'Mount Aso',
nameKo: '아소산',
description:
'세계 최대 규모의 칼데라를 가진 활화산입니다. 아소산 로프웨이를 타고 분화구를 관람할 수 있습니다.',
location: '아소시',
estimatedTime: '반나절',
admissionFee: 1200,
category: 'nature',
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80',
coordinates: { lat: 32.8844, lng: 131.1036 },
},
{
id: '3',
name: 'Kurokawa Onsen',
nameKo: '구로카와 온천',
description:
'일본에서 가장 아름다운 온천 마을 중 하나로, 전통적인 일본 풍경을 즐길 수 있습니다.',
location: '아소군 미나미아소무라',
estimatedTime: '하루',
category: 'onsen',
imageUrl: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=800&q=80',
},
{
id: '4',
name: 'Suizenji Jojuen Garden',
nameKo: '수이젠지 조주엔',
description:
'400년 전통의 정원으로, 일본의 미니어처 풍경을 재현한 아름다운 정원입니다.',
location: '구마모토시 주오구 수이젠지 공원 8-1',
estimatedTime: '1-2시간',
admissionFee: 400,
category: 'temple',
imageUrl: 'https://images.unsplash.com/photo-1494500764479-0c8f2919a3d8?w=800&q=80',
coordinates: { lat: 32.7897, lng: 130.7331 },
},
{
id: '5',
name: 'Amakusa',
nameKo: '아마쿠사',
description:
'크리스천 다이묘의 역사가 있는 아름다운 섬들로, 바다와 교회가 어우러진 풍경을 즐길 수 있습니다.',
location: '아마쿠사시',
estimatedTime: '하루',
category: 'other',
imageUrl: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&q=80',
},
{
id: '6',
name: 'Kumamoto Ramen',
nameKo: '구마모토 라멘',
description:
'돼지뼈 육수에 마늘 기름을 넣은 구마모토 특유의 라멘을 맛볼 수 있습니다.',
location: '구마모토시 전역',
estimatedTime: '1시간',
category: 'food',
imageUrl: 'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=800&q=80',
},
{
id: '7',
name: 'Kikuchi Gorge',
nameKo: '기쿠치협곡',
description:
'아름다운 계곡과 폭포가 있는 자연 명소로, 특히 가을 단풍이 유명합니다.',
location: '기쿠치시',
estimatedTime: '1-2시간',
category: 'nature',
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
coordinates: { lat: 33.0167, lng: 130.8167 },
},
{
id: '8',
name: 'Kusasenri',
nameKo: '쿠사센리',
description:
'아소산 중턱에 있는 넓은 초원으로, 말 타기와 하이킹을 즐길 수 있습니다.',
location: '아소시',
estimatedTime: '1-2시간',
category: 'nature',
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80',
coordinates: { lat: 32.8844, lng: 131.1036 },
},
{
id: '9',
name: 'Sarakuwa Suigen',
nameKo: '사라카와수원',
description:
'명수 100선에 선정된 깨끗한 물이 나오는 수원지입니다.',
location: '구마모토시',
estimatedTime: '30분-1시간',
category: 'nature',
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
coordinates: { lat: 32.8031, lng: 130.7079 },
},
{
id: '10',
name: 'Sakuranobaba',
nameKo: '사쿠라노바바',
description:
'벚꽃이 아름다운 공원으로, 봄에는 벚꽃 축제가 열립니다.',
location: '구마모토시',
estimatedTime: '1시간',
category: 'other',
imageUrl: 'https://images.unsplash.com/photo-1522383225653-1113756be3d8?w=800&q=80',
coordinates: { lat: 32.8031, lng: 130.7079 },
},
{
id: '11',
name: 'Shimotori Arcade',
nameKo: '시모토리아케이드',
description:
'구마모토의 대표적인 쇼핑 거리로, 다양한 상점과 맛집이 있습니다.',
location: '구마모토시 주오구',
estimatedTime: '1-2시간',
category: 'other',
imageUrl: 'https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=800&q=80',
coordinates: { lat: 32.8031, lng: 130.7079 },
},
]
export { attractions }
const Attractions = () => {
const getCategoryEmoji = (category: Attraction['category']) => {
switch (category) {
case 'castle':
return '🏯'
case 'nature':
return '⛰️'
case 'onsen':
return '♨️'
case 'temple':
return '🏛️'
case 'food':
return '🍜'
default:
return '📍'
}
}
const getCategoryName = (category: Attraction['category']) => {
switch (category) {
case 'castle':
return '성'
case 'nature':
return '자연'
case 'onsen':
return '온천'
case 'temple':
return '정원/사원'
case 'food':
return '맛집'
default:
return '기타'
}
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">
🗾
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{attractions.map((attraction) => (
<div
key={attraction.id}
className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-white"
>
{attraction.imageUrl && (
<div className="w-full h-48 overflow-hidden bg-gray-200">
<img
src={attraction.imageUrl}
alt={attraction.nameKo}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
</div>
)}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-2xl">
{getCategoryEmoji(attraction.category)}
</span>
<div>
<h3 className="font-semibold text-gray-800">
{attraction.nameKo}
</h3>
<p className="text-xs text-gray-500">{attraction.name}</p>
</div>
</div>
<span className="px-2 py-1 bg-kumamoto-light text-xs rounded">
{getCategoryName(attraction.category)}
</span>
</div>
<p className="text-sm text-gray-600 mb-3">{attraction.description}</p>
<div className="space-y-1 text-xs text-gray-500">
<div>📍 {attraction.location}</div>
<div> : {attraction.estimatedTime}</div>
{attraction.admissionFee && (
<div>💰 : {attraction.admissionFee.toLocaleString()}</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
export default Attractions

136
src/components/Budget.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { TravelPlan } from '../types'
import { useState } from 'react'
interface BudgetProps {
travelPlan: TravelPlan
setTravelPlan: (plan: TravelPlan) => void
}
const Budget = ({ travelPlan, setTravelPlan }: BudgetProps) => {
const [exchangeRate, setExchangeRate] = useState(140) // 1엔 = 약 140원 (예시)
const [editing, setEditing] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const budget = travelPlan.budget
// total을 제외한 나머지 항목들의 합 계산
const totalYen =
budget.accommodation +
budget.food +
budget.transportation +
budget.activities +
budget.shopping
const totalWon = totalYen * exchangeRate
const categories = [
{ key: 'accommodation', label: '숙소', emoji: '🏨', color: 'bg-blue-100' },
{ key: 'food', label: '식사', emoji: '🍽️', color: 'bg-green-100' },
{ key: 'transportation', label: '교통', emoji: '🚅', color: 'bg-yellow-100' },
{ key: 'activities', label: '관광/활동', emoji: '🎫', color: 'bg-purple-100' },
{ key: 'shopping', label: '쇼핑', emoji: '🛍️', color: 'bg-pink-100' },
] as const
const updateBudget = (key: keyof typeof budget, value: number) => {
setTravelPlan({
...travelPlan,
budget: {
...budget,
[key]: value,
},
})
}
const startEdit = (key: string, currentValue: number) => {
setEditing(key)
setEditValue(currentValue.toString())
}
const saveEdit = (key: keyof typeof budget) => {
const value = parseFloat(editValue) || 0
updateBudget(key, value)
setEditing(null)
setEditValue('')
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">💰 </h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
(1 = ?)
</label>
<input
type="number"
value={exchangeRate}
onChange={(e) => setExchangeRate(parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div className="space-y-3 mb-6">
{categories.map(({ key, label, emoji, color }) => (
<div key={key} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{emoji}</span>
<span className="text-sm font-medium text-gray-700">
{label}
</span>
</div>
<div className="flex items-center gap-2">
{editing === key ? (
<>
<input
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-24 px-2 py-1 border rounded text-sm"
autoFocus
onBlur={() => saveEdit(key)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit(key)
}}
/>
<span className="text-xs text-gray-500"></span>
</>
) : (
<>
<span
className="text-sm font-semibold text-gray-800 cursor-pointer hover:text-kumamoto-blue"
onClick={() => startEdit(key, budget[key])}
>
{budget[key].toLocaleString()}
</span>
<span className="text-xs text-gray-500">
({(budget[key] * exchangeRate).toLocaleString()})
</span>
</>
)}
</div>
</div>
))}
</div>
<div className="border-t pt-4 space-y-2">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-700"> </span>
<div className="text-right">
<div className="text-lg font-bold text-kumamoto-green">
{totalYen.toLocaleString()}
</div>
<div className="text-sm text-gray-600">
{totalWon.toLocaleString()}
</div>
</div>
</div>
</div>
<div className="mt-4 p-3 bg-kumamoto-light rounded text-sm text-gray-600">
💡 .
</div>
</div>
)
}
export default Budget

View File

@@ -0,0 +1,159 @@
import { TravelPlan, ChecklistItem } from '../types'
import { useState } from 'react'
interface ChecklistProps {
travelPlan: TravelPlan
setTravelPlan: (plan: TravelPlan) => void
}
const Checklist = ({ travelPlan, setTravelPlan }: ChecklistProps) => {
const [newItem, setNewItem] = useState('')
const [newCategory, setNewCategory] =
useState<ChecklistItem['category']>('preparation')
const categories = [
{ key: 'preparation', label: '준비물', emoji: '🎒' },
{ key: 'shopping', label: '쇼핑 목록', emoji: '🛍️' },
{ key: 'visit', label: '방문할 곳', emoji: '📍' },
{ key: 'other', label: '기타', emoji: '📝' },
] as const
const addItem = () => {
if (!newItem.trim()) return
const item: ChecklistItem = {
id: Date.now().toString(),
text: newItem,
checked: false,
category: newCategory,
}
setTravelPlan({
...travelPlan,
checklist: [...travelPlan.checklist, item],
})
setNewItem('')
}
const toggleItem = (id: string) => {
setTravelPlan({
...travelPlan,
checklist: travelPlan.checklist.map((item) =>
item.id === id ? { ...item, checked: !item.checked } : item
),
})
}
const deleteItem = (id: string) => {
setTravelPlan({
...travelPlan,
checklist: travelPlan.checklist.filter((item) => item.id !== id),
})
}
const getItemsByCategory = (category: ChecklistItem['category']) => {
return travelPlan.checklist.filter((item) => item.category === category)
}
const getCategoryLabel = (category: ChecklistItem['category']) => {
return categories.find((c) => c.key === category)?.label || '기타'
}
const getCategoryEmoji = (category: ChecklistItem['category']) => {
return categories.find((c) => c.key === category)?.emoji || '📝'
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6"> </h2>
<div className="mb-4 space-y-2">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') addItem()
}}
className="w-full px-3 py-2 border rounded"
placeholder="새 항목 추가..."
/>
<select
value={newCategory}
onChange={(e) =>
setNewCategory(e.target.value as ChecklistItem['category'])
}
className="w-full px-3 py-2 border rounded text-sm"
>
{categories.map(({ key, label }) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<button
onClick={addItem}
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
>
</button>
</div>
<div className="space-y-4">
{categories.map(({ key: category }) => {
const items = getItemsByCategory(category)
if (items.length === 0) return null
return (
<div key={category} className="border-t pt-3">
<h3 className="font-semibold text-gray-700 mb-2 flex items-center gap-2">
<span>{getCategoryEmoji(category)}</span>
<span>{getCategoryLabel(category)}</span>
</h3>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 group"
>
<input
type="checkbox"
checked={item.checked}
onChange={() => toggleItem(item.id)}
className="w-5 h-5 text-kumamoto-green rounded"
/>
<span
className={`flex-1 text-sm ${
item.checked
? 'line-through text-gray-400'
: 'text-gray-700'
}`}
>
{item.text}
</span>
<button
onClick={() => deleteItem(item.id)}
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 text-sm"
>
</button>
</div>
))}
</div>
</div>
)
})}
</div>
{travelPlan.checklist.length === 0 && (
<p className="text-gray-400 text-sm text-center py-4">
.
</p>
)}
</div>
)
}
export default Checklist

24
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,24 @@
const Header = () => {
return (
<header className="relative bg-kumamoto-green text-white shadow-lg overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center opacity-20"
style={{
backgroundImage:
'url(https://images.unsplash.com/photo-1494500764479-0c8f2919a3d8?w=1920&q=80)',
}}
/>
<div className="relative container mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">🇯🇵 </h1>
<div className="text-sm opacity-90">
</div>
</div>
</div>
</header>
)
}
export default Header

146
src/components/Map.tsx Normal file
View File

@@ -0,0 +1,146 @@
import { useEffect, useRef } from 'react'
import { Attraction } from '../types'
interface MapProps {
attractions: Attraction[]
}
declare global {
interface Window {
google: typeof google
}
}
const Map = ({ attractions }: MapProps) => {
const mapRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<google.maps.Map | null>(null)
const markersRef = useRef<google.maps.Marker[]>([])
useEffect(() => {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
// Google Maps API 스크립트 로드
if (!window.google && apiKey) {
const script = document.createElement('script')
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`
script.async = true
script.defer = true
script.onload = () => {
initMap()
}
script.onerror = () => {
console.error('Google Maps API 로드 실패')
}
document.head.appendChild(script)
} else if (window.google) {
initMap()
}
function initMap() {
if (!mapRef.current || !window.google) return
// 구마모토 중심 좌표
const kumamotoCenter = { lat: 32.8031, lng: 130.7079 }
// 지도 생성 (위성 지도)
const map = new window.google.maps.Map(mapRef.current, {
center: kumamotoCenter,
zoom: 10,
mapTypeId: window.google.maps.MapTypeId.SATELLITE, // 위성 지도
mapTypeControl: true,
mapTypeControlOptions: {
style: window.google.maps.MapTypeControlStyle.HORIZONTAL_BAR,
position: window.google.maps.ControlPosition.TOP_RIGHT,
mapTypeIds: [
window.google.maps.MapTypeId.SATELLITE,
window.google.maps.MapTypeId.ROADMAP,
window.google.maps.MapTypeId.HYBRID,
],
},
})
mapInstanceRef.current = map
// 기존 마커 제거
markersRef.current.forEach((marker) => {
marker.setMap(null)
})
markersRef.current = []
// 관광지 마커 추가
attractions.forEach((attraction) => {
if (attraction.coordinates) {
const marker = new window.google.maps.Marker({
position: {
lat: attraction.coordinates.lat,
lng: attraction.coordinates.lng,
},
map: map,
title: attraction.nameKo,
label: {
text: attraction.nameKo,
color: '#fff',
fontSize: '11px',
fontWeight: 'bold',
},
})
// 정보창 추가
const infoWindow = new window.google.maps.InfoWindow({
content: `
<div style="padding: 8px; max-width: 250px;">
<h3 style="margin: 0 0 8px 0; font-weight: bold; color: #333; font-size: 16px;">${attraction.nameKo}</h3>
<p style="margin: 0 0 4px 0; color: #666; font-size: 13px;">${attraction.description}</p>
<p style="margin: 0; color: #888; font-size: 12px;">📍 ${attraction.location}</p>
</div>
`,
})
marker.addListener('click', () => {
infoWindow.open(map, marker)
})
markersRef.current.push(marker)
}
})
}
return () => {
// cleanup
markersRef.current.forEach((marker) => {
marker.setMap(null)
})
markersRef.current = []
}
}, [attractions])
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺 </h2>
<div
ref={mapRef}
className="w-full h-96 rounded-lg overflow-hidden border border-gray-200"
style={{ minHeight: '400px' }}
/>
{!apiKey && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-800">
💡 Google Maps API .
<br />
<span className="text-xs mt-1 block">
<strong> $200 </strong> !<br />
로드: 28,000 <br />
API : <a href="https://console.cloud.google.com/google/maps-apis" target="_blank" rel="noopener noreferrer" className="underline">Google Cloud Console</a><br />
<code className="px-1 bg-blue-100 rounded">.env</code> <code className="px-1 bg-blue-100 rounded">VITE_GOOGLE_MAPS_API_KEY=your_key</code>
</span>
</div>
)}
</div>
)
}
export default Map

307
src/components/Timeline.tsx Normal file
View File

@@ -0,0 +1,307 @@
import { format, eachDayOfInterval } from 'date-fns'
import { TravelPlan, Activity } from '../types'
import { useState } from 'react'
interface TimelineProps {
travelPlan: TravelPlan
setTravelPlan: (plan: TravelPlan) => void
}
const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
const days = eachDayOfInterval({
start: travelPlan.startDate,
end: travelPlan.endDate,
})
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [editingTime, setEditingTime] = useState<string | null>(null)
const [editingTimeValue, setEditingTimeValue] = useState('')
const [newActivity, setNewActivity] = useState<Partial<Activity>>({
time: '',
title: '',
description: '',
location: '',
type: 'other',
})
const addActivity = (date: Date) => {
if (!newActivity.time || !newActivity.title) return
const activity: Activity = {
id: Date.now().toString(),
time: newActivity.time,
title: newActivity.title,
description: newActivity.description,
location: newActivity.location,
type: newActivity.type as Activity['type'],
}
const updatedSchedule = [...travelPlan.schedule]
const dayIndex = updatedSchedule.findIndex(
(d) => format(d.date, 'yyyy-MM-dd') === format(date, 'yyyy-MM-dd')
)
if (dayIndex >= 0) {
updatedSchedule[dayIndex].activities.push(activity)
updatedSchedule[dayIndex].activities.sort((a, b) =>
a.time.localeCompare(b.time)
)
} else {
updatedSchedule.push({
date,
activities: [activity],
})
}
setTravelPlan({
...travelPlan,
schedule: updatedSchedule,
})
setNewActivity({
time: '',
title: '',
description: '',
location: '',
type: 'other',
})
setSelectedDay(null)
}
const getDaySchedule = (date: Date) => {
return (
travelPlan.schedule.find(
(d) => format(d.date, 'yyyy-MM-dd') === format(date, 'yyyy-MM-dd')
)?.activities || []
)
}
const getDayLabel = (date: Date, index: number) => {
const dayNames = ['월', '화', '수', '목', '금', '토', '일']
const dayName = dayNames[date.getDay()]
return `${index + 1}일차 (${dayName})`
}
const updateActivityTime = (activityId: string, newTime: string) => {
const updatedSchedule = travelPlan.schedule.map((daySchedule) => ({
...daySchedule,
activities: daySchedule.activities.map((activity) =>
activity.id === activityId
? { ...activity, time: newTime }
: activity
),
}))
// 시간 순으로 다시 정렬
updatedSchedule.forEach((daySchedule) => {
daySchedule.activities.sort((a, b) => a.time.localeCompare(b.time))
})
setTravelPlan({
...travelPlan,
schedule: updatedSchedule,
})
setEditingTime(null)
}
const deleteActivity = (activityId: string) => {
const updatedSchedule = travelPlan.schedule.map((daySchedule) => ({
...daySchedule,
activities: daySchedule.activities.filter(
(activity) => activity.id !== activityId
),
}))
setTravelPlan({
...travelPlan,
schedule: updatedSchedule,
})
}
const startEditTime = (activityId: string, currentTime: string) => {
setEditingTime(activityId)
setEditingTimeValue(currentTime)
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">📅 </h2>
<div className="space-y-6">
{days.map((day, index) => {
const activities = getDaySchedule(day)
const isSelected = selectedDay?.getTime() === day.getTime()
return (
<div
key={day.toISOString()}
className="border-l-4 border-kumamoto-blue pl-4"
>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">
{format(day, 'yyyy년 M월 d일')} ({getDayLabel(day, index)})
</h3>
</div>
<button
onClick={() => setSelectedDay(isSelected ? null : day)}
className="px-3 py-1 bg-kumamoto-blue text-white rounded hover:bg-opacity-90 text-sm"
>
{isSelected ? '취소' : '+ 일정 추가'}
</button>
</div>
{isSelected && (
<div className="mb-4 p-4 bg-gray-50 rounded-lg space-y-3">
<input
type="time"
value={newActivity.time}
onChange={(e) =>
setNewActivity({ ...newActivity, time: e.target.value })
}
className="w-full px-3 py-2 border rounded"
placeholder="시간"
/>
<input
type="text"
value={newActivity.title}
onChange={(e) =>
setNewActivity({ ...newActivity, title: e.target.value })
}
className="w-full px-3 py-2 border rounded"
placeholder="일정 제목"
/>
<input
type="text"
value={newActivity.location}
onChange={(e) =>
setNewActivity({
...newActivity,
location: e.target.value,
})
}
className="w-full px-3 py-2 border rounded"
placeholder="장소 (선택)"
/>
<textarea
value={newActivity.description}
onChange={(e) =>
setNewActivity({
...newActivity,
description: e.target.value,
})
}
className="w-full px-3 py-2 border rounded"
placeholder="설명 (선택)"
rows={2}
/>
<select
value={newActivity.type}
onChange={(e) =>
setNewActivity({
...newActivity,
type: e.target.value as Activity['type'],
})
}
className="w-full px-3 py-2 border rounded"
>
<option value="attraction"></option>
<option value="food"></option>
<option value="accommodation"></option>
<option value="transport"></option>
<option value="other"></option>
</select>
<button
onClick={() => addActivity(day)}
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
>
</button>
</div>
)}
{activities.length > 0 ? (
<div className="space-y-2">
{activities.map((activity) => (
<div
key={activity.id}
className="bg-gray-50 p-3 rounded-lg flex items-start gap-3 group"
>
{editingTime === activity.id ? (
<div className="min-w-[80px]">
<input
type="time"
value={editingTimeValue}
onChange={(e) => setEditingTimeValue(e.target.value)}
onBlur={() => updateActivityTime(activity.id, editingTimeValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateActivityTime(activity.id, editingTimeValue)
} else if (e.key === 'Escape') {
setEditingTime(null)
}
}}
className="w-full px-2 py-1 border border-kumamoto-blue rounded text-sm font-semibold text-kumamoto-blue"
autoFocus
/>
</div>
) : (
<div
className="font-semibold text-kumamoto-blue min-w-[60px] cursor-pointer hover:text-kumamoto-green transition-colors"
onClick={() => startEditTime(activity.id, activity.time)}
title="시간을 클릭하여 수정"
>
{activity.time}
</div>
)}
<div className="flex-1">
<div className="font-medium text-gray-800">
{activity.title}
</div>
{activity.location && (
<div className="text-sm text-gray-600">
📍 {activity.location}
</div>
)}
{activity.description && (
<div className="text-sm text-gray-500 mt-1">
{activity.description}
</div>
)}
<div className="flex items-center gap-2 mt-1">
<span className="inline-block px-2 py-0.5 bg-kumamoto-light text-xs rounded">
{activity.type === 'attraction'
? '관광지'
: activity.type === 'food'
? '식사'
: activity.type === 'accommodation'
? '숙소'
: activity.type === 'transport'
? '교통'
: '기타'}
</span>
<button
onClick={() => deleteActivity(activity.id)}
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 text-xs transition-opacity"
title="일정 삭제"
>
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-sm"> .</p>
)}
</div>
)
})}
</div>
</div>
)
}
export default Timeline

13
src/index.css Normal file
View File

@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

54
src/types.ts Normal file
View File

@@ -0,0 +1,54 @@
export interface DaySchedule {
date: Date
activities: Activity[]
}
export interface Activity {
id: string
time: string
title: string
description?: string
location?: string
type: 'attraction' | 'food' | 'accommodation' | 'transport' | 'other'
}
export interface Budget {
total: number
accommodation: number
food: number
transportation: number
shopping: number
activities: number
}
export interface ChecklistItem {
id: string
text: string
checked: boolean
category: 'preparation' | 'shopping' | 'visit' | 'other'
}
export interface TravelPlan {
startDate: Date
endDate: Date
schedule: DaySchedule[]
budget: Budget
checklist: ChecklistItem[]
}
export interface Attraction {
id: string
name: string
nameKo: string
description: string
location: string
estimatedTime: string
admissionFee?: number
imageUrl?: string
category: 'castle' | 'nature' | 'onsen' | 'temple' | 'food' | 'other'
coordinates?: {
lat: number
lng: number
}
}

20
tailwind.config.js Normal file
View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
kumamoto: {
green: '#4A7C59',
blue: '#5B8FA8',
light: '#F5F7F6',
}
}
},
},
plugins: [],
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
},
})