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

This commit is contained in:
2026-07-03 16:49:50 -04:00
parent c327a3905d
commit 6231befaa8
18 changed files with 179 additions and 281 deletions
-2
View File
@@ -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
View File
@@ -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"
+68
View File
@@ -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();
});
});
+17
View File
@@ -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;
}
-11
View File
@@ -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();
});
});
-3
View File
@@ -1,3 +0,0 @@
export function setupAuthInterceptors() {
return () => {};
}
+1 -1
View File
@@ -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`;
+3 -16
View File
@@ -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)' }}
+1 -18
View File
@@ -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]
);
}
+3 -5
View File
@@ -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>
+30 -41
View File
@@ -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>
);
+3 -3
View File
@@ -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>
+14 -14
View File
@@ -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 = () => {
-5
View File
@@ -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;
+3 -5
View File
@@ -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>
+6 -10
View File
@@ -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>
+2 -31
View File
@@ -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');
});
});
+4 -17
View File
@@ -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;
}
}