@@ -17,8 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"epubjs": "^0.3.93",
|
||||
"nosleep.js": "^0.12.0",
|
||||
|
||||
+24
-99
@@ -1,4 +1,4 @@
|
||||
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||
import { Outlet, Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import DocumentsPage from './pages/DocumentsPage';
|
||||
@@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminLogsPage from './pages/AdminLogsPage';
|
||||
import ReaderPage from './pages/ReaderPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { AdminRoute } from './auth/AdminRoute';
|
||||
|
||||
export function Routes() {
|
||||
return (
|
||||
<ReactRoutes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="documents"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="documents/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="progress"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProgressPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="activity"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="search"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SearchPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/import"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminImportPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/import-results"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminImportResultsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminUsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/logs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="documents" element={<DocumentsPage />} />
|
||||
<Route path="documents/:id" element={<DocumentPage />} />
|
||||
<Route path="progress" element={<ProgressPage />} />
|
||||
<Route path="activity" element={<ActivityPage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="import" element={<AdminImportPage />} />
|
||||
<Route path="import-results" element={<AdminImportResultsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route
|
||||
path="/reader/:id"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AdminRoute } from './AdminRoute';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
|
||||
describe('AdminRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children for admin users', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminRoute>
|
||||
<div>Admin Panel</div>
|
||||
</AdminRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to the home page', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<div>Admin Panel</div>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<div>Home Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Role Guard - Runs behind ProtectedRoute, so auth is already resolved and `user` is populated by the time this renders.
|
||||
export function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { setupAuthInterceptors } from './authInterceptor';
|
||||
|
||||
describe('setupAuthInterceptors', () => {
|
||||
it('is a no-op when auth is handled by HttpOnly cookies', () => {
|
||||
const cleanup = setupAuthInterceptors();
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
expect(() => cleanup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function setupAuthInterceptors() {
|
||||
return () => {};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||
const baseClass =
|
||||
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
|
||||
'inline-flex items-center justify-center px-4 py-2 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
@@ -12,12 +11,8 @@ const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
const { themeMode, setThemeMode } = useTheme();
|
||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||
const fetchedUser =
|
||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||
const userData = user ?? fetchedUser;
|
||||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -45,14 +40,6 @@ export default function Layout() {
|
||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||
}, [currentPageTitle]);
|
||||
|
||||
if (isCheckingAuth) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex h-16 w-full items-center justify-between">
|
||||
@@ -135,7 +122,7 @@ export default function Layout() {
|
||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
||||
>
|
||||
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
||||
<span>{user?.username ?? 'User'}</span>
|
||||
<span
|
||||
className="text-content transition-transform duration-200"
|
||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
|
||||
@@ -29,11 +29,6 @@ interface UseEpubReaderResult {
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
goToHref: (href: string) => Promise<void>;
|
||||
setTheme: (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useEpubReader({
|
||||
@@ -210,17 +205,6 @@ export function useEpubReader({
|
||||
await readerRef.current?.displayHref(href);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
async (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => {
|
||||
await readerRef.current?.applyThemeChange(theme);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewerRef: setViewerNode,
|
||||
@@ -232,8 +216,7 @@ export function useEpubReader({
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToHref,
|
||||
setTheme,
|
||||
}),
|
||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
|
||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,11 +35,9 @@ export default function AdminLogsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Filter
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LoadingState } from '../components';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError, removeToast } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
covers: false,
|
||||
@@ -61,6 +63,7 @@ export default function AdminPage() {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator.
|
||||
const startedToastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
@@ -88,20 +91,20 @@ export default function AdminPage() {
|
||||
const handleMetadataMatch = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'METADATA_MATCH' } },
|
||||
{
|
||||
onSuccess: () => showInfo('Metadata matching started'),
|
||||
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Metadata matching started',
|
||||
error: 'Failed to start metadata matching',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCacheTables = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'CACHE_TABLES' } },
|
||||
{
|
||||
onSuccess: () => showInfo('Cache tables started'),
|
||||
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Cache tables started',
|
||||
error: 'Failed to start cache tables',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,8 +138,8 @@ export default function AdminPage() {
|
||||
<label htmlFor="backup_documents">Documents</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit">
|
||||
<div className="w-40">
|
||||
<Button variant="secondary" type="submit" className="w-full">
|
||||
Backup
|
||||
</Button>
|
||||
</div>
|
||||
@@ -151,8 +154,8 @@ export default function AdminPage() {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
||||
<div className="w-40">
|
||||
<Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
@@ -161,35 +164,21 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Tasks</p>
|
||||
<table className="min-w-full bg-surface text-sm text-content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="pl-0">
|
||||
<p>Metadata Matching</p>
|
||||
</td>
|
||||
<td className="float-right py-2">
|
||||
<div className="h-10 w-40 text-base">
|
||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Cache Tables</p>
|
||||
</td>
|
||||
<td className="float-right py-2">
|
||||
<div className="h-10 w-40 text-base">
|
||||
<Button variant="secondary" onClick={handleCacheTables}>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mb-4 text-lg font-semibold text-content">Tasks</p>
|
||||
<ul className="flex flex-col gap-3 text-sm text-content">
|
||||
<li className="flex items-center justify-between gap-4">
|
||||
<p>Metadata Matching</p>
|
||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4">
|
||||
<p>Cache Tables</p>
|
||||
<Button variant="secondary" onClick={handleCacheTables}>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-conten
|
||||
const popupClassName =
|
||||
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||
const editInputClassName =
|
||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
||||
'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600';
|
||||
|
||||
interface EditableFieldProps {
|
||||
label: string;
|
||||
@@ -92,7 +92,7 @@ function EditableField({
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||
className="h-32 w-full grow rounded border border-border bg-surface-muted p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
||||
rows={5}
|
||||
/>
|
||||
) : (
|
||||
@@ -162,7 +162,7 @@ export default function DocumentPage() {
|
||||
{document.filepath && (
|
||||
<a
|
||||
href={`/reader/${document.id}`}
|
||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
|
||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
||||
>
|
||||
Read
|
||||
</a>
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { Document } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { LoadingState, Pagination, TextInput } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import {
|
||||
getDocumentsViewMode,
|
||||
setDocumentsViewMode,
|
||||
@@ -121,7 +121,8 @@ export default function DocumentsPage() {
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showInfo, showWarning, showError } = useToasts();
|
||||
const { showWarning } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
useEffect(() => {
|
||||
setDocumentsViewMode(viewMode);
|
||||
@@ -138,7 +139,7 @@ export default function DocumentsPage() {
|
||||
const previousPage = documentsResponse?.previous_page;
|
||||
const nextPage = documentsResponse?.next_page;
|
||||
|
||||
const uploadDocument = async (file: File | undefined) => {
|
||||
const uploadDocument = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.epub')) {
|
||||
@@ -146,18 +147,17 @@ export default function DocumentsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
data: {
|
||||
document_file: file,
|
||||
createMutation.mutate(
|
||||
{ data: { document_file: file } },
|
||||
toastMutationOptions({
|
||||
success: 'Document uploaded successfully!',
|
||||
error: 'Failed to upload document',
|
||||
onSuccess: () => {
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
showInfo('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
|
||||
@@ -89,11 +89,6 @@ export default function ReaderPage() {
|
||||
}
|
||||
}, [document?.title]);
|
||||
|
||||
const { setTheme } = reader;
|
||||
useEffect(() => {
|
||||
setTheme({ colorScheme, fontFamily, fontSize });
|
||||
}, [colorScheme, fontFamily, fontSize, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopBarOpen || isBottomBarOpen) {
|
||||
return;
|
||||
|
||||
@@ -102,11 +102,9 @@ export function SearchPageView({
|
||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -124,11 +124,9 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -195,11 +193,9 @@ export default function SettingsPage() {
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,25 +6,8 @@ describe('getErrorMessage', () => {
|
||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||
});
|
||||
|
||||
it('prefers response.data.message over top-level message', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
response: {
|
||||
data: {
|
||||
message: 'Response message',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe('Response message');
|
||||
});
|
||||
|
||||
it('falls back to top-level message when response.data.message is unavailable', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
})
|
||||
).toBe('Top-level message');
|
||||
it('reads the top-level message from API error bodies', () => {
|
||||
expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('uses the fallback for null, empty, and unknown values', () => {
|
||||
@@ -32,17 +15,5 @@ describe('getErrorMessage', () => {
|
||||
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
|
||||
expect(
|
||||
getErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback message'
|
||||
)
|
||||
).toBe('Fallback message');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,23 +3,10 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const errorWithResponse = error as {
|
||||
message?: unknown;
|
||||
response?: {
|
||||
data?: {
|
||||
message?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const responseMessage = errorWithResponse.response?.data?.message;
|
||||
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
|
||||
return errorWithResponse.message;
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const { message } = error as { message?: unknown };
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user