feat: use gpt to create project, readme
This commit is contained in:
90
README.md
90
README.md
@@ -1,2 +1,92 @@
|
||||
# traveling-around-the-world
|
||||
|
||||
一個以 React 為核心的單頁式應用程式(SPA),用來記錄自己走訪過的國家、城市與時間軸。初期資料儲存在瀏覽器 LocalStorage,並預留後端 Express + MongoDB 的擴充架構,後續可平滑升級成多人同步的雲端服務。
|
||||
|
||||
## Monorepo 結構
|
||||
|
||||
```
|
||||
traveling-around-the-world/
|
||||
├─ client/ # Vite + React + TS 的前端 SPA
|
||||
├─ server/ # Express + MongoDB (未來) API 服務
|
||||
└─ shared/ # 前後端共用的型別定義
|
||||
```
|
||||
|
||||
- `client` 預設採 LocalStorage 擷取資料,並透過 `VisitProvider` 可快速切換成 API 模式。
|
||||
- `server` 已放好 Express + Mongoose 架構與 RESTful routes,只需接上 MongoDB 並啟動即可提供資料。
|
||||
- `shared` 放行程型別 `VisitDto`,讓前後端保持相同資料契約。
|
||||
|
||||
## 前端(client)
|
||||
|
||||
### 技術與工具
|
||||
- Vite + React 18 + TypeScript
|
||||
- React Router 規劃 SPA 路由
|
||||
- Zustand 追蹤 UI 狀態(表單抽屜開關與初始座標)
|
||||
- React Query 統一處理資料讀寫與快取
|
||||
- React Hook Form + Zod 表單驗證
|
||||
- react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡
|
||||
- Tailwind CSS (JIT) 打造快速 UI
|
||||
|
||||
### 模組切分
|
||||
- `features/map`:Leaflet 地圖、點擊事件、旅遊標記
|
||||
- `features/visits`:資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件
|
||||
- `components/layout`:頁面主框架、側邊欄
|
||||
- `state`:Zustand store 控制表單抽屜
|
||||
|
||||
### LocalStorage → API 的切換
|
||||
- 預設 `VisitProvider mode="local"` 會載入 `localVisitRepository`。
|
||||
- 改為 `mode="api"` 並設定 `apiBaseUrl` 後,就會改用 `VisitApiClient` 透過 REST API 存取。
|
||||
- Vite `proxy` 已轉發 `/api` 至 `http://localhost:4000`,未來後端啟動後無需改動前端呼叫。
|
||||
|
||||
### 開發啟動
|
||||
1. 安裝相依套件:`cd client && npm install`
|
||||
2. 啟動開發伺服器:`npm run dev`
|
||||
3. 瀏覽器開啟 `http://localhost:5173`
|
||||
|
||||
> **注意**:Leaflet 需要載入 CSS(已在 `TravelMap` 中引入 `leaflet/dist/leaflet.css`)。
|
||||
|
||||
## 後端(server)
|
||||
|
||||
### 技術堆疊
|
||||
- Express 4
|
||||
- Mongoose 8 + MongoDB(內建 visit schema,資料結構與前端共用)
|
||||
- Zod 驗證 API 請求 payload
|
||||
- Helmet、CORS、Morgan 提供基礎安全與紀錄
|
||||
|
||||
### 架構亮點
|
||||
- `src/app.ts` 建立 Express App、共用中介層、`/health` 健康檢查
|
||||
- `src/modules/visits`:模型、Service、Controller、Router 切分清晰
|
||||
- `visit.mapper.ts` 將 Mongoose Doc 轉成 `shared/visit.ts` 的 DTO
|
||||
- `infra/db/mongo.ts` 佈建 Mongo 連線抽象
|
||||
|
||||
### 啟動指令
|
||||
1. 安裝套件:`cd server && npm install`
|
||||
2. 建立 `.env`(可複製 `.env.example`)並設定 `MONGODB_URI`
|
||||
3. 開發模式:`npm run dev`
|
||||
4. 編譯產出:`npm run build` -> `dist/`
|
||||
|
||||
RESTful 端點:
|
||||
- `GET /api/visits`:取得全部足跡
|
||||
- `POST /api/visits`:新增足跡
|
||||
- `GET /api/visits/:id`
|
||||
- `PUT /api/visits/:id`
|
||||
- `DELETE /api/visits/:id`
|
||||
|
||||
## 共享型別(shared)
|
||||
- `shared/visit.ts` 定義 Visit DTO,前端 `Visit` 及後端回傳皆採相同結構。
|
||||
- Vite 透過 `@shared/*` alias 匯入,後端則直接使用相對路徑,確保型別同步。
|
||||
|
||||
## 開發流程建議
|
||||
1. **LocalStorage MVP**:僅啟動前端即可使用,點擊地圖開啟表單、資料存在瀏覽器。
|
||||
2. **串接 API**:建立 MongoDB,啟動 `npm run dev` 後將前端 `VisitProvider` 切換到 API 模式。
|
||||
3. **Geocoding**:`features/visits/utils/geocoding.ts` 預留反向地理編碼函式,可透過自建 API Proxy 存取 Nominatim。
|
||||
4. **照片/檔案**:後端可擴增 S3/Cloudinary 上傳,再於前端 `VisitCreateInput` 增加檔案欄位。
|
||||
|
||||
## 待辦與下一步
|
||||
- [ ] 實作 geocoding proxy,於點擊地圖後自動填入國家 / 城市
|
||||
- [ ] 加入使用者認證(如 Auth0 或自建 JWT)以支援多人服務
|
||||
- [ ] 將訪問紀錄導出成時間線或統計圖表(D3 / Recharts)
|
||||
- [ ] 建立 Vitest / Jest 單元測試與 React Testing Library 元件測試
|
||||
- [ ] 規劃 CI(GitHub Actions)自動執行 `lint` 與 `test`
|
||||
|
||||
## 授權
|
||||
本專案採用 MIT License,詳見 `LICENSE`。
|
||||
|
24
client/.eslintrc.cjs
Normal file
24
client/.eslintrc.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier'
|
||||
],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
};
|
6
client/.gitignore
vendored
Normal file
6
client/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.DS_Store
|
||||
.env
|
||||
coverage
|
5
client/.prettierrc
Normal file
5
client/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "none"
|
||||
}
|
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Traveling Around The World</title>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
6010
client/package-lock.json
generated
Normal file
6010
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
client/package.json
Normal file
44
client/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "traveling-around-the-world-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.29.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"zustand": "^4.5.2",
|
||||
"zod": "^3.22.4",
|
||||
"nanoid": "^5.0.7",
|
||||
"@hookform/resolvers": "^3.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20"
|
||||
}
|
||||
}
|
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
1
client/public/README.md
Normal file
1
client/public/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Static assets belong here (e.g., favicon, manifest).
|
5
client/public/favicon.svg
Normal file
5
client/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<circle cx="32" cy="32" r="30" fill="#0b7285"/>
|
||||
<path d="M20 26c4-8 16-10 24 0s-8 20-8 20l-4-8-8 8" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="24" r="4" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 310 B |
21
client/src/app/App.tsx
Normal file
21
client/src/app/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { VisitProvider } from '../features/visits/context/VisitProvider';
|
||||
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
||||
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
||||
import { TravelMap } from '../features/map/TravelMap';
|
||||
|
||||
function HomePage() {
|
||||
return <PrimaryLayout sidebar={<VisitSidebar />} content={<TravelMap />} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<VisitProvider mode="local">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</VisitProvider>
|
||||
);
|
||||
}
|
35
client/src/components/layout/PrimaryLayout.tsx
Normal file
35
client/src/components/layout/PrimaryLayout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PrimaryLayoutProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Traveling Around The World</h1>
|
||||
<p className="text-sm text-slate-500">紀錄你走過的每一步足跡</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm text-slate-500">
|
||||
<a href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
|
||||
OpenStreetMap
|
||||
</a>
|
||||
<a href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
|
||||
React Leaflet
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 overflow-hidden">
|
||||
<section className="w-full max-w-lg border-r border-slate-200">
|
||||
{sidebar}
|
||||
</section>
|
||||
<section className="flex-1">{content}</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
70
client/src/components/layout/VisitSidebar.tsx
Normal file
70
client/src/components/layout/VisitSidebar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Visit } from '../../features/visits/types';
|
||||
import { VisitList } from '../../features/visits/components/VisitList';
|
||||
import { VisitForm } from '../../features/visits/components/VisitForm';
|
||||
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
||||
import { useVisitDrawerStore } from '../../state/useVisitDrawerStore';
|
||||
|
||||
export function VisitSidebar() {
|
||||
const { isOpen, initialLocation, open, close } = useVisitDrawerStore();
|
||||
const [editingVisit, setEditingVisit] = useState<Visit | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setEditingVisit(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">我的旅程</h2>
|
||||
<p className="text-sm text-slate-500">點擊地圖任意位置開始記錄</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary/90"
|
||||
onClick={() => open(initialLocation)}
|
||||
>
|
||||
新增足跡
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<VisitSummary />
|
||||
|
||||
<section className="flex-1">
|
||||
<VisitList
|
||||
onEdit={(visit) => {
|
||||
setEditingVisit(visit);
|
||||
open({ lat: visit.location.lat, lng: visit.location.lng });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{isOpen ? (
|
||||
<section className="rounded-lg bg-white p-4 shadow-md">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{editingVisit ? '編輯足跡' : '新增足跡'}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
setEditingVisit(null);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
關閉
|
||||
</button>
|
||||
</div>
|
||||
<VisitForm
|
||||
editingVisit={editingVisit ?? undefined}
|
||||
onCompleted={() => {
|
||||
setEditingVisit(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
55
client/src/features/map/TravelMap.tsx
Normal file
55
client/src/features/map/TravelMap.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useEffect } from 'react';
|
||||
import type { LeafletMouseEvent } from 'leaflet';
|
||||
import { ensureLeafletConfig } from './leafletConfig';
|
||||
import { useVisitDrawerStore } from '../../state/useVisitDrawerStore';
|
||||
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||
|
||||
const INITIAL_POSITION: [number, number] = [20, 0];
|
||||
const INITIAL_ZOOM = 2;
|
||||
|
||||
function MapEvents() {
|
||||
const openDrawer = useVisitDrawerStore((state) => state.open);
|
||||
|
||||
useMapEvents({
|
||||
click: (event: LeafletMouseEvent) => {
|
||||
const { lat, lng } = event.latlng;
|
||||
openDrawer({ lat, lng });
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TravelMap() {
|
||||
useEffect(() => {
|
||||
ensureLeafletConfig();
|
||||
}, []);
|
||||
|
||||
const { data: visits = [] } = useVisitsQuery();
|
||||
|
||||
return (
|
||||
<MapContainer className="h-full w-full" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
||||
<TileLayer
|
||||
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapEvents />
|
||||
{visits.map((visit) => (
|
||||
<Marker key={visit.id} position={[visit.location.lat, visit.location.lng]}>
|
||||
<Popup>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{visit.location.city ?? visit.location.country}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{visit.date.start}
|
||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||
</p>
|
||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
20
client/src/features/map/leafletConfig.ts
Normal file
20
client/src/features/map/leafletConfig.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import L from 'leaflet';
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png?url';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png?url';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png?url';
|
||||
|
||||
let isConfigured = false;
|
||||
|
||||
export function ensureLeafletConfig() {
|
||||
if (isConfigured) return;
|
||||
const icon = L.icon({
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
iconUrl: markerIcon,
|
||||
shadowUrl: markerShadow,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [0, -41]
|
||||
});
|
||||
L.Marker.prototype.options.icon = icon;
|
||||
isConfigured = true;
|
||||
}
|
63
client/src/features/visits/api/visitApiClient.ts
Normal file
63
client/src/features/visits/api/visitApiClient.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types';
|
||||
|
||||
type HttpClient = typeof fetch;
|
||||
|
||||
export interface VisitApiClientConfig {
|
||||
baseUrl: string;
|
||||
client?: HttpClient;
|
||||
}
|
||||
|
||||
export class VisitApiClient implements VisitRepository {
|
||||
private readonly baseUrl: string;
|
||||
private readonly client: HttpClient;
|
||||
|
||||
constructor({ baseUrl, client = fetch }: VisitApiClientConfig) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
private buildUrl(path = ''): string {
|
||||
const normalized = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${this.baseUrl}/${normalized}`;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Visit[]> {
|
||||
const response = await this.client(this.buildUrl('visits'));
|
||||
if (!response.ok) throw new Error('Failed to fetch visits');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getById(id: VisitId): Promise<Visit | undefined> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`));
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) throw new Error('Failed to fetch visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async create(input: VisitCreateInput): Promise<Visit> {
|
||||
const response = await this.client(this.buildUrl('visits'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async update(id: VisitId, input: VisitUpdateInput): Promise<Visit> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async remove(id: VisitId): Promise<void> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete visit');
|
||||
}
|
||||
}
|
207
client/src/features/visits/components/VisitForm.tsx
Normal file
207
client/src/features/visits/components/VisitForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Visit, VisitCreateInput } from '../types';
|
||||
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
||||
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
|
||||
|
||||
const visitFormSchema = z
|
||||
.object({
|
||||
country: z.string().min(1, '請填寫國家'),
|
||||
city: z.string().optional(),
|
||||
start: z.string().min(1, '請選擇開始日期'),
|
||||
end: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
lat: z.number(),
|
||||
lng: z.number()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.end) return true;
|
||||
return new Date(data.end) >= new Date(data.start);
|
||||
},
|
||||
{ message: '結束日期需晚於開始日期', path: ['end'] }
|
||||
);
|
||||
|
||||
type VisitFormSchema = z.infer<typeof visitFormSchema>;
|
||||
|
||||
interface VisitFormProps {
|
||||
editingVisit?: Visit | null;
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
|
||||
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
||||
const closeDrawer = useVisitDrawerStore((state) => state.close);
|
||||
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
|
||||
|
||||
const createMutation = useCreateVisitMutation();
|
||||
const updateMutation = useUpdateVisitMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful }
|
||||
} = useForm<VisitFormSchema>({
|
||||
resolver: zodResolver(visitFormSchema),
|
||||
defaultValues: {
|
||||
country: editingVisit?.location.country ?? '',
|
||||
city: editingVisit?.location.city ?? '',
|
||||
start: editingVisit?.date.start ?? '',
|
||||
end: editingVisit?.date.end ?? '',
|
||||
notes: editingVisit?.notes ?? '',
|
||||
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
||||
lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocation && !editingVisit) {
|
||||
setValue('lat', initialLocation.lat);
|
||||
setValue('lng', initialLocation.lng);
|
||||
}
|
||||
}, [initialLocation, editingVisit, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitSuccessful) {
|
||||
closeDrawer();
|
||||
onCompleted?.();
|
||||
}
|
||||
}, [isSubmitSuccessful, closeDrawer, onCompleted]);
|
||||
|
||||
async function onSubmit(values: VisitFormSchema) {
|
||||
const payload: VisitCreateInput = {
|
||||
location: {
|
||||
country: values.country,
|
||||
city: values.city,
|
||||
lat: values.lat,
|
||||
lng: values.lng
|
||||
},
|
||||
date: {
|
||||
start: values.start,
|
||||
end: values.end || undefined
|
||||
},
|
||||
notes: values.notes || undefined
|
||||
};
|
||||
|
||||
if (editingVisit) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingVisit.id,
|
||||
data: {
|
||||
...payload,
|
||||
location: payload.location,
|
||||
date: payload.date
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="country">
|
||||
國家
|
||||
</label>
|
||||
<input
|
||||
id="country"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="例如:日本"
|
||||
{...register('country')}
|
||||
/>
|
||||
{errors.country ? <p className="text-sm text-rose-500">{errors.country.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="city">
|
||||
城市
|
||||
</label>
|
||||
<input
|
||||
id="city"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="例如:東京"
|
||||
{...register('city')}
|
||||
/>
|
||||
{errors.city ? <p className="text-sm text-rose-500">{errors.city.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="start">
|
||||
開始日期
|
||||
</label>
|
||||
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
|
||||
{errors.start ? <p className="text-sm text-rose-500">{errors.start.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="end">
|
||||
結束日期
|
||||
</label>
|
||||
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
|
||||
{errors.end ? <p className="text-sm text-rose-500">{errors.end.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="notes">
|
||||
備註
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="留下旅程的感想或提醒"
|
||||
{...register('notes')}
|
||||
/>
|
||||
{errors.notes ? <p className="text-sm text-rose-500">{errors.notes.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lat">
|
||||
緯度
|
||||
</label>
|
||||
<input
|
||||
id="lat"
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
{...register('lat', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.lat ? <p className="text-sm text-rose-500">{errors.lat.message}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lng">
|
||||
經度
|
||||
</label>
|
||||
<input
|
||||
id="lng"
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
{...register('lng', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.lng ? <p className="text-sm text-rose-500">{errors.lng.message}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-4 py-2"
|
||||
onClick={() => closeDrawer()}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{editingVisit ? '儲存變更' : '新增足跡'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
60
client/src/features/visits/components/VisitList.tsx
Normal file
60
client/src/features/visits/components/VisitList.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
useDeleteVisitMutation,
|
||||
useVisitsQuery
|
||||
} from '../hooks/useVisitQueries';
|
||||
import type { Visit } from '../types';
|
||||
|
||||
interface VisitListProps {
|
||||
onEdit: (visit: Visit) => void;
|
||||
}
|
||||
|
||||
export function VisitList({ onEdit }: VisitListProps) {
|
||||
const { data: visits = [], isLoading } = useVisitsQuery();
|
||||
const deleteMutation = useDeleteVisitMutation();
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-slate-500">讀取中...</p>;
|
||||
}
|
||||
|
||||
if (!visits.length) {
|
||||
return <p className="text-sm text-slate-500">目前尚未新增任何旅遊記錄。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id} className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{visit.location.country}
|
||||
{visit.location.city ? ` · ${visit.location.city}` : null}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{visit.date.start}
|
||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||
</p>
|
||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200"
|
||||
onClick={() => onEdit(visit)}
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200"
|
||||
onClick={() => deleteMutation.mutate(visit.id)}
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
31
client/src/features/visits/components/VisitSummary.tsx
Normal file
31
client/src/features/visits/components/VisitSummary.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useVisitsQuery } from '../hooks/useVisitQueries';
|
||||
|
||||
export function VisitSummary() {
|
||||
const { data: visits = [] } = useVisitsQuery();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
visits.forEach((visit) => countries.add(visit.location.country));
|
||||
return {
|
||||
totalVisits: visits.length,
|
||||
uniqueCountries: countries.size
|
||||
};
|
||||
}, [visits]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-slate-500">統計</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-center">
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">旅程數</dt>
|
||||
<dd className="text-xl font-bold">{stats.totalVisits}</dd>
|
||||
</div>
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">國家數</dt>
|
||||
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
35
client/src/features/visits/context/VisitProvider.tsx
Normal file
35
client/src/features/visits/context/VisitProvider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { localVisitRepository } from '../storage/localVisitRepository';
|
||||
import type { VisitRepository } from '../types';
|
||||
import { VisitApiClient } from '../api/visitApiClient';
|
||||
|
||||
export type VisitProviderMode = 'local' | 'api';
|
||||
|
||||
interface VisitProviderProps {
|
||||
children: ReactNode;
|
||||
mode?: VisitProviderMode;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
const VisitRepositoryContext = createContext<VisitRepository>(localVisitRepository);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: VisitProviderProps) {
|
||||
const repository = useMemo<VisitRepository>(() => {
|
||||
if (mode === 'api') {
|
||||
return new VisitApiClient({ baseUrl: apiBaseUrl });
|
||||
}
|
||||
return localVisitRepository;
|
||||
}, [mode, apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<VisitRepositoryContext.Provider value={repository}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</VisitRepositoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVisitRepository(): VisitRepository {
|
||||
return useContext(VisitRepositoryContext);
|
||||
}
|
48
client/src/features/visits/hooks/useVisitQueries.ts
Normal file
48
client/src/features/visits/hooks/useVisitQueries.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitUpdateInput } from '../types';
|
||||
import { useVisitRepository } from '../context/VisitProvider';
|
||||
|
||||
const VISITS_QUERY_KEY = ['visits'];
|
||||
|
||||
export function useVisitsQuery() {
|
||||
const repository = useVisitRepository();
|
||||
return useQuery({
|
||||
queryKey: VISITS_QUERY_KEY,
|
||||
queryFn: () => repository.getAll()
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: VisitCreateInput) => repository.create(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: VisitId; data: VisitUpdateInput }) => repository.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: VisitId) => repository.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type VisitListData = Visit[];
|
61
client/src/features/visits/storage/localVisitRepository.ts
Normal file
61
client/src/features/visits/storage/localVisitRepository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage';
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'traveling-around-the-world::visits';
|
||||
|
||||
function stamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function loadVisits(): Visit[] {
|
||||
return loadFromStorage<Visit[]>(STORAGE_KEY, []);
|
||||
}
|
||||
|
||||
function persistVisits(visits: Visit[]): void {
|
||||
saveToStorage(STORAGE_KEY, visits);
|
||||
}
|
||||
|
||||
export const localVisitRepository: VisitRepository = {
|
||||
async getAll() {
|
||||
return loadVisits();
|
||||
},
|
||||
async getById(id) {
|
||||
return loadVisits().find((visit) => visit.id === id);
|
||||
},
|
||||
async create(input) {
|
||||
const visits = loadVisits();
|
||||
const now = stamp();
|
||||
const visit: Visit = {
|
||||
id: nanoid(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...input
|
||||
};
|
||||
persistVisits([visit, ...visits]);
|
||||
return visit;
|
||||
},
|
||||
async update(id, input) {
|
||||
const visits = loadVisits();
|
||||
const index = visits.findIndex((visit) => visit.id === id);
|
||||
if (index < 0) {
|
||||
throw new Error(`Visit with id ${id} not found`);
|
||||
}
|
||||
const updated: Visit = {
|
||||
...visits[index],
|
||||
...input,
|
||||
location: input.location ?? visits[index].location,
|
||||
date: input.date ?? visits[index].date,
|
||||
updatedAt: stamp()
|
||||
};
|
||||
visits[index] = updated;
|
||||
persistVisits(visits);
|
||||
return updated;
|
||||
},
|
||||
async remove(id) {
|
||||
const visits = loadVisits().filter((visit) => visit.id !== id);
|
||||
persistVisits(visits);
|
||||
}
|
||||
};
|
||||
|
||||
export type LocalVisitRepository = typeof localVisitRepository;
|
18
client/src/features/visits/types.ts
Normal file
18
client/src/features/visits/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { VisitDto, VisitId, VisitLocation } from '@shared/visit';
|
||||
|
||||
export type Visit = VisitDto;
|
||||
export type VisitCreateInput = Omit<VisitDto, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type VisitUpdateInput = Partial<Omit<VisitCreateInput, 'location' | 'date'>> & {
|
||||
location?: VisitLocation;
|
||||
date?: VisitDto['date'];
|
||||
};
|
||||
|
||||
export type { VisitId, VisitLocation } from '@shared/visit';
|
||||
|
||||
export interface VisitRepository {
|
||||
getAll(): Promise<Visit[]>;
|
||||
getById(id: VisitId): Promise<Visit | undefined>;
|
||||
create(input: VisitCreateInput): Promise<Visit>;
|
||||
update(id: VisitId, input: VisitUpdateInput): Promise<Visit>;
|
||||
remove(id: VisitId): Promise<void>;
|
||||
}
|
23
client/src/features/visits/utils/geocoding.ts
Normal file
23
client/src/features/visits/utils/geocoding.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { VisitLocation } from '../types';
|
||||
|
||||
export interface ReverseGeocodeResult {
|
||||
country: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
export async function reverseGeocode(lat: number, lng: number): Promise<ReverseGeocodeResult | null> {
|
||||
// TODO: Integrate with a reverse geocoding service (e.g., Nominatim) once backend proxy is available.
|
||||
// For now, return null and let users input manually.
|
||||
void lat;
|
||||
void lng;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyGeocodeFallback(location: VisitLocation, geocode?: ReverseGeocodeResult | null): VisitLocation {
|
||||
if (!geocode) return location;
|
||||
return {
|
||||
...location,
|
||||
country: geocode.country || location.country,
|
||||
city: geocode.city ?? location.city
|
||||
};
|
||||
}
|
24
client/src/lib/browserStorage.ts
Normal file
24
client/src/lib/browserStorage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
export const safeLocalStorage: Storage | undefined = isBrowser ? window.localStorage : undefined;
|
||||
|
||||
export function loadFromStorage<T>(key: string, fallback: T): T {
|
||||
if (!safeLocalStorage) return fallback;
|
||||
try {
|
||||
const raw = safeLocalStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load ${key} from localStorage`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToStorage<T>(key: string, value: T): void {
|
||||
if (!safeLocalStorage) return;
|
||||
try {
|
||||
safeLocalStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to persist ${key} to localStorage`, error);
|
||||
}
|
||||
}
|
18
client/src/main.tsx
Normal file
18
client/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles/global.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
if (!container) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
16
client/src/state/useVisitDrawerStore.ts
Normal file
16
client/src/state/useVisitDrawerStore.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import type { VisitLocation } from '../features/visits/types';
|
||||
|
||||
interface VisitDrawerState {
|
||||
isOpen: boolean;
|
||||
initialLocation?: Pick<VisitLocation, 'lat' | 'lng'>;
|
||||
open: (location?: Pick<VisitLocation, 'lat' | 'lng'>) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useVisitDrawerStore = create<VisitDrawerState>((set) => ({
|
||||
isOpen: false,
|
||||
initialLocation: undefined,
|
||||
open: (location) => set({ isOpen: true, initialLocation: location }),
|
||||
close: () => set({ isOpen: false, initialLocation: undefined })
|
||||
}));
|
13
client/src/styles/global.css
Normal file
13
client/src/styles/global.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
6
client/src/vite-env.d.ts
vendored
Normal file
6
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.png?url' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
16
client/tailwind.config.ts
Normal file
16
client/tailwind.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0b7285',
|
||||
accent: '#51cf66'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
37
client/tsconfig.json
Normal file
37
client/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@shared/*": [
|
||||
"../shared/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
15
client/tsconfig.node.json
Normal file
15
client/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
21
client/vite.config.ts
Normal file
21
client/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': fileURLToPath(new URL('../shared', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
2
server/.env.example
Normal file
2
server/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
MONGODB_URI=mongodb://localhost:27017/travel-journal
|
||||
PORT=4000
|
13
server/.eslintrc.cjs
Normal file
13
server/.eslintrc.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
env: {
|
||||
node: true
|
||||
}
|
||||
};
|
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
coverage
|
33
server/package.json
Normal file
33
server/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "traveling-around-the-world-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"helmet": "^7.1.0",
|
||||
"mongoose": "^8.3.3",
|
||||
"morgan": "^1.10.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/morgan": "^1.9.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
27
server/src/app.ts
Normal file
27
server/src/app.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import { visitRouter } from './modules/visits/visit.routes';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(morgan('dev'));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/api/visits', visitRouter);
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
15
server/src/config/env.ts
Normal file
15
server/src/config/env.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
const get = (key: string, fallback?: string): string => {
|
||||
const value = process.env[key] ?? fallback;
|
||||
if (!value) {
|
||||
throw new Error(`Missing environment variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const env = {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||
mongoUri: get('MONGODB_URI', 'mongodb://localhost:27017/travel-journal')
|
||||
};
|
18
server/src/index.ts
Normal file
18
server/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from './app';
|
||||
import { connectMongo } from './infra/db/mongo';
|
||||
import { env } from './config/env';
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await connectMongo();
|
||||
const app = createApp();
|
||||
app.listen(env.port, () => {
|
||||
console.log(`API server ready at http://localhost:${env.port}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize server', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
10
server/src/infra/db/mongo.ts
Normal file
10
server/src/infra/db/mongo.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
export async function connectMongo(): Promise<void> {
|
||||
await mongoose.connect(env.mongoUri);
|
||||
}
|
||||
|
||||
export function disconnectMongo(): Promise<void> {
|
||||
return mongoose.disconnect();
|
||||
}
|
49
server/src/modules/visits/visit.controller.ts
Normal file
49
server/src/modules/visits/visit.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createVisit,
|
||||
deleteVisit,
|
||||
getVisit,
|
||||
listVisits,
|
||||
updateVisit
|
||||
} from './visit.service';
|
||||
import { visitInputSchema, visitUpdateSchema } from './visit.types';
|
||||
import { toVisitDto } from './visit.mapper';
|
||||
|
||||
export async function listVisitsHandler(_req: Request, res: Response) {
|
||||
const visits = await listVisits();
|
||||
res.json(visits.map(toVisitDto));
|
||||
}
|
||||
|
||||
export async function createVisitHandler(req: Request, res: Response) {
|
||||
const parsed = visitInputSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ errors: parsed.error.flatten() });
|
||||
}
|
||||
const visit = await createVisit(parsed.data);
|
||||
res.status(201).json(toVisitDto(visit));
|
||||
}
|
||||
|
||||
export async function getVisitHandler(req: Request, res: Response) {
|
||||
const visit = await getVisit(req.params.id);
|
||||
if (!visit) {
|
||||
return res.status(404).json({ message: 'Visit not found' });
|
||||
}
|
||||
res.json(toVisitDto(visit));
|
||||
}
|
||||
|
||||
export async function updateVisitHandler(req: Request, res: Response) {
|
||||
const parsed = visitUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ errors: parsed.error.flatten() });
|
||||
}
|
||||
const visit = await updateVisit(req.params.id, parsed.data);
|
||||
if (!visit) {
|
||||
return res.status(404).json({ message: 'Visit not found' });
|
||||
}
|
||||
res.json(toVisitDto(visit));
|
||||
}
|
||||
|
||||
export async function deleteVisitHandler(req: Request, res: Response) {
|
||||
await deleteVisit(req.params.id);
|
||||
res.status(204).send();
|
||||
}
|
24
server/src/modules/visits/visit.mapper.ts
Normal file
24
server/src/modules/visits/visit.mapper.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VisitDoc } from './visit.model';
|
||||
import type { VisitDto } from '../../../shared/visit';
|
||||
|
||||
export function toVisitDto(doc: VisitDoc): VisitDto {
|
||||
const json = doc.toJSON();
|
||||
return {
|
||||
id: json.id,
|
||||
location: {
|
||||
country: json.location.country,
|
||||
city: json.location.city,
|
||||
lat: json.location.lat,
|
||||
lng: json.location.lng
|
||||
},
|
||||
date: {
|
||||
start: new Date(json.date.start).toISOString(),
|
||||
end: json.date.end ? new Date(json.date.end).toISOString() : undefined
|
||||
},
|
||||
notes: json.notes,
|
||||
tags: json.tags,
|
||||
photos: json.photos,
|
||||
createdAt: new Date(json.createdAt).toISOString(),
|
||||
updatedAt: new Date(json.updatedAt).toISOString()
|
||||
};
|
||||
}
|
50
server/src/modules/visits/visit.model.ts
Normal file
50
server/src/modules/visits/visit.model.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Schema, model, type Document } from 'mongoose';
|
||||
|
||||
export interface VisitDoc extends Document {
|
||||
location: {
|
||||
country: string;
|
||||
city?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
date: {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
};
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
photos?: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const visitSchema = new Schema<VisitDoc>(
|
||||
{
|
||||
location: {
|
||||
country: { type: String, required: true },
|
||||
city: { type: String },
|
||||
lat: { type: Number, required: true },
|
||||
lng: { type: Number, required: true }
|
||||
},
|
||||
date: {
|
||||
start: { type: Date, required: true },
|
||||
end: { type: Date }
|
||||
},
|
||||
notes: { type: String },
|
||||
tags: [{ type: String }],
|
||||
photos: [{ type: String }]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
visitSchema.set('toJSON', {
|
||||
virtuals: true,
|
||||
versionKey: false,
|
||||
transform: (_doc, ret) => {
|
||||
ret.id = ret._id;
|
||||
delete ret._id;
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
|
||||
export const VisitModel = model<VisitDoc>('Visit', visitSchema);
|
16
server/src/modules/visits/visit.routes.ts
Normal file
16
server/src/modules/visits/visit.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createVisitHandler,
|
||||
deleteVisitHandler,
|
||||
getVisitHandler,
|
||||
listVisitsHandler,
|
||||
updateVisitHandler
|
||||
} from './visit.controller';
|
||||
|
||||
export const visitRouter = Router();
|
||||
|
||||
visitRouter.get('/', listVisitsHandler);
|
||||
visitRouter.post('/', createVisitHandler);
|
||||
visitRouter.get('/:id', getVisitHandler);
|
||||
visitRouter.put('/:id', updateVisitHandler);
|
||||
visitRouter.delete('/:id', deleteVisitHandler);
|
23
server/src/modules/visits/visit.service.ts
Normal file
23
server/src/modules/visits/visit.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FilterQuery } from 'mongoose';
|
||||
import { VisitModel, type VisitDoc } from './visit.model';
|
||||
import type { VisitInput, VisitUpdateInput } from './visit.types';
|
||||
|
||||
export async function listVisits(filter: FilterQuery<VisitDoc> = {}): Promise<VisitDoc[]> {
|
||||
return VisitModel.find(filter).sort({ 'date.start': -1 }).exec();
|
||||
}
|
||||
|
||||
export async function getVisit(id: string): Promise<VisitDoc | null> {
|
||||
return VisitModel.findById(id).exec();
|
||||
}
|
||||
|
||||
export async function createVisit(data: VisitInput): Promise<VisitDoc> {
|
||||
return VisitModel.create(data);
|
||||
}
|
||||
|
||||
export async function updateVisit(id: string, data: VisitUpdateInput): Promise<VisitDoc | null> {
|
||||
return VisitModel.findByIdAndUpdate(id, data, { new: true }).exec();
|
||||
}
|
||||
|
||||
export async function deleteVisit(id: string): Promise<void> {
|
||||
await VisitModel.findByIdAndDelete(id).exec();
|
||||
}
|
26
server/src/modules/visits/visit.types.ts
Normal file
26
server/src/modules/visits/visit.types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const dateSchema = z.coerce.date();
|
||||
|
||||
export const visitSchema = z.object({
|
||||
location: z.object({
|
||||
country: z.string().min(1),
|
||||
city: z.string().optional(),
|
||||
lat: z.number(),
|
||||
lng: z.number()
|
||||
}),
|
||||
date: z.object({
|
||||
start: dateSchema,
|
||||
end: dateSchema.optional()
|
||||
}),
|
||||
notes: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
photos: z.array(z.string().url()).optional()
|
||||
});
|
||||
|
||||
export const visitInputSchema = visitSchema;
|
||||
|
||||
export const visitUpdateSchema = visitSchema.partial();
|
||||
|
||||
export type VisitInput = z.infer<typeof visitInputSchema>;
|
||||
export type VisitUpdateInput = z.infer<typeof visitUpdateSchema>;
|
8
server/tsconfig.build.json
Normal file
8
server/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
17
server/tsconfig.json
Normal file
17
server/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
24
shared/visit.ts
Normal file
24
shared/visit.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type VisitId = string;
|
||||
|
||||
export interface VisitLocation {
|
||||
country: string;
|
||||
city?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface VisitDateRange {
|
||||
start: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export interface VisitDto {
|
||||
id: VisitId;
|
||||
location: VisitLocation;
|
||||
date: VisitDateRange;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
photos?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
Reference in New Issue
Block a user