From 5a8b773a0b565f2d5faa335e9271a6f20e1de4c2 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 3 Jul 2026 18:59:50 -0400 Subject: [PATCH] cleanup 5 --- frontend/AGENTS.md | 5 +- frontend/src/components/SegmentedControl.tsx | 48 ++++++++++++++++ frontend/src/components/ToastContext.tsx | 14 ++++- frontend/src/components/index.ts | 1 + frontend/src/hooks/useMutationWithToast.ts | 37 ++++++++++++ frontend/src/lib/reader/EBookReader.ts | 6 +- frontend/src/pages/AdminPage.tsx | 23 +++++--- frontend/src/pages/DocumentPage.tsx | 34 ++++------- frontend/src/pages/DocumentsPage.tsx | 57 +++++++++---------- frontend/src/pages/HomePage.tsx | 40 ++++++------- frontend/src/pages/LoginPage.test.tsx | 2 + frontend/src/pages/ReaderPage.tsx | 60 +++++++++----------- frontend/src/pages/RegisterPage.test.tsx | 2 + 13 files changed, 206 insertions(+), 123 deletions(-) create mode 100644 frontend/src/components/SegmentedControl.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 132aca9..49e81ce 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -26,7 +26,10 @@ Also follow the repository root guide at `../AGENTS.md`. - Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`. - Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them. - For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc. -- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. +- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. React Query `isLoading` is initial-load-only (false on background refetches), so a full-page early-return on `isLoading` is fine for pages with no persistent filter form. +- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync` → `getResponseError` → toast blocks. +- Use `SegmentedControl` for active/inactive toggle groups (view mode, period, reader theme/font) rather than re-implementing `option.map` + ternary class toggling; pass per-call `activeClassName`/`inactiveClassName`. +- For a persistent/progress toast that resolves in place (long-running actions), create it with `showInfo(msg, 0)` and finish with `updateToast(id, { message, type, duration })`. - Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first. - Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`. - Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape. diff --git a/frontend/src/components/SegmentedControl.tsx b/frontend/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..9c4ae4c --- /dev/null +++ b/frontend/src/components/SegmentedControl.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react'; +import { cn } from '../utils/cn'; + +interface SegmentedOption { + value: T; + label: ReactNode; +} + +interface SegmentedControlProps { + options: SegmentedOption[]; + value: T; + onChange: (value: T) => void; + activeClassName: string; + inactiveClassName: string; + className?: string; + buttonClassName?: string; + ariaLabel?: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + activeClassName, + inactiveClassName, + className, + buttonClassName, + ariaLabel, +}: SegmentedControlProps) { + return ( +
+ {options.map(option => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/ToastContext.tsx b/frontend/src/components/ToastContext.tsx index 1963bdc..09efeef 100644 --- a/frontend/src/components/ToastContext.tsx +++ b/frontend/src/components/ToastContext.tsx @@ -1,11 +1,18 @@ import { createContext, useContext, useState, useCallback, ReactNode } from 'react'; import { Toast, ToastType, ToastProps } from './Toast'; +interface ToastUpdate { + message?: string; + type?: ToastType; + duration?: number; +} + interface ToastContextType { showToast: (message: string, type?: ToastType, duration?: number) => string; showInfo: (message: string, duration?: number) => string; showWarning: (message: string, duration?: number) => string; showError: (message: string, duration?: number) => string; + updateToast: (id: string, update: ToastUpdate) => void; removeToast: (id: string) => void; clearToasts: () => void; } @@ -49,13 +56,18 @@ export function ToastProvider({ children }: { children: ReactNode }) { [showToast] ); + // In-Place Update - Long-running flows show a persistent toast (duration 0) that resolves into its result toast; changing duration restarts the Toast's auto-dismiss timer. + const updateToast = useCallback((id: string, update: ToastUpdate) => { + setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast))); + }, []); + const clearToasts = useCallback(() => { setToasts([]); }, []); return ( {children} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index b61ccd9..8cd29a6 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -17,3 +17,4 @@ export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; export { Button } from './Button'; export { Table, type Column, type TableProps } from './Table'; export { IconInput } from './IconInput'; +export { SegmentedControl } from './SegmentedControl'; diff --git a/frontend/src/hooks/useMutationWithToast.ts b/frontend/src/hooks/useMutationWithToast.ts index bcff3be..081e756 100644 --- a/frontend/src/hooks/useMutationWithToast.ts +++ b/frontend/src/hooks/useMutationWithToast.ts @@ -29,3 +29,40 @@ export function useMutationWithToast() { }; }; } + +interface RunToastMutationOptions { + error: string; + success?: string; + onSuccess?: (response: T) => void; +} + +/** + * Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an + * editor open on failure). Runs the action, treats non-2xx as failure, toasts accordingly, and + * resolves to `true` only on success. + */ +export function useToastMutation() { + const { showInfo, showError } = useToasts(); + + return async function runWithToast( + action: () => Promise, + { error, success, onSuccess }: RunToastMutationOptions + ): Promise { + try { + const response = await action(); + const message = getResponseError(response); + if (message) { + showError(`${error}: ${message}`); + return false; + } + onSuccess?.(response); + if (success) { + showInfo(success); + } + return true; + } catch (err) { + showError(`${error}: ${getErrorMessage(err)}`); + return false; + } + }; +} diff --git a/frontend/src/lib/reader/EBookReader.ts b/frontend/src/lib/reader/EBookReader.ts index fa4a151..2a9bb0d 100644 --- a/frontend/src/lib/reader/EBookReader.ts +++ b/frontend/src/lib/reader/EBookReader.ts @@ -244,13 +244,13 @@ export class EBookReader { const prevPage = this.prevPage.bind(this); this.keyupHandler = (event: KeyboardEvent) => { - if ((event.keyCode || event.which) === 37) { + if (event.key === 'ArrowLeft') { void prevPage(); } - if ((event.keyCode || event.which) === 39) { + if (event.key === 'ArrowRight') { void nextPage(); } - if ((event.keyCode || event.which) === 84) { + if (event.key === 't' || event.key === 'T') { const currentTheme = this.readerSettings.theme?.colorScheme || 'tan'; const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme); const colorScheme = diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index c2859d7..f9c3728 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -13,7 +13,7 @@ interface BackupTypes { export default function AdminPage() { const postAdminAction = usePostAdminAction(); - const { showInfo, showError, removeToast } = useToasts(); + const { showInfo, showError, updateToast } = useToasts(); const toastMutationOptions = useMutationWithToast(); const [backupTypes, setBackupTypes] = useState({ @@ -61,8 +61,8 @@ export default function AdminPage() { e.preventDefault(); if (!restoreFile) return; - // Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator. - const startedToastId = showInfo('Restore started', 0); + // Progress Toast - Restore is long-running; a persistent 'started' toast resolves in place into the result toast on completion. + const toastId = showInfo('Restore started', 0); try { const response = await postAdminAction.mutateAsync({ @@ -72,18 +72,23 @@ export default function AdminPage() { }, }); - removeToast(startedToastId); - const message = getResponseError(response); if (message) { - showError('Restore failed: ' + message); + updateToast(toastId, { + type: 'error', + message: `Restore failed: ${message}`, + duration: 5000, + }); return; } - showInfo('Restore completed successfully'); + updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 }); } catch (error) { - removeToast(startedToastId); - showError('Restore failed: ' + getErrorMessage(error)); + updateToast(toastId, { + type: 'error', + message: `Restore failed: ${getErrorMessage(error)}`, + duration: 5000, + }); } }; diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index c8ab697..55f927e 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -8,16 +8,11 @@ import { } from '../generated/anthoLumeAPIV1'; import type { EditDocumentBody } from '../generated/model'; import { formatDuration } from '../utils/formatters'; -import { getErrorMessage, getResponseError } from '../utils/errors'; +import { useToastMutation } from '../hooks/useMutationWithToast'; import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; -import { useToasts } from '../components/ToastContext'; const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content'; -const popupClassName = - 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200'; -const editInputClassName = - 'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600'; interface EditableFieldProps { label: string; @@ -100,7 +95,7 @@ function EditableField({ type="text" value={draft} onChange={e => setDraft(e.target.value)} - className={editInputClassName} + className="w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600" /> )} @@ -118,7 +113,7 @@ export default function DocumentPage() { query: { enabled: Boolean(id) }, }); const editMutation = useEditDocument(); - const { showError } = useToasts(); + const runWithToast = useToastMutation(); const [showTimeReadInfo, setShowTimeReadInfo] = useState(false); @@ -136,21 +131,12 @@ export default function DocumentPage() { const secondsPerPercent = document.seconds_per_percent || 0; const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); - const save = async (data: EditDocumentBody): Promise => { - try { - const response = await editMutation.mutateAsync({ id: document.id, data }); - const message = getResponseError(response); - if (message) { - showError('Failed to save: ' + message); - return false; - } - queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); - return true; - } catch (err) { - showError('Failed to save: ' + getErrorMessage(err)); - return false; - } - }; + const save = (data: EditDocumentBody): Promise => + runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), { + error: 'Failed to save', + onSuccess: response => + queryClient.setQueryData(getGetDocumentQueryKey(document.id), response), + }); return (
@@ -235,7 +221,7 @@ export default function DocumentPage() {
diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index e2e60d2..39c70c0 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -3,10 +3,11 @@ import { Link } from 'react-router-dom'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import type { Document } from '../generated/model'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; -import { LoadingState, Pagination, TextInput, IconInput } from '../components'; +import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components'; import { useToasts } from '../components/ToastContext'; import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { formatDuration } from '../utils/formatters'; +import { cn } from '../utils/cn'; import { useDebouncedState } from '../hooks/useDebouncedState'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; @@ -99,6 +100,16 @@ function DocumentItem({ doc, layout }: DocumentItemProps) { ); } +function EmptyDocuments({ className }: { className?: string }) { + return ( +
+ No documents found. +
+ ); +} + export default function DocumentsPage() { const [search, setSearch, debouncedSearch] = useDebouncedState('', 300); const [page, setPage] = useState(1); @@ -148,13 +159,6 @@ export default function DocumentsPage() { } }; - const getViewModeButtonClasses = (mode: DocumentsViewMode) => - `rounded px-3 py-1 text-sm font-medium transition-colors ${ - viewMode === mode - ? 'bg-content text-content-inverse' - : 'text-content-muted hover:bg-surface-muted' - }`; - return (
@@ -170,22 +174,19 @@ export default function DocumentsPage() { />
-
- - -
+ + className="inline-flex rounded border border-border bg-surface p-1" + ariaLabel="Document view mode" + value={viewMode} + onChange={setViewMode} + buttonClassName="rounded px-3 py-1 text-sm font-medium transition-colors" + activeClassName="bg-content text-content-inverse" + inactiveClassName="text-content-muted hover:bg-surface-muted" + options={[ + { value: 'grid', label: 'Grid' }, + { value: 'list', label: 'List' }, + ]} + />
@@ -196,9 +197,7 @@ export default function DocumentsPage() { ) : docs && docs.length > 0 ? ( docs.map(doc => ) ) : ( -
- No documents found. -
+ )}
) : ( @@ -208,9 +207,7 @@ export default function DocumentsPage() { ) : docs && docs.length > 0 ? ( docs.map(doc => ) ) : ( -
- No documents found. -
+ )} )} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e6ae0d3..5fbb0c7 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,12 +1,8 @@ import { useState } from 'react'; -import { LoadingState } from '../components'; +import { LoadingState, SegmentedControl } from '../components'; import { Link } from 'react-router-dom'; import { useGetHome } from '../generated/anthoLumeAPIV1'; -import type { - LeaderboardData, - LeaderboardEntry, - UserStreak, -} from '../generated/model'; +import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model'; import ReadingHistoryGraph from '../components/ReadingHistoryGraph'; import { formatNumber, formatDuration } from '../utils/formatters'; @@ -115,9 +111,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) { const currentData = data[selectedPeriod]; - const getPeriodClassName = (period: TimePeriod) => - `cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`; - return (
@@ -126,20 +119,21 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {

{name} Leaderboard

-
- - - - -
+ + className="flex items-center gap-2 text-xs" + ariaLabel={`${name} leaderboard period`} + value={selectedPeriod} + onChange={setSelectedPeriod} + buttonClassName="cursor-pointer" + activeClassName="text-content" + inactiveClassName="text-content-subtle hover:text-content" + options={[ + { value: 'all', label: 'all' }, + { value: 'year', label: 'year' }, + { value: 'month', label: 'month' }, + { value: 'week', label: 'week' }, + ]} + />
diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx index 3145fdb..c9f750a 100644 --- a/frontend/src/pages/LoginPage.test.tsx +++ b/frontend/src/pages/LoginPage.test.tsx @@ -51,6 +51,7 @@ describe('LoginPage', () => { showInfo: vi.fn(), showWarning: vi.fn(), showError: vi.fn(), + updateToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn(), }); @@ -112,6 +113,7 @@ describe('LoginPage', () => { showInfo: vi.fn(), showWarning: vi.fn(), showError: showErrorMock, + updateToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn(), }); diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx index 0ee10c6..4c02852 100644 --- a/frontend/src/pages/ReaderPage.tsx +++ b/frontend/src/pages/ReaderPage.tsx @@ -2,15 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1'; import { LoadingState } from '../components/LoadingState'; +import { SegmentedControl } from '../components/SegmentedControl'; import { CloseIcon } from '../icons'; import { READER_COLOR_SCHEMES, READER_FONT_FAMILIES, + type ReaderColorScheme, + type ReaderFontFamily, getReaderDevice, useLocalSetting, } from '../utils/localSettings'; import { useEpubReader } from '../hooks/useEpubReader'; +const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm'; +const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content'; +const READER_SEGMENT_INACTIVE = + 'border-border text-content-muted hover:bg-surface-muted hover:text-content'; + export default function ReaderPage() { const { id } = useParams<{ id: string }>(); const [isTopBarOpen, setIsTopBarOpen] = useState(false); @@ -214,42 +222,30 @@ export default function ReaderPage() {

Theme

-
- {READER_COLOR_SCHEMES.map(option => ( - - ))} -
+ + className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5" + ariaLabel="Reader theme" + value={colorScheme} + onChange={setColorScheme} + buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`} + activeClassName={READER_SEGMENT_ACTIVE} + inactiveClassName={READER_SEGMENT_INACTIVE} + options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))} + />

Font

-
- {READER_FONT_FAMILIES.map(option => ( - - ))} -
+ + className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4" + ariaLabel="Reader font" + value={fontFamily} + onChange={setFontFamily} + buttonClassName={READER_SEGMENT_BUTTON} + activeClassName={READER_SEGMENT_ACTIVE} + inactiveClassName={READER_SEGMENT_INACTIVE} + options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))} + />
diff --git a/frontend/src/pages/RegisterPage.test.tsx b/frontend/src/pages/RegisterPage.test.tsx index bcb1d9a..48723cb 100644 --- a/frontend/src/pages/RegisterPage.test.tsx +++ b/frontend/src/pages/RegisterPage.test.tsx @@ -51,6 +51,7 @@ describe('RegisterPage', () => { showInfo: vi.fn(), showWarning: vi.fn(), showError: vi.fn(), + updateToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn(), }); @@ -113,6 +114,7 @@ describe('RegisterPage', () => { showInfo: vi.fn(), showWarning: vi.fn(), showError: showErrorMock, + updateToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn(), });