feat: add category management UI and relocate navbar controls to quick-actions modal
This commit is contained in:
@@ -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}>
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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' }}>
|
||||
|
72
client/src/components/overlay/NavControlsDialog.tsx
Normal file
72
client/src/components/overlay/NavControlsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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[];
|
@@ -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;
|
25
client/src/features/categories/types.ts
Normal file
25
client/src/features/categories/types.ts
Normal 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>;
|
||||
}
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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 },
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -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"
|
||||
|
@@ -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": "儲存變更"
|
||||
|
21
client/src/state/useVisitCategorySelectionStore.ts
Normal file
21
client/src/state/useVisitCategorySelectionStore.ts
Normal 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 };
|
||||
})
|
||||
}));
|
Reference in New Issue
Block a user