feat: add category management UI and relocate navbar controls to quick-actions modal

This commit is contained in:
2025-10-01 13:05:43 +08:00
parent c6b1cc23da
commit 2dbfc43cad
19 changed files with 821 additions and 27 deletions

View File

@@ -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<SupportedLanguage, string> = {
@@ -7,14 +8,19 @@ const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
en: 'language.en'
};
export function LanguageSwitcher() {
interface LanguageSwitcherProps {
selectProps?: Partial<SelectProps<SupportedLanguage>>;
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 (
<FormControl size="small">
<FormControl size="small" fullWidth={fullWidth} sx={{ minWidth: fullWidth ? undefined : 140 }}>
<InputLabel id="language-switcher-label">{t('language.label')}</InputLabel>
<Select
labelId="language-switcher-label"
@@ -24,7 +30,7 @@ export function LanguageSwitcher() {
const selected = event.target.value as SupportedLanguage;
void i18n.changeLanguage(selected);
}}
sx={{ minWidth: 140 }}
{...selectProps}
>
{SUPPORTED_LANGUAGES.map((language) => (
<MenuItem key={language} value={language}>

View File

@@ -6,16 +6,17 @@ import {
Divider,
Drawer,
IconButton,
Link,
Stack,
Toolbar,
Tooltip,
Typography,
useMediaQuery
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { useTheme } from '@mui/material/styles';
import { LanguageSwitcher } from './LanguageSwitcher';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { NavControlsDialog } from '../overlay/NavControlsDialog';
interface PrimaryLayoutProps {
sidebar: ReactNode;
@@ -30,6 +31,7 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
const [isSidebarOpen, setIsSidebarOpen] = useState(isDesktop);
const [isNavControlsOpen, setIsNavControlsOpen] = useState(false);
useEffect(() => {
setIsSidebarOpen(isDesktop);
@@ -67,16 +69,12 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' } }}>
<Link href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
{t('nav.openStreetMap')}
</Link>
<Link href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
{t('nav.reactLeaflet')}
</Link>
</Stack>
<LanguageSwitcher />
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title={t('nav.openControls')}>
<IconButton color="primary" onClick={() => setIsNavControlsOpen(true)}>
<MoreVertIcon />
</IconButton>
</Tooltip>
</Stack>
</Toolbar>
</AppBar>
@@ -116,6 +114,7 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
<Box component="main" sx={{ flexGrow: 1, minHeight: 0 }}>{content}</Box>
</Box>
<NavControlsDialog open={isNavControlsOpen} onClose={() => setIsNavControlsOpen(false)} />
</Box>
);
}

View File

@@ -3,6 +3,7 @@ import { Button, Card, CardContent, Stack, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { VisitList } from '../../features/visits/components/VisitList';
import { VisitSummary } from '../../features/visits/components/VisitSummary';
import { VisitCategorySelector } from '../../features/categories/components/VisitCategorySelector';
import { useVisitModalStore } from '../../state/useVisitModalStore';
export function VisitSidebar() {
@@ -31,6 +32,8 @@ export function VisitSidebar() {
</Button>
</Stack>
<VisitCategorySelector />
<VisitSummary />
<Card sx={{ flex: 1, overflow: 'hidden', display: 'flex' }}>

View File

@@ -0,0 +1,72 @@
import { Dialog, DialogActions, DialogContent, DialogTitle, Stack, Button, Typography } from '@mui/material';
import LaunchIcon from '@mui/icons-material/Launch';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from '../layout/LanguageSwitcher';
import { VisitCategoryNavSelect } from '../../features/categories/components/VisitCategoryNavSelect';
interface NavControlsDialogProps {
open: boolean;
onClose: () => void;
}
export function NavControlsDialog({ open, onClose }: NavControlsDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>{t('nav.controls')}</DialogTitle>
<DialogContent dividers>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
{t('nav.controlsDescription')}
</Typography>
<Stack spacing={1.5}>
<Typography variant="subtitle2" color="text.secondary">
{t('categories.selectLabel')}
</Typography>
<VisitCategoryNavSelect fullWidth />
</Stack>
<Stack spacing={1.5}>
<Typography variant="subtitle2" color="text.secondary">
{t('language.label')}
</Typography>
<LanguageSwitcher fullWidth />
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2" color="text.secondary">
{t('nav.links')}
</Typography>
<Stack spacing={1}>
<Button
variant="outlined"
endIcon={<LaunchIcon fontSize="small" />}
component="a"
href="https://www.openstreetmap.org/"
target="_blank"
rel="noreferrer"
>
{t('nav.openStreetMap')}
</Button>
<Button
variant="outlined"
endIcon={<LaunchIcon fontSize="small" />}
component="a"
href="https://react-leaflet.js.org/"
target="_blank"
rel="noreferrer"
>
{t('nav.reactLeaflet')}
</Button>
</Stack>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('nav.close')}</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -9,10 +9,12 @@ import {
import CloseIcon from '@mui/icons-material/Close';
import { useVisitModalStore } from '../../state/useVisitModalStore';
import { VisitForm } from '../../features/visits/components/VisitForm';
import { useVisitCategorySelectionStore } from '../../state/useVisitCategorySelectionStore';
export function VisitModal() {
const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore();
const { t } = useTranslation();
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
return (
<Dialog open={isOpen} onClose={close} maxWidth="sm" fullWidth>
@@ -30,7 +32,13 @@ export function VisitModal() {
</IconButton>
</DialogTitle>
<DialogContent dividers>
<VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} />
<VisitForm
editingVisit={editingVisit}
initialLocation={initialLocation}
selectedCategoryId={selectedCategoryId}
onCompleted={close}
onCancel={close}
/>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,83 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
FormControl,
InputLabel,
MenuItem,
Select,
type SelectChangeEvent
} from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import {
UNCATEGORIZED_CATEGORY_ID,
useVisitCategorySelectionStore
} from '../../../state/useVisitCategorySelectionStore';
import { useVisitCategoriesQuery } from '../hooks/useVisitCategoryQueries';
const ALL_OPTION_VALUE = '__all__';
interface VisitCategoryNavSelectProps {
fullWidth?: boolean;
sx?: SxProps<Theme>;
}
export function VisitCategoryNavSelect({ fullWidth = false, sx }: VisitCategoryNavSelectProps) {
const { t } = useTranslation();
const { data: categories = [], isLoading } = useVisitCategoriesQuery();
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
const setSelectedCategoryId = useVisitCategorySelectionStore((state) => state.setSelectedCategoryId);
const selectValue = selectedCategoryId ?? ALL_OPTION_VALUE;
const options = useMemo(() => {
return [
{ value: ALL_OPTION_VALUE, label: t('categories.all') },
{ value: UNCATEGORIZED_CATEGORY_ID, label: t('categories.uncategorized') },
...categories.map((category) => ({ value: category.id, label: category.name }))
];
}, [categories, t]);
const handleChange = (event: SelectChangeEvent<string>) => {
const value = event.target.value;
if (value === ALL_OPTION_VALUE) {
setSelectedCategoryId(null);
return;
}
setSelectedCategoryId(value);
};
useEffect(() => {
if (!selectedCategoryId || selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
return;
}
const exists = categories.some((category) => category.id === selectedCategoryId);
if (!exists) {
setSelectedCategoryId(null);
}
}, [categories, selectedCategoryId, setSelectedCategoryId]);
return (
<FormControl
size="small"
fullWidth={fullWidth}
sx={{ minWidth: fullWidth ? undefined : 180, ...sx }}
disabled={isLoading}
>
<InputLabel id="visit-category-nav-select-label">{t('categories.selectLabel')}</InputLabel>
<Select
labelId="visit-category-nav-select-label"
label={t('categories.selectLabel')}
value={selectValue}
onChange={handleChange}
>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -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<VisitCategory | null>(null);
const [renameValue, setRenameValue] = useState('');
const [categoryPendingDeletion, setCategoryPendingDeletion] = useState<VisitCategory | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, px: 2 }}>
<Stack spacing={1.5}>
<Typography variant="subtitle2" color="text.secondary">
{t('categories.title')}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{chips.map((chip) => (
<Chip
key={chip.id ?? 'all'}
label={chip.label}
color={chip.isSelected ? 'primary' : 'default'}
variant={chip.isSelected ? 'filled' : 'outlined'}
onClick={() => setSelectedCategoryId(chip.id)}
disabled={isLoading && chip.id !== null && chip.id !== UNCATEGORIZED_CATEGORY_ID}
/>
))}
{isLoading ? <CircularProgress size={18} /> : null}
</Stack>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', gap: 1 }}>
<TextField
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
placeholder={t('categories.addPlaceholder')}
size="small"
fullWidth
disabled={isCreating}
/>
<Button
type="submit"
variant="contained"
startIcon={<AddIcon />}
disabled={!newCategoryName.trim() || isCreating}
>
{t('categories.addButton')}
</Button>
</Box>
{categories.length ? (
<Stack spacing={1.5}>
<Typography variant="subtitle2" color="text.secondary">
{t('categories.manage')}
</Typography>
<Stack spacing={1}>
{categories.map((category) => (
<Stack
key={category.id}
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ border: 1, borderColor: 'divider', borderRadius: 1, px: 1, py: 0.75 }}
>
<Typography
variant="body2"
color="text.primary"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', pr: 1 }}
>
{category.name}
</Typography>
<Stack direction="row" spacing={0.5}>
<IconButton
size="small"
onClick={() => handleEdit(category)}
aria-label={t('categories.edit')}
disabled={isRenaming || isDeleting}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => handleRequestDelete(category)}
aria-label={t('categories.delete')}
disabled={isRenaming || isDeleting}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
))}
</Stack>
</Stack>
) : null}
</Stack>
</CardContent>
</Card>
<Dialog open={Boolean(editingCategory)} onClose={handleCloseRenameDialog} fullWidth maxWidth="xs">
<Box component="form" onSubmit={handleRenameSubmit}>
<DialogTitle>{t('categories.renameTitle')}</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
<TextField
autoFocus
margin="dense"
fullWidth
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
placeholder={t('categories.renamePlaceholder')}
disabled={isRenaming}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseRenameDialog} disabled={isRenaming}>
{t('nav.close')}
</Button>
<Button type="submit" variant="contained" disabled={isRenaming || !renameValue.trim()}>
{t('categories.renameSubmit')}
</Button>
</DialogActions>
</Box>
</Dialog>
<Dialog open={Boolean(categoryPendingDeletion)} onClose={handleCloseDeleteDialog} fullWidth maxWidth="xs">
<DialogTitle>{t('categories.deleteTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
{t('categories.deleteMessage', { name: categoryPendingDeletion?.name ?? '' })}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteDialog} disabled={isDeleting}>
{t('nav.close')}
</Button>
<Button onClick={handleDeleteConfirm} color="error" variant="contained" disabled={isDeleting}>
{t('categories.deleteConfirm')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -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[];

View File

@@ -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<VisitCategory[]>(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;

View File

@@ -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<VisitCategory[]>;
getById(id: VisitCategoryId): Promise<VisitCategory | undefined>;
create(input: VisitCategoryCreateInput): Promise<VisitCategory>;
update(id: VisitCategoryId, input: VisitCategoryUpdateInput): Promise<VisitCategory>;
remove(id: VisitCategoryId): Promise<void>;
detachVisitsFromCategory?(categoryId: VisitCategoryId): Promise<void>;
}

View File

@@ -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
/>
<TextField
select
label={t('form.category')}
{...register('categoryId')}
error={Boolean(errors.categoryId)}
helperText={errors.categoryId ? t(errors.categoryId.message ?? '') : ''}
fullWidth
SelectProps={{ displayEmpty: true }}
>
<MenuItem value="">{t('categories.uncategorized')}</MenuItem>
{categories.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</TextField>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField

View File

@@ -1,9 +1,11 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { Button, Card, CardActions, CardContent, CircularProgress, Stack, Typography } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
import type { Visit } from '../types';
import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore';
interface VisitListProps {
onEdit: (visit: Visit) => 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 (
<Typography variant="body2" color="text.secondary" sx={{ p: 3 }}>
{t('list.empty')}
{selectedCategoryId ? t('list.emptyForCategory') : t('list.empty')}
</Typography>
);
}
return (
<Stack spacing={2} sx={{ p: 2 }}>
{visits.map((visit) => (
{filteredVisits.map((visit) => (
<Card key={visit.id} variant="outlined">
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>

View File

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

View File

@@ -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<VisitRepository>(localVisitRepository);
const VisitCategoryRepositoryContext = createContext<VisitCategoryRepository>(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<VisitCategoryRepository>(() => {
return localVisitCategoryRepository;
}, []);
return (
<VisitRepositoryContext.Provider value={repository}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<VisitCategoryRepositoryContext.Provider value={categoryRepository}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</VisitCategoryRepositoryContext.Provider>
</VisitRepositoryContext.Provider>
);
}
@@ -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);
}

View File

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

View File

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

View File

@@ -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": "儲存變更"

View File

@@ -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<string | null>(CATEGORY_SELECTION_STORAGE_KEY, null);
interface VisitCategorySelectionState {
selectedCategoryId: string | null;
setSelectedCategoryId: (categoryId: string | null) => void;
}
export const useVisitCategorySelectionStore = create<VisitCategorySelectionState>((set) => ({
selectedCategoryId: initialSelectedCategoryId,
setSelectedCategoryId: (categoryId) =>
set(() => {
saveToStorage(CATEGORY_SELECTION_STORAGE_KEY, categoryId);
return { selectedCategoryId: categoryId };
})
}));

View File

@@ -14,6 +14,7 @@ export interface VisitDateRange {
export interface VisitDto {
id: VisitId;
categoryId?: string;
location: VisitLocation;
date: VisitDateRange;
notes?: string;