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
|
- Vite + React 18 + TypeScript
|
||||||
- React Router 規劃 SPA 路由
|
- React Router 規劃 SPA 路由
|
||||||
- Zustand 追蹤 UI 狀態(表單抽屜開關與初始座標)
|
- Zustand 追蹤 UI 狀態(訪問表單 Modal 與初始座標)
|
||||||
- React Query 統一處理資料讀寫與快取
|
- React Query 統一處理資料讀寫與快取
|
||||||
- React Hook Form + Zod 表單驗證
|
- React Hook Form + Zod 表單驗證
|
||||||
- react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡
|
- react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡
|
||||||
@@ -30,7 +30,8 @@ traveling-around-the-world/
|
|||||||
- `features/map`:Leaflet 地圖、點擊事件、旅遊標記
|
- `features/map`:Leaflet 地圖、點擊事件、旅遊標記
|
||||||
- `features/visits`:資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件
|
- `features/visits`:資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件
|
||||||
- `components/layout`:頁面主框架、側邊欄
|
- `components/layout`:頁面主框架、側邊欄
|
||||||
- `state`:Zustand store 控制表單抽屜
|
- `state`:Zustand store 控制訪問建立/編輯的 Modal
|
||||||
|
- `components/overlay/VisitModal`:以 Modal 呈現足跡表單,地圖右下角的 `+` 漂浮按鈕或點擊地圖即可開啟
|
||||||
|
|
||||||
### LocalStorage → API 的切換
|
### LocalStorage → API 的切換
|
||||||
- 預設 `VisitProvider mode="local"` 會載入 `localVisitRepository`。
|
- 預設 `VisitProvider mode="local"` 會載入 `localVisitRepository`。
|
||||||
@@ -82,7 +83,7 @@ RESTful 端點:
|
|||||||
## 開發流程建議
|
## 開發流程建議
|
||||||
1. **LocalStorage MVP**:僅啟動前端即可使用,點擊地圖開啟表單、資料存在瀏覽器。
|
1. **LocalStorage MVP**:僅啟動前端即可使用,點擊地圖開啟表單、資料存在瀏覽器。
|
||||||
2. **串接 API**:建立 MongoDB,啟動 `npm run dev` 後將前端 `VisitProvider` 切換到 API 模式。
|
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` 增加檔案欄位。
|
4. **照片/檔案**:後端可擴增 S3/Cloudinary 上傳,再於前端 `VisitCreateInput` 增加檔案欄位。
|
||||||
|
|
||||||
## 待辦與下一步
|
## 待辦與下一步
|
||||||
|
@@ -3,6 +3,7 @@ import { VisitProvider } from '../features/visits/context/VisitProvider';
|
|||||||
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
||||||
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
||||||
import { TravelMap } from '../features/map/TravelMap';
|
import { TravelMap } from '../features/map/TravelMap';
|
||||||
|
import { VisitModal } from '../components/overlay/VisitModal';
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return <PrimaryLayout sidebar={<VisitSidebar />} content={<TravelMap />} />;
|
return <PrimaryLayout sidebar={<VisitSidebar />} content={<TravelMap />} />;
|
||||||
@@ -16,6 +17,7 @@ export function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<VisitModal />
|
||||||
</VisitProvider>
|
</VisitProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,21 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Visit } from '../../features/visits/types';
|
|
||||||
import { VisitList } from '../../features/visits/components/VisitList';
|
import { VisitList } from '../../features/visits/components/VisitList';
|
||||||
import { VisitForm } from '../../features/visits/components/VisitForm';
|
|
||||||
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
||||||
import { useVisitDrawerStore } from '../../state/useVisitDrawerStore';
|
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||||
|
|
||||||
export function VisitSidebar() {
|
export function VisitSidebar() {
|
||||||
const { isOpen, initialLocation, open, close } = useVisitDrawerStore();
|
|
||||||
const [editingVisit, setEditingVisit] = useState<Visit | null>(null);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||||
useEffect(() => {
|
const openForEdit = useVisitModalStore((state) => state.openForEdit);
|
||||||
if (!isOpen) {
|
|
||||||
setEditingVisit(null);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary/90"
|
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')}
|
{t('sidebar.add')}
|
||||||
</button>
|
</button>
|
||||||
@@ -36,39 +27,8 @@ export function VisitSidebar() {
|
|||||||
<VisitSummary />
|
<VisitSummary />
|
||||||
|
|
||||||
<section className="flex-1">
|
<section className="flex-1">
|
||||||
<VisitList
|
<VisitList onEdit={openForEdit} />
|
||||||
onEdit={(visit) => {
|
|
||||||
setEditingVisit(visit);
|
|
||||||
open({ lat: visit.location.lat, lng: visit.location.lng });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
</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>
|
</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 { useEffect } from 'react';
|
||||||
import type { LeafletMouseEvent } from 'leaflet';
|
import type { LeafletMouseEvent } from 'leaflet';
|
||||||
import { ensureLeafletConfig } from './leafletConfig';
|
import { ensureLeafletConfig } from './leafletConfig';
|
||||||
import { useVisitDrawerStore } from '../../state/useVisitDrawerStore';
|
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||||
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const INITIAL_POSITION: [number, number] = [20, 0];
|
const INITIAL_POSITION: [number, number] = [20, 0];
|
||||||
const INITIAL_ZOOM = 2;
|
const INITIAL_ZOOM = 2;
|
||||||
|
|
||||||
function MapEvents() {
|
function MapEvents() {
|
||||||
const openDrawer = useVisitDrawerStore((state) => state.open);
|
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||||
|
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
click: (event: LeafletMouseEvent) => {
|
click: (event: LeafletMouseEvent) => {
|
||||||
const { lat, lng } = event.latlng;
|
const { lat, lng } = event.latlng;
|
||||||
openDrawer({ lat, lng });
|
openForCreate({ lat, lng });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,27 +30,40 @@ export function TravelMap() {
|
|||||||
|
|
||||||
const { data: visits = [] } = useVisitsQuery();
|
const { data: visits = [] } = useVisitsQuery();
|
||||||
|
|
||||||
|
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer className="h-full w-full" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
<div className="relative h-full w-full">
|
||||||
<TileLayer
|
<MapContainer className="h-full w-full z-0" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
||||||
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
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) => (
|
<MapEvents />
|
||||||
<Marker key={visit.id} position={[visit.location.lat, visit.location.lng]}>
|
{visits.map((visit) => (
|
||||||
<Popup>
|
<Marker key={visit.id} position={[visit.location.lat, visit.location.lng]}>
|
||||||
<div className="space-y-1">
|
<Popup>
|
||||||
<p className="font-semibold">{visit.location.city ?? visit.location.country}</p>
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-slate-500">
|
<p className="font-semibold">{visit.location.city ?? visit.location.country}</p>
|
||||||
{visit.date.start}
|
<p className="text-sm text-slate-500">
|
||||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
{visit.date.start}
|
||||||
</p>
|
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
</p>
|
||||||
</div>
|
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||||
</Popup>
|
</div>
|
||||||
</Marker>
|
</Popup>
|
||||||
))}
|
</Marker>
|
||||||
</MapContainer>
|
))}
|
||||||
|
</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 { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { Visit, VisitCreateInput } from '../types';
|
import type { Visit, VisitCreateInput } from '../types';
|
||||||
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
||||||
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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
|
const visitFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -30,12 +37,14 @@ type VisitFormSchema = z.infer<typeof visitFormSchema>;
|
|||||||
interface VisitFormProps {
|
interface VisitFormProps {
|
||||||
editingVisit?: Visit | null;
|
editingVisit?: Visit | null;
|
||||||
onCompleted?: () => void;
|
onCompleted?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
initialLocation?: { lat: number; lng: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation }: VisitFormProps) {
|
||||||
const closeDrawer = useVisitDrawerStore((state) => state.close);
|
|
||||||
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||||
|
const [geocodeFailed, setGeocodeFailed] = useState(false);
|
||||||
|
|
||||||
const createMutation = useCreateVisitMutation();
|
const createMutation = useCreateVisitMutation();
|
||||||
const updateMutation = useUpdateVisitMutation();
|
const updateMutation = useUpdateVisitMutation();
|
||||||
@@ -44,13 +53,14 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
|
getValues,
|
||||||
formState: { errors, isSubmitting, isSubmitSuccessful }
|
formState: { errors, isSubmitting, isSubmitSuccessful }
|
||||||
} = useForm<VisitFormSchema>({
|
} = useForm<VisitFormSchema>({
|
||||||
resolver: zodResolver(visitFormSchema),
|
resolver: zodResolver(visitFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
country: editingVisit?.location.country ?? '',
|
country: editingVisit?.location.country ?? '',
|
||||||
city: editingVisit?.location.city ?? '',
|
city: editingVisit?.location.city ?? '',
|
||||||
start: editingVisit?.date.start ?? '',
|
start: editingVisit?.date.start ?? getTodayDate(),
|
||||||
end: editingVisit?.date.end ?? '',
|
end: editingVisit?.date.end ?? '',
|
||||||
notes: editingVisit?.notes ?? '',
|
notes: editingVisit?.notes ?? '',
|
||||||
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
||||||
@@ -66,11 +76,69 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
|||||||
}, [initialLocation, editingVisit, setValue]);
|
}, [initialLocation, editingVisit, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmitSuccessful) {
|
if (editingVisit) {
|
||||||
closeDrawer();
|
setValue('start', editingVisit.date.start, { shouldDirty: false });
|
||||||
onCompleted?.();
|
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) {
|
async function onSubmit(values: VisitFormSchema) {
|
||||||
const payload: VisitCreateInput = {
|
const payload: VisitCreateInput = {
|
||||||
@@ -190,11 +258,16 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded border border-slate-300 px-4 py-2"
|
className="rounded border border-slate-300 px-4 py-2"
|
||||||
onClick={() => closeDrawer()}
|
onClick={() => onCancel?.()}
|
||||||
>
|
>
|
||||||
{t('form.cancel')}
|
{t('form.cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
@@ -5,12 +5,79 @@ export interface ReverseGeocodeResult {
|
|||||||
city?: string;
|
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> {
|
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.
|
const url = new URL('https://nominatim.openstreetmap.org/reverse');
|
||||||
// For now, return null and let users input manually.
|
url.searchParams.set('format', 'jsonv2');
|
||||||
void lat;
|
url.searchParams.set('lat', String(lat));
|
||||||
void lng;
|
url.searchParams.set('lon', String(lng));
|
||||||
return null;
|
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 {
|
export function applyGeocodeFallback(location: VisitLocation, geocode?: ReverseGeocodeResult | null): VisitLocation {
|
||||||
|
@@ -50,5 +50,9 @@
|
|||||||
"countryRequired": "Country is required",
|
"countryRequired": "Country is required",
|
||||||
"startDateRequired": "Please choose a start date",
|
"startDateRequired": "Please choose a start date",
|
||||||
"endAfterStart": "End date must be after the 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": "請填寫國家",
|
"countryRequired": "請填寫國家",
|
||||||
"startDateRequired": "請選擇開始日期",
|
"startDateRequired": "請選擇開始日期",
|
||||||
"endAfterStart": "結束日期需晚於開始日期"
|
"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