+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.
|
- 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,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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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>) => {
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
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');
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user