Initial commit: 구마모토 여행 계획 사이트
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
30
Dockerfile
Normal 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
54
README.md
Normal 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
15
docker-compose.dev.yml
Normal 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
12
docker-compose.yml
Normal 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
14
index.html
Normal 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
17
nginx.conf
Normal 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
3120
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
146
src/App.tsx
Normal file
146
src/App.tsx
Normal 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
|
||||
|
||||
232
src/components/Attractions.tsx
Normal file
232
src/components/Attractions.tsx
Normal 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
136
src/components/Budget.tsx
Normal 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
|
||||
|
||||
159
src/components/Checklist.tsx
Normal file
159
src/components/Checklist.tsx
Normal 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
24
src/components/Header.tsx
Normal 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
146
src/components/Map.tsx
Normal 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
307
src/components/Timeline.tsx
Normal 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
13
src/index.css
Normal 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
11
src/main.tsx
Normal 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
54
src/types.ts
Normal 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
20
tailwind.config.js
Normal 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
22
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
11
vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user