@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend clean tests
|
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend dev_noauth clean tests
|
||||||
|
|
||||||
DEV_ENV = GIN_MODE=release \
|
DEV_ENV = GIN_MODE=release \
|
||||||
CONFIG_PATH=./data \
|
CONFIG_PATH=./data \
|
||||||
@@ -9,6 +9,8 @@ DEV_ENV = GIN_MODE=release \
|
|||||||
COOKIE_AUTH_KEY=1234 \
|
COOKIE_AUTH_KEY=1234 \
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DEV_USER ?= evan
|
||||||
|
|
||||||
build_local: legacy_tailwind
|
build_local: legacy_tailwind
|
||||||
go mod download
|
go mod download
|
||||||
rm -r ./build || true
|
rm -r ./build || true
|
||||||
@@ -51,6 +53,9 @@ dev_backend:
|
|||||||
dev_frontend:
|
dev_frontend:
|
||||||
cd frontend && pnpm run dev
|
cd frontend && pnpm run dev
|
||||||
|
|
||||||
|
dev_noauth:
|
||||||
|
DISABLE_AUTH=true DISABLE_AUTH_USER=$(DEV_USER) $(MAKE) dev
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf ./build
|
rm -rf ./build
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -18,9 +18,15 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- Use local icon components from `src/icons/`.
|
- Use local icon components from `src/icons/`.
|
||||||
- Do not add external icon libraries.
|
- Do not add external icon libraries.
|
||||||
- Prefer generated types from `src/generated/model/` over `any`.
|
- 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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 {
|
interface BaseButtonProps {
|
||||||
variant?: 'default' | 'secondary';
|
variant?: 'default' | 'secondary';
|
||||||
@@ -7,7 +7,6 @@ interface BaseButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
|
||||||
|
|
||||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||||
const baseClass =
|
const baseClass =
|
||||||
@@ -31,15 +30,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
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 {
|
interface FieldProps {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isEditing?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
export function Field({ label, children }: FieldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded">
|
<div className="relative rounded">
|
||||||
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { useAuth } from '../auth/AuthContext';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { navItems, adminNavItems } from './navigation';
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function hasPrefix(path: string, prefix: string): boolean {
|
function hasPrefix(path: string, prefix: string): boolean {
|
||||||
return path.startsWith(prefix);
|
return path.startsWith(prefix);
|
||||||
@@ -146,7 +125,7 @@ export default function HamburgerMenu() {
|
|||||||
|
|
||||||
{hasPrefix(location.pathname, '/admin') && (
|
{hasPrefix(location.pathname, '/admin') && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{adminSubItems.map(item => (
|
{adminNavItems.map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { UserIcon, DropdownIcon } from '../icons';
|
|||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import type { ThemeMode } from '../utils/localSettings';
|
import type { ThemeMode } from '../utils/localSettings';
|
||||||
import HamburgerMenu from './HamburgerMenu';
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
|
import { getPageTitle } from './navigation';
|
||||||
|
|
||||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||||
|
|
||||||
@@ -38,23 +39,7 @@ export default function Layout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navItems = [
|
const currentPageTitle = getPageTitle(location.pathname);
|
||||||
{ 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';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||||
|
|||||||
@@ -94,45 +94,6 @@ import { Skeleton } from './components/Skeleton';
|
|||||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
<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`
|
#### `SkeletonTable`
|
||||||
|
|
||||||
Table placeholder:
|
Table placeholder:
|
||||||
@@ -142,33 +103,6 @@ Table placeholder:
|
|||||||
<SkeletonTable rows={10} columns={6} showHeader={false} />
|
<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
|
## Integration with Table Component
|
||||||
|
|
||||||
The Table component now supports skeleton loading:
|
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
|
- `clsx` - Utility for constructing className strings
|
||||||
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
- `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 type { GraphDataPoint } from '../generated/model';
|
||||||
|
import { formatUtcDate } from '../utils/formatters';
|
||||||
|
|
||||||
interface ReadingHistoryGraphProps {
|
interface ReadingHistoryGraphProps {
|
||||||
data: GraphDataPoint[];
|
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) {
|
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||||
const svgWidth = 800;
|
const svgWidth = 800;
|
||||||
const svgHeight = 70;
|
const svgHeight = 70;
|
||||||
@@ -199,7 +192,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
|||||||
left: '50%',
|
left: '50%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{formatDate(point.date)}</span>
|
<span>{formatUtcDate(point.date)}</span>
|
||||||
<span>{point.minutes_read} minutes</span>
|
<span>{point.minutes_read} minutes</span>
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
interface SkeletonTableProps {
|
||||||
rows?: number;
|
rows?: number;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
@@ -158,58 +89,3 @@ export function SkeletonTable({
|
|||||||
</div>
|
</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>[] = [
|
const columns: Column<TestRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
id: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
|
render: row => row.name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
id: 'role',
|
||||||
header: 'Role',
|
header: 'Role',
|
||||||
|
render: row => row.role,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -41,9 +43,9 @@ describe('Table', () => {
|
|||||||
it('uses a custom render function for column output', () => {
|
it('uses a custom render function for column output', () => {
|
||||||
const customColumns: Column<TestRow>[] = [
|
const customColumns: Column<TestRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
id: 'name',
|
||||||
header: '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 { ReactNode } from 'react';
|
||||||
import { Skeleton } from './Skeleton';
|
import { SkeletonTable } from './Skeleton';
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
export interface Column<T extends object> {
|
export interface Column<T> {
|
||||||
key: keyof T;
|
id: string;
|
||||||
header: string;
|
header: ReactNode;
|
||||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
|
render: (row: T, index: number) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableProps<T extends object> {
|
export interface TableProps<T> {
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
data: T[];
|
data: T[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: ReactNode;
|
||||||
rowKey?: keyof T | ((row: T) => string);
|
rowKey?: keyof T | ((row: T) => string);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonTable({
|
export function Table<T>({
|
||||||
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>({
|
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -86,7 +45,7 @@ export function Table<T extends object>({
|
|||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<th
|
<th
|
||||||
key={String(column.key)}
|
key={column.id}
|
||||||
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
||||||
>
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
@@ -106,12 +65,10 @@ export function Table<T extends object>({
|
|||||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<td
|
<td
|
||||||
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
key={column.id}
|
||||||
className={`p-3 text-content ${column.className || ''}`}
|
className={`p-3 text-content ${column.className || ''}`}
|
||||||
>
|
>
|
||||||
{column.render
|
{column.render(row, index)}
|
||||||
? column.render(row[column.key], row, index)
|
|
||||||
: (row[column.key] as React.ReactNode)}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</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;
|
onClose?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToastStyles = (_type: ToastType) => {
|
const baseStyles =
|
||||||
const baseStyles =
|
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||||
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
|
||||||
|
|
||||||
const typeStyles = {
|
const typeStyles: Record<ToastType, string> = {
|
||||||
info: 'border-secondary-500 bg-secondary-100',
|
info: 'border-secondary-500 bg-secondary-100',
|
||||||
warning: 'border-yellow-500 bg-yellow-100',
|
warning: 'border-yellow-500 bg-yellow-100',
|
||||||
error: 'border-red-500 bg-red-100',
|
error: 'border-red-500 bg-red-100',
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconStyles = {
|
const iconStyles: Record<ToastType, string> = {
|
||||||
info: 'text-secondary-700',
|
info: 'text-secondary-700',
|
||||||
warning: 'text-yellow-700',
|
warning: 'text-yellow-700',
|
||||||
error: 'text-red-700',
|
error: 'text-red-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyles = {
|
const textStyles: Record<ToastType, string> = {
|
||||||
info: 'text-secondary-900',
|
info: 'text-secondary-900',
|
||||||
warning: 'text-yellow-900',
|
warning: 'text-yellow-900',
|
||||||
error: 'text-red-900',
|
error: 'text-red-900',
|
||||||
};
|
|
||||||
|
|
||||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||||
|
|
||||||
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsAnimatingOut(true);
|
setIsAnimatingOut(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -20,34 +20,31 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showToast = useCallback(
|
const showToast = useCallback(
|
||||||
(message: string, _type: ToastType = 'info', _duration?: number): string => {
|
(message: string, type: ToastType = 'info', duration?: number): string => {
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const id = crypto.randomUUID();
|
||||||
setToasts(prev => [
|
setToasts(prev => [...prev, { id, type, message, duration, onClose: removeToast }]);
|
||||||
...prev,
|
|
||||||
{ id, type: _type, message, duration: _duration, onClose: removeToast },
|
|
||||||
]);
|
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
[removeToast]
|
[removeToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showInfo = useCallback(
|
const showInfo = useCallback(
|
||||||
(message: string, _duration?: number) => {
|
(message: string, duration?: number) => {
|
||||||
return showToast(message, 'info', _duration);
|
return showToast(message, 'info', duration);
|
||||||
},
|
},
|
||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showWarning = useCallback(
|
const showWarning = useCallback(
|
||||||
(message: string, _duration?: number) => {
|
(message: string, duration?: number) => {
|
||||||
return showToast(message, 'warning', _duration);
|
return showToast(message, 'warning', duration);
|
||||||
},
|
},
|
||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showError = useCallback(
|
const showError = useCallback(
|
||||||
(message: string, _duration?: number) => {
|
(message: string, duration?: number) => {
|
||||||
return showToast(message, 'error', _duration);
|
return showToast(message, 'error', duration);
|
||||||
},
|
},
|
||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,23 +2,13 @@
|
|||||||
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
||||||
|
|
||||||
// Toast components
|
// Toast components
|
||||||
export { Toast } from './Toast';
|
|
||||||
export { ToastProvider, useToasts } from './ToastContext';
|
export { ToastProvider, useToasts } from './ToastContext';
|
||||||
export type { ToastType, ToastProps } from './Toast';
|
|
||||||
|
|
||||||
// Skeleton components
|
// Skeleton components
|
||||||
export {
|
export { Skeleton, SkeletonTable } from './Skeleton';
|
||||||
Skeleton,
|
|
||||||
SkeletonText,
|
|
||||||
SkeletonAvatar,
|
|
||||||
SkeletonCard,
|
|
||||||
SkeletonTable,
|
|
||||||
SkeletonButton,
|
|
||||||
PageLoader,
|
|
||||||
InlineLoader,
|
|
||||||
} from './Skeleton';
|
|
||||||
export { LoadingState } from './LoadingState';
|
export { LoadingState } from './LoadingState';
|
||||||
export { Pagination } from './Pagination';
|
export { Pagination } from './Pagination';
|
||||||
|
export { TextInput } from './TextInput';
|
||||||
|
|
||||||
// Field components
|
// Field components
|
||||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
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 {
|
interface UseEpubReaderResult {
|
||||||
viewerRef: (_node: HTMLDivElement | null) => void;
|
viewerRef: (node: HTMLDivElement | null) => void;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
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 { Table, type Column } from '../components/Table';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
|
const ACTIVITY_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const documentID = searchParams.get('document') || undefined;
|
const documentID = searchParams.get('document') || undefined;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 25;
|
const limit = ACTIVITY_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -27,28 +29,28 @@ export default function ActivityPage() {
|
|||||||
|
|
||||||
const columns: Column<Activity>[] = [
|
const columns: Column<Activity>[] = [
|
||||||
{
|
{
|
||||||
key: 'document_id' as const,
|
id: 'document',
|
||||||
header: 'Document',
|
header: 'Document',
|
||||||
render: (_value, row) => (
|
render: row => (
|
||||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'start_time' as const,
|
id: 'start_time',
|
||||||
header: 'Time',
|
header: 'Time',
|
||||||
render: value => String(value || 'N/A'),
|
render: row => row.start_time || 'N/A',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'duration' as const,
|
id: 'duration',
|
||||||
header: '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',
|
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 { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { LoadingState } from '../components';
|
||||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
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 { getErrorMessage } from '../utils/errors';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { FolderOpenIcon } from '../icons';
|
import { FolderOpenIcon } from '../icons';
|
||||||
@@ -11,6 +13,7 @@ export default function AdminImportPage() {
|
|||||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||||
const { showInfo, showError } = useToasts();
|
const { showInfo, showError } = useToasts();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||||
currentPath ? { directory: currentPath } : {}
|
currentPath ? { directory: currentPath } : {}
|
||||||
@@ -19,7 +22,7 @@ export default function AdminImportPage() {
|
|||||||
const postImport = usePostImport();
|
const postImport = usePostImport();
|
||||||
|
|
||||||
const directoryResponse =
|
const directoryResponse =
|
||||||
directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
|
directoryData?.status === 200 ? directoryData.data : null;
|
||||||
const directories = directoryResponse?.items ?? [];
|
const directories = directoryResponse?.items ?? [];
|
||||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||||
|
|
||||||
@@ -48,9 +51,7 @@ export default function AdminImportPage() {
|
|||||||
{
|
{
|
||||||
onSuccess: _response => {
|
onSuccess: _response => {
|
||||||
showInfo('Import completed successfully');
|
showInfo('Import completed successfully');
|
||||||
setTimeout(() => {
|
navigate('/admin/import-results');
|
||||||
window.location.href = '/admin/import-results';
|
|
||||||
}, 1500);
|
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
showError('Import failed: ' + getErrorMessage(error));
|
showError('Import failed: ' + getErrorMessage(error));
|
||||||
@@ -64,7 +65,7 @@ export default function AdminImportPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && !currentPath) {
|
if (isLoading && !currentPath) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDirectory) {
|
if (selectedDirectory) {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
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';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export default function AdminImportResultsPage() {
|
export default function AdminImportResultsPage() {
|
||||||
const { data: resultsData, isLoading } = useGetImportResults();
|
const { data: resultsData, isLoading } = useGetImportResults();
|
||||||
const results =
|
const results =
|
||||||
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
resultsData?.status === 200 ? resultsData.data.results || [] : [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,11 +34,8 @@ export default function AdminImportResultsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
results.map((result: ImportResult, index: number) => (
|
results.map((result: ImportResult, index: number) => (
|
||||||
<tr key={index}>
|
<tr key={result.path ?? index}>
|
||||||
<td
|
<td className="grid grid-cols-[4rem_auto] border-b border-border p-3">
|
||||||
className="grid border-b border-border p-3"
|
|
||||||
style={{ gridTemplateColumns: '4rem auto' }}
|
|
||||||
>
|
|
||||||
<span className="text-content-muted">Name:</span>
|
<span className="text-content-muted">Name:</span>
|
||||||
{result.id ? (
|
{result.id ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
import { useState, useEffect, FormEvent } from 'react';
|
import { SyntheticEvent } from 'react';
|
||||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||||
import type { LogsResponse } from '../generated/model';
|
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, TextInput } from '../components';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon } from '../icons';
|
import { Search2Icon } from '../icons';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
|
||||||
const [activeFilter, setActiveFilter] = useState('');
|
|
||||||
const debouncedFilter = useDebounce(filter, 300);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveFilter(debouncedFilter);
|
|
||||||
}, [debouncedFilter]);
|
|
||||||
|
|
||||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
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();
|
e.preventDefault();
|
||||||
setActiveFilter(filter);
|
flushFilter();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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} />
|
<Search2Icon size={15} hoverable={false} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => setFilter(e.target.value)}
|
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"
|
placeholder="JQ Filter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,13 +43,11 @@ export default function AdminLogsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex w-full flex-col-reverse overflow-scroll font-mono text-content">
|
||||||
className="flex w-full flex-col-reverse overflow-scroll text-content"
|
|
||||||
style={{ fontFamily: 'monospace' }}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingState className="min-h-40 w-full" />
|
<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) => (
|
logs.map((log, index) => (
|
||||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
{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 { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
|
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||||
|
|
||||||
interface BackupTypes {
|
interface BackupTypes {
|
||||||
covers: boolean;
|
covers: boolean;
|
||||||
@@ -20,7 +22,7 @@ export default function AdminPage() {
|
|||||||
});
|
});
|
||||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||||
|
|
||||||
const handleBackupSubmit = async (e: FormEvent) => {
|
const handleBackupSubmit = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const backupTypesList: string[] = [];
|
const backupTypesList: string[] = [];
|
||||||
if (backupTypes.covers) backupTypesList.push('COVERS');
|
if (backupTypes.covers) backupTypesList.push('COVERS');
|
||||||
@@ -31,6 +33,7 @@ export default function AdminPage() {
|
|||||||
formData.append('action', 'BACKUP');
|
formData.append('action', 'BACKUP');
|
||||||
backupTypesList.forEach(value => formData.append('backup_types', value));
|
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', {
|
const response = await fetch('/api/v1/admin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -40,43 +43,21 @@ export default function AdminPage() {
|
|||||||
throw new Error('Backup failed: ' + response.statusText);
|
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') {
|
if (completed) {
|
||||||
try {
|
showInfo('Backup completed successfully');
|
||||||
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.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Backup failed: ' + getErrorMessage(error));
|
showError('Backup failed: ' + getErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreSubmit = async (e: FormEvent) => {
|
const handleRestoreSubmit = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!restoreFile) return;
|
if (!restoreFile) return;
|
||||||
|
|
||||||
@@ -125,7 +106,7 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||||
import type { User, UsersResponse } from '../generated/model';
|
import type { User } from '../generated/model';
|
||||||
import { AddIcon, DeleteIcon } from '../icons';
|
import { AddIcon, DeleteIcon } from '../icons';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const { showInfo, showError } = useToasts();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
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();
|
e.preventDefault();
|
||||||
if (!newUsername || !newPassword) return;
|
if (!newUsername || !newPassword) return;
|
||||||
|
|
||||||
@@ -30,22 +34,17 @@ export default function AdminUsersPage() {
|
|||||||
is_admin: newIsAdmin,
|
is_admin: newIsAdmin,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: response => {
|
success: 'User created successfully',
|
||||||
if (response.status < 200 || response.status >= 300) {
|
error: 'Failed to create user',
|
||||||
showError('Failed to create user: ' + getErrorMessage(response.data));
|
onSuccess: () => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showInfo('User created successfully');
|
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setNewUsername('');
|
setNewUsername('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setNewIsAdmin(false);
|
setNewIsAdmin(false);
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
|
})
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,18 +53,11 @@ export default function AdminUsersPage() {
|
|||||||
{
|
{
|
||||||
data: { operation: 'DELETE', user: userId },
|
data: { operation: 'DELETE', user: userId },
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: response => {
|
success: 'User deleted successfully',
|
||||||
if (response.status < 200 || response.status >= 300) {
|
error: 'Failed to delete user',
|
||||||
showError('Failed to delete user: ' + getErrorMessage(response.data));
|
onSuccess: refetch,
|
||||||
return;
|
})
|
||||||
}
|
|
||||||
|
|
||||||
showInfo('User deleted successfully');
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,18 +68,11 @@ export default function AdminUsersPage() {
|
|||||||
{
|
{
|
||||||
data: { operation: 'UPDATE', user: userId, password },
|
data: { operation: 'UPDATE', user: userId, password },
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: response => {
|
success: 'Password updated successfully',
|
||||||
if (response.status < 200 || response.status >= 300) {
|
error: 'Failed to update password',
|
||||||
showError('Failed to update password: ' + getErrorMessage(response.data));
|
onSuccess: refetch,
|
||||||
return;
|
})
|
||||||
}
|
|
||||||
|
|
||||||
showInfo('Password updated successfully');
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,23 +81,80 @@ export default function AdminUsersPage() {
|
|||||||
{
|
{
|
||||||
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
|
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: response => {
|
success: `User permissions updated to ${isAdmin ? 'admin' : 'user'}`,
|
||||||
if (response.status < 200 || response.status >= 300) {
|
error: 'Failed to update admin status',
|
||||||
showError('Failed to update admin status: ' + getErrorMessage(response.data));
|
onSuccess: refetch,
|
||||||
return;
|
})
|
||||||
}
|
|
||||||
|
|
||||||
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
if (isLoading) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,89 +195,42 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-full overflow-scroll rounded shadow-sm">
|
<Table columns={userColumns} data={users} rowKey="id" />
|
||||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
|
||||||
<thead className="text-content-muted">
|
{resetUserId && (
|
||||||
<tr>
|
<div
|
||||||
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase">
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
|
||||||
<button onClick={() => setShowAddForm(!showAddForm)}>
|
onClick={() => setResetUserId(null)}
|
||||||
<AddIcon size={20} />
|
>
|
||||||
</button>
|
<form
|
||||||
</th>
|
className="w-80 rounded bg-surface p-4 shadow-lg"
|
||||||
<th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
|
onClick={e => e.stopPropagation()}
|
||||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
onSubmit={e => {
|
||||||
Password
|
e.preventDefault();
|
||||||
</th>
|
if (!resetPassword) return;
|
||||||
<th className="border-b border-border p-3 text-center font-normal uppercase">
|
handleUpdatePassword(resetUserId, resetPassword);
|
||||||
Permissions
|
setResetUserId(null);
|
||||||
</th>
|
}}
|
||||||
<th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
|
>
|
||||||
Created
|
<p className="mb-3 text-content">Reset password for {resetUserId}</p>
|
||||||
</th>
|
<TextInput
|
||||||
</tr>
|
type="password"
|
||||||
</thead>
|
value={resetPassword}
|
||||||
<tbody>
|
onChange={e => setResetPassword(e.target.value)}
|
||||||
{users.length === 0 ? (
|
placeholder="New password"
|
||||||
<tr>
|
autoFocus
|
||||||
<td className="p-3 text-center" colSpan={5}>
|
/>
|
||||||
No Results
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
</td>
|
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}>
|
||||||
</tr>
|
Cancel
|
||||||
) : (
|
</Button>
|
||||||
users.map((user: User) => (
|
<Button type="submit" disabled={!resetPassword}>
|
||||||
<tr key={user.id}>
|
Save
|
||||||
<td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
|
</Button>
|
||||||
<button onClick={() => handleDeleteUser(user.id)}>
|
</div>
|
||||||
<DeleteIcon size={20} />
|
</form>
|
||||||
</button>
|
</div>
|
||||||
</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>
|
|
||||||
</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,
|
useEditDocument,
|
||||||
getGetDocumentQueryKey,
|
getGetDocumentQueryKey,
|
||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import { Document } from '../generated/model/document';
|
import type { EditDocumentBody } from '../generated/model';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import {
|
import { getErrorMessage } from '../utils/errors';
|
||||||
DeleteIcon,
|
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||||
ActivityIcon,
|
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||||
SearchIcon,
|
import { useToasts } from '../components/ToastContext';
|
||||||
DownloadIcon,
|
|
||||||
EditIcon,
|
|
||||||
InfoIcon,
|
|
||||||
CloseIcon,
|
|
||||||
CheckIcon,
|
|
||||||
} from '../icons';
|
|
||||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
|
||||||
|
|
||||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||||
const popupClassName =
|
const popupClassName =
|
||||||
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||||
const popupInputClassName = 'rounded bg-surface p-2 text-content';
|
|
||||||
const editInputClassName =
|
const editInputClassName =
|
||||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
'w-full rounded border border-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() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||||
const editMutation = useEditDocument();
|
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 [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||||
|
|
||||||
const [editTitle, setEditTitle] = useState('');
|
|
||||||
const [editAuthor, setEditAuthor] = useState('');
|
|
||||||
const [editDescription, setEditDescription] = useState('');
|
|
||||||
|
|
||||||
if (docLoading) {
|
if (docLoading) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!docData || docData.status !== 200) {
|
if (!docData || docData.status !== 200) {
|
||||||
return <div className="text-content-muted">Document not found</div>;
|
return <div className="text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = docData.data.document as Document;
|
const document = docData.data.document;
|
||||||
|
|
||||||
if (!document) {
|
|
||||||
return <div className="text-content-muted">Document not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentage = document.percentage ?? 0;
|
const percentage = document.percentage ?? 0;
|
||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
const startEditing = (field: 'title' | 'author' | 'description') => {
|
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
||||||
if (field === 'title') setEditTitle(document.title);
|
try {
|
||||||
if (field === 'author') setEditAuthor(document.author);
|
const response = await editMutation.mutateAsync({ id: document.id, data });
|
||||||
if (field === 'description') setEditDescription(document.description || '');
|
if (response.status !== 200) {
|
||||||
};
|
showError('Failed to save: ' + getErrorMessage(response.data));
|
||||||
|
return false;
|
||||||
const saveTitle = () => {
|
|
||||||
editMutation.mutate(
|
|
||||||
{ id: document.id, data: { title: editTitle } },
|
|
||||||
{
|
|
||||||
onSuccess: response => {
|
|
||||||
setIsEditingTitle(false);
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
|
||||||
},
|
|
||||||
onError: () => setIsEditingTitle(false),
|
|
||||||
}
|
}
|
||||||
);
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||||
};
|
return true;
|
||||||
|
} catch (err) {
|
||||||
const saveAuthor = () => {
|
showError('Failed to save: ' + getErrorMessage(err));
|
||||||
editMutation.mutate(
|
return false;
|
||||||
{ 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),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative size-full">
|
||||||
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
|
<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">
|
<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
|
||||||
<img
|
className="w-full rounded object-fill"
|
||||||
className="w-full rounded object-fill"
|
src={`/api/v1/documents/${document.id}/cover`}
|
||||||
src={`/api/v1/documents/${document.id}/cover`}
|
alt={`${document.title} cover`}
|
||||||
alt={`${document.title} cover`}
|
/>
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{document.filepath && (
|
{document.filepath && (
|
||||||
<a
|
<a
|
||||||
@@ -141,77 +180,7 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 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
|
<a
|
||||||
href={`/activity?document=${document.id}`}
|
href={`/activity?document=${document.id}`}
|
||||||
aria-label="Activity"
|
aria-label="Activity"
|
||||||
@@ -220,55 +189,6 @@ export default function DocumentPage() {
|
|||||||
<ActivityIcon size={28} />
|
<ActivityIcon size={28} />
|
||||||
</a>
|
</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 ? (
|
{document.filepath ? (
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/documents/${document.id}/file`}
|
href={`/api/v1/documents/${document.id}/file`}
|
||||||
@@ -287,117 +207,17 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||||
<Field
|
<EditableField
|
||||||
isEditing={isEditingTitle}
|
label="Title"
|
||||||
label={
|
value={document.title}
|
||||||
<>
|
onSave={value => save({ title: value })}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Field
|
<EditableField
|
||||||
isEditing={isEditingAuthor}
|
label="Author"
|
||||||
label={
|
value={document.author}
|
||||||
<>
|
onSave={value => save({ author: value })}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={
|
label={
|
||||||
@@ -450,63 +270,13 @@ export default function DocumentPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<EditableField
|
||||||
isEditing={isEditingDescription}
|
label="Description"
|
||||||
label={
|
value={document.description || ''}
|
||||||
<>
|
multiline
|
||||||
<FieldLabel>Description</FieldLabel>
|
valueClassName="hyphens-auto text-justify"
|
||||||
<FieldActions>
|
onSave={value => save({ description: value })}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
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 { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState, Pagination } from '../components';
|
import { LoadingState, Pagination, TextInput } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import {
|
import {
|
||||||
getDocumentsViewMode,
|
getDocumentsViewMode,
|
||||||
@@ -14,149 +14,115 @@ import {
|
|||||||
type DocumentsViewMode,
|
type DocumentsViewMode,
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
|
|
||||||
interface DocumentCardProps {
|
const DOCUMENTS_PAGE_SIZE = 9;
|
||||||
|
|
||||||
|
interface DocumentItemProps {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
|
layout: 'grid' | 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentCard({ doc }: DocumentCardProps) {
|
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const percentage = doc.percentage || 0;
|
const percentage = doc.percentage || 0;
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||||
|
|
||||||
return (
|
const open = () => navigate(`/documents/${doc.id}`);
|
||||||
<div className="relative w-full">
|
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||||
<div
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
role="link"
|
event.preventDefault();
|
||||||
tabIndex={0}
|
open();
|
||||||
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 === ' ') {
|
const icons = (
|
||||||
event.preventDefault();
|
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||||
navigate(`/documents/${doc.id}`);
|
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||||
}
|
<ActivityIcon size={20} />
|
||||||
}}
|
</Link>
|
||||||
>
|
{doc.filepath ? (
|
||||||
<div className="relative my-auto h-48 min-w-fit">
|
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||||
<img
|
<DownloadIcon size={20} />
|
||||||
className="h-full rounded object-cover"
|
</a>
|
||||||
src={`/api/v1/documents/${doc.id}/cover`}
|
) : (
|
||||||
alt={doc.title}
|
<DownloadIcon size={20} disabled />
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
|
||||||
<div className="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">Title</p>
|
|
||||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">Author</p>
|
|
||||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">Progress</p>
|
|
||||||
<p className="font-medium">{percentage}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">Time Read</p>
|
|
||||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
|
||||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
|
||||||
<ActivityIcon size={20} />
|
|
||||||
</Link>
|
|
||||||
{doc.filepath ? (
|
|
||||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
|
||||||
<DownloadIcon size={20} />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<DownloadIcon size={20} disabled />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
const fields = [
|
||||||
doc: Document;
|
{ 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) {
|
if (layout === 'grid') {
|
||||||
const navigate = useNavigate();
|
return (
|
||||||
const percentage = doc.percentage || 0;
|
<div className="relative w-full">
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
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}`)}
|
onClick={open}
|
||||||
onKeyDown={event => {
|
onKeyDown={onKeyDown}
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
navigate(`/documents/${doc.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="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 className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||||
<div>
|
{fields.map(f => (
|
||||||
<p className="text-content-subtle">Title</p>
|
<div key={f.label}>
|
||||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
<p className="text-content-subtle">{f.label}</p>
|
||||||
</div>
|
<p className="font-medium">{f.value}</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 />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [limit] = useState(9);
|
const limit = DOCUMENTS_PAGE_SIZE;
|
||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showInfo, showWarning, showError } = useToasts();
|
const { showInfo, showWarning, showError } = useToasts();
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDocumentsViewMode(viewMode);
|
setDocumentsViewMode(viewMode);
|
||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
@@ -167,12 +133,12 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||||
const createMutation = useCreateDocument();
|
const createMutation = useCreateDocument();
|
||||||
const docs = (data?.data as DocumentsResponse | undefined)?.documents;
|
const documentsResponse = data?.status === 200 ? data.data : undefined;
|
||||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
const docs = documentsResponse?.documents;
|
||||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
|
const previousPage = documentsResponse?.previous_page;
|
||||||
|
const nextPage = documentsResponse?.next_page;
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadDocument = async (file: File | undefined) => {
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.name.endsWith('.epub')) {
|
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">
|
<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} />
|
<Search2Icon size={15} hoverable={false} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
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"
|
placeholder="Search Author / Title"
|
||||||
name="search"
|
name="search"
|
||||||
/>
|
/>
|
||||||
@@ -251,7 +216,7 @@ export default function DocumentsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingState className="col-span-full min-h-48" />
|
<LoadingState className="col-span-full min-h-48" />
|
||||||
) : docs && docs.length > 0 ? (
|
) : 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">
|
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||||
No documents found.
|
No documents found.
|
||||||
@@ -263,7 +228,7 @@ export default function DocumentsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingState className="min-h-48" />
|
<LoadingState className="min-h-48" />
|
||||||
) : docs && docs.length > 0 ? (
|
) : 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">
|
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||||
No documents found.
|
No documents found.
|
||||||
@@ -276,59 +241,47 @@ export default function DocumentsPage() {
|
|||||||
page={page}
|
page={page}
|
||||||
previousPage={previousPage}
|
previousPage={previousPage}
|
||||||
nextPage={nextPage}
|
nextPage={nextPage}
|
||||||
total={(data?.data as DocumentsResponse | undefined)?.total}
|
total={documentsResponse?.total}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||||
<input
|
{uploadMode && (
|
||||||
type="checkbox"
|
<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">
|
||||||
id="upload-file-button"
|
<div className="flex flex-col gap-2">
|
||||||
className="hidden"
|
<input
|
||||||
checked={uploadMode}
|
type="file"
|
||||||
onChange={() => setUploadMode(!uploadMode)}
|
accept=".epub"
|
||||||
/>
|
id="document_file"
|
||||||
<div
|
name="document_file"
|
||||||
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'}`}
|
ref={fileInputRef}
|
||||||
>
|
/>
|
||||||
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
|
<button
|
||||||
<input
|
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
||||||
type="file"
|
type="button"
|
||||||
accept=".epub"
|
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])}
|
||||||
id="document_file"
|
>
|
||||||
name="document_file"
|
Upload File
|
||||||
ref={fileInputRef}
|
</button>
|
||||||
onChange={handleFileChange}
|
</div>
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
type="button"
|
||||||
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
|
|
||||||
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
|
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}
|
onClick={handleCancelUpload}
|
||||||
>
|
>
|
||||||
Cancel Upload
|
Cancel Upload
|
||||||
</div>
|
</button>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<label
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadMode(!uploadMode)}
|
||||||
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
|
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" />
|
<UploadIcon size={34} className="text-content-inverse" />
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { LoadingState } from '../components';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||||
import type {
|
import type {
|
||||||
HomeResponse,
|
|
||||||
LeaderboardData,
|
LeaderboardData,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
UserStreak,
|
UserStreak,
|
||||||
@@ -154,7 +154,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
<div>
|
<div>
|
||||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={item.user_id}
|
||||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
|
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -172,14 +172,14 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
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 dbInfo = homeResponse?.database_info;
|
||||||
const streaks = homeResponse?.streaks?.streaks;
|
const streaks = homeResponse?.streaks?.streaks;
|
||||||
const graphData = homeResponse?.graph_data?.graph_data;
|
const graphData = homeResponse?.graph_data?.graph_data;
|
||||||
const userStats = homeResponse?.user_statistics;
|
const userStats = homeResponse?.user_statistics;
|
||||||
|
|
||||||
if (homeLoading) {
|
if (homeLoading) {
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -201,9 +201,9 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{streaks?.map((streak: UserStreak, index: number) => (
|
{streaks?.map((streak: UserStreak) => (
|
||||||
<StreakCard
|
<StreakCard
|
||||||
key={index}
|
key={streak.window}
|
||||||
window={streak.window as 'DAY' | 'WEEK'}
|
window={streak.window as 'DAY' | 'WEEK'}
|
||||||
currentStreak={streak.current_streak}
|
currentStreak={streak.current_streak}
|
||||||
currentStreakStartDate={streak.current_streak_start_date}
|
currentStreakStartDate={streak.current_streak_start_date}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import { useState, FormEvent, useEffect } from 'react';
|
import { useState, SyntheticEvent, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRegistrationEnabled(infoData: unknown): boolean {
|
export function getRegistrationEnabled(infoData: unknown): boolean {
|
||||||
if (!infoData || typeof infoData !== 'object') {
|
if (!infoData || typeof infoData !== 'object') {
|
||||||
@@ -34,84 +24,6 @@ export function getRegistrationEnabled(infoData: unknown): boolean {
|
|||||||
return infoData.data.registration_enabled;
|
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() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -134,7 +46,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -148,14 +60,16 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginPageView
|
<AuthFormView
|
||||||
username={username}
|
username={username}
|
||||||
password={password}
|
password={password}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
registrationEnabled={registrationEnabled}
|
|
||||||
onUsernameChange={setUsername}
|
onUsernameChange={setUsername}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
onSubmit={handleSubmit}
|
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 { Pagination } from '../components';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
|
|
||||||
|
const PROGRESS_PAGE_SIZE = 15;
|
||||||
|
|
||||||
export default function ProgressPage() {
|
export default function ProgressPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 15;
|
const limit = PROGRESS_PAGE_SIZE;
|
||||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||||
const response = data?.status === 200 ? data.data : undefined;
|
const response = data?.status === 200 ? data.data : undefined;
|
||||||
const progress = response?.progress ?? [];
|
const progress = response?.progress ?? [];
|
||||||
|
|
||||||
const columns: Column<Progress>[] = [
|
const columns: Column<Progress>[] = [
|
||||||
{
|
{
|
||||||
key: 'document_id' as const,
|
id: 'document',
|
||||||
header: 'Document',
|
header: 'Document',
|
||||||
render: (_value, row) => (
|
render: row => (
|
||||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'device_name' as const,
|
id: 'device_name',
|
||||||
header: 'Device Name',
|
header: 'Device Name',
|
||||||
render: value => String(value || 'Unknown'),
|
render: row => row.device_name || 'Unknown',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'percentage' as const,
|
id: 'percentage',
|
||||||
header: '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',
|
header: 'Created At',
|
||||||
render: value =>
|
render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'),
|
||||||
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, FormEvent, useEffect } from 'react';
|
import { useState, SyntheticEvent, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
|
import { getRegistrationEnabled } from './LoginPage';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -19,10 +20,7 @@ export default function RegisterPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const registrationEnabled =
|
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
|
||||||
? infoData.data.registration_enabled
|
|
||||||
: false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingAuth && isAuthenticated) {
|
if (!isCheckingAuth && isAuthenticated) {
|
||||||
@@ -35,7 +33,7 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -49,68 +47,17 @@ export default function RegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-content">
|
<AuthFormView
|
||||||
<div className="flex w-full flex-wrap">
|
username={username}
|
||||||
<div className="flex w-full flex-col md:w-1/2">
|
password={password}
|
||||||
<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">
|
isLoading={isLoading}
|
||||||
<p className="text-center text-3xl">Welcome.</p>
|
onUsernameChange={setUsername}
|
||||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
onPasswordChange={setPassword}
|
||||||
<div className="flex flex-col pt-4">
|
onSubmit={handleSubmit}
|
||||||
<div className="relative flex">
|
submitLabel="Register"
|
||||||
<input
|
submittingLabel="Registering..."
|
||||||
type="text"
|
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
||||||
value={username}
|
footer={authFormFooter({ to: '/login', text: 'Login here.' }, true)}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,12 @@ describe('SearchPage', () => {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
} as ReturnType<typeof useGetSearch>);
|
} 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', () => {
|
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 { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||||
import type { SearchItem } from '../generated/model';
|
import type { SearchItem } from '../generated/model';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { LoadingState } from '../components';
|
import { TextInput } from '../components';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { inputClassName } from '../components/TextInput';
|
||||||
|
import { Table, type Column } from '../components/Table';
|
||||||
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
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 {
|
interface SearchPageViewProps {
|
||||||
query: string;
|
query: string;
|
||||||
source: GetSearchSource;
|
source: GetSearchSource;
|
||||||
@@ -14,7 +39,7 @@ interface SearchPageViewProps {
|
|||||||
results: SearchItem[];
|
results: SearchItem[];
|
||||||
onQueryChange: (value: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onSourceChange: (value: GetSearchSource) => void;
|
onSourceChange: (value: GetSearchSource) => void;
|
||||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSearchResults(data: unknown): SearchItem[] {
|
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">
|
<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} />
|
<Search2Icon size={15} hoverable={false} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => onQueryChange(e.target.value)}
|
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"
|
placeholder="Query"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +96,7 @@ export function SearchPageView({
|
|||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
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.LibGen}>Library Genesis</option>
|
||||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||||
@@ -86,81 +110,15 @@ export function SearchPageView({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
<Table columns={searchColumns} data={results} loading={isLoading} rowKey="id" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery, activeQuery, flushQuery] = useDebouncedState('', 300);
|
||||||
const [activeQuery, setActiveQuery] = useState('');
|
|
||||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||||
const debouncedQuery = useDebounce(query, 300);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveQuery(debouncedQuery);
|
|
||||||
}, [debouncedQuery]);
|
|
||||||
|
|
||||||
const { data, isLoading } = useGetSearch(
|
const { data, isLoading } = useGetSearch(
|
||||||
{ query: activeQuery, source },
|
{ query: activeQuery, source },
|
||||||
@@ -172,9 +130,9 @@ export default function SearchPage() {
|
|||||||
);
|
);
|
||||||
const results = getSearchResults(data);
|
const results = getSearchResults(data);
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveQuery(query.trim());
|
flushQuery(query.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 { 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 { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import type { ThemeMode } from '../utils/localSettings';
|
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 }> = [
|
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
||||||
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
|
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
|
||||||
{ value: 'dark', label: 'Dark', description: 'Always use the dark 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() {
|
export default function SettingsPage() {
|
||||||
const { data, isLoading } = useGetSettings();
|
const { data, isLoading } = useGetSettings();
|
||||||
const updateSettings = useUpdateSettings();
|
const updateSettings = useUpdateSettings();
|
||||||
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
|
const settingsData = data?.status === 200 ? data.data : null;
|
||||||
const { showInfo, showError } = useToasts();
|
const { showError } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -31,7 +48,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [settingsData]);
|
}, [settingsData]);
|
||||||
|
|
||||||
const handlePasswordSubmit = async (e: FormEvent) => {
|
const handlePasswordSubmit = (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!password || !newPassword) {
|
if (!password || !newPassword) {
|
||||||
@@ -39,93 +56,33 @@ export default function SettingsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
updateSettings.mutate(
|
||||||
const response = await updateSettings.mutateAsync({
|
{ data: { password, new_password: newPassword } },
|
||||||
data: {
|
toastMutationOptions({
|
||||||
password,
|
success: 'Password updated successfully',
|
||||||
new_password: newPassword,
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
updateSettings.mutate(
|
||||||
const response = await updateSettings.mutateAsync({
|
{ data: { timezone } },
|
||||||
data: {
|
toastMutationOptions({
|
||||||
timezone,
|
success: 'Timezone updated successfully',
|
||||||
},
|
error: 'Failed to update 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));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<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 (
|
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">
|
<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} />
|
<PasswordIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
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"
|
placeholder="Password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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} />
|
<PasswordIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
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"
|
placeholder="New Password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +181,7 @@ export default function SettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={timezone || 'UTC'}
|
value={timezone || 'UTC'}
|
||||||
onChange={e => setTimezone(e.target.value)}
|
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="UTC">UTC</option>
|
||||||
<option value="America/New_York">America/New_York</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">
|
<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>
|
<p className="text-lg font-semibold text-content">Devices</p>
|
||||||
<table className="min-w-full bg-surface text-sm">
|
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} 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';
|
export type ResolvedThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -53,21 +58,20 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
resolveThemeMode(getThemeMode())
|
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(() => {
|
useEffect(() => {
|
||||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
setResolvedThemeMode(applyThemeMode(themeModeState));
|
||||||
}, [themeModeState]);
|
}, [themeModeState]);
|
||||||
|
|
||||||
|
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined' || themeModeState !== 'system') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
const handleSystemThemeChange = () => {
|
const handleSystemThemeChange = () => {
|
||||||
if (themeModeState === 'system') {
|
setResolvedThemeMode(applyThemeMode('system'));
|
||||||
setResolvedThemeMode(applyThemeMode('system'));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQueryList.addEventListener('change', handleSystemThemeChange);
|
mediaQueryList.addEventListener('change', handleSystemThemeChange);
|
||||||
@@ -76,19 +80,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [themeModeState]);
|
}, [themeModeState]);
|
||||||
|
|
||||||
|
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (event.key && event.key !== 'antholume:settings') {
|
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setThemeModeState(getThemeMode());
|
||||||
const nextThemeMode = getThemeMode();
|
|
||||||
setThemeModeState(nextThemeMode);
|
|
||||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
window.addEventListener('storage', handleStorage);
|
||||||
@@ -100,7 +102,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
||||||
setThemeMode(nextThemeMode);
|
setThemeMode(nextThemeMode);
|
||||||
setThemeModeState(nextThemeMode);
|
setThemeModeState(nextThemeMode);
|
||||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
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(' ');
|
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 ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
||||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
||||||
|
|
||||||
const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||||
|
|
||||||
interface LocalSettings {
|
interface LocalSettings {
|
||||||
themeMode?: ThemeMode;
|
themeMode?: ThemeMode;
|
||||||
|
|||||||
Reference in New Issue
Block a user