diff --git a/README.md b/README.md index 6094aef..aa8ff32 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ traveling-around-the-world/ ### 技術與工具 - Vite + React 18 + TypeScript - React Router 規劃 SPA 路由 -- Zustand 追蹤 UI 狀態(表單抽屜開關與初始座標) +- Zustand 追蹤 UI 狀態(訪問表單 Modal 與初始座標) - React Query 統一處理資料讀寫與快取 - React Hook Form + Zod 表單驗證 - react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡 @@ -30,7 +30,8 @@ traveling-around-the-world/ - `features/map`:Leaflet 地圖、點擊事件、旅遊標記 - `features/visits`:資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件 - `components/layout`:頁面主框架、側邊欄 -- `state`:Zustand store 控制表單抽屜 +- `state`:Zustand store 控制訪問建立/編輯的 Modal +- `components/overlay/VisitModal`:以 Modal 呈現足跡表單,地圖右下角的 `+` 漂浮按鈕或點擊地圖即可開啟 ### LocalStorage → API 的切換 - 預設 `VisitProvider mode="local"` 會載入 `localVisitRepository`。 @@ -82,7 +83,7 @@ RESTful 端點: ## 開發流程建議 1. **LocalStorage MVP**:僅啟動前端即可使用,點擊地圖開啟表單、資料存在瀏覽器。 2. **串接 API**:建立 MongoDB,啟動 `npm run dev` 後將前端 `VisitProvider` 切換到 API 模式。 -3. **Geocoding**:`features/visits/utils/geocoding.ts` 預留反向地理編碼函式,可透過自建 API Proxy 存取 Nominatim。 +3. **Geocoding**:前端已透過 Nominatim 反向地理編碼在點擊地圖時預填國家 / 城市;若需更高配額可在後端建立 Proxy。 4. **照片/檔案**:後端可擴增 S3/Cloudinary 上傳,再於前端 `VisitCreateInput` 增加檔案欄位。 ## 待辦與下一步 diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 7813c09..a6bd55e 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -3,6 +3,7 @@ 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'; +import { VisitModal } from '../components/overlay/VisitModal'; function HomePage() { return } content={} />; @@ -16,6 +17,7 @@ export function App() { } /> + ); } diff --git a/client/src/components/layout/VisitSidebar.tsx b/client/src/components/layout/VisitSidebar.tsx index de135ab..b95df1c 100644 --- a/client/src/components/layout/VisitSidebar.tsx +++ b/client/src/components/layout/VisitSidebar.tsx @@ -1,21 +1,12 @@ -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -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'; +import { useVisitModalStore } from '../../state/useVisitModalStore'; export function VisitSidebar() { - const { isOpen, initialLocation, open, close } = useVisitDrawerStore(); - const [editingVisit, setEditingVisit] = useState(null); const { t } = useTranslation(); - - useEffect(() => { - if (!isOpen) { - setEditingVisit(null); - } - }, [isOpen]); + const openForCreate = useVisitModalStore((state) => state.openForCreate); + const openForEdit = useVisitModalStore((state) => state.openForEdit); return ( ); } diff --git a/client/src/components/overlay/VisitModal.tsx b/client/src/components/overlay/VisitModal.tsx new file mode 100644 index 0000000..0e17f51 --- /dev/null +++ b/client/src/components/overlay/VisitModal.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { useVisitModalStore } from '../../state/useVisitModalStore'; +import { VisitForm } from '../../features/visits/components/VisitForm'; + +export function VisitModal() { + const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore(); + const { t } = useTranslation(); + + if (!isOpen) return null; + + return ( +
+
+
+
+

+ {editingVisit ? t('sidebar.drawerTitleEdit') : t('sidebar.drawerTitleCreate')} +

+

{t('sidebar.subtitle')}

