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

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