feat: move visit creation to modal with fab and geocoding updates

This commit is contained in:
2025-09-30 16:22:34 +08:00
parent cf266880bb
commit ac298047dd
11 changed files with 277 additions and 104 deletions

View File

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

View File

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

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

View File

@@ -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 (
<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>
<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"
/>
<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>
<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>
);
}

View File

@@ -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>

View File

@@ -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<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;
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 {

View File

@@ -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"
}
}

View File

@@ -50,5 +50,9 @@
"countryRequired": "請填寫國家",
"startDateRequired": "請選擇開始日期",
"endAfterStart": "結束日期需晚於開始日期"
},
"geocoding": {
"detecting": "自動判斷地點中...",
"failed": "暫時無法自動辨識,請手動輸入"
}
}

View File

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

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