cleanup 5
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 18:59:50 -04:00
parent c9c9563a1f
commit 5a8b773a0b
13 changed files with 206 additions and 123 deletions
+4 -1
View File
@@ -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.
@@ -0,0 +1,48 @@
import { ReactNode } from 'react';
import { cn } from '../utils/cn';
interface SegmentedOption<T extends string> {
value: T;
label: ReactNode;
}
interface SegmentedControlProps<T extends string> {
options: SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
activeClassName: string;
inactiveClassName: string;
className?: string;
buttonClassName?: string;
ariaLabel?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
activeClassName,
inactiveClassName,
className,
buttonClassName,
ariaLabel,
}: SegmentedControlProps<T>) {
return (
<div className={className} role="group" aria-label={ariaLabel}>
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
className={cn(
buttonClassName,
value === option.value ? activeClassName : inactiveClassName
)}
>
{option.label}
</button>
))}
</div>
);
}
+13 -1
View File
@@ -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 (
<ToastContext.Provider
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }}
>
{children}
<ToastContainer toasts={toasts} />
+1
View File
@@ -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';
@@ -29,3 +29,40 @@ export function useMutationWithToast() {
};
};
}
interface RunToastMutationOptions<T> {
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<T extends ApiResponseLike>(
action: () => Promise<T>,
{ error, success, onSuccess }: RunToastMutationOptions<T>
): Promise<boolean> {
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;
}
};
}
+3 -3
View File
@@ -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 =
+14 -9
View File
@@ -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<BackupTypes>({
@@ -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,
});
}
};
+10 -24
View File
@@ -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"
/>
)}
</div>
@@ -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<boolean> => {
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<boolean> =>
runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), {
error: 'Failed to save',
onSuccess: response =>
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
});
return (
<div className="relative size-full">
@@ -235,7 +221,7 @@ export default function DocumentPage() {
<InfoIcon size={18} />
</button>
<div
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
+27 -30
View File
@@ -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 (
<div
className={cn('rounded bg-surface p-6 text-center text-content-muted shadow-lg', className)}
>
No documents found.
</div>
);
}
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 (
<div className="flex flex-col gap-4">
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
@@ -170,22 +174,19 @@ export default function DocumentsPage() {
/>
</IconInput>
</div>
<div className="inline-flex rounded border border-border bg-surface p-1">
<button
type="button"
onClick={() => setViewMode('grid')}
className={getViewModeButtonClasses('grid')}
>
Grid
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={getViewModeButtonClasses('list')}
>
List
</button>
</div>
<SegmentedControl<DocumentsViewMode>
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' },
]}
/>
</div>
</div>
@@ -196,9 +197,7 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
) : (
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
</div>
<EmptyDocuments className="col-span-full" />
)}
</div>
) : (
@@ -208,9 +207,7 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
) : (
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
</div>
<EmptyDocuments />
)}
</div>
)}
+17 -23
View File
@@ -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 (
<div className="w-full">
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
@@ -126,20 +119,21 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
{name} Leaderboard
</p>
<div className="flex items-center gap-2 text-xs">
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
all
</button>
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
year
</button>
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
month
</button>
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
week
</button>
</div>
<SegmentedControl<TimePeriod>
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' },
]}
/>
</div>
</div>
+2
View File
@@ -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(),
});
+28 -32
View File
@@ -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() {
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
Theme
</p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
{READER_COLOR_SCHEMES.map(option => (
<button
key={option}
type="button"
onClick={() => setColorScheme(option)}
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
colorScheme === option
? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
}`}
>
{option}
</button>
))}
</div>
<SegmentedControl<ReaderColorScheme>
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 }))}
/>
</div>
<div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
{READER_FONT_FAMILIES.map(option => (
<button
key={option}
type="button"
onClick={() => setFontFamily(option)}
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
}`}
>
{option}
</button>
))}
</div>
<SegmentedControl<ReaderFontFamily>
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 }))}
/>
</div>
<div>
+2
View File
@@ -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(),
});