+5
-5
@@ -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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -43,10 +43,10 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
### Important behavior
|
||||
|
||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||
- Do not assume non-2xx responses throw.
|
||||
- Trust the generated discriminated-union response types and narrow on `status`; avoid hand-rolled re-validation of fields the schema already guarantees.
|
||||
- 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.
|
||||
- The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
|
||||
- Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
|
||||
- Use `getErrorMessage` (`src/utils/errors.ts`) to display caught errors; `ApiError.message` already contains the server-provided error message when present.
|
||||
- Centralize mutation error/success feedback via `useMutationWithToast` or `useToastMutation` rather than inline toast/catch duplication.
|
||||
|
||||
## 4) Auth / Query State
|
||||
|
||||
|
||||
@@ -8,10 +8,16 @@ export default defineConfig({
|
||||
target: 'src/generated',
|
||||
schemas: 'src/generated/model',
|
||||
client: 'react-query',
|
||||
httpClient: 'fetch',
|
||||
mock: false,
|
||||
override: {
|
||||
useQuery: true,
|
||||
mutations: true,
|
||||
fetch: {
|
||||
includeHttpResponseReturnType: false,
|
||||
},
|
||||
mutator: {
|
||||
path: './src/utils/apiFetch.ts',
|
||||
name: 'apiFetch',
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
type AuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
authUserFromMutation,
|
||||
} from './authHelpers';
|
||||
import { getResponseError } from '../utils/errors';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
@@ -50,19 +48,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await loginMutation.mutateAsync({
|
||||
const user = await loginMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = authUserFromMutation(response);
|
||||
if (!user) {
|
||||
throw new Error(getResponseError(response) ?? 'Login failed');
|
||||
}
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), response);
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
@@ -76,19 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
const user = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = authUserFromMutation(response);
|
||||
if (!user) {
|
||||
throw new Error(getResponseError(response) ?? 'Registration failed');
|
||||
}
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), response);
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
authUserFromMutation,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
@@ -31,11 +30,7 @@ describe('authHelpers', () => {
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
headers: new Headers(),
|
||||
},
|
||||
meData: { username: 'evan', is_admin: false },
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
@@ -47,20 +42,7 @@ describe('authHelpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
data: { code: 401, message: 'unauthorized' },
|
||||
headers: new Headers(),
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
@@ -108,32 +90,4 @@ describe('authHelpers', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { getMeResponse, loginResponse, registerResponse } from '../generated/anthoLumeAPIV1';
|
||||
import type { LoginResponse } from '../generated/model';
|
||||
|
||||
export type AuthUser = LoginResponse;
|
||||
@@ -34,7 +33,7 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
}
|
||||
|
||||
export function resolveAuthStateFromMe(params: {
|
||||
meData?: getMeResponse;
|
||||
meData?: LoginResponse;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
@@ -45,11 +44,11 @@ export function resolveAuthStateFromMe(params: {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
return getAuthenticatedAuthState(meData.data);
|
||||
if (meData) {
|
||||
return getAuthenticatedAuthState(meData);
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
if (meError) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
@@ -58,7 +57,3 @@ export function resolveAuthStateFromMe(params: {
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function authUserFromMutation(response: loginResponse | registerResponse): AuthUser | null {
|
||||
return response.status === 200 || response.status === 201 ? response.data : null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SettingsIcon, GitIcon } from '../icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { navItems, adminNavItems } from './navigation';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
function hasPrefix(path: string, prefix: string): boolean {
|
||||
@@ -47,7 +46,7 @@ export default function HamburgerMenu() {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
const version = dataForStatus(infoData, 200)?.version ?? 'v1.0.0';
|
||||
const version = infoData?.version ?? 'v1.0.0';
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
export interface UseAuthFormResult {
|
||||
username: string;
|
||||
@@ -29,7 +28,7 @@ export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: { staleTime: Infinity },
|
||||
});
|
||||
const registrationEnabled = dataForStatus(infoData, 200)?.registration_enabled ?? false;
|
||||
const registrationEnabled = infoData?.registration_enabled ?? false;
|
||||
|
||||
const submit = useCallback(
|
||||
async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { UpdateProgressRequest } from '../generated/model/updateProgressReq
|
||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface UseEpubReaderOptions {
|
||||
documentId: string;
|
||||
@@ -96,11 +96,7 @@ export function useEpubReader({
|
||||
// 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).
|
||||
try {
|
||||
const response = await updateProgress(payload);
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`Failed to save progress: ${message}`);
|
||||
}
|
||||
await updateProgress(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -108,11 +104,7 @@ export function useEpubReader({
|
||||
|
||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||
try {
|
||||
const response = await createActivity(payload);
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`Failed to save activity: ${message}`);
|
||||
}
|
||||
await createActivity(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save activity: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface ToastMutationOptions {
|
||||
success: string;
|
||||
@@ -9,19 +9,15 @@ interface ToastMutationOptions {
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||
return {
|
||||
onSuccess: (response: ApiResponseLike) => {
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`${error}: ${message}`);
|
||||
return;
|
||||
}
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
showInfo(success);
|
||||
},
|
||||
@@ -33,29 +29,24 @@ export function useMutationWithToast() {
|
||||
interface RunToastMutationOptions<T> {
|
||||
error: 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
|
||||
* editor open on failure). Runs the action, treats non-2xx as failure, toasts accordingly, and
|
||||
* resolves to `true` only on success.
|
||||
* editor open on failure). Runs the action, toasts on success/failure, and resolves to `true` only
|
||||
* when the action succeeds.
|
||||
*/
|
||||
export function useToastMutation() {
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
return async function runWithToast<T extends ApiResponseLike>(
|
||||
return async function runWithToast<T>(
|
||||
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);
|
||||
const result = await action();
|
||||
onSuccess?.(result);
|
||||
if (success) {
|
||||
showInfo(success);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||
import App from './App';
|
||||
import { ApiError } from './utils/apiFetch';
|
||||
import './index.css';
|
||||
|
||||
initializeThemeMode();
|
||||
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
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: {
|
||||
retry: 0,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Table, type Column } from '../components/Table';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDuration, formatDateTime } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 25;
|
||||
|
||||
@@ -22,7 +21,7 @@ export default function ActivityPage() {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
const response = dataForStatus(data, 200);
|
||||
const response = data;
|
||||
const activities = response?.activities ?? [];
|
||||
|
||||
const columns: Column<Activity>[] = [
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { DirectoryItem } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
@@ -21,7 +20,7 @@ export default function AdminImportPage() {
|
||||
|
||||
const postImport = usePostImport();
|
||||
|
||||
const directoryResponse = dataForStatus(directoryData, 200);
|
||||
const directoryResponse = directoryData;
|
||||
const directories = directoryResponse?.items ?? [];
|
||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||
import { Table, type Column } from '../components';
|
||||
import type { ImportResult } from '../generated/model';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
export default function AdminImportResultsPage() {
|
||||
const { data: resultsData, isLoading } = useGetImportResults();
|
||||
const results = dataForStatus(resultsData, 200)?.results ?? [];
|
||||
const results = resultsData?.results ?? [];
|
||||
|
||||
const columns: Column<ImportResult>[] = [
|
||||
{
|
||||
|
||||
@@ -3,14 +3,13 @@ import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import { Button, LoadingState, TextInput, IconInput } from '../components';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon } from '../icons';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
|
||||
|
||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||
|
||||
const logs = dataForStatus(logsData, 200)?.logs ?? [];
|
||||
const logs = logsData?.logs ?? [];
|
||||
|
||||
const handleFilterSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||
|
||||
interface BackupTypes {
|
||||
@@ -65,23 +65,13 @@ export default function AdminPage() {
|
||||
const toastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
const response = await postAdminAction.mutateAsync({
|
||||
await postAdminAction.mutateAsync({
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
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 });
|
||||
} catch (error) {
|
||||
updateToast(toastId, {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
interface AddUserFormProps {
|
||||
onCreate: (_username: string, _password: string, _isAdmin: boolean) => void;
|
||||
@@ -117,7 +116,7 @@ export default function AdminUsersPage() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
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) => {
|
||||
if (!username || !password) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import type { EditDocumentBody } from '../generated/model';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
import { useToastMutation } from '../hooks/useMutationWithToast';
|
||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||
@@ -122,7 +121,7 @@ export default function DocumentPage() {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const doc = dataForStatus(docData, 200)?.document;
|
||||
const doc = docData?.document;
|
||||
|
||||
if (!doc) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { cn } from '../utils/cn';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const DOCUMENTS_PAGE_SIZE = 9;
|
||||
|
||||
@@ -117,7 +116,7 @@ export default function DocumentsPage() {
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||
const createMutation = useCreateDocument();
|
||||
const documentsResponse = dataForStatus(data, 200);
|
||||
const documentsResponse = data;
|
||||
const docs = documentsResponse?.documents;
|
||||
const previousPage = documentsResponse?.previous_page;
|
||||
const nextPage = documentsResponse?.next_page;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
|
||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string;
|
||||
@@ -156,7 +155,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
export default function HomePage() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
|
||||
const homeResponse = dataForStatus(homeData, 200);
|
||||
const homeResponse = homeData;
|
||||
const dbInfo = homeResponse?.database_info;
|
||||
const streaks = homeResponse?.streaks?.streaks;
|
||||
const graphData = homeResponse?.graph_data?.graph_data;
|
||||
|
||||
@@ -56,12 +56,9 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
|
||||
@@ -154,12 +151,9 @@ describe('LoginPage', () => {
|
||||
|
||||
it('shows the registration link only when registration is enabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
const { rerender } = render(
|
||||
@@ -171,12 +165,9 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
rerender(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Table, type Column } from '../components/Table';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const PROGRESS_PAGE_SIZE = 15;
|
||||
|
||||
@@ -13,7 +12,7 @@ export default function ProgressPage() {
|
||||
const { page, setPage } = usePaginatedList();
|
||||
const limit = PROGRESS_PAGE_SIZE;
|
||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||
const response = dataForStatus(data, 200);
|
||||
const response = data;
|
||||
const progress = response?.progress ?? [];
|
||||
|
||||
const columns: Column<Progress>[] = [
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useLocalSetting,
|
||||
} from '../utils/localSettings';
|
||||
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_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
|
||||
@@ -40,8 +39,8 @@ export default function ReaderPage() {
|
||||
enabled: Boolean(id),
|
||||
},
|
||||
});
|
||||
const doc = dataForStatus(documentResponse, 200)?.document;
|
||||
const progress = dataForStatus(progressResponse, 200)?.progress;
|
||||
const doc = documentResponse?.document;
|
||||
const progress = progressResponse?.progress;
|
||||
|
||||
const deviceId = defaultDeviceId;
|
||||
const deviceName = defaultDeviceName;
|
||||
|
||||
@@ -56,12 +56,9 @@ describe('RegisterPage', () => {
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
@@ -155,12 +152,9 @@ describe('RegisterPage', () => {
|
||||
|
||||
it('redirects to login when registration is disabled', async () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
@@ -177,12 +171,9 @@ describe('RegisterPage', () => {
|
||||
|
||||
it('disables the form when registration is disabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
|
||||
@@ -14,12 +14,9 @@ describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
});
|
||||
@@ -66,8 +63,6 @@ describe('SearchPage', () => {
|
||||
|
||||
it('renders search results from the generated hook response', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
@@ -80,7 +75,6 @@ describe('SearchPage', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
|
||||
import { inputClassName } from '../components/TextInput';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon, BookIcon } from '../icons';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
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>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWith
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const deviceColumns: Column<Device>[] = [
|
||||
{
|
||||
@@ -192,7 +191,7 @@ function DevicesSection({ devices }: { devices: Device[] }) {
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = dataForStatus(data, 200);
|
||||
const settingsData = data;
|
||||
const { showError } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
const runWithToast = useToastMutation();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
@@ -12,20 +15,3 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user