+7
-1
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()}`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -11,37 +11,31 @@ export interface ToastProps {
|
||||
onClose?: (id: string) => void;
|
||||
}
|
||||
|
||||
const getToastStyles = (_type: ToastType) => {
|
||||
const baseStyles =
|
||||
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||
const baseStyles =
|
||||
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||
|
||||
const typeStyles = {
|
||||
info: 'border-secondary-500 bg-secondary-100',
|
||||
warning: 'border-yellow-500 bg-yellow-100',
|
||||
error: 'border-red-500 bg-red-100',
|
||||
};
|
||||
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 = {
|
||||
info: 'text-secondary-700',
|
||||
warning: 'text-yellow-700',
|
||||
error: 'text-red-700',
|
||||
};
|
||||
const iconStyles: Record<ToastType, string> = {
|
||||
info: 'text-secondary-700',
|
||||
warning: 'text-yellow-700',
|
||||
error: 'text-red-700',
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
info: 'text-secondary-900',
|
||||
warning: 'text-yellow-900',
|
||||
error: 'text-red-900',
|
||||
};
|
||||
|
||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
||||
const textStyles: Record<ToastType, string> = {
|
||||
info: 'text-secondary-900',
|
||||
warning: 'text-yellow-900',
|
||||
error: 'text-red-900',
|
||||
};
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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`;
|
||||
const completed = await streamResponseToFile(response, {
|
||||
suggestedName: backupFilename(),
|
||||
mimeType: 'application/zip',
|
||||
extension: '.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 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();
|
||||
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.'
|
||||
);
|
||||
if (completed) {
|
||||
showInfo('Backup completed successfully');
|
||||
}
|
||||
} 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 (
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+134
-364
@@ -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),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveDescription = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { description: editDescription } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingDescription(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingDescription(false),
|
||||
}
|
||||
);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
return true;
|
||||
} catch (err) {
|
||||
showError('Failed to save: ' + getErrorMessage(err));
|
||||
return 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>
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.title}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Title"
|
||||
value={document.title}
|
||||
onSave={value => save({ title: value })}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.author}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Author"
|
||||
value={document.author}
|
||||
onSave={value => save({ author: value })}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={
|
||||
@@ -450,63 +270,13 @@ 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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue className="hyphens-auto text-justify">
|
||||
{document.description || 'N/A'}
|
||||
</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={document.description || ''}
|
||||
multiline
|
||||
valueClassName="hyphens-auto text-justify"
|
||||
onSave={value => save({ description: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,149 +14,115 @@ 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 => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
const open = () => navigate(`/documents/${doc.id}`);
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
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} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: Document;
|
||||
}
|
||||
const fields = [
|
||||
{ label: 'Title', value: doc.title || 'Unknown' },
|
||||
{ label: 'Author', value: doc.author || 'Unknown' },
|
||||
{ label: 'Progress', value: `${percentage}%` },
|
||||
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
|
||||
];
|
||||
|
||||
function DocumentListItem({ doc }: DocumentListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
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={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
{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">
|
||||
<input
|
||||
type="file"
|
||||
accept=".epub"
|
||||
id="document_file"
|
||||
name="document_file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
<button
|
||||
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
||||
type="button"
|
||||
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
<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>);
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</form>
|
||||
<label htmlFor="upload-file-button">
|
||||
<div
|
||||
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
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</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>
|
||||
<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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
updateSettings.mutate(
|
||||
{ data: { password, new_password: newPassword } },
|
||||
toastMutationOptions({
|
||||
success: 'Password updated successfully',
|
||||
error: 'Failed to update password',
|
||||
onSuccess: () => {
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Password updated successfully');
|
||||
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>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user