+
+ +
+ +
+
+ ); +} diff --git a/client/src/features/map/TravelMap.tsx b/client/src/features/map/TravelMap.tsx index a54a143..248b907 100644 --- a/client/src/features/map/TravelMap.tsx +++ b/client/src/features/map/TravelMap.tsx @@ -3,19 +3,20 @@ import 'leaflet/dist/leaflet.css'; import { useEffect } from 'react'; import type { LeafletMouseEvent } from 'leaflet'; import { ensureLeafletConfig } from './leafletConfig'; -import { useVisitDrawerStore } from '../../state/useVisitDrawerStore'; +import { useVisitModalStore } from '../../state/useVisitModalStore'; import { useVisitsQuery } from '../visits/hooks/useVisitQueries'; +import { useTranslation } from 'react-i18next'; const INITIAL_POSITION: [number, number] = [20, 0]; const INITIAL_ZOOM = 2; function MapEvents() { - const openDrawer = useVisitDrawerStore((state) => state.open); + const openForCreate = useVisitModalStore((state) => state.openForCreate); useMapEvents({ click: (event: LeafletMouseEvent) => { const { lat, lng } = event.latlng; - openDrawer({ lat, lng }); + openForCreate({ lat, lng }); } }); @@ -29,27 +30,40 @@ export function TravelMap() { const { data: visits = [] } = useVisitsQuery(); + const openForCreate = useVisitModalStore((state) => state.openForCreate); + const { t } = useTranslation(); + return ( - - - - {visits.map((visit) => ( - - -
-

{visit.location.city ?? visit.location.country}

-

- {visit.date.start} - {visit.date.end ? ` — ${visit.date.end}` : null} -

- {visit.notes ?

{visit.notes}

: null} -
-
-
- ))} -
+
+ + + + {visits.map((visit) => ( + + +
+

{visit.location.city ?? visit.location.country}

+

+ {visit.date.start} + {visit.date.end ? ` — ${visit.date.end}` : null} +

+ {visit.notes ?

{visit.notes}

: null} +
+
+
+ ))} +
+ +
); } diff --git a/client/src/features/visits/components/VisitForm.tsx b/client/src/features/visits/components/VisitForm.tsx index 8557295..be38f1f 100644 --- a/client/src/features/visits/components/VisitForm.tsx +++ b/client/src/features/visits/components/VisitForm.tsx @@ -1,11 +1,18 @@ -import { useEffect } from 'react'; +import { useEffect, useState } 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'; import { useTranslation } from 'react-i18next'; +import { reverseGeocode } from '../utils/geocoding'; + +function getTodayDate(): string { + const now = new Date(); + const offsetMinutes = now.getTimezoneOffset(); + const localMidnight = new Date(now.getTime() - offsetMinutes * 60 * 1000); + return localMidnight.toISOString().split('T')[0]; +} const visitFormSchema = z .object({ @@ -30,12 +37,14 @@ type VisitFormSchema = z.infer; interface VisitFormProps { editingVisit?: Visit | null; onCompleted?: () => void; + onCancel?: () => void; + initialLocation?: { lat: number; lng: number }; } -export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) { - const closeDrawer = useVisitDrawerStore((state) => state.close); - const initialLocation = useVisitDrawerStore((state) => state.initialLocation); +export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation }: VisitFormProps) { const { t } = useTranslation(); + const [isGeocoding, setIsGeocoding] = useState(false); + const [geocodeFailed, setGeocodeFailed] = useState(false); const createMutation = useCreateVisitMutation(); const updateMutation = useUpdateVisitMutation(); @@ -44,13 +53,14 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) { register, handleSubmit, setValue, + getValues, formState: { errors, isSubmitting, isSubmitSuccessful } } = useForm({ resolver: zodResolver(visitFormSchema), defaultValues: { country: editingVisit?.location.country ?? '', city: editingVisit?.location.city ?? '', - start: editingVisit?.date.start ?? '', + start: editingVisit?.date.start ?? getTodayDate(), end: editingVisit?.date.end ?? '', notes: editingVisit?.notes ?? '', lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0, @@ -66,11 +76,69 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) { }, [initialLocation, editingVisit, setValue]); useEffect(() => { - if (isSubmitSuccessful) { - closeDrawer(); - onCompleted?.(); + if (editingVisit) { + setValue('start', editingVisit.date.start, { shouldDirty: false }); + setValue('end', editingVisit.date.end ?? '', { shouldDirty: false }); + } else { + setValue('start', getTodayDate(), { shouldDirty: false }); + setValue('end', '', { shouldDirty: false }); } - }, [isSubmitSuccessful, closeDrawer, onCompleted]); + }, [editingVisit, setValue]); + + useEffect(() => { + if (!initialLocation || editingVisit) { + return; + } + + let cancelled = false; + setGeocodeFailed(false); + setIsGeocoding(true); + + void reverseGeocode(initialLocation.lat, initialLocation.lng) + .then((result) => { + if (cancelled) { + return; + } + + if (!result) { + setGeocodeFailed(true); + return; + } + + setGeocodeFailed(false); + + if (!getValues('country')) { + setValue('country', result.country, { shouldDirty: true }); + } + if (result.city && !getValues('city')) { + setValue('city', result.city, { shouldDirty: true }); + } + }) + .catch(() => { + if (!cancelled) { + setGeocodeFailed(true); + } + }) + .finally(() => { + if (!cancelled) { + setIsGeocoding(false); + } + }); + + return () => { + cancelled = true; + }; + }, [initialLocation, editingVisit, getValues, setValue]); + + useEffect(() => { + if (isSubmitSuccessful) { + if (onCompleted) { + onCompleted(); + } else { + onCancel?.(); + } + } + }, [isSubmitSuccessful, onCancel, onCompleted]); async function onSubmit(values: VisitFormSchema) { const payload: VisitCreateInput = { @@ -190,11 +258,16 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) { + {isGeocoding ?

{t('geocoding.detecting')}

: null} + {!isGeocoding && geocodeFailed ? ( +

{t('geocoding.failed')}

+ ) : null} +
diff --git a/client/src/features/visits/utils/geocoding.ts b/client/src/features/visits/utils/geocoding.ts index 9f542b8..e3b9e84 100644 --- a/client/src/features/visits/utils/geocoding.ts +++ b/client/src/features/visits/utils/geocoding.ts @@ -5,12 +5,79 @@ export interface ReverseGeocodeResult { city?: string; } +interface NominatimAddress { + country?: string; + country_code?: string; + city?: string; + town?: string; + village?: string; + municipality?: string; + city_district?: string; + county?: string; + state?: string; + suburb?: string; +} + +interface NominatimResponse { + address?: NominatimAddress; + country_code?: string; +} + +function deriveCity(address: NominatimAddress | undefined, countryCode?: string): string | undefined { + if (!address) return undefined; + const normalizedCountry = countryCode?.toUpperCase() ?? address.country_code?.toUpperCase(); + + const baseCity = address.city ?? address.town ?? address.municipality ?? address.village ?? address.city_district; + + if (normalizedCountry === 'JP') { + const isTokyoPrefecture = address.state ? /東京/u.test(address.state) : false; + if (baseCity && isTokyoPrefecture && /\bCity$/u.test(baseCity)) { + return address.state; + } + // Japanese responses often return special wards (xx区). Prefer prefecture (state) when ward-level comes back. + if (baseCity && /[区區]$/u.test(baseCity) && address.state) { + return address.state; + } + if (baseCity) { + return baseCity; + } + return address.state; + } + + return baseCity ?? address.county ?? address.state ?? address.suburb; +} + export async function reverseGeocode(lat: number, lng: number): Promise { - // 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; + const url = new URL('https://nominatim.openstreetmap.org/reverse'); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('lat', String(lat)); + url.searchParams.set('lon', String(lng)); + url.searchParams.set('zoom', '10'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('accept-language', 'en'); + + try { + const response = await fetch(url.toString()); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as NominatimResponse; + const address = data.address ?? {}; + const country = address.country; + if (!country) return null; + + const cityCandidate = deriveCity(address, data.country_code); + + return { + country, + city: cityCandidate ?? undefined + }; + } catch (error) { + console.warn('reverse geocode failed', error); + return null; + } } export function applyGeocodeFallback(location: VisitLocation, geocode?: ReverseGeocodeResult | null): VisitLocation { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a5b1915..be0cb18 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -50,5 +50,9 @@ "countryRequired": "Country is required", "startDateRequired": "Please choose a start date", "endAfterStart": "End date must be after the start date" + }, + "geocoding": { + "detecting": "Detecting location...", + "failed": "Could not detect automatically, please fill in manually" } } diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 3e94b8d..258855f 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -50,5 +50,9 @@ "countryRequired": "請填寫國家", "startDateRequired": "請選擇開始日期", "endAfterStart": "結束日期需晚於開始日期" + }, + "geocoding": { + "detecting": "自動判斷地點中...", + "failed": "暫時無法自動辨識,請手動輸入" } } diff --git a/client/src/state/useVisitDrawerStore.ts b/client/src/state/useVisitDrawerStore.ts deleted file mode 100644 index fb3424c..0000000 --- a/client/src/state/useVisitDrawerStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; -import type { VisitLocation } from '../features/visits/types'; - -interface VisitDrawerState { - isOpen: boolean; - initialLocation?: Pick; - open: (location?: Pick) => void; - close: () => void; -} - -export const useVisitDrawerStore = create((set) => ({ - isOpen: false, - initialLocation: undefined, - open: (location) => set({ isOpen: true, initialLocation: location }), - close: () => set({ isOpen: false, initialLocation: undefined }) -})); diff --git a/client/src/state/useVisitModalStore.ts b/client/src/state/useVisitModalStore.ts new file mode 100644 index 0000000..8e8f8f5 --- /dev/null +++ b/client/src/state/useVisitModalStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import type { Visit } from '../features/visits/types'; + +interface VisitModalState { + isOpen: boolean; + initialLocation?: { lat: number; lng: number }; + editingVisit?: Visit; + openForCreate: (location?: { lat: number; lng: number }) => void; + openForEdit: (visit: Visit) => void; + close: () => void; +} + +export const useVisitModalStore = create((set) => ({ + isOpen: false, + initialLocation: undefined, + editingVisit: undefined, + openForCreate: (location) => + set({ + isOpen: true, + initialLocation: location, + editingVisit: undefined + }), + openForEdit: (visit) => + set({ + isOpen: true, + initialLocation: { lat: visit.location.lat, lng: visit.location.lng }, + editingVisit: visit + }), + close: () => set({ isOpen: false, editingVisit: undefined, initialLocation: undefined }) +}));