feat: move visit creation to modal with fab and geocoding updates
This commit is contained in:
@@ -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` 增加檔案欄位。
|
||||
|
||||
## 待辦與下一步
|
||||
|
@@ -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 <PrimaryLayout sidebar={<VisitSidebar />} content={<TravelMap />} />;
|
||||
@@ -16,6 +17,7 @@ export function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<VisitModal />
|
||||
</VisitProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -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<Visit | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setEditingVisit(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||
const openForEdit = useVisitModalStore((state) => state.openForEdit);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4">
|
||||
@@ -27,7 +18,7 @@ export function VisitSidebar() {
|
||||
<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)}
|
||||
onClick={() => openForCreate()}
|
||||
>
|
||||
{t('sidebar.add')}
|
||||
</button>
|
||||
@@ -36,39 +27,8 @@ export function VisitSidebar() {
|
||||
<VisitSummary />
|
||||
|
||||
<section className="flex-1">
|
||||
<VisitList
|
||||
onEdit={(visit) => {
|
||||
setEditingVisit(visit);
|
||||
open({ lat: visit.location.lat, lng: visit.location.lng });
|
||||
}}
|
||||
/>
|
||||
<VisitList onEdit={openForEdit} />
|
||||
</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">
|
||||
{t(editingVisit ? 'sidebar.drawerTitleEdit' : 'sidebar.drawerTitleCreate')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
setEditingVisit(null);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t('sidebar.close')}
|
||||
</button>
|
||||
</div>
|
||||
<VisitForm
|
||||
editingVisit={editingVisit ?? undefined}
|
||||
onCompleted={() => {
|
||||
setEditingVisit(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
34
client/src/components/overlay/VisitModal.tsx
Normal file
34
client/src/components/overlay/VisitModal.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4 py-10">
|
||||
<div className="relative w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<header className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{editingVisit ? t('sidebar.drawerTitleEdit') : t('sidebar.drawerTitleCreate')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sidebar.close')}
|
||||
className="rounded bg-slate-100 px-2 py-1 text-sm text-slate-500 hover:bg-slate-200"
|
||||
onClick={close}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
<VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -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,8 +30,12 @@ export function TravelMap() {
|
||||
|
||||
const { data: visits = [] } = useVisitsQuery();
|
||||
|
||||
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MapContainer className="h-full w-full" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
||||
<div className="relative h-full w-full">
|
||||
<MapContainer className="h-full w-full z-0" 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"
|
||||
@@ -51,5 +56,14 @@ export function TravelMap() {
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sidebar.add')}
|
||||
className="absolute bottom-5 right-5 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-2xl font-bold text-white shadow-lg transition hover:bg-primary/90"
|
||||
onClick={() => openForCreate()}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -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<typeof visitFormSchema>;
|
||||
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<VisitFormSchema>({
|
||||
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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isGeocoding ? <p className="text-xs text-slate-500">{t('geocoding.detecting')}</p> : null}
|
||||
{!isGeocoding && geocodeFailed ? (
|
||||
<p className="text-xs text-rose-500">{t('geocoding.failed')}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-4 py-2"
|
||||
onClick={() => closeDrawer()}
|
||||
onClick={() => onCancel?.()}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</button>
|
||||
|
@@ -5,14 +5,81 @@ 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<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;
|
||||
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 {
|
||||
if (!geocode) return location;
|
||||
return {
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -50,5 +50,9 @@
|
||||
"countryRequired": "請填寫國家",
|
||||
"startDateRequired": "請選擇開始日期",
|
||||
"endAfterStart": "結束日期需晚於開始日期"
|
||||
},
|
||||
"geocoding": {
|
||||
"detecting": "自動判斷地點中...",
|
||||
"failed": "暫時無法自動辨識,請手動輸入"
|
||||
}
|
||||
}
|
||||
|
@@ -1,16 +0,0 @@
|
||||
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 })
|
||||
}));
|
30
client/src/state/useVisitModalStore.ts
Normal file
30
client/src/state/useVisitModalStore.ts
Normal file
@@ -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<VisitModalState>((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 })
|
||||
}));
|
Reference in New Issue
Block a user