diff --git a/client/src/components/layout/LanguageSwitcher.tsx b/client/src/components/layout/LanguageSwitcher.tsx index ccb3620..bd00272 100644 --- a/client/src/components/layout/LanguageSwitcher.tsx +++ b/client/src/components/layout/LanguageSwitcher.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import type { SelectProps } from '@mui/material/Select'; import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n'; const LABEL_LOOKUP: Record = { @@ -7,14 +8,19 @@ const LABEL_LOOKUP: Record = { en: 'language.en' }; -export function LanguageSwitcher() { +interface LanguageSwitcherProps { + selectProps?: Partial>; + fullWidth?: boolean; +} + +export function LanguageSwitcher({ selectProps, fullWidth = false }: LanguageSwitcherProps = {}) { const { t, i18n } = useTranslation(); const currentLanguage = SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant'; return ( - + {t('language.label')} + {options.map((option) => ( + + {option.label} + + ))} + + + ); +} diff --git a/client/src/features/categories/components/VisitCategorySelector.tsx b/client/src/features/categories/components/VisitCategorySelector.tsx new file mode 100644 index 0000000..98543bb --- /dev/null +++ b/client/src/features/categories/components/VisitCategorySelector.tsx @@ -0,0 +1,306 @@ +import { type FormEvent, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, + Typography +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { + useCreateVisitCategoryMutation, + useDeleteVisitCategoryMutation, + useUpdateVisitCategoryMutation, + useVisitCategoriesQuery +} from '../hooks/useVisitCategoryQueries'; +import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import type { VisitCategory } from '../types'; + +export function VisitCategorySelector() { + const { t } = useTranslation(); + const { data: categories = [], isLoading } = useVisitCategoriesQuery(); + const createCategoryMutation = useCreateVisitCategoryMutation(); + const updateCategoryMutation = useUpdateVisitCategoryMutation(); + const deleteCategoryMutation = useDeleteVisitCategoryMutation(); + const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId); + const setSelectedCategoryId = useVisitCategorySelectionStore((state) => state.setSelectedCategoryId); + const [newCategoryName, setNewCategoryName] = useState(''); + const [editingCategory, setEditingCategory] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [categoryPendingDeletion, setCategoryPendingDeletion] = useState(null); + + const chips = useMemo(() => { + const chipItems = [ + { + id: null as string | null, + label: t('categories.all'), + isSelected: selectedCategoryId === null + }, + { + id: UNCATEGORIZED_CATEGORY_ID, + label: t('categories.uncategorized'), + isSelected: selectedCategoryId === UNCATEGORIZED_CATEGORY_ID + } + ]; + + return [ + ...chipItems, + ...categories.map((category) => ({ + id: category.id, + label: category.name, + isSelected: selectedCategoryId === category.id + })) + ]; + }, [categories, selectedCategoryId, t]); + + useEffect(() => { + if (!selectedCategoryId) { + return; + } + + if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) { + return; + } + + const exists = categories.some((category) => category.id === selectedCategoryId); + if (!exists) { + setSelectedCategoryId(null); + } + }, [categories, selectedCategoryId, setSelectedCategoryId]); + + useEffect(() => { + if (editingCategory) { + setRenameValue(editingCategory.name); + } else { + setRenameValue(''); + } + }, [editingCategory]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmed = newCategoryName.trim(); + if (!trimmed) { + return; + } + + try { + const category = await createCategoryMutation.mutateAsync({ name: trimmed }); + setNewCategoryName(''); + setSelectedCategoryId(category.id); + } catch (error) { + console.error(error); + } + } + + function handleEdit(category: VisitCategory) { + setEditingCategory(category); + } + + function handleRequestDelete(category: VisitCategory) { + setCategoryPendingDeletion(category); + } + + async function handleRenameSubmit(event: FormEvent) { + event.preventDefault(); + if (!editingCategory) { + return; + } + + const trimmed = renameValue.trim(); + if (!trimmed) { + return; + } + + if (trimmed === editingCategory.name) { + setEditingCategory(null); + return; + } + + try { + await updateCategoryMutation.mutateAsync({ id: editingCategory.id, data: { name: trimmed } }); + setEditingCategory(null); + } catch (error) { + console.error(error); + } + } + + async function handleDeleteConfirm() { + if (!categoryPendingDeletion) { + return; + } + + const categoryId = categoryPendingDeletion.id; + + try { + await deleteCategoryMutation.mutateAsync(categoryId); + if (selectedCategoryId === categoryId) { + setSelectedCategoryId(null); + } + setCategoryPendingDeletion(null); + } catch (error) { + console.error(error); + } + } + + const isCreating = createCategoryMutation.isPending; + const isRenaming = updateCategoryMutation.isPending; + const isDeleting = deleteCategoryMutation.isPending; + + const handleCloseRenameDialog = () => { + if (isRenaming) { + return; + } + setEditingCategory(null); + }; + + const handleCloseDeleteDialog = () => { + if (isDeleting) { + return; + } + setCategoryPendingDeletion(null); + }; + + return ( + <> + + + + + {t('categories.title')} + + + {chips.map((chip) => ( + setSelectedCategoryId(chip.id)} + disabled={isLoading && chip.id !== null && chip.id !== UNCATEGORIZED_CATEGORY_ID} + /> + ))} + {isLoading ? : null} + + + setNewCategoryName(event.target.value)} + placeholder={t('categories.addPlaceholder')} + size="small" + fullWidth + disabled={isCreating} + /> + + + {categories.length ? ( + + + {t('categories.manage')} + + + {categories.map((category) => ( + + + {category.name} + + + handleEdit(category)} + aria-label={t('categories.edit')} + disabled={isRenaming || isDeleting} + > + + + handleRequestDelete(category)} + aria-label={t('categories.delete')} + disabled={isRenaming || isDeleting} + > + + + + + ))} + + + ) : null} + + + + + + + {t('categories.renameTitle')} + + setRenameValue(event.target.value)} + placeholder={t('categories.renamePlaceholder')} + disabled={isRenaming} + /> + + + + + + + + + + {t('categories.deleteTitle')} + + + {t('categories.deleteMessage', { name: categoryPendingDeletion?.name ?? '' })} + + + + + + + + + ); +} diff --git a/client/src/features/categories/hooks/useVisitCategoryQueries.ts b/client/src/features/categories/hooks/useVisitCategoryQueries.ts new file mode 100644 index 0000000..32ec04f --- /dev/null +++ b/client/src/features/categories/hooks/useVisitCategoryQueries.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + VisitCategory, + VisitCategoryCreateInput, + VisitCategoryId, + VisitCategoryUpdateInput +} from '../types'; +import { useVisitCategoryRepository } from '../../visits/context/VisitProvider'; + +const VISIT_CATEGORIES_QUERY_KEY = ['visitCategories']; + +export function useVisitCategoriesQuery() { + const repository = useVisitCategoryRepository(); + return useQuery({ + queryKey: VISIT_CATEGORIES_QUERY_KEY, + queryFn: () => repository.getAll() + }); +} + +export function useCreateVisitCategoryMutation() { + const repository = useVisitCategoryRepository(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: VisitCategoryCreateInput) => repository.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY }); + } + }); +} + +export function useUpdateVisitCategoryMutation() { + const repository = useVisitCategoryRepository(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: VisitCategoryId; data: VisitCategoryUpdateInput }) => + repository.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY }); + } + }); +} + +export function useDeleteVisitCategoryMutation() { + const repository = useVisitCategoryRepository(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: VisitCategoryId) => repository.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY }); + } + }); +} + +export type VisitCategoryListData = VisitCategory[]; diff --git a/client/src/features/categories/storage/localVisitCategoryRepository.ts b/client/src/features/categories/storage/localVisitCategoryRepository.ts new file mode 100644 index 0000000..f46513a --- /dev/null +++ b/client/src/features/categories/storage/localVisitCategoryRepository.ts @@ -0,0 +1,70 @@ +import { nanoid } from 'nanoid'; +import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage'; +import type { VisitCategory, VisitCategoryRepository } from '../types'; +import { localVisitRepository } from '../../visits/storage/localVisitRepository'; + +const STORAGE_KEY = 'traveling-around-the-world::visitCategories'; + +function stamp(): string { + return new Date().toISOString(); +} + +function loadCategories(): VisitCategory[] { + return loadFromStorage(STORAGE_KEY, []); +} + +function persistCategories(categories: VisitCategory[]): void { + saveToStorage(STORAGE_KEY, categories); +} + +export const localVisitCategoryRepository: VisitCategoryRepository = { + async getAll() { + return loadCategories(); + }, + async getById(id) { + return loadCategories().find((category) => category.id === id); + }, + async create(input) { + const categories = loadCategories(); + const now = stamp(); + const category: VisitCategory = { + id: nanoid(), + createdAt: now, + updatedAt: now, + name: input.name + }; + persistCategories([category, ...categories]); + return category; + }, + async update(id, input) { + const categories = loadCategories(); + const idx = categories.findIndex((category) => category.id === id); + if (idx < 0) { + throw new Error(`Category with id ${id} not found`); + } + const updated: VisitCategory = { + ...categories[idx], + ...input, + updatedAt: stamp() + }; + categories[idx] = updated; + persistCategories(categories); + return updated; + }, + async remove(id) { + const categories = loadCategories().filter((category) => category.id !== id); + persistCategories(categories); + if (this.detachVisitsFromCategory) { + await this.detachVisitsFromCategory(id); + } + }, + async detachVisitsFromCategory(categoryId) { + const visits = await localVisitRepository.getAll(); + const updates = visits + .filter((visit) => visit.categoryId === categoryId) + .map((visit) => localVisitRepository.update(visit.id, { categoryId: undefined })); + await Promise.all(updates); + } +}; + +export type LocalVisitCategoryRepository = typeof localVisitCategoryRepository; diff --git a/client/src/features/categories/types.ts b/client/src/features/categories/types.ts new file mode 100644 index 0000000..1d8b26d --- /dev/null +++ b/client/src/features/categories/types.ts @@ -0,0 +1,25 @@ +export type VisitCategoryId = string; + +export interface VisitCategory { + id: VisitCategoryId; + name: string; + createdAt: string; + updatedAt: string; +} + +export interface VisitCategoryCreateInput { + name: string; +} + +export interface VisitCategoryUpdateInput { + name: string; +} + +export interface VisitCategoryRepository { + getAll(): Promise; + getById(id: VisitCategoryId): Promise; + create(input: VisitCategoryCreateInput): Promise; + update(id: VisitCategoryId, input: VisitCategoryUpdateInput): Promise; + remove(id: VisitCategoryId): Promise; + detachVisitsFromCategory?(categoryId: VisitCategoryId): Promise; +} diff --git a/client/src/features/visits/components/VisitForm.tsx b/client/src/features/visits/components/VisitForm.tsx index d27e629..0077eef 100644 --- a/client/src/features/visits/components/VisitForm.tsx +++ b/client/src/features/visits/components/VisitForm.tsx @@ -7,6 +7,7 @@ import { Box, Button, Grid, + MenuItem, Stack, TextField, Typography @@ -15,6 +16,8 @@ import type { Visit, VisitCreateInput } from '../types'; import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries'; import { useTranslation } from 'react-i18next'; import { reverseGeocode } from '../utils/geocoding'; +import { useVisitCategoriesQuery } from '../../categories/hooks/useVisitCategoryQueries'; +import { UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore'; function getTodayDate(): string { const now = new Date(); @@ -30,6 +33,7 @@ const visitFormSchema = z start: z.string().min(1, 'validation.startDateRequired'), end: z.string().optional(), notes: z.string().optional(), + categoryId: z.string().optional(), lat: z.number(), lng: z.number() }) @@ -48,15 +52,29 @@ interface VisitFormProps { onCompleted?: () => void; onCancel?: () => void; initialLocation?: { lat: number; lng: number }; + selectedCategoryId?: string | null; } -export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation }: VisitFormProps) { +export function VisitForm({ + editingVisit, + onCompleted, + onCancel, + initialLocation, + selectedCategoryId +}: VisitFormProps) { const { t } = useTranslation(); const [isGeocoding, setIsGeocoding] = useState(false); const [geocodeFailed, setGeocodeFailed] = useState(false); const createMutation = useCreateVisitMutation(); const updateMutation = useUpdateVisitMutation(); + const { data: categories = [] } = useVisitCategoriesQuery(); + + const defaultCategoryId = editingVisit?.categoryId + ? editingVisit.categoryId + : selectedCategoryId && selectedCategoryId !== UNCATEGORIZED_CATEGORY_ID + ? selectedCategoryId + : ''; const { register, @@ -72,6 +90,7 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation start: editingVisit?.date.start ?? getTodayDate(), end: editingVisit?.date.end ?? '', notes: editingVisit?.notes ?? '', + categoryId: defaultCategoryId, lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0, lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0 } @@ -84,6 +103,25 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation } }, [initialLocation, editingVisit, setValue]); + useEffect(() => { + if (editingVisit) { + return; + } + + if (!selectedCategoryId || selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) { + setValue('categoryId', '', { shouldDirty: false }); + return; + } + + const exists = categories.some((category) => category.id === selectedCategoryId); + if (!exists) { + setValue('categoryId', '', { shouldDirty: false }); + return; + } + + setValue('categoryId', selectedCategoryId, { shouldDirty: false }); + }, [categories, editingVisit, selectedCategoryId, setValue]); + useEffect(() => { if (editingVisit) { setValue('start', editingVisit.date.start, { shouldDirty: false }); @@ -151,6 +189,7 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation async function onSubmit(values: VisitFormSchema) { const payload: VisitCreateInput = { + categoryId: values.categoryId ? values.categoryId : undefined, location: { country: values.country, city: values.city, @@ -200,6 +239,23 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation fullWidth /> + + {t('categories.uncategorized')} + {categories.map((category) => ( + + {category.name} + + ))} + + void; @@ -13,6 +15,19 @@ export function VisitList({ onEdit }: VisitListProps) { const { data: visits = [], isLoading } = useVisitsQuery(); const deleteMutation = useDeleteVisitMutation(); const { t } = useTranslation(); + const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId); + + const filteredVisits = useMemo(() => { + if (!selectedCategoryId) { + return visits; + } + + if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) { + return visits.filter((visit) => !visit.categoryId); + } + + return visits.filter((visit) => visit.categoryId === selectedCategoryId); + }, [visits, selectedCategoryId]); if (isLoading) { return ( @@ -25,17 +40,17 @@ export function VisitList({ onEdit }: VisitListProps) { ); } - if (!visits.length) { + if (!filteredVisits.length) { return ( - {t('list.empty')} + {selectedCategoryId ? t('list.emptyForCategory') : t('list.empty')} ); } return ( - {visits.map((visit) => ( + {filteredVisits.map((visit) => ( diff --git a/client/src/features/visits/components/VisitSummary.tsx b/client/src/features/visits/components/VisitSummary.tsx index 749f47c..9c05c64 100644 --- a/client/src/features/visits/components/VisitSummary.tsx +++ b/client/src/features/visits/components/VisitSummary.tsx @@ -2,19 +2,33 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Card, CardContent, Typography } from '@mui/material'; import { useVisitsQuery } from '../hooks/useVisitQueries'; +import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore'; export function VisitSummary() { const { data: visits = [] } = useVisitsQuery(); const { t } = useTranslation(); + const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId); + + const filteredVisits = useMemo(() => { + if (!selectedCategoryId) { + return visits; + } + + if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) { + return visits.filter((visit) => !visit.categoryId); + } + + return visits.filter((visit) => visit.categoryId === selectedCategoryId); + }, [visits, selectedCategoryId]); const stats = useMemo(() => { const countries = new Set(); - visits.forEach((visit) => countries.add(visit.location.country)); + filteredVisits.forEach((visit) => countries.add(visit.location.country)); return { - totalVisits: visits.length, + totalVisits: filteredVisits.length, uniqueCountries: countries.size }; - }, [visits]); + }, [filteredVisits]); const summaryItems = [ { label: t('summary.totalVisits'), value: stats.totalVisits }, diff --git a/client/src/features/visits/context/VisitProvider.tsx b/client/src/features/visits/context/VisitProvider.tsx index c64f624..e05d92a 100644 --- a/client/src/features/visits/context/VisitProvider.tsx +++ b/client/src/features/visits/context/VisitProvider.tsx @@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { localVisitRepository } from '../storage/localVisitRepository'; import type { VisitRepository } from '../types'; import { VisitApiClient } from '../api/visitApiClient'; +import type { VisitCategoryRepository } from '../../categories/types'; +import { localVisitCategoryRepository } from '../../categories/storage/localVisitCategoryRepository'; export type VisitProviderMode = 'local' | 'api'; @@ -13,6 +15,7 @@ interface VisitProviderProps { } const VisitRepositoryContext = createContext(localVisitRepository); +const VisitCategoryRepositoryContext = createContext(localVisitCategoryRepository); const queryClient = new QueryClient(); export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: VisitProviderProps) { @@ -23,9 +26,15 @@ export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: return localVisitRepository; }, [mode, apiBaseUrl]); + const categoryRepository = useMemo(() => { + return localVisitCategoryRepository; + }, []); + return ( - {children} + + {children} + ); } @@ -33,3 +42,7 @@ export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: export function useVisitRepository(): VisitRepository { return useContext(VisitRepositoryContext); } + +export function useVisitCategoryRepository(): VisitCategoryRepository { + return useContext(VisitCategoryRepositoryContext); +} diff --git a/client/src/features/visits/storage/localVisitRepository.ts b/client/src/features/visits/storage/localVisitRepository.ts index 7f595ec..f15a3a0 100644 --- a/client/src/features/visits/storage/localVisitRepository.ts +++ b/client/src/features/visits/storage/localVisitRepository.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid'; import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage'; -import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types'; +import type { Visit, VisitRepository } from '../types'; const STORAGE_KEY = 'traveling-around-the-world::visits'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 09511cf..8b248e7 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -5,7 +5,12 @@ }, "nav": { "openStreetMap": "OpenStreetMap", - "reactLeaflet": "React Leaflet" + "reactLeaflet": "React Leaflet", + "controls": "Quick Actions", + "controlsDescription": "Manage common settings and resources", + "links": "Resources", + "openControls": "More", + "close": "Close" }, "language": { "label": "Language", @@ -22,6 +27,23 @@ "toggleOpen": "Show sidebar", "toggleClose": "Hide sidebar" }, + "categories": { + "title": "Collections", + "addPlaceholder": "Name a new collection", + "addButton": "Add", + "all": "All", + "uncategorized": "Uncategorized", + "selectLabel": "Collection", + "manage": "Manage collections", + "edit": "Rename", + "delete": "Delete", + "renameTitle": "Rename collection", + "renamePlaceholder": "Enter a new name", + "renameSubmit": "Save", + "deleteTitle": "Delete collection", + "deleteMessage": "Delete \"{{name}}\"? Existing visits will move to Uncategorized.", + "deleteConfirm": "Delete" + }, "summary": { "title": "Stats", "totalVisits": "Trips", @@ -30,6 +52,7 @@ "list": { "loading": "Loading...", "empty": "No trips recorded yet.", + "emptyForCategory": "No trips recorded in this collection yet.", "edit": "Edit", "delete": "Delete" }, @@ -44,6 +67,7 @@ "notesPlaceholder": "Capture thoughts or reminders about the trip", "latitude": "Latitude", "longitude": "Longitude", + "category": "Collection", "cancel": "Cancel", "create": "Add Visit", "update": "Save Changes" diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 8ec8b59..a414df9 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -5,7 +5,12 @@ }, "nav": { "openStreetMap": "OpenStreetMap", - "reactLeaflet": "React Leaflet" + "reactLeaflet": "React Leaflet", + "controls": "快速操作", + "controlsDescription": "管理常用設定與資源", + "links": "資源", + "openControls": "更多", + "close": "關閉" }, "language": { "label": "語言", @@ -22,6 +27,23 @@ "toggleOpen": "展開側邊欄", "toggleClose": "收合側邊欄" }, + "categories": { + "title": "旅程分類", + "addPlaceholder": "輸入新的分類名稱", + "addButton": "新增", + "all": "全部", + "uncategorized": "未分類", + "selectLabel": "分類", + "manage": "管理分類", + "edit": "重新命名", + "delete": "刪除", + "renameTitle": "重新命名分類", + "renamePlaceholder": "輸入新的名稱", + "renameSubmit": "儲存", + "deleteTitle": "刪除分類", + "deleteMessage": "確定要刪除「{{name}}」嗎?既有旅程會移至未分類。", + "deleteConfirm": "刪除" + }, "summary": { "title": "統計", "totalVisits": "旅程數", @@ -30,6 +52,7 @@ "list": { "loading": "讀取中...", "empty": "目前尚未新增任何旅遊記錄。", + "emptyForCategory": "這個分類還沒有旅程。", "edit": "編輯", "delete": "刪除" }, @@ -44,6 +67,7 @@ "notesPlaceholder": "留下旅程的感想或提醒", "latitude": "緯度", "longitude": "經度", + "category": "分類", "cancel": "取消", "create": "新增足跡", "update": "儲存變更" diff --git a/client/src/state/useVisitCategorySelectionStore.ts b/client/src/state/useVisitCategorySelectionStore.ts new file mode 100644 index 0000000..9c8685c --- /dev/null +++ b/client/src/state/useVisitCategorySelectionStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; +import { loadFromStorage, saveToStorage } from '../lib/browserStorage'; + +export const UNCATEGORIZED_CATEGORY_ID = '__uncategorized__'; +const CATEGORY_SELECTION_STORAGE_KEY = 'traveling-around-the-world::selectedCategoryId'; + +const initialSelectedCategoryId = loadFromStorage(CATEGORY_SELECTION_STORAGE_KEY, null); + +interface VisitCategorySelectionState { + selectedCategoryId: string | null; + setSelectedCategoryId: (categoryId: string | null) => void; +} + +export const useVisitCategorySelectionStore = create((set) => ({ + selectedCategoryId: initialSelectedCategoryId, + setSelectedCategoryId: (categoryId) => + set(() => { + saveToStorage(CATEGORY_SELECTION_STORAGE_KEY, categoryId); + return { selectedCategoryId: categoryId }; + }) +})); diff --git a/shared/visit.ts b/shared/visit.ts index e068e2b..702ea01 100644 --- a/shared/visit.ts +++ b/shared/visit.ts @@ -14,6 +14,7 @@ export interface VisitDateRange { export interface VisitDto { id: VisitId; + categoryId?: string; location: VisitLocation; date: VisitDateRange; notes?: string;