@@ -17,8 +17,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.16",
|
"@tanstack/react-query": "^5.62.16",
|
||||||
"ajv": "^8.18.0",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
|
|||||||
+19
-94
@@ -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 Layout from './components/Layout';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import DocumentsPage from './pages/DocumentsPage';
|
import DocumentsPage from './pages/DocumentsPage';
|
||||||
@@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
|||||||
import AdminLogsPage from './pages/AdminLogsPage';
|
import AdminLogsPage from './pages/AdminLogsPage';
|
||||||
import ReaderPage from './pages/ReaderPage';
|
import ReaderPage from './pages/ReaderPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
import { AdminRoute } from './auth/AdminRoute';
|
||||||
|
|
||||||
export function Routes() {
|
export function Routes() {
|
||||||
return (
|
return (
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route
|
<Route
|
||||||
index
|
path="/"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<HomePage />
|
<Layout />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Route
|
<Route index element={<HomePage />} />
|
||||||
path="documents"
|
<Route path="documents" element={<DocumentsPage />} />
|
||||||
element={
|
<Route path="documents/:id" element={<DocumentPage />} />
|
||||||
<ProtectedRoute>
|
<Route path="progress" element={<ProgressPage />} />
|
||||||
<DocumentsPage />
|
<Route path="activity" element={<ActivityPage />} />
|
||||||
</ProtectedRoute>
|
<Route path="search" element={<SearchPage />} />
|
||||||
}
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
/>
|
<Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}>
|
||||||
<Route
|
<Route index element={<AdminPage />} />
|
||||||
path="documents/:id"
|
<Route path="import" element={<AdminImportPage />} />
|
||||||
element={
|
<Route path="import-results" element={<AdminImportResultsPage />} />
|
||||||
<ProtectedRoute>
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<DocumentPage />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
</ProtectedRoute>
|
</Route>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<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>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/reader/:id"
|
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 getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||||
const baseClass =
|
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') {
|
if (variant === 'secondary') {
|
||||||
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
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 { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { UserIcon, DropdownIcon } from '../icons';
|
import { UserIcon, DropdownIcon } from '../icons';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
@@ -12,12 +11,8 @@ const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { themeMode, setThemeMode } = useTheme();
|
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 [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -45,14 +40,6 @@ export default function Layout() {
|
|||||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||||
}, [currentPageTitle]);
|
}, [currentPageTitle]);
|
||||||
|
|
||||||
if (isCheckingAuth) {
|
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-screen bg-canvas">
|
||||||
<div className="flex h-16 w-full items-center justify-between">
|
<div className="flex h-16 w-full items-center justify-between">
|
||||||
@@ -135,7 +122,7 @@ export default function Layout() {
|
|||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
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
|
<span
|
||||||
className="text-content transition-transform duration-200"
|
className="text-content transition-transform duration-200"
|
||||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ interface UseEpubReaderResult {
|
|||||||
nextPage: () => Promise<void>;
|
nextPage: () => Promise<void>;
|
||||||
prevPage: () => Promise<void>;
|
prevPage: () => Promise<void>;
|
||||||
goToHref: (href: string) => Promise<void>;
|
goToHref: (href: string) => Promise<void>;
|
||||||
setTheme: (theme: {
|
|
||||||
colorScheme?: ReaderColorScheme;
|
|
||||||
fontFamily?: ReaderFontFamily;
|
|
||||||
fontSize?: number;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEpubReader({
|
export function useEpubReader({
|
||||||
@@ -210,17 +205,6 @@ export function useEpubReader({
|
|||||||
await readerRef.current?.displayHref(href);
|
await readerRef.current?.displayHref(href);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTheme = useCallback(
|
|
||||||
async (theme: {
|
|
||||||
colorScheme?: ReaderColorScheme;
|
|
||||||
fontFamily?: ReaderFontFamily;
|
|
||||||
fontSize?: number;
|
|
||||||
}) => {
|
|
||||||
await readerRef.current?.applyThemeChange(theme);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
viewerRef: setViewerNode,
|
viewerRef: setViewerNode,
|
||||||
@@ -232,8 +216,7 @@ export function useEpubReader({
|
|||||||
nextPage,
|
nextPage,
|
||||||
prevPage,
|
prevPage,
|
||||||
goToHref,
|
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>
|
</div>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Filter
|
Filter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LoadingState } from '../components';
|
|||||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
import { useGetAdmin, 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 { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export default function AdminPage() {
|
|||||||
const { isLoading } = useGetAdmin();
|
const { isLoading } = useGetAdmin();
|
||||||
const postAdminAction = usePostAdminAction();
|
const postAdminAction = usePostAdminAction();
|
||||||
const { showInfo, showError, removeToast } = useToasts();
|
const { showInfo, showError, removeToast } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||||
covers: false,
|
covers: false,
|
||||||
@@ -61,6 +63,7 @@ export default function AdminPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!restoreFile) return;
|
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);
|
const startedToastId = showInfo('Restore started', 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -88,20 +91,20 @@ export default function AdminPage() {
|
|||||||
const handleMetadataMatch = () => {
|
const handleMetadataMatch = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
{ data: { action: 'METADATA_MATCH' } },
|
{ data: { action: 'METADATA_MATCH' } },
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: () => showInfo('Metadata matching started'),
|
success: 'Metadata matching started',
|
||||||
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
error: 'Failed to start metadata matching',
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCacheTables = () => {
|
const handleCacheTables = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
{ data: { action: 'CACHE_TABLES' } },
|
{ data: { action: 'CACHE_TABLES' } },
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: () => showInfo('Cache tables started'),
|
success: 'Cache tables started',
|
||||||
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
error: 'Failed to start cache tables',
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,8 +138,8 @@ export default function AdminPage() {
|
|||||||
<label htmlFor="backup_documents">Documents</label>
|
<label htmlFor="backup_documents">Documents</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 w-40">
|
<div className="w-40">
|
||||||
<Button variant="secondary" type="submit">
|
<Button variant="secondary" type="submit" className="w-full">
|
||||||
Backup
|
Backup
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,8 +154,8 @@ export default function AdminPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 w-40">
|
<div className="w-40">
|
||||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
<Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,35 +164,21 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="mb-4 text-lg font-semibold text-content">Tasks</p>
|
||||||
<table className="min-w-full bg-surface text-sm text-content">
|
<ul className="flex flex-col gap-3 text-sm text-content">
|
||||||
<tbody>
|
<li className="flex items-center justify-between gap-4">
|
||||||
<tr>
|
|
||||||
<td className="pl-0">
|
|
||||||
<p>Metadata Matching</p>
|
<p>Metadata Matching</p>
|
||||||
</td>
|
|
||||||
<td className="float-right py-2">
|
|
||||||
<div className="h-10 w-40 text-base">
|
|
||||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
</td>
|
<li className="flex items-center justify-between gap-4">
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<p>Cache Tables</p>
|
<p>Cache Tables</p>
|
||||||
</td>
|
|
||||||
<td className="float-right py-2">
|
|
||||||
<div className="h-10 w-40 text-base">
|
|
||||||
<Button variant="secondary" onClick={handleCacheTables}>
|
<Button variant="secondary" onClick={handleCacheTables}>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
</td>
|
</ul>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-conten
|
|||||||
const popupClassName =
|
const popupClassName =
|
||||||
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||||
const editInputClassName =
|
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 {
|
interface EditableFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -92,7 +92,7 @@ function EditableField({
|
|||||||
<textarea
|
<textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={e => setDraft(e.target.value)}
|
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}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -162,7 +162,7 @@ export default function DocumentPage() {
|
|||||||
{document.filepath && (
|
{document.filepath && (
|
||||||
<a
|
<a
|
||||||
href={`/reader/${document.id}`}
|
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
|
Read
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import type { Document } from '../generated/model';
|
|||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState, Pagination, TextInput } from '../components';
|
import { LoadingState, Pagination, TextInput } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
|
||||||
import {
|
import {
|
||||||
getDocumentsViewMode,
|
getDocumentsViewMode,
|
||||||
setDocumentsViewMode,
|
setDocumentsViewMode,
|
||||||
@@ -121,7 +121,8 @@ export default function DocumentsPage() {
|
|||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showInfo, showWarning, showError } = useToasts();
|
const { showWarning } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDocumentsViewMode(viewMode);
|
setDocumentsViewMode(viewMode);
|
||||||
@@ -138,7 +139,7 @@ export default function DocumentsPage() {
|
|||||||
const previousPage = documentsResponse?.previous_page;
|
const previousPage = documentsResponse?.previous_page;
|
||||||
const nextPage = documentsResponse?.next_page;
|
const nextPage = documentsResponse?.next_page;
|
||||||
|
|
||||||
const uploadDocument = async (file: File | undefined) => {
|
const uploadDocument = (file: File | undefined) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.name.endsWith('.epub')) {
|
if (!file.name.endsWith('.epub')) {
|
||||||
@@ -146,18 +147,17 @@ export default function DocumentsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
createMutation.mutate(
|
||||||
await createMutation.mutateAsync({
|
{ data: { document_file: file } },
|
||||||
data: {
|
toastMutationOptions({
|
||||||
document_file: file,
|
success: 'Document uploaded successfully!',
|
||||||
},
|
error: 'Failed to upload document',
|
||||||
});
|
onSuccess: () => {
|
||||||
showInfo('Document uploaded successfully!');
|
|
||||||
setUploadMode(false);
|
setUploadMode(false);
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
},
|
||||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
})
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelUpload = () => {
|
const handleCancelUpload = () => {
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ export default function ReaderPage() {
|
|||||||
}
|
}
|
||||||
}, [document?.title]);
|
}, [document?.title]);
|
||||||
|
|
||||||
const { setTheme } = reader;
|
|
||||||
useEffect(() => {
|
|
||||||
setTheme({ colorScheme, fontFamily, fontSize });
|
|
||||||
}, [colorScheme, fontFamily, fontSize, setTheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTopBarOpen || isBottomBarOpen) {
|
if (isTopBarOpen || isBottomBarOpen) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -102,11 +102,9 @@ export function SearchPageView({
|
|||||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,9 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,11 +193,9 @@ export default function SettingsPage() {
|
|||||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,8 @@ describe('getErrorMessage', () => {
|
|||||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers response.data.message over top-level message', () => {
|
it('reads the top-level message from API error bodies', () => {
|
||||||
expect(
|
expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized');
|
||||||
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('uses the fallback for null, empty, and unknown values', () => {
|
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(undefined, 'Fallback message')).toBe('Fallback message');
|
||||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||||
expect(getErrorMessage({ message: ' ' }, '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;
|
return error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === 'object' && error !== null) {
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
const errorWithResponse = error as {
|
const { message } = error as { message?: unknown };
|
||||||
message?: unknown;
|
if (typeof message === 'string' && message.trim() !== '') {
|
||||||
response?: {
|
return message;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user