feat: use gpt to create project, readme

This commit is contained in:
2025-09-30 15:39:32 +08:00
parent e23d5e829f
commit 99a97139df
50 changed files with 7476 additions and 0 deletions

View File

@@ -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 元件測試
- [ ] 規劃 CIGitHub Actions自動執行 `lint``test`
## 授權
本專案採用 MIT License詳見 `LICENSE`

24
client/.eslintrc.cjs Normal file
View 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
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
.DS_Store
.env
coverage

5
client/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "none"
}

13
client/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

44
client/package.json Normal file
View 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
View File

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

1
client/public/README.md Normal file
View File

@@ -0,0 +1 @@
Static assets belong here (e.g., favicon, manifest).

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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');
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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[];

View 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;

View 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>;
}

View 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
};
}

View 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
View 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>
);

View 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 })
}));

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
MONGODB_URI=mongodb://localhost:27017/travel-journal
PORT=4000

13
server/.eslintrc.cjs Normal file
View 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
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
coverage

33
server/package.json Normal file
View 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
View 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
View 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
View 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();

View 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();
}

View 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();
}

View 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()
};
}

View 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);

View 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);

View 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();
}

View 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>;

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false,
"noEmit": false
}
}

17
server/tsconfig.json Normal file
View 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
View 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;
}