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

This commit is contained in:
2026-07-03 20:48:29 -04:00
parent 9158648f3f
commit 09daf6d511
30 changed files with 444 additions and 1597 deletions
+5 -5
View File
@@ -27,7 +27,7 @@ Also follow the repository root guide at `../AGENTS.md`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them. - 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. - 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. 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. - 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. - 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`toast/catch 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`. - 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 })`. - 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. - 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.
@@ -43,10 +43,10 @@ Also follow the repository root guide at `../AGENTS.md`.
### Important behavior ### Important behavior
- The generated client returns `{ data, status, headers }` for both success and error responses. - The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
- Do not assume non-2xx responses throw. - Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
- Trust the generated discriminated-union response types and narrow on `status`; avoid hand-rolled re-validation of fields the schema already guarantees. - Use `getErrorMessage` (`src/utils/errors.ts`) to display caught errors; `ApiError.message` already contains the server-provided error message when present.
- Centralize mutation/reqest error handling via `useMutationWithToast` (toast options) or `getResponseError` (`utils/errors`) for imperative flows — don't re-implement inline `status` checks or `'message' in response.data` extraction. - Centralize mutation error/success feedback via `useMutationWithToast` or `useToastMutation` rather than inline toast/catch duplication.
## 4) Auth / Query State ## 4) Auth / Query State
+8 -2
View File
@@ -8,10 +8,16 @@ export default defineConfig({
target: 'src/generated', target: 'src/generated',
schemas: 'src/generated/model', schemas: 'src/generated/model',
client: 'react-query', client: 'react-query',
httpClient: 'fetch',
mock: false, mock: false,
override: { override: {
useQuery: true, fetch: {
mutations: true, includeHttpResponseReturnType: false,
},
mutator: {
path: './src/utils/apiFetch.ts',
name: 'apiFetch',
},
}, },
}, },
input: { input: {
+4 -16
View File
@@ -12,9 +12,7 @@ import {
type AuthState, type AuthState,
getUnauthenticatedAuthState, getUnauthenticatedAuthState,
resolveAuthStateFromMe, resolveAuthStateFromMe,
authUserFromMutation,
} from './authHelpers'; } from './authHelpers';
import { getResponseError } from '../utils/errors';
interface AuthContextType extends AuthState { interface AuthContextType extends AuthState {
login: (_username: string, _password: string) => Promise<void>; login: (_username: string, _password: string) => Promise<void>;
@@ -50,19 +48,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback( const login = useCallback(
async (username: string, password: string) => { async (username: string, password: string) => {
try { try {
const response = await loginMutation.mutateAsync({ const user = await loginMutation.mutateAsync({
data: { data: {
username, username,
password, password,
}, },
}); });
const user = authUserFromMutation(response); queryClient.setQueryData(getGetMeQueryKey(), user);
if (!user) {
throw new Error(getResponseError(response) ?? 'Login failed');
}
queryClient.setQueryData(getGetMeQueryKey(), response);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
@@ -76,19 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const register = useCallback( const register = useCallback(
async (username: string, password: string) => { async (username: string, password: string) => {
try { try {
const response = await registerMutation.mutateAsync({ const user = await registerMutation.mutateAsync({
data: { data: {
username, username,
password, password,
}, },
}); });
const user = authUserFromMutation(response); queryClient.setQueryData(getGetMeQueryKey(), user);
if (!user) {
throw new Error(getResponseError(response) ?? 'Registration failed');
}
queryClient.setQueryData(getGetMeQueryKey(), response);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
+2 -48
View File
@@ -3,7 +3,6 @@ import {
getCheckingAuthState, getCheckingAuthState,
getUnauthenticatedAuthState, getUnauthenticatedAuthState,
resolveAuthStateFromMe, resolveAuthStateFromMe,
authUserFromMutation,
type AuthState, type AuthState,
} from './authHelpers'; } from './authHelpers';
@@ -31,11 +30,7 @@ describe('authHelpers', () => {
it('resolves auth state from a successful /auth/me response', () => { it('resolves auth state from a successful /auth/me response', () => {
expect( expect(
resolveAuthStateFromMe({ resolveAuthStateFromMe({
meData: { meData: { username: 'evan', is_admin: false },
status: 200,
data: { username: 'evan', is_admin: false },
headers: new Headers(),
},
meError: undefined, meError: undefined,
meLoading: false, meLoading: false,
previousState, previousState,
@@ -47,20 +42,7 @@ describe('authHelpers', () => {
}); });
}); });
it('resolves auth state to unauthenticated on 401 or query error', () => { it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
expect(
resolveAuthStateFromMe({
meData: {
status: 401,
data: { code: 401, message: 'unauthorized' },
headers: new Headers(),
},
meError: undefined,
meLoading: false,
previousState,
})
).toEqual(getUnauthenticatedAuthState());
expect( expect(
resolveAuthStateFromMe({ resolveAuthStateFromMe({
meData: undefined, meData: undefined,
@@ -108,32 +90,4 @@ describe('authHelpers', () => {
isCheckingAuth: false, isCheckingAuth: false,
}); });
}); });
it('extracts the user from successful login and register responses', () => {
expect(
authUserFromMutation({
status: 200,
data: { username: 'evan', is_admin: false },
headers: new Headers(),
})
).toEqual({ username: 'evan', is_admin: false });
expect(
authUserFromMutation({
status: 201,
data: { username: 'evan', is_admin: true },
headers: new Headers(),
})
).toEqual({ username: 'evan', is_admin: true });
});
it('returns null for unsuccessful auth mutation responses', () => {
expect(
authUserFromMutation({
status: 401,
data: { code: 401, message: 'unauthorized' },
headers: new Headers(),
})
).toBeNull();
});
}); });
+4 -9
View File
@@ -1,4 +1,3 @@
import type { getMeResponse, loginResponse, registerResponse } from '../generated/anthoLumeAPIV1';
import type { LoginResponse } from '../generated/model'; import type { LoginResponse } from '../generated/model';
export type AuthUser = LoginResponse; export type AuthUser = LoginResponse;
@@ -34,7 +33,7 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
} }
export function resolveAuthStateFromMe(params: { export function resolveAuthStateFromMe(params: {
meData?: getMeResponse; meData?: LoginResponse;
meError?: unknown; meError?: unknown;
meLoading: boolean; meLoading: boolean;
previousState: AuthState; previousState: AuthState;
@@ -45,11 +44,11 @@ export function resolveAuthStateFromMe(params: {
return getCheckingAuthState(previousState); return getCheckingAuthState(previousState);
} }
if (meData?.status === 200) { if (meData) {
return getAuthenticatedAuthState(meData.data); return getAuthenticatedAuthState(meData);
} }
if (meError || meData?.status === 401) { if (meError) {
return getUnauthenticatedAuthState(); return getUnauthenticatedAuthState();
} }
@@ -58,7 +57,3 @@ export function resolveAuthStateFromMe(params: {
isCheckingAuth: false, isCheckingAuth: false,
}; };
} }
export function authUserFromMutation(response: loginResponse | registerResponse): AuthUser | null {
return response.status === 200 || response.status === 201 ? response.data : null;
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1'; import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { navItems, adminNavItems } from './navigation'; import { navItems, adminNavItems } from './navigation';
import { dataForStatus } from '../utils/apiResponses';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
function hasPrefix(path: string, prefix: string): boolean { function hasPrefix(path: string, prefix: string): boolean {
@@ -47,7 +46,7 @@ export default function HamburgerMenu() {
staleTime: Infinity, staleTime: Infinity,
}, },
}); });
const version = dataForStatus(infoData, 200)?.version ?? 'v1.0.0'; const version = infoData?.version ?? 'v1.0.0';
const closeMenu = () => setIsOpen(false); const closeMenu = () => setIsOpen(false);
return ( return (
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -3,7 +3,6 @@ import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { dataForStatus } from '../utils/apiResponses';
export interface UseAuthFormResult { export interface UseAuthFormResult {
username: string; username: string;
@@ -29,7 +28,7 @@ export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({ const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
query: { staleTime: Infinity }, query: { staleTime: Infinity },
}); });
const registrationEnabled = dataForStatus(infoData, 200)?.registration_enabled ?? false; const registrationEnabled = infoData?.registration_enabled ?? false;
const submit = useCallback( const submit = useCallback(
async (e: SyntheticEvent<HTMLFormElement>) => { async (e: SyntheticEvent<HTMLFormElement>) => {
+3 -11
View File
@@ -5,7 +5,7 @@ import type { UpdateProgressRequest } from '../generated/model/updateProgressReq
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader'; import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings'; import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
interface UseEpubReaderOptions { interface UseEpubReaderOptions {
documentId: string; documentId: string;
@@ -96,11 +96,7 @@ export function useEpubReader({
// Swallow Save Failures - Transient progress-save errors must not take down the reader // Swallow Save Failures - Transient progress-save errors must not take down the reader
// (they previously routed to onError, which hides the whole book behind an error overlay). // (they previously routed to onError, which hides the whole book behind an error overlay).
try { try {
const response = await updateProgress(payload); await updateProgress(payload);
const message = getResponseError(response);
if (message) {
showError(`Failed to save progress: ${message}`);
}
} catch (err) { } catch (err) {
showError(`Failed to save progress: ${getErrorMessage(err)}`); showError(`Failed to save progress: ${getErrorMessage(err)}`);
} }
@@ -108,11 +104,7 @@ export function useEpubReader({
const saveActivity = async (payload: CreateActivityRequest) => { const saveActivity = async (payload: CreateActivityRequest) => {
try { try {
const response = await createActivity(payload); await createActivity(payload);
const message = getResponseError(response);
if (message) {
showError(`Failed to save activity: ${message}`);
}
} catch (err) { } catch (err) {
showError(`Failed to save activity: ${getErrorMessage(err)}`); showError(`Failed to save activity: ${getErrorMessage(err)}`);
} }
+10 -19
View File
@@ -1,5 +1,5 @@
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
interface ToastMutationOptions { interface ToastMutationOptions {
success: string; success: string;
@@ -9,19 +9,15 @@ interface ToastMutationOptions {
/** /**
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call, * Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
* centralizing the shared "toast success / toast error / treat non-2xx as failure" pattern. * centralizing the shared "toast success / toast error" pattern. The generated client throws on
* non-2xx, so success and failure map cleanly onto React Query's onSuccess/onError.
*/ */
export function useMutationWithToast() { export function useMutationWithToast() {
const { showInfo, showError } = useToasts(); const { showInfo, showError } = useToasts();
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) { return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return { return {
onSuccess: (response: ApiResponseLike) => { onSuccess: () => {
const message = getResponseError(response);
if (message) {
showError(`${error}: ${message}`);
return;
}
onSuccess?.(); onSuccess?.();
showInfo(success); showInfo(success);
}, },
@@ -33,29 +29,24 @@ export function useMutationWithToast() {
interface RunToastMutationOptions<T> { interface RunToastMutationOptions<T> {
error: string; error: string;
success?: string; success?: string;
onSuccess?: (response: T) => void; onSuccess?: (result: T) => void;
} }
/** /**
* Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an * 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 * editor open on failure). Runs the action, toasts on success/failure, and resolves to `true` only
* resolves to `true` only on success. * when the action succeeds.
*/ */
export function useToastMutation() { export function useToastMutation() {
const { showInfo, showError } = useToasts(); const { showInfo, showError } = useToasts();
return async function runWithToast<T extends ApiResponseLike>( return async function runWithToast<T>(
action: () => Promise<T>, action: () => Promise<T>,
{ error, success, onSuccess }: RunToastMutationOptions<T> { error, success, onSuccess }: RunToastMutationOptions<T>
): Promise<boolean> { ): Promise<boolean> {
try { try {
const response = await action(); const result = await action();
const message = getResponseError(response); onSuccess?.(result);
if (message) {
showError(`${error}: ${message}`);
return false;
}
onSuccess?.(response);
if (success) { if (success) {
showInfo(success); showInfo(success);
} }
+9 -1
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from './components/ToastContext'; import { ToastProvider } from './components/ToastContext';
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider'; import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
import App from './App'; import App from './App';
import { ApiError } from './utils/apiFetch';
import './index.css'; import './index.css';
initializeThemeMode(); initializeThemeMode();
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
retry: 1, // 4xx responses are deterministic (e.g. /auth/me 401 when logged out); only retry transient
// network/5xx failures.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) {
return false;
}
return failureCount < 1;
},
}, },
mutations: { mutations: {
retry: 0, retry: 0,
+1 -2
View File
@@ -6,7 +6,6 @@ import { Table, type Column } from '../components/Table';
import { documentColumn } from '../components/documentColumn'; import { documentColumn } from '../components/documentColumn';
import { usePaginatedList } from '../hooks/usePaginatedList'; import { usePaginatedList } from '../hooks/usePaginatedList';
import { formatDuration, formatDateTime } from '../utils/formatters'; import { formatDuration, formatDateTime } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const ACTIVITY_PAGE_SIZE = 25; const ACTIVITY_PAGE_SIZE = 25;
@@ -22,7 +21,7 @@ export default function ActivityPage() {
page, page,
limit, limit,
}); });
const response = dataForStatus(data, 200); const response = data;
const activities = response?.activities ?? []; const activities = response?.activities ?? [];
const columns: Column<Activity>[] = [ const columns: Column<Activity>[] = [
+1 -2
View File
@@ -6,7 +6,6 @@ import type { DirectoryItem } from '../generated/model';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons'; import { FolderOpenIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportPage() { export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
@@ -21,7 +20,7 @@ export default function AdminImportPage() {
const postImport = usePostImport(); const postImport = usePostImport();
const directoryResponse = dataForStatus(directoryData, 200); const directoryResponse = directoryData;
const directories = directoryResponse?.items ?? []; const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data'; const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
@@ -2,11 +2,10 @@ import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import { Table, type Column } from '../components'; import { Table, type Column } from '../components';
import type { ImportResult } from '../generated/model'; import type { ImportResult } from '../generated/model';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportResultsPage() { export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults(); const { data: resultsData, isLoading } = useGetImportResults();
const results = dataForStatus(resultsData, 200)?.results ?? []; const results = resultsData?.results ?? [];
const columns: Column<ImportResult>[] = [ const columns: Column<ImportResult>[] = [
{ {
+1 -2
View File
@@ -3,14 +3,13 @@ import { useGetLogs } from '../generated/anthoLumeAPIV1';
import { Button, LoadingState, TextInput, IconInput } from '../components'; import { Button, LoadingState, TextInput, IconInput } from '../components';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon } from '../icons'; import { Search2Icon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300); const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {}); const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
const logs = dataForStatus(logsData, 200)?.logs ?? []; const logs = logsData?.logs ?? [];
const handleFilterSubmit = (e: SyntheticEvent) => { const handleFilterSubmit = (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
+2 -12
View File
@@ -3,7 +3,7 @@ import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { getErrorMessage, getResponseError } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { streamResponseToFile, backupFilename } from '../utils/download'; import { streamResponseToFile, backupFilename } from '../utils/download';
interface BackupTypes { interface BackupTypes {
@@ -65,23 +65,13 @@ export default function AdminPage() {
const toastId = showInfo('Restore started', 0); const toastId = showInfo('Restore started', 0);
try { try {
const response = await postAdminAction.mutateAsync({ await postAdminAction.mutateAsync({
data: { data: {
action: 'RESTORE', action: 'RESTORE',
restore_file: restoreFile, restore_file: restoreFile,
}, },
}); });
const message = getResponseError(response);
if (message) {
updateToast(toastId, {
type: 'error',
message: `Restore failed: ${message}`,
duration: 5000,
});
return;
}
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 }); updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
} catch (error) { } catch (error) {
updateToast(toastId, { updateToast(toastId, {
+1 -2
View File
@@ -8,7 +8,6 @@ import { AddIcon, DeleteIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDate } from '../utils/formatters'; import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
interface AddUserFormProps { interface AddUserFormProps {
onCreate: (_username: string, _password: string, _isAdmin: boolean) => void; onCreate: (_username: string, _password: string, _isAdmin: boolean) => void;
@@ -117,7 +116,7 @@ export default function AdminUsersPage() {
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [resetUserId, setResetUserId] = useState<string | null>(null); const [resetUserId, setResetUserId] = useState<string | null>(null);
const users = dataForStatus(usersData, 200)?.users ?? []; const users = usersData?.users ?? [];
const handleCreateUser = (username: string, password: string, isAdmin: boolean) => { const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
if (!username || !password) { if (!username || !password) {
+1 -2
View File
@@ -8,7 +8,6 @@ import {
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model'; import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
import { useToastMutation } from '../hooks/useMutationWithToast'; import { useToastMutation } from '../hooks/useMutationWithToast';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
@@ -122,7 +121,7 @@ export default function DocumentPage() {
return <LoadingState />; return <LoadingState />;
} }
const doc = dataForStatus(docData, 200)?.document; const doc = docData?.document;
if (!doc) { if (!doc) {
return <div className="text-content-muted">Document not found</div>; return <div className="text-content-muted">Document not found</div>;
+1 -2
View File
@@ -11,7 +11,6 @@ import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { usePaginatedList } from '../hooks/usePaginatedList'; import { usePaginatedList } from '../hooks/usePaginatedList';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
import { dataForStatus } from '../utils/apiResponses';
const DOCUMENTS_PAGE_SIZE = 9; const DOCUMENTS_PAGE_SIZE = 9;
@@ -117,7 +116,7 @@ export default function DocumentsPage() {
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch }); const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument(); const createMutation = useCreateDocument();
const documentsResponse = dataForStatus(data, 200); const documentsResponse = data;
const docs = documentsResponse?.documents; const docs = documentsResponse?.documents;
const previousPage = documentsResponse?.previous_page; const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page; const nextPage = documentsResponse?.next_page;
+1 -2
View File
@@ -5,7 +5,6 @@ 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 ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters'; import { formatNumber, formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
interface InfoCardProps { interface InfoCardProps {
title: string; title: string;
@@ -156,7 +155,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() { export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome(); const { data: homeData, isLoading: homeLoading } = useGetHome();
const homeResponse = dataForStatus(homeData, 200); const homeResponse = homeData;
const dbInfo = homeResponse?.database_info; const dbInfo = homeResponse?.database_info;
const streaks = homeResponse?.streaks?.streaks; const streaks = homeResponse?.streaks?.streaks;
const graphData = homeResponse?.graph_data?.graph_data; const graphData = homeResponse?.graph_data?.graph_data;
-9
View File
@@ -56,12 +56,9 @@ describe('LoginPage', () => {
}); });
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: false, registration_enabled: false,
}, },
},
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
}); });
@@ -154,12 +151,9 @@ describe('LoginPage', () => {
it('shows the registration link only when registration is enabled', () => { it('shows the registration link only when registration is enabled', () => {
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: true, registration_enabled: true,
}, },
},
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
const { rerender } = render( const { rerender } = render(
@@ -171,12 +165,9 @@ describe('LoginPage', () => {
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: false, registration_enabled: false,
}, },
},
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
rerender( rerender(
+1 -2
View File
@@ -5,7 +5,6 @@ import { Table, type Column } from '../components/Table';
import { documentColumn } from '../components/documentColumn'; import { documentColumn } from '../components/documentColumn';
import { usePaginatedList } from '../hooks/usePaginatedList'; import { usePaginatedList } from '../hooks/usePaginatedList';
import { formatDate } from '../utils/formatters'; import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const PROGRESS_PAGE_SIZE = 15; const PROGRESS_PAGE_SIZE = 15;
@@ -13,7 +12,7 @@ export default function ProgressPage() {
const { page, setPage } = usePaginatedList(); const { page, setPage } = usePaginatedList();
const limit = PROGRESS_PAGE_SIZE; const limit = PROGRESS_PAGE_SIZE;
const { data, isLoading } = useGetProgressList({ page, limit }); const { data, isLoading } = useGetProgressList({ page, limit });
const response = dataForStatus(data, 200); const response = data;
const progress = response?.progress ?? []; const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [ const columns: Column<Progress>[] = [
+2 -3
View File
@@ -13,7 +13,6 @@ import {
useLocalSetting, useLocalSetting,
} from '../utils/localSettings'; } from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader'; import { useEpubReader } from '../hooks/useEpubReader';
import { dataForStatus } from '../utils/apiResponses';
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm'; 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_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
@@ -40,8 +39,8 @@ export default function ReaderPage() {
enabled: Boolean(id), enabled: Boolean(id),
}, },
}); });
const doc = dataForStatus(documentResponse, 200)?.document; const doc = documentResponse?.document;
const progress = dataForStatus(progressResponse, 200)?.progress; const progress = progressResponse?.progress;
const deviceId = defaultDeviceId; const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName; const deviceName = defaultDeviceName;
-9
View File
@@ -56,12 +56,9 @@ describe('RegisterPage', () => {
}); });
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: true, registration_enabled: true,
}, },
},
isLoading: false, isLoading: false,
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
}); });
@@ -155,12 +152,9 @@ describe('RegisterPage', () => {
it('redirects to login when registration is disabled', async () => { it('redirects to login when registration is disabled', async () => {
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: false, registration_enabled: false,
}, },
},
isLoading: false, isLoading: false,
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
@@ -177,12 +171,9 @@ describe('RegisterPage', () => {
it('disables the form when registration is disabled', () => { it('disables the form when registration is disabled', () => {
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: { data: {
registration_enabled: false, registration_enabled: false,
}, },
},
isLoading: false, isLoading: false,
} as ReturnType<typeof useGetInfo>); } as ReturnType<typeof useGetInfo>);
-6
View File
@@ -14,12 +14,9 @@ describe('SearchPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedUseGetSearch.mockReturnValue({ mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: { data: {
results: [], results: [],
}, },
},
isLoading: false, isLoading: false,
} as ReturnType<typeof useGetSearch>); } as ReturnType<typeof useGetSearch>);
}); });
@@ -66,8 +63,6 @@ describe('SearchPage', () => {
it('renders search results from the generated hook response', () => { it('renders search results from the generated hook response', () => {
mockedUseGetSearch.mockReturnValue({ mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: { data: {
results: [ results: [
{ {
@@ -80,7 +75,6 @@ describe('SearchPage', () => {
}, },
], ],
}, },
},
isLoading: false, isLoading: false,
} as ReturnType<typeof useGetSearch>); } as ReturnType<typeof useGetSearch>);
+1 -2
View File
@@ -6,7 +6,6 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
import { inputClassName } from '../components/TextInput'; import { inputClassName } from '../components/TextInput';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, BookIcon } from '../icons'; import { Search2Icon, BookIcon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
const searchColumns: Column<SearchItem>[] = [ const searchColumns: Column<SearchItem>[] = [
{ {
@@ -87,7 +86,7 @@ export default function SearchPage() {
}, },
} }
); );
const results = dataForStatus(data, 200)?.results ?? []; const results = data?.results ?? [];
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
+1 -2
View File
@@ -9,7 +9,6 @@ import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWith
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import type { ThemeMode } from '../utils/localSettings';
import { formatDateTime } from '../utils/formatters'; import { formatDateTime } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const deviceColumns: Column<Device>[] = [ const deviceColumns: Column<Device>[] = [
{ {
@@ -192,7 +191,7 @@ function DevicesSection({ devices }: { devices: Device[] }) {
export default function SettingsPage() { export default function SettingsPage() {
const { data, isLoading } = useGetSettings(); const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings(); const updateSettings = useUpdateSettings();
const settingsData = dataForStatus(data, 200); const settingsData = data;
const { showError } = useToasts(); const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast(); const toastMutationOptions = useMutationWithToast();
const runWithToast = useToastMutation(); const runWithToast = useToastMutation();
+55
View File
@@ -0,0 +1,55 @@
import type { ErrorResponse } from '../generated/model';
// Thrown by the generated API client for any non-2xx response. `body` is the parsed error payload
// (the API's ErrorResponse for documented failures); `message` is the server-provided message when
// present, so `error.message` is always display-ready.
export class ApiError<TBody = ErrorResponse> extends Error {
readonly status: number;
readonly body: TBody;
constructor(status: number, body: TBody, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
const EMPTY_BODY_STATUSES = new Set([204, 205, 304]);
function messageFromBody(body: unknown, fallback: string): string {
if (body && typeof body === 'object' && 'message' in body) {
const { message } = body as { message?: unknown };
if (typeof message === 'string' && message.trim() !== '') {
return message;
}
}
return fallback;
}
async function parseBody(response: Response): Promise<unknown> {
if (EMPTY_BODY_STATUSES.has(response.status)) {
return undefined;
}
const raw = await response.text();
if (!raw) {
return undefined;
}
const contentType = response.headers.get('content-type') ?? '';
return contentType.includes('application/json') ? JSON.parse(raw) : raw;
}
// Orval mutator - The generated client calls this for every request. Returns the parsed success
// body directly and throws ApiError on non-2xx, so React Query surfaces failures via isError/onError.
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
const body = await parseBody(response);
if (!response.ok) {
throw new ApiError(response.status, body, messageFromBody(body, response.statusText || 'Request failed'));
}
return body as T;
}
-13
View File
@@ -1,13 +0,0 @@
type ApiResponse = {
status: number;
data: unknown;
};
export function dataForStatus<TResponse extends ApiResponse, TStatus extends TResponse['status']>(
response: TResponse | undefined,
status: TStatus
): Extract<TResponse, { status: TStatus }>['data'] | undefined {
return response?.status === status
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
: undefined;
}
+3 -17
View File
@@ -1,3 +1,6 @@
// Extracts a display message from a caught error. The generated client throws ApiError (an Error
// subclass whose `message` is the server-provided message), so this covers both API and unexpected
// failures in a single catch path.
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string { export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
return error.message; return error.message;
@@ -12,20 +15,3 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
return fallback; return fallback;
} }
export interface ApiResponseLike {
status: number;
data: unknown;
}
/**
* Non-2xx Check - The generated client resolves non-2xx instead of throwing; this returns the
* extracted error message for failure responses, or null for success (2xx) so callers can branch.
*/
export function getResponseError(response: ApiResponseLike): string | null {
if (response.status >= 200 && response.status < 300) {
return null;
}
return getErrorMessage(response.data, 'Request failed');
}