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

This commit is contained in:
2026-07-03 16:23:43 -04:00
parent 45e018b024
commit c327a3905d
43 changed files with 1015 additions and 1757 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend clean tests
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend dev_noauth clean tests
DEV_ENV = GIN_MODE=release \
CONFIG_PATH=./data \
@@ -9,6 +9,8 @@ DEV_ENV = GIN_MODE=release \
COOKIE_AUTH_KEY=1234 \
LOG_LEVEL=debug
DEV_USER ?= evan
build_local: legacy_tailwind
go mod download
rm -r ./build || true
@@ -51,6 +53,9 @@ dev_backend:
dev_frontend:
cd frontend && pnpm run dev
dev_noauth:
DISABLE_AUTH=true DISABLE_AUTH_USER=$(DEV_USER) $(MAKE) dev
clean:
rm -rf ./build
+7 -1
View File
@@ -18,9 +18,15 @@ Also follow the repository root guide at `../AGENTS.md`.
- Use local icon components from `src/icons/`.
- Do not add external icon libraries.
- Prefer generated types from `src/generated/model/` over `any`.
- Unwrap API responses by narrowing on `status` (e.g. `data?.status === 200 ? data.data : undefined`); the generated response types are discriminated unions, so this needs no `as XResponse` cast.
- Type form submit handlers as `SyntheticEvent` (optionally `SyntheticEvent<HTMLFormElement>`); `@types/react@19` deprecates `FormEvent`.
- Route mutation success/error feedback through `useMutationWithToast` (`src/hooks/`); error-toast-on-failure is the app-wide pattern (treat non-2xx as failure).
- Shared input styling lives in `TextInput` / the exported `inputClassName` (`src/components/TextInput.tsx`); reuse rather than re-pasting the input class string.
- Render tabular data with the shared `<Table>` (`src/components/Table.tsx`). Columns are `{ id, header: ReactNode, render(row, index), className? }``id` is decoupled from data, so action columns are first-class. The table owns its own loading skeleton and empty state; don't hand-roll `<table>` markup for data grids. (Genuinely different widget shapes — e.g. a file-browser or a card-grid — are fine to build bespoke.)
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches.
- 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.
- Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`.
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
+1 -14
View File
@@ -1,4 +1,4 @@
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
interface BaseButtonProps {
variant?: 'default' | 'secondary';
@@ -7,7 +7,6 @@ interface BaseButtonProps {
}
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass =
@@ -31,15 +30,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = 'Button';
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
({ variant = 'default', children, className = '', ...props }, ref) => {
return (
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
{children}
</a>
);
}
);
ButtonLink.displayName = 'ButtonLink';
+1 -2
View File
@@ -3,10 +3,9 @@ import { ReactNode } from 'react';
interface FieldProps {
label: ReactNode;
children: ReactNode;
isEditing?: boolean;
}
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
export function Field({ label, children }: FieldProps) {
return (
<div className="relative rounded">
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
+3 -24
View File
@@ -1,30 +1,9 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
import { SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
interface NavItem {
path: string;
label: string;
icon: React.ElementType;
title: string;
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
];
const adminSubItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
];
import { navItems, adminNavItems } from './navigation';
function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix);
@@ -146,7 +125,7 @@ export default function HamburgerMenu() {
{hasPrefix(location.pathname, '/admin') && (
<div className="flex flex-col gap-4">
{adminSubItems.map(item => (
{adminNavItems.map(item => (
<Link
key={item.path}
to={item.path}
+2 -17
View File
@@ -6,6 +6,7 @@ import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu';
import { getPageTitle } from './navigation';
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
@@ -38,23 +39,7 @@ export default function Layout() {
};
}, []);
const navItems = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
{ path: '/admin/users', title: 'Admin - Users' },
{ path: '/admin/logs', title: 'Admin - Logs' },
{ path: '/admin', title: 'Admin - General' },
{ path: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
];
const currentPageTitle =
navItems.find(item =>
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
)?.title || 'Home';
const currentPageTitle = getPageTitle(location.pathname);
useEffect(() => {
document.title = `AnthoLume - ${currentPageTitle}`;
+1 -67
View File
@@ -94,45 +94,6 @@ import { Skeleton } from './components/Skeleton';
<Skeleton variant="rectangular" width="100%" height={200} />
```
#### `SkeletonText`
Multiple lines of text skeleton:
```tsx
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
```
#### `SkeletonAvatar`
Avatar placeholder:
```tsx
<SkeletonAvatar size="md" />
<SkeletonAvatar size={56} />
```
#### `SkeletonCard`
Card placeholder with optional elements:
```tsx
// Default card
<SkeletonCard />
// With avatar
<SkeletonCard showAvatar />
// Custom configuration
<SkeletonCard
showAvatar
showTitle
showText
textLines={4}
className="max-w-sm"
/>
```
#### `SkeletonTable`
Table placeholder:
@@ -142,33 +103,6 @@ Table placeholder:
<SkeletonTable rows={10} columns={6} showHeader={false} />
```
#### `SkeletonButton`
Button placeholder:
```tsx
<SkeletonButton width={120} />
<SkeletonButton className="w-full" />
```
#### `PageLoader`
Full-page loading indicator:
```tsx
<PageLoader message="Loading your documents..." />
```
#### `InlineLoader`
Small inline loading spinner:
```tsx
<InlineLoader size="sm" />
<InlineLoader size="md" />
<InlineLoader size="lg" />
```
## Integration with Table Component
The Table component now supports skeleton loading:
@@ -200,4 +134,4 @@ The theme is controlled via Tailwind's `dark:` classes, which respond to the sys
- `clsx` - Utility for constructing className strings
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
- `lucide-react` - Icon library used by Toast component
- Local icon components in `src/icons/`
@@ -1,4 +1,5 @@
import type { GraphDataPoint } from '../generated/model';
import { formatUtcDate } from '../utils/formatters';
interface ReadingHistoryGraphProps {
data: GraphDataPoint[];
@@ -147,14 +148,6 @@ export function getSVGGraphData(
};
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800;
const svgHeight = 70;
@@ -199,7 +192,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
left: '50%',
}}
>
<span>{formatDate(point.date)}</span>
<span>{formatUtcDate(point.date)}</span>
<span>{point.minutes_read} minutes</span>
</div>
</div>
-124
View File
@@ -44,75 +44,6 @@ export function Skeleton({
);
}
interface SkeletonTextProps {
lines?: number;
className?: string;
lineClassName?: string;
}
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
/>
))}
</div>
);
}
interface SkeletonAvatarProps {
size?: number | 'sm' | 'md' | 'lg';
className?: string;
}
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
const sizeMap = {
sm: 32,
md: 40,
lg: 56,
};
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
}
interface SkeletonCardProps {
className?: string;
showAvatar?: boolean;
showTitle?: boolean;
showText?: boolean;
textLines?: number;
}
export function SkeletonCard({
className = '',
showAvatar = false,
showTitle = true,
showText = true,
textLines = 3,
}: SkeletonCardProps) {
return (
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
{showAvatar && (
<div className="mb-4 flex items-start gap-4">
<SkeletonAvatar />
<div className="flex-1">
<Skeleton variant="text" className="mb-2 w-3/4" />
<Skeleton variant="text" className="w-1/2" />
</div>
</div>
)}
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
{showText && <SkeletonText lines={textLines} />}
</div>
);
}
interface SkeletonTableProps {
rows?: number;
columns?: number;
@@ -158,58 +89,3 @@ export function SkeletonTable({
</div>
);
}
interface SkeletonButtonProps {
className?: string;
width?: string | number;
}
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
return (
<Skeleton
variant="rectangular"
height={36}
width={width || '100%'}
className={cn('rounded', className)}
/>
);
}
interface PageLoaderProps {
message?: string;
className?: string;
}
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return (
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
<div className="relative">
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
</div>
<p className="text-sm font-medium text-content-muted">{message}</p>
</div>
);
}
interface InlineLoaderProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = {
sm: 'h-4 w-4 border-2',
md: 'h-6 w-6 border-[3px]',
lg: 'h-8 w-8 border-4',
};
return (
<div className={cn('flex items-center justify-center', className)}>
<div
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
/>
</div>
);
}
export { SkeletonTable as SkeletonTableExport };
+6 -4
View File
@@ -10,12 +10,14 @@ interface TestRow {
const columns: Column<TestRow>[] = [
{
key: 'name',
id: 'name',
header: 'Name',
render: row => row.name,
},
{
key: 'role',
id: 'role',
header: 'Role',
render: row => row.role,
},
];
@@ -41,9 +43,9 @@ describe('Table', () => {
it('uses a custom render function for column output', () => {
const customColumns: Column<TestRow>[] = [
{
key: 'name',
id: 'name',
header: 'Name',
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
render: (row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
},
];
+12 -55
View File
@@ -1,63 +1,22 @@
import React from 'react';
import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn';
import { ReactNode } from 'react';
import { SkeletonTable } from './Skeleton';
export interface Column<T extends object> {
key: keyof T;
header: string;
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
export interface Column<T> {
id: string;
header: ReactNode;
className?: string;
render: (row: T, index: number) => ReactNode;
}
export interface TableProps<T extends object> {
export interface TableProps<T> {
columns: Column<T>[];
data: T[];
loading?: boolean;
emptyMessage?: string;
emptyMessage?: ReactNode;
rowKey?: keyof T | ((row: T) => string);
}
function SkeletonTable({
rows = 5,
columns = 4,
className = '',
}: {
rows?: number;
columns?: number;
className?: string;
}) {
return (
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function Table<T extends object>({
export function Table<T>({
columns,
data,
loading = false,
@@ -86,7 +45,7 @@ export function Table<T extends object>({
<tr className="border-b border-border">
{columns.map(column => (
<th
key={String(column.key)}
key={column.id}
className={`p-3 text-left text-content-muted ${column.className || ''}`}
>
{column.header}
@@ -106,12 +65,10 @@ export function Table<T extends object>({
<tr key={getRowKey(row, index)} className="border-b border-border">
{columns.map(column => (
<td
key={`${getRowKey(row, index)}-${String(column.key)}`}
key={column.id}
className={`p-3 text-content ${column.className || ''}`}
>
{column.render
? column.render(row[column.key], row, index)
: (row[column.key] as React.ReactNode)}
{column.render(row, index)}
</td>
))}
</tr>
+15
View File
@@ -0,0 +1,15 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { cn } from '../utils/cn';
export const inputClassName =
'w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600';
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ className, ...props }, ref) => (
<input ref={ref} className={cn(inputClassName, className)} {...props} />
)
);
TextInput.displayName = 'TextInput';
+6 -12
View File
@@ -11,37 +11,31 @@ export interface ToastProps {
onClose?: (id: string) => void;
}
const getToastStyles = (_type: ToastType) => {
const baseStyles =
const baseStyles =
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
const typeStyles = {
const typeStyles: Record<ToastType, string> = {
info: 'border-secondary-500 bg-secondary-100',
warning: 'border-yellow-500 bg-yellow-100',
error: 'border-red-500 bg-red-100',
};
};
const iconStyles = {
const iconStyles: Record<ToastType, string> = {
info: 'text-secondary-700',
warning: 'text-yellow-700',
error: 'text-red-700',
};
};
const textStyles = {
const textStyles: Record<ToastType, string> = {
info: 'text-secondary-900',
warning: 'text-yellow-900',
error: 'text-red-900',
};
return { baseStyles, typeStyles, iconStyles, textStyles };
};
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
const handleClose = useCallback(() => {
setIsAnimatingOut(true);
setTimeout(() => {
+9 -12
View File
@@ -20,34 +20,31 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}, []);
const showToast = useCallback(
(message: string, _type: ToastType = 'info', _duration?: number): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
setToasts(prev => [
...prev,
{ id, type: _type, message, duration: _duration, onClose: removeToast },
]);
(message: string, type: ToastType = 'info', duration?: number): string => {
const id = crypto.randomUUID();
setToasts(prev => [...prev, { id, type, message, duration, onClose: removeToast }]);
return id;
},
[removeToast]
);
const showInfo = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'info', _duration);
(message: string, duration?: number) => {
return showToast(message, 'info', duration);
},
[showToast]
);
const showWarning = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'warning', _duration);
(message: string, duration?: number) => {
return showToast(message, 'warning', duration);
},
[showToast]
);
const showError = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'error', _duration);
(message: string, duration?: number) => {
return showToast(message, 'error', duration);
},
[showToast]
);
+2 -12
View File
@@ -2,23 +2,13 @@
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
// Toast components
export { Toast } from './Toast';
export { ToastProvider, useToasts } from './ToastContext';
export type { ToastType, ToastProps } from './Toast';
// Skeleton components
export {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader,
} from './Skeleton';
export { Skeleton, SkeletonTable } from './Skeleton';
export { LoadingState } from './LoadingState';
export { Pagination } from './Pagination';
export { TextInput } from './TextInput';
// Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
+46
View File
@@ -0,0 +1,46 @@
import type { ElementType } from 'react';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
export interface NavItem {
path: string;
label: string;
icon: ElementType;
}
export const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
{ path: '/progress', label: 'Progress', icon: ActivityIcon },
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
{ path: '/search', label: 'Search', icon: SearchIcon },
];
export const adminNavItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
];
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
const pageTitles: { path: string; title: string }[] = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
{ path: '/admin/users', title: 'Admin - Users' },
{ path: '/admin/logs', title: 'Admin - Logs' },
{ path: '/admin', title: 'Admin - General' },
{ path: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
];
export function getPageTitle(pathname: string): string {
return (
pageTitles.find(item =>
item.path === '/' ? pathname === item.path : pathname.startsWith(item.path)
)?.title ?? 'Home'
);
}
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
afterEach(() => {
vi.useRealTimers();
});
it('returns the initial value immediately', () => {
const { result } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'initial', delay: 300 },
});
expect(result.current).toBe('initial');
});
it('delays updates until the debounce interval has passed', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'initial', delay: 300 },
});
rerender({ value: 'updated', delay: 300 });
expect(result.current).toBe('initial');
act(() => {
vi.advanceTimersByTime(299);
});
expect(result.current).toBe('initial');
act(() => {
vi.advanceTimersByTime(1);
});
expect(result.current).toBe('updated');
});
it('cancels the previous timer when the value changes again', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'first', delay: 300 },
});
rerender({ value: 'second', delay: 300 });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'third', delay: 300 });
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('first');
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('third');
});
});
-23
View File
@@ -1,23 +0,0 @@
import { useState, useEffect } from 'react';
/**
* Debounces a value by delaying updates until after a specified delay
* @param value The value to debounce
* @param delay The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebouncedState } from './useDebouncedState';
describe('useDebouncedState', () => {
afterEach(() => {
vi.useRealTimers();
});
it('returns the initial value for both the input and the debounced value', () => {
const { result } = renderHook(() => useDebouncedState('initial', 300));
const [value, , debounced] = result.current;
expect(value).toBe('initial');
expect(debounced).toBe('initial');
});
it('updates the input immediately but delays the debounced value', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => {
const setValue = result.current[1];
setValue('updated');
});
expect(result.current[0]).toBe('updated');
expect(result.current[2]).toBe('initial');
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current[2]).toBe('updated');
});
it('cancels the previous timer when the value changes again', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('first', 300));
act(() => result.current[1]('second'));
act(() => vi.advanceTimersByTime(200));
act(() => result.current[1]('third'));
act(() => vi.advanceTimersByTime(100));
expect(result.current[2]).toBe('first');
act(() => vi.advanceTimersByTime(200));
expect(result.current[2]).toBe('third');
});
it('flush resolves the debounced value immediately, skipping the debounce window', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => result.current[1]('updated'));
expect(result.current[2]).toBe('initial');
act(() => {
const flush = result.current[3];
flush();
});
expect(result.current[2]).toBe('updated');
});
it('flush with an argument sets both the input and the resolved value', () => {
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => {
const flush = result.current[3];
flush('forced');
});
expect(result.current[0]).toBe('forced');
expect(result.current[2]).toBe('forced');
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useState, useEffect, useCallback } from 'react';
const DEFAULT_DELAY = 300;
/**
* Owns a search/filter input and a debounced view of it. The fourth value, `flush`,
* resolves the debounced value immediately (e.g. on an explicit submit button)
* without waiting for the debounce window to elapse.
*/
export function useDebouncedState<T>(initialValue: T, delay: number = DEFAULT_DELAY) {
const [value, setValue] = useState<T>(initialValue);
const [debounced, setDebounced] = useState<T>(initialValue);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
// Flush - skip the debounce window so an explicit submit resolves the active value right away.
const flush = useCallback((next?: T) => {
if (next !== undefined) setValue(next);
setDebounced(next !== undefined ? next : value);
}, [value]);
return [value, setValue, debounced, flush] as const;
}
+1 -1
View File
@@ -20,7 +20,7 @@ interface UseEpubReaderOptions {
}
interface UseEpubReaderResult {
viewerRef: (_node: HTMLDivElement | null) => void;
viewerRef: (node: HTMLDivElement | null) => void;
isReady: boolean;
isLoading: boolean;
error: string | null;
@@ -0,0 +1,35 @@
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
interface ApiResponse {
status: number;
data: unknown;
}
interface ToastMutationOptions {
success: string;
error: string;
onSuccess?: () => void;
}
/**
* 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.
*/
export function useMutationWithToast() {
const { showInfo, showError } = useToasts();
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return {
onSuccess: (response: ApiResponse) => {
if (response.status < 200 || response.status >= 300) {
showError(`${error}: ${getErrorMessage(response.data)}`);
return;
}
onSuccess?.();
showInfo(success);
},
onError: (err: unknown) => showError(`${error}: ${getErrorMessage(err)}`),
};
};
}
+11 -9
View File
@@ -6,11 +6,13 @@ import { Pagination } from '../components';
import { Table, type Column } from '../components/Table';
import { formatDuration } from '../utils/formatters';
const ACTIVITY_PAGE_SIZE = 25;
export default function ActivityPage() {
const [searchParams] = useSearchParams();
const documentID = searchParams.get('document') || undefined;
const [page, setPage] = useState(1);
const limit = 25;
const limit = ACTIVITY_PAGE_SIZE;
useEffect(() => {
setPage(1);
@@ -27,28 +29,28 @@ export default function ActivityPage() {
const columns: Column<Activity>[] = [
{
key: 'document_id' as const,
id: 'document',
header: 'Document',
render: (_value, row) => (
render: row => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{
key: 'start_time' as const,
id: 'start_time',
header: 'Time',
render: value => String(value || 'N/A'),
render: row => row.start_time || 'N/A',
},
{
key: 'duration' as const,
id: 'duration',
header: 'Duration',
render: value => formatDuration(typeof value === 'number' ? value : 0),
render: row => formatDuration(row.duration ?? 0),
},
{
key: 'end_percentage' as const,
id: 'end_percentage',
header: 'Percent',
render: value => (typeof value === 'number' ? `${value}%` : '0%'),
render: row => (typeof row.end_percentage === 'number' ? `${row.end_percentage}%` : '0%'),
},
];
+7 -6
View File
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { LoadingState } from '../components';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import type { DirectoryItem, DirectoryListResponse } from '../generated/model';
import type { DirectoryItem } from '../generated/model';
import { getErrorMessage } from '../utils/errors';
import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons';
@@ -11,6 +13,7 @@ export default function AdminImportPage() {
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { showInfo, showError } = useToasts();
const navigate = useNavigate();
const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {}
@@ -19,7 +22,7 @@ export default function AdminImportPage() {
const postImport = usePostImport();
const directoryResponse =
directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
directoryData?.status === 200 ? directoryData.data : null;
const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
@@ -48,9 +51,7 @@ export default function AdminImportPage() {
{
onSuccess: _response => {
showInfo('Import completed successfully');
setTimeout(() => {
window.location.href = '/admin/import-results';
}, 1500);
navigate('/admin/import-results');
},
onError: error => {
showError('Import failed: ' + getErrorMessage(error));
@@ -64,7 +65,7 @@ export default function AdminImportPage() {
};
if (isLoading && !currentPath) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
if (selectedDirectory) {
@@ -1,14 +1,15 @@
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import type { ImportResult, ImportResultsResponse } from '../generated/model';
import { LoadingState } from '../components';
import type { ImportResult } from '../generated/model';
import { Link } from 'react-router-dom';
export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults();
const results =
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
resultsData?.status === 200 ? resultsData.data.results || [] : [];
if (isLoading) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
return (
@@ -33,11 +34,8 @@ export default function AdminImportResultsPage() {
</tr>
) : (
results.map((result: ImportResult, index: number) => (
<tr key={index}>
<td
className="grid border-b border-border p-3"
style={{ gridTemplateColumns: '4rem auto' }}
>
<tr key={result.path ?? index}>
<td className="grid grid-cols-[4rem_auto] border-b border-border p-3">
<span className="text-content-muted">Name:</span>
{result.id ? (
<Link
+11 -20
View File
@@ -1,27 +1,20 @@
import { useState, useEffect, FormEvent } from 'react';
import { SyntheticEvent } from 'react';
import { useGetLogs } from '../generated/anthoLumeAPIV1';
import type { LogsResponse } from '../generated/model';
import { Button } from '../components/Button';
import { LoadingState } from '../components';
import { useDebounce } from '../hooks/useDebounce';
import { LoadingState, TextInput } from '../components';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon } from '../icons';
export default function AdminLogsPage() {
const [filter, setFilter] = useState('');
const [activeFilter, setActiveFilter] = useState('');
const debouncedFilter = useDebounce(filter, 300);
useEffect(() => {
setActiveFilter(debouncedFilter);
}, [debouncedFilter]);
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : [];
const handleFilterSubmit = (e: FormEvent) => {
const handleFilterSubmit = (e: SyntheticEvent) => {
e.preventDefault();
setActiveFilter(filter);
flushFilter();
};
return (
@@ -33,11 +26,11 @@ export default function AdminLogsPage() {
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
<Search2Icon size={15} hoverable={false} />
</span>
<input
<TextInput
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface p-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
className="p-2"
placeholder="JQ Filter"
/>
</div>
@@ -50,13 +43,11 @@ export default function AdminLogsPage() {
</form>
</div>
<div
className="flex w-full flex-col-reverse overflow-scroll text-content"
style={{ fontFamily: 'monospace' }}
>
<div className="flex w-full flex-col-reverse overflow-scroll font-mono text-content">
{isLoading ? (
<LoadingState className="min-h-40 w-full" />
) : (
// Key By Index - Log lines have no stable id and can repeat; this list is append-only and fully replaced on refetch.
logs.map((log, index) => (
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
{typeof log === 'string' ? log : JSON.stringify(log)}
+12 -31
View File
@@ -1,8 +1,10 @@
import { useState, FormEvent } from 'react';
import { useState, SyntheticEvent } from 'react';
import { LoadingState } from '../components';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
import { streamResponseToFile, backupFilename } from '../utils/download';
interface BackupTypes {
covers: boolean;
@@ -20,7 +22,7 @@ export default function AdminPage() {
});
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const handleBackupSubmit = async (e: FormEvent) => {
const handleBackupSubmit = async (e: SyntheticEvent) => {
e.preventDefault();
const backupTypesList: string[] = [];
if (backupTypes.covers) backupTypesList.push('COVERS');
@@ -31,6 +33,7 @@ export default function AdminPage() {
formData.append('action', 'BACKUP');
backupTypesList.forEach(value => formData.append('backup_types', value));
// Streaming Fetch - The generated client buffers; the backup can be large, so this endpoint intentionally uses a raw streaming download.
const response = await fetch('/api/v1/admin', {
method: 'POST',
body: formData,
@@ -40,43 +43,21 @@ export default function AdminPage() {
throw new Error('Backup failed: ' + response.statusText);
}
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
const completed = await streamResponseToFile(response, {
suggestedName: backupFilename(),
mimeType: 'application/zip',
extension: '.zip',
});
const writable = await handle.createWritable();
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
if (completed) {
showInfo('Backup completed successfully');
} catch (err) {
if ((err as Error).name !== 'AbortError') {
showError('Backup failed: ' + (err as Error).message);
}
}
} else {
showError(
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
);
}
} catch (error) {
showError('Backup failed: ' + getErrorMessage(error));
}
};
const handleRestoreSubmit = async (e: FormEvent) => {
const handleRestoreSubmit = async (e: SyntheticEvent) => {
e.preventDefault();
if (!restoreFile) return;
@@ -125,7 +106,7 @@ export default function AdminPage() {
};
if (isLoading) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
return (
+129 -134
View File
@@ -1,23 +1,27 @@
import { useState, FormEvent } from 'react';
import { useState, SyntheticEvent } from 'react';
import { LoadingState, TextInput } from '../components';
import { Button } from '../components/Button';
import { Table, type Column } from '../components/Table';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import type { User, UsersResponse } from '../generated/model';
import type { User } from '../generated/model';
import { AddIcon, DeleteIcon } from '../icons';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({});
const updateUser = useUpdateUser();
const { showInfo, showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState('');
const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
const users = usersData?.status === 200 ? (usersData.data.users ?? []) : [];
const handleCreateUser = (e: FormEvent) => {
const handleCreateUser = (e: SyntheticEvent) => {
e.preventDefault();
if (!newUsername || !newPassword) return;
@@ -30,22 +34,17 @@ export default function AdminUsersPage() {
is_admin: newIsAdmin,
},
},
{
onSuccess: response => {
if (response.status < 200 || response.status >= 300) {
showError('Failed to create user: ' + getErrorMessage(response.data));
return;
}
showInfo('User created successfully');
toastMutationOptions({
success: 'User created successfully',
error: 'Failed to create user',
onSuccess: () => {
setShowAddForm(false);
setNewUsername('');
setNewPassword('');
setNewIsAdmin(false);
refetch();
},
onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
}
})
);
};
@@ -54,18 +53,11 @@ export default function AdminUsersPage() {
{
data: { operation: 'DELETE', user: userId },
},
{
onSuccess: response => {
if (response.status < 200 || response.status >= 300) {
showError('Failed to delete user: ' + getErrorMessage(response.data));
return;
}
showInfo('User deleted successfully');
refetch();
},
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
}
toastMutationOptions({
success: 'User deleted successfully',
error: 'Failed to delete user',
onSuccess: refetch,
})
);
};
@@ -76,18 +68,11 @@ export default function AdminUsersPage() {
{
data: { operation: 'UPDATE', user: userId, password },
},
{
onSuccess: response => {
if (response.status < 200 || response.status >= 300) {
showError('Failed to update password: ' + getErrorMessage(response.data));
return;
}
showInfo('Password updated successfully');
refetch();
},
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
}
toastMutationOptions({
success: 'Password updated successfully',
error: 'Failed to update password',
onSuccess: refetch,
})
);
};
@@ -96,23 +81,80 @@ export default function AdminUsersPage() {
{
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
},
{
onSuccess: response => {
if (response.status < 200 || response.status >= 300) {
showError('Failed to update admin status: ' + getErrorMessage(response.data));
return;
}
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
refetch();
},
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
}
toastMutationOptions({
success: `User permissions updated to ${isAdmin ? 'admin' : 'user'}`,
error: 'Failed to update admin status',
onSuccess: refetch,
})
);
};
const permissionButtonClass = (active: boolean) =>
`rounded-md px-2 py-1 ${
active
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`;
const userColumns: Column<User>[] = [
{
id: 'actions',
className: 'w-12',
header: (
<button onClick={() => setShowAddForm(!showAddForm)} aria-label="Add user">
<AddIcon size={20} />
</button>
),
render: user => (
<button onClick={() => handleDeleteUser(user.id)} aria-label="Delete user">
<DeleteIcon size={20} />
</button>
),
},
{ id: 'user', header: 'User', render: user => user.id },
{
id: 'password',
header: 'Password',
render: user => (
<button
onClick={() => {
setResetUserId(user.id);
setResetPassword('');
}}
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
>
Reset
</button>
),
},
{
id: 'permissions',
header: 'Permissions',
className: 'text-center',
render: user => (
<div className="flex justify-center gap-2">
<button
onClick={() => handleToggleAdmin(user.id, true)}
disabled={user.admin}
className={permissionButtonClass(user.admin)}
>
admin
</button>
<button
onClick={() => handleToggleAdmin(user.id, false)}
disabled={!user.admin}
className={permissionButtonClass(!user.admin)}
>
user
</button>
</div>
),
},
{ id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at },
];
if (isLoading) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
return (
@@ -153,89 +195,42 @@ export default function AdminUsersPage() {
</div>
)}
<div className="min-w-full overflow-scroll rounded shadow-sm">
<table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-content-muted">
<tr>
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase">
<button onClick={() => setShowAddForm(!showAddForm)}>
<AddIcon size={20} />
</button>
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Password
</th>
<th className="border-b border-border p-3 text-center font-normal uppercase">
Permissions
</th>
<th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
Created
</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="p-3 text-center" colSpan={5}>
No Results
</td>
</tr>
) : (
users.map((user: User) => (
<tr key={user.id}>
<td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
<button onClick={() => handleDeleteUser(user.id)}>
<DeleteIcon size={20} />
</button>
</td>
<td className="border-b border-border p-3">
<p>{user.id}</p>
</td>
<td className="border-b border-border px-3">
<button
onClick={() => {
const password = prompt(`Enter new password for ${user.id}`);
if (password) handleUpdatePassword(user.id, password);
<Table columns={userColumns} data={users} rowKey="id" />
{resetUserId && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
onClick={() => setResetUserId(null)}
>
<form
className="w-80 rounded bg-surface p-4 shadow-lg"
onClick={e => e.stopPropagation()}
onSubmit={e => {
e.preventDefault();
if (!resetPassword) return;
handleUpdatePassword(resetUserId, resetPassword);
setResetUserId(null);
}}
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
>
Reset
</button>
</td>
<td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
<button
onClick={() => handleToggleAdmin(user.id, true)}
disabled={user.admin}
className={`rounded-md px-2 py-1 ${
user.admin
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`}
>
admin
</button>
<button
onClick={() => handleToggleAdmin(user.id, false)}
disabled={!user.admin}
className={`rounded-md px-2 py-1 ${
!user.admin
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`}
>
user
</button>
</td>
<td className="border-b border-border p-3">
<p>{user.created_at}</p>
</td>
</tr>
))
)}
</tbody>
</table>
<p className="mb-3 text-content">Reset password for {resetUserId}</p>
<TextInput
type="password"
value={resetPassword}
onChange={e => setResetPassword(e.target.value)}
placeholder="New password"
autoFocus
/>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}>
Cancel
</Button>
<Button type="submit" disabled={!resetPassword}>
Save
</Button>
</div>
</form>
</div>
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { SyntheticEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/Button';
import { TextInput } from '../components';
interface AuthFormViewProps {
username: string;
password: string;
isLoading: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void | Promise<void>;
submitLabel: string;
submittingLabel: string;
inputsDisabled?: boolean;
footer: ReactNode;
}
export function AuthFormView({
username,
password,
isLoading,
onUsernameChange,
onPasswordChange,
onSubmit,
submitLabel,
submittingLabel,
inputsDisabled = false,
footer,
}: AuthFormViewProps) {
return (
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<TextInput
type="text"
value={username}
onChange={e => onUsernameChange(e.target.value)}
placeholder="Username"
required
disabled={isLoading || inputsDisabled}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<TextInput
type="password"
value={password}
onChange={e => onPasswordChange(e.target.value)}
placeholder="Password"
required
disabled={isLoading || inputsDisabled}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading || inputsDisabled}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-hidden focus:ring-2 disabled:opacity-50"
>
{isLoading ? submittingLabel : submitLabel}
</Button>
</form>
<div className="py-12 text-center">{footer}</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}
export function authFormFooter(
primaryLink: { to: string; text: string },
showPrimary: boolean
) {
return (
<>
{showPrimary && (
<p>
<Link to={primaryLink.to} className="font-semibold underline">
{primaryLink.text}
</Link>
</p>
)}
<p className={showPrimary ? 'mt-4' : ''}>
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</>
);
}
-156
View File
@@ -1,156 +0,0 @@
import { useState } from 'react';
import { useToasts } from '../components/ToastContext';
import {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader,
} from '../components/Skeleton';
export default function ComponentDemoPage() {
const { showInfo, showWarning, showError, showToast } = useToasts();
const [isLoading, setIsLoading] = useState(false);
const handleDemoClick = () => {
setIsLoading(true);
showInfo('Starting demo operation...');
setTimeout(() => {
setIsLoading(false);
showInfo('Demo operation completed successfully!');
}, 2000);
};
const handleErrorClick = () => {
showError('This is a sample error message');
};
const handleWarningClick = () => {
showWarning('This is a sample warning message', 10000);
};
const handleCustomToast = () => {
showToast('Custom toast message', 'info', 3000);
};
return (
<div className="space-y-8 p-4 text-content">
<h1 className="text-2xl font-bold">UI Components Demo</h1>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
<div className="flex flex-wrap gap-4">
<button
onClick={handleDemoClick}
disabled={isLoading}
className="rounded bg-secondary-500 px-4 py-2 text-secondary-foreground hover:bg-secondary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
</button>
<button
onClick={handleWarningClick}
className="rounded bg-yellow-500 px-4 py-2 text-white hover:bg-yellow-600"
>
Show Warning Toast (10s)
</button>
<button
onClick={handleErrorClick}
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
>
Show Error Toast
</button>
<button
onClick={handleCustomToast}
className="rounded bg-primary-500 px-4 py-2 text-primary-foreground hover:bg-primary-600"
>
Show Custom Toast
</button>
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Basic Skeletons</h3>
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton variant="text" className="w-3/4" />
<Skeleton variant="text" className="w-1/2" />
<div className="flex items-center gap-4">
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="rectangular" width={100} height={40} />
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Text</h3>
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Avatar</h3>
<div className="flex items-center gap-4">
<SkeletonAvatar size="sm" />
<SkeletonAvatar size="md" />
<SkeletonAvatar size="lg" />
<SkeletonAvatar size={72} />
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Button</h3>
<div className="flex flex-wrap gap-2">
<SkeletonButton width={120} />
<SkeletonButton className="w-full max-w-xs" />
</div>
</div>
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<SkeletonCard />
<SkeletonCard showAvatar />
<SkeletonCard showAvatar showTitle showText textLines={4} />
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
<SkeletonTable rows={5} columns={4} />
</section>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
<PageLoader message="Loading demo content..." />
</section>
<section className="rounded-lg bg-surface p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
<div className="flex items-center gap-8">
<div className="text-center">
<InlineLoader size="sm" />
<p className="mt-2 text-sm text-content-muted">Small</p>
</div>
<div className="text-center">
<InlineLoader size="md" />
<p className="mt-2 text-sm text-content-muted">Medium</p>
</div>
<div className="text-center">
<InlineLoader size="lg" />
<p className="mt-2 text-sm text-content-muted">Large</p>
</div>
</div>
</section>
</div>
);
}
+124 -354
View File
@@ -6,119 +6,158 @@ import {
useEditDocument,
getGetDocumentQueryKey,
} from '../generated/anthoLumeAPIV1';
import { Document } from '../generated/model/document';
import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters';
import {
DeleteIcon,
ActivityIcon,
SearchIcon,
DownloadIcon,
EditIcon,
InfoIcon,
CloseIcon,
CheckIcon,
} from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
import { getErrorMessage } from '../utils/errors';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
import { useToasts } from '../components/ToastContext';
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
const popupClassName =
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
const popupInputClassName = 'rounded bg-surface p-2 text-content';
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';
interface EditableFieldProps {
label: string;
value: string;
multiline?: boolean;
valueClassName?: string;
onSave: (value: string) => Promise<boolean>;
}
function EditableField({
label,
value,
multiline = false,
valueClassName,
onSave,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(value);
const startEdit = () => {
setDraft(value);
setIsEditing(true);
};
// Keep Editor Open On Failure - Only close once the save actually succeeds so a failed edit isn't silently lost.
const confirm = async () => {
if (await onSave(draft)) setIsEditing(false);
};
return (
<Field
label={
<>
<FieldLabel>{label}</FieldLabel>
<FieldActions>
{isEditing ? (
<div className="flex gap-1">
<button
type="button"
onClick={() => setIsEditing(false)}
className={iconButtonClassName}
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={confirm}
className={iconButtonClassName}
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={startEdit}
className={iconButtonClassName}
aria-label={`Edit ${label.toLowerCase()}`}
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditing ? (
<div className="relative mt-1 flex gap-2">
{multiline ? (
<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"
rows={5}
/>
) : (
<input
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
className={editInputClassName}
/>
)}
</div>
) : (
<FieldValue className={valueClassName}>{value || 'N/A'}</FieldValue>
)}
</Field>
);
}
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
const editMutation = useEditDocument();
const { showError } = useToasts();
const [showEditCover, setShowEditCover] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showIdentify, setShowIdentify] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingAuthor, setIsEditingAuthor] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editAuthor, setEditAuthor] = useState('');
const [editDescription, setEditDescription] = useState('');
if (docLoading) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
if (!docData || docData.status !== 200) {
return <div className="text-content-muted">Document not found</div>;
}
const document = docData.data.document as Document;
if (!document) {
return <div className="text-content-muted">Document not found</div>;
}
const document = docData.data.document;
const percentage = document.percentage ?? 0;
const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
const startEditing = (field: 'title' | 'author' | 'description') => {
if (field === 'title') setEditTitle(document.title);
if (field === 'author') setEditAuthor(document.author);
if (field === 'description') setEditDescription(document.description || '');
};
const saveTitle = () => {
editMutation.mutate(
{ id: document.id, data: { title: editTitle } },
{
onSuccess: response => {
setIsEditingTitle(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingTitle(false),
const save = async (data: EditDocumentBody): Promise<boolean> => {
try {
const response = await editMutation.mutateAsync({ id: document.id, data });
if (response.status !== 200) {
showError('Failed to save: ' + getErrorMessage(response.data));
return false;
}
);
};
const saveAuthor = () => {
editMutation.mutate(
{ id: document.id, data: { author: editAuthor } },
{
onSuccess: response => {
setIsEditingAuthor(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingAuthor(false),
return true;
} catch (err) {
showError('Failed to save: ' + getErrorMessage(err));
return false;
}
);
};
const saveDescription = () => {
editMutation.mutate(
{ id: document.id, data: { description: editDescription } },
{
onSuccess: response => {
setIsEditingDescription(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingDescription(false),
}
);
};
return (
<div className="relative size-full">
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
<img
className="w-full rounded object-fill"
src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`}
/>
</label>
{document.filepath && (
<a
@@ -141,77 +180,7 @@ export default function DocumentPage() {
</div>
</div>
<div className="relative">
<input
type="checkbox"
id="edit-cover-checkbox"
className="hidden"
checked={showEditCover}
onChange={e => setShowEditCover(e.target.checked)}
/>
<div
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="flex w-72 flex-col gap-2 text-sm">
<input
type="file"
id="cover_file"
name="cover_file"
className={popupInputClassName}
/>
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Upload Cover
</button>
</form>
<form className="flex w-72 flex-col gap-2 text-sm">
<input
type="checkbox"
checked
id="remove_cover"
name="remove_cover"
className="hidden"
/>
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Remove Cover
</button>
</form>
</div>
</div>
<div className="relative my-auto flex grow justify-between text-content-muted">
<div className="relative">
<button
type="button"
onClick={() => setShowDelete(!showDelete)}
className={iconButtonClassName}
aria-label="Delete"
>
<DeleteIcon size={28} />
</button>
<div
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="w-24 text-sm">
<button
type="submit"
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
>
Delete
</button>
</form>
</div>
</div>
<a
href={`/activity?document=${document.id}`}
aria-label="Activity"
@@ -220,55 +189,6 @@ export default function DocumentPage() {
<ActivityIcon size={28} />
</a>
<div className="relative">
<button
type="button"
onClick={() => setShowIdentify(!showIdentify)}
aria-label="Identify"
className={iconButtonClassName}
>
<SearchIcon size={28} />
</button>
<div
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="flex flex-col gap-2 text-sm">
<input
type="text"
id="title"
name="title"
placeholder="Title"
defaultValue={document.title}
className={popupInputClassName}
/>
<input
type="text"
id="author"
name="author"
placeholder="Author"
defaultValue={document.author}
className={popupInputClassName}
/>
<input
type="text"
id="isbn"
name="isbn"
placeholder="ISBN 10 / ISBN 13"
defaultValue={document.isbn13 || document.isbn10}
className={popupInputClassName}
/>
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Identify
</button>
</form>
</div>
</div>
{document.filepath ? (
<a
href={`/api/v1/documents/${document.id}/file`}
@@ -287,117 +207,17 @@ export default function DocumentPage() {
</div>
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
<Field
isEditing={isEditingTitle}
label={
<>
<FieldLabel>Title</FieldLabel>
<FieldActions>
{isEditingTitle ? (
<div className="flex gap-1">
<button
type="button"
onClick={() => setIsEditingTitle(false)}
className={iconButtonClassName}
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveTitle}
className={iconButtonClassName}
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
startEditing('title');
setIsEditingTitle(true);
}}
className={iconButtonClassName}
aria-label="Edit title"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingTitle ? (
<div className="relative mt-1 flex gap-2">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className={editInputClassName}
<EditableField
label="Title"
value={document.title}
onSave={value => save({ title: value })}
/>
</div>
) : (
<FieldValue>{document.title}</FieldValue>
)}
</Field>
<Field
isEditing={isEditingAuthor}
label={
<>
<FieldLabel>Author</FieldLabel>
<FieldActions>
{isEditingAuthor ? (
<>
<button
type="button"
onClick={() => setIsEditingAuthor(false)}
className={iconButtonClassName}
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveAuthor}
className={iconButtonClassName}
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('author');
setIsEditingAuthor(true);
}}
className={iconButtonClassName}
aria-label="Edit author"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingAuthor ? (
<div className="relative mt-1 flex gap-2">
<input
type="text"
value={editAuthor}
onChange={e => setEditAuthor(e.target.value)}
className={editInputClassName}
<EditableField
label="Author"
value={document.author}
onSave={value => save({ author: value })}
/>
</div>
) : (
<FieldValue>{document.author}</FieldValue>
)}
</Field>
<Field
label={
@@ -450,64 +270,14 @@ export default function DocumentPage() {
</Field>
</div>
<Field
isEditing={isEditingDescription}
label={
<>
<FieldLabel>Description</FieldLabel>
<FieldActions>
{isEditingDescription ? (
<>
<button
type="button"
onClick={() => setIsEditingDescription(false)}
className={iconButtonClassName}
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveDescription}
className={iconButtonClassName}
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('description');
setIsEditingDescription(true);
}}
className={iconButtonClassName}
aria-label="Edit description"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingDescription ? (
<div className="relative mt-1 flex gap-2">
<textarea
value={editDescription}
onChange={e => setEditDescription(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"
rows={5}
<EditableField
label="Description"
value={document.description || ''}
multiline
valueClassName="hyphens-auto text-justify"
onSave={value => save({ description: value })}
/>
</div>
) : (
<FieldValue className="hyphens-auto text-justify">
{document.description || 'N/A'}
</FieldValue>
)}
</Field>
</div>
</div>
);
}
+101 -148
View File
@@ -1,12 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document, DocumentsResponse } from '../generated/model';
import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState, Pagination } from '../components';
import { LoadingState, Pagination, TextInput } from '../components';
import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { getErrorMessage } from '../utils/errors';
import {
getDocumentsViewMode,
@@ -14,121 +14,27 @@ import {
type DocumentsViewMode,
} from '../utils/localSettings';
interface DocumentCardProps {
const DOCUMENTS_PAGE_SIZE = 9;
interface DocumentItemProps {
doc: Document;
layout: 'grid' | 'list';
}
function DocumentCard({ doc }: DocumentCardProps) {
function DocumentItem({ doc, layout }: DocumentItemProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
return (
<div className="relative w-full">
<div
role="link"
tabIndex={0}
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
const open = () => navigate(`/documents/${doc.id}`);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/documents/${doc.id}`);
open();
}
}}
>
<div className="relative my-auto h-48 min-w-fit">
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm text-content">
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
<DownloadIcon size={20} />
</a>
) : (
<DownloadIcon size={20} disabled />
)}
</div>
</div>
</div>
);
}
interface DocumentListItemProps {
doc: Document;
}
function DocumentListItem({ doc }: DocumentListItemProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
return (
<div
role="link"
tabIndex={0}
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/documents/${doc.id}`);
}
}}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
<div>
<p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
<div>
<p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
<div>
<p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
<div>
<p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
};
const icons = (
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
@@ -141,22 +47,82 @@ function DocumentListItem({ doc }: DocumentListItemProps) {
<DownloadIcon size={20} disabled />
)}
</div>
);
const fields = [
{ label: 'Title', value: doc.title || 'Unknown' },
{ label: 'Author', value: doc.author || 'Unknown' },
{ label: 'Progress', value: `${percentage}%` },
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
];
if (layout === 'grid') {
return (
<div className="relative w-full">
<div
role="link"
tabIndex={0}
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<div className="relative my-auto h-48 min-w-fit">
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm text-content">
{fields.map(f => (
<div key={f.label} className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
</div>
))}
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
{icons}
</div>
</div>
</div>
);
}
return (
<div
role="link"
tabIndex={0}
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
{fields.map(f => (
<div key={f.label}>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
))}
</div>
{icons}
</div>
</div>
);
}
export default function DocumentsPage() {
const [search, setSearch] = useState('');
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
const [page, setPage] = useState(1);
const [limit] = useState(9);
const limit = DOCUMENTS_PAGE_SIZE;
const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts();
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
setDocumentsViewMode(viewMode);
}, [viewMode]);
@@ -167,12 +133,12 @@ export default function DocumentsPage() {
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument();
const docs = (data?.data as DocumentsResponse | undefined)?.documents;
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
const documentsResponse = data?.status === 200 ? data.data : undefined;
const docs = documentsResponse?.documents;
const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const uploadDocument = async (file: File | undefined) => {
if (!file) return;
if (!file.name.endsWith('.epub')) {
@@ -217,11 +183,10 @@ export default function DocumentsPage() {
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
<Search2Icon size={15} hoverable={false} />
</span>
<input
<TextInput
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Search Author / Title"
name="search"
/>
@@ -251,7 +216,7 @@ export default function DocumentsPage() {
{isLoading ? (
<LoadingState className="col-span-full min-h-48" />
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
) : (
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
@@ -263,7 +228,7 @@ export default function DocumentsPage() {
{isLoading ? (
<LoadingState className="min-h-48" />
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
) : (
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
@@ -276,59 +241,47 @@ export default function DocumentsPage() {
page={page}
previousPage={previousPage}
nextPage={nextPage}
total={(data?.data as DocumentsResponse | undefined)?.total}
total={documentsResponse?.total}
limit={limit}
onPageChange={setPage}
/>
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
<input
type="checkbox"
id="upload-file-button"
className="hidden"
checked={uploadMode}
onChange={() => setUploadMode(!uploadMode)}
/>
<div
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200 ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
>
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
{uploadMode && (
<div className="absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200">
<div className="flex flex-col gap-2">
<input
type="file"
accept=".epub"
id="document_file"
name="document_file"
ref={fileInputRef}
onChange={handleFileChange}
/>
<button
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
type="submit"
onClick={e => {
e.preventDefault();
handleFileChange({
target: { files: fileInputRef.current?.files },
} as React.ChangeEvent<HTMLInputElement>);
}}
type="button"
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])}
>
Upload File
</button>
</form>
<label htmlFor="upload-file-button">
<div
</div>
<button
type="button"
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
onClick={handleCancelUpload}
>
Cancel Upload
</button>
</div>
</label>
</div>
<label
)}
<button
type="button"
onClick={() => setUploadMode(!uploadMode)}
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
htmlFor="upload-file-button"
aria-label="Upload document"
>
<UploadIcon size={34} className="text-content-inverse" />
</label>
</button>
</div>
</div>
);
+6 -6
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { LoadingState } from '../components';
import { Link } from 'react-router-dom';
import { useGetHome } from '../generated/anthoLumeAPIV1';
import type {
HomeResponse,
LeaderboardData,
LeaderboardEntry,
UserStreak,
@@ -154,7 +154,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<div>
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
<div
key={index}
key={item.user_id}
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
>
<div>
@@ -172,14 +172,14 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome();
const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
const homeResponse = homeData?.status === 200 ? homeData.data : null;
const dbInfo = homeResponse?.database_info;
const streaks = homeResponse?.streaks?.streaks;
const graphData = homeResponse?.graph_data?.graph_data;
const userStats = homeResponse?.user_statistics;
if (homeLoading) {
return <div className="text-content-muted">Loading...</div>;
return <LoadingState />;
}
return (
@@ -201,9 +201,9 @@ export default function HomePage() {
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{streaks?.map((streak: UserStreak, index: number) => (
{streaks?.map((streak: UserStreak) => (
<StreakCard
key={index}
key={streak.window}
window={streak.window as 'DAY' | 'WEEK'}
currentStreak={streak.current_streak}
currentStreakStartDate={streak.current_streak_start_date}
+8 -94
View File
@@ -1,19 +1,9 @@
import { useState, FormEvent, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useState, SyntheticEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
interface LoginPageViewProps {
username: string;
password: string;
isLoading: boolean;
registrationEnabled: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
}
import { AuthFormView, authFormFooter } from './AuthFormView';
export function getRegistrationEnabled(infoData: unknown): boolean {
if (!infoData || typeof infoData !== 'object') {
@@ -34,84 +24,6 @@ export function getRegistrationEnabled(infoData: unknown): boolean {
return infoData.data.registration_enabled;
}
export function LoginPageView({
username,
password,
isLoading,
registrationEnabled,
onUsernameChange,
onPasswordChange,
onSubmit,
}: LoginPageViewProps) {
return (
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<input
type="text"
value={username}
onChange={e => onUsernameChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Username"
required
disabled={isLoading}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<input
type="password"
value={password}
onChange={e => onPasswordChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Password"
required
disabled={isLoading}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-hidden focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
<div className="py-12 text-center">
{registrationEnabled && (
<p>
Don&apos;t have an account?{' '}
<Link to="/register" className="font-semibold underline">
Register here.
</Link>
</p>
)}
<p className={registrationEnabled ? 'mt-4' : ''}>
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -134,7 +46,7 @@ export default function LoginPage() {
}
}, [isAuthenticated, isCheckingAuth, navigate]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
@@ -148,14 +60,16 @@ export default function LoginPage() {
};
return (
<LoginPageView
<AuthFormView
username={username}
password={password}
isLoading={isLoading}
registrationEnabled={registrationEnabled}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
submitLabel="Login"
submittingLabel="Logging in..."
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
/>
);
}
+11 -10
View File
@@ -5,38 +5,39 @@ import type { Progress } from '../generated/model';
import { Pagination } from '../components';
import { Table, type Column } from '../components/Table';
const PROGRESS_PAGE_SIZE = 15;
export default function ProgressPage() {
const [page, setPage] = useState(1);
const limit = 15;
const limit = PROGRESS_PAGE_SIZE;
const { data, isLoading } = useGetProgressList({ page, limit });
const response = data?.status === 200 ? data.data : undefined;
const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [
{
key: 'document_id' as const,
id: 'document',
header: 'Document',
render: (_value, row) => (
render: row => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{
key: 'device_name' as const,
id: 'device_name',
header: 'Device Name',
render: value => String(value || 'Unknown'),
render: row => row.device_name || 'Unknown',
},
{
key: 'percentage' as const,
id: 'percentage',
header: 'Percentage',
render: value => (typeof value === 'number' ? `${Math.round(value)}%` : '0%'),
render: row => (typeof row.percentage === 'number' ? `${Math.round(row.percentage)}%` : '0%'),
},
{
key: 'created_at' as const,
id: 'created_at',
header: 'Created At',
render: value =>
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'),
},
];
+17 -70
View File
@@ -1,9 +1,10 @@
import { useState, FormEvent, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useState, SyntheticEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { AuthFormView, authFormFooter } from './AuthFormView';
import { getRegistrationEnabled } from './LoginPage';
export default function RegisterPage() {
const [username, setUsername] = useState('');
@@ -19,10 +20,7 @@ export default function RegisterPage() {
},
});
const registrationEnabled =
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
? infoData.data.registration_enabled
: false;
const registrationEnabled = getRegistrationEnabled(infoData);
useEffect(() => {
if (!isCheckingAuth && isAuthenticated) {
@@ -35,7 +33,7 @@ export default function RegisterPage() {
}
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
const handleSubmit = async (e: FormEvent) => {
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
@@ -49,68 +47,17 @@ export default function RegisterPage() {
};
return (
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Username"
required
disabled={isLoading || isLoadingInfo || !registrationEnabled}
<AuthFormView
username={username}
password={password}
isLoading={isLoading}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
submitLabel="Register"
submittingLabel="Registering..."
inputsDisabled={isLoadingInfo || !registrationEnabled}
footer={authFormFooter({ to: '/login', text: 'Login here.' }, true)}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Password"
required
disabled={isLoading || isLoadingInfo || !registrationEnabled}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading || isLoadingInfo || !registrationEnabled}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-hidden focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
<div className="py-12 text-center">
<p>
Trying to login?{' '}
<Link to="/login" className="font-semibold underline">
Login here.
</Link>
</p>
<p className="mt-4">
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}
+5 -2
View File
@@ -50,9 +50,12 @@ describe('SearchPage', () => {
isLoading: true,
} as ReturnType<typeof useGetSearch>);
render(<SearchPage />);
const { container } = render(<SearchPage />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Loading renders the Table skeleton (animated placeholder rows), and the search form stays mounted.
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
expect(screen.getByPlaceholderText('Query')).toBeInTheDocument();
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
});
it('shows an empty state when there are no results', () => {
+35 -77
View File
@@ -1,12 +1,37 @@
import { useState, useEffect, FormEvent } from 'react';
import { useState, SyntheticEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1';
import { GetSearchSource } from '../generated/model/getSearchSource';
import type { SearchItem } from '../generated/model';
import { Button } from '../components/Button';
import { LoadingState } from '../components';
import { useDebounce } from '../hooks/useDebounce';
import { TextInput } from '../components';
import { inputClassName } from '../components/TextInput';
import { Table, type Column } from '../components/Table';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
const searchColumns: Column<SearchItem>[] = [
{
id: 'download',
header: '',
className: 'w-12 text-content-muted',
render: () => (
<button className="hover:text-primary-600" title="Download">
<DownloadIcon size={15} />
</button>
),
},
{ id: 'document', header: 'Document', render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}` },
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
{
id: 'date',
header: 'Date',
className: 'hidden md:table-cell',
render: item => item.upload_date || 'N/A',
},
];
interface SearchPageViewProps {
query: string;
source: GetSearchSource;
@@ -14,7 +39,7 @@ interface SearchPageViewProps {
results: SearchItem[];
onQueryChange: (value: string) => void;
onSourceChange: (value: GetSearchSource) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
}
export function getSearchResults(data: unknown): SearchItem[] {
@@ -56,11 +81,10 @@ export function SearchPageView({
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
<Search2Icon size={15} hoverable={false} />
</span>
<input
<TextInput
type="text"
value={query}
onChange={e => onQueryChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Query"
/>
</div>
@@ -72,7 +96,7 @@ export function SearchPageView({
<select
value={source}
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
className={inputClassName}
>
<option value={GetSearchSource.LibGen}>Library Genesis</option>
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
@@ -86,81 +110,15 @@ export function SearchPageView({
</form>
</div>
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
<table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
<thead className="text-content-muted">
<tr>
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Document
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Series
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">Type</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">Size</th>
<th className="hidden border-b border-border p-3 text-left font-normal uppercase md:table-cell">
Date
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="p-3 text-center" colSpan={6}>
<LoadingState />
</td>
</tr>
)}
{!isLoading && results.length === 0 && (
<tr>
<td className="p-3 text-center" colSpan={6}>
No Results
</td>
</tr>
)}
{!isLoading &&
results.map(item => (
<tr key={item.id}>
<td className="border-b border-border p-3 text-content-muted">
<button className="hover:text-primary-600" title="Download">
<DownloadIcon size={15} />
</button>
</td>
<td className="border-b border-border p-3">
{item.author || 'N/A'} - {item.title || 'N/A'}
</td>
<td className="border-b border-border p-3">
<p>{item.series || 'N/A'}</p>
</td>
<td className="border-b border-border p-3">
<p>{item.file_type || 'N/A'}</p>
</td>
<td className="border-b border-border p-3">
<p>{item.file_size || 'N/A'}</p>
</td>
<td className="hidden border-b border-border p-3 md:table-cell">
<p>{item.upload_date || 'N/A'}</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Table columns={searchColumns} data={results} loading={isLoading} rowKey="id" />
</div>
</div>
);
}
export default function SearchPage() {
const [query, setQuery] = useState('');
const [activeQuery, setActiveQuery] = useState('');
const [query, setQuery, activeQuery, flushQuery] = useDebouncedState('', 300);
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
setActiveQuery(debouncedQuery);
}, [debouncedQuery]);
const { data, isLoading } = useGetSearch(
{ query: activeQuery, source },
@@ -172,9 +130,9 @@ export default function SearchPage() {
);
const results = getSearchResults(data);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setActiveQuery(query.trim());
flushQuery(query.trim());
};
return (
+45 -131
View File
@@ -1,13 +1,29 @@
import { useState, useEffect, FormEvent } from 'react';
import { useState, useEffect, SyntheticEvent } from 'react';
import { LoadingState, TextInput } from '../components';
import { inputClassName } from '../components/TextInput';
import { Table, type Column } from '../components/Table';
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
import type { Device, SettingsResponse } from '../generated/model';
import type { Device } from '../generated/model';
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
const deviceColumns: Column<Device>[] = [
{
id: 'name',
header: 'Name',
className: 'pl-0',
render: device => device.device_name || 'Unknown',
},
{ id: 'last_synced', header: 'Last Sync', render: device => formatDeviceDate(device.last_synced) },
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
];
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
{ value: 'dark', label: 'Dark', description: 'Always use the dark palette.' },
@@ -17,8 +33,9 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
const { showInfo, showError } = useToasts();
const settingsData = data?.status === 200 ? data.data : null;
const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
const [password, setPassword] = useState('');
@@ -31,7 +48,7 @@ export default function SettingsPage() {
}
}, [settingsData]);
const handlePasswordSubmit = async (e: FormEvent) => {
const handlePasswordSubmit = (e: SyntheticEvent) => {
e.preventDefault();
if (!password || !newPassword) {
@@ -39,93 +56,33 @@ export default function SettingsPage() {
return;
}
try {
const response = await updateSettings.mutateAsync({
data: {
password,
new_password: newPassword,
},
});
if (response.status >= 200 && response.status < 300) {
showInfo('Password updated successfully');
updateSettings.mutate(
{ data: { password, new_password: newPassword } },
toastMutationOptions({
success: 'Password updated successfully',
error: 'Failed to update password',
onSuccess: () => {
setPassword('');
setNewPassword('');
return;
}
showError('Failed to update password: ' + getErrorMessage(response.data));
} catch (error) {
showError('Failed to update password: ' + getErrorMessage(error));
}
},
})
);
};
const handleTimezoneSubmit = async (e: FormEvent) => {
const handleTimezoneSubmit = (e: SyntheticEvent) => {
e.preventDefault();
try {
const response = await updateSettings.mutateAsync({
data: {
timezone,
},
});
if (response.status >= 200 && response.status < 300) {
showInfo('Timezone updated successfully');
return;
}
showError('Failed to update timezone: ' + getErrorMessage(response.data));
} catch (error) {
showError('Failed to update timezone: ' + getErrorMessage(error));
}
updateSettings.mutate(
{ data: { timezone } },
toastMutationOptions({
success: 'Timezone updated successfully',
error: 'Failed to update timezone',
})
);
};
if (isLoading) {
return (
<div className="flex w-full flex-col gap-4 md:flex-row">
<div>
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
<div className="mb-4 size-16 rounded-full bg-surface-strong" />
<div className="h-6 w-32 rounded bg-surface-strong" />
</div>
</div>
<div className="flex grow flex-col gap-4">
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-10 w-40 rounded bg-surface-strong" />
</div>
</div>
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-10 w-40 rounded bg-surface-strong" />
</div>
</div>
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => (
<div key={mode.value} className="h-24 rounded bg-surface-strong" />
))}
</div>
</div>
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-24 rounded bg-surface-strong" />
<div className="mb-4 flex gap-4">
<div className="h-6 flex-1 rounded bg-surface-strong" />
<div className="h-6 flex-1 rounded bg-surface-strong" />
<div className="h-6 flex-1 rounded bg-surface-strong" />
</div>
<div className="h-32 flex-1 rounded bg-surface-strong" />
</div>
</div>
</div>
);
return <LoadingState />;
}
return (
@@ -146,11 +103,10 @@ export default function SettingsPage() {
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
<PasswordIcon size={15} />
</span>
<input
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="Password"
/>
</div>
@@ -160,11 +116,10 @@ export default function SettingsPage() {
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
<PasswordIcon size={15} />
</span>
<input
<TextInput
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
placeholder="New Password"
/>
</div>
@@ -226,7 +181,7 @@ export default function SettingsPage() {
<select
value={timezone || 'UTC'}
onChange={e => setTimezone(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600"
className={inputClassName}
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
@@ -250,48 +205,7 @@ export default function SettingsPage() {
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Devices</p>
<table className="min-w-full bg-surface text-sm">
<thead className="text-content-muted">
<tr>
<th className="border-b border-border p-3 pl-0 text-left font-normal uppercase">
Name
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Last Sync
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Created
</th>
</tr>
</thead>
<tbody className="text-content">
{!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr>
<td className="p-3 text-center" colSpan={3}>
No Results
</td>
</tr>
) : (
settingsData.devices.map((device: Device) => (
<tr key={device.id}>
<td className="p-3 pl-0">
<p>{device.device_name || 'Unknown'}</p>
</td>
<td className="p-3">
<p>
{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}
</p>
</td>
<td className="p-3">
<p>
{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}
</p>
</td>
</tr>
))
)}
</tbody>
</table>
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
</div>
</div>
</div>
+12 -11
View File
@@ -7,7 +7,12 @@ import {
useState,
type ReactNode,
} from 'react';
import { getThemeMode, setThemeMode, type ThemeMode } from '../utils/localSettings';
import {
getThemeMode,
setThemeMode,
LOCAL_SETTINGS_KEY,
type ThemeMode,
} from '../utils/localSettings';
export type ResolvedThemeMode = 'light' | 'dark';
@@ -53,21 +58,20 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
resolveThemeMode(getThemeMode())
);
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just sets `themeModeState`.
useEffect(() => {
setResolvedThemeMode(applyThemeMode(themeModeState));
}, [themeModeState]);
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
useEffect(() => {
if (typeof window === 'undefined') {
if (typeof window === 'undefined' || themeModeState !== 'system') {
return undefined;
}
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
if (themeModeState === 'system') {
setResolvedThemeMode(applyThemeMode('system'));
}
};
mediaQueryList.addEventListener('change', handleSystemThemeChange);
@@ -76,19 +80,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
};
}, [themeModeState]);
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== 'antholume:settings') {
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
return;
}
const nextThemeMode = getThemeMode();
setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
setThemeModeState(getThemeMode());
};
window.addEventListener('storage', handleStorage);
@@ -100,7 +102,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
setThemeMode(nextThemeMode);
setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
}, []);
const value = useMemo(
+57
View File
@@ -0,0 +1,57 @@
interface StreamToFileOptions {
suggestedName: string;
mimeType: string;
extension: string;
}
/**
* Streams a response body to a user-chosen file. Uses the File System Access API when
* available (large-file friendly, no memory buffering); otherwise falls back to a
* blob/object-URL download. Returns whether the download completed (false if the user
* cancelled or the browser lacks any download capability).
*/
export async function streamResponseToFile(
response: Response,
{ suggestedName, mimeType, extension }: StreamToFileOptions
): Promise<boolean> {
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
try {
const handle = await window.showSaveFilePicker({
suggestedName,
types: [{ description: 'File', accept: { [mimeType]: [extension] } }],
});
const writable = await handle.createWritable();
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
return true;
} catch (err) {
// User-cancelled picker resolves as AbortError; treat as "no download".
if ((err as Error).name === 'AbortError') return false;
throw err;
}
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return true;
}
export function backupFilename(): string {
return `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
}
+8
View File
@@ -70,3 +70,11 @@ export function formatDuration(seconds: number): string {
return parts.join(' ');
}
export function formatUtcDate(dateString: string): string {
const date = new Date(dateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
+1 -1
View File
@@ -3,7 +3,7 @@ export type DocumentsViewMode = 'grid' | 'list';
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
const LOCAL_SETTINGS_KEY = 'antholume:settings';
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings {
themeMode?: ThemeMode;