@@ -33,6 +33,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- 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.
|
||||||
|
- Reuse shared primitives instead of re-rolling them: `formatDate` / `formatDateTime` (`src/utils/formatters.ts`) for user-facing timestamps (`formatUtcDate` is intentionally UTC, for graph day-buckets only); `SegmentedControl` for toggle groups (default `pill` variant needs only `options`/`value`/`onChange`; pass `variant="unstyled"` for bespoke shapes like grids/inline text); `usePaginatedList` + `documentColumn` for paginated list/table pages.
|
||||||
|
|
||||||
## 3) Generated API client
|
## 3) Generated API client
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- Run frontend tests with `pnpm run test`.
|
- Run frontend tests with `pnpm run test`.
|
||||||
- `pnpm run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
- `pnpm run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||||
- When possible, validate changed files directly before escalating to full-project fixes.
|
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||||
|
- `pnpm run format` currently reports pre-existing style violations in files unrelated to most changes; format only the files you touched (`npx prettier --write <files>`) rather than reformatting the whole tree.
|
||||||
|
|
||||||
## 7) Live Dev Server Debugging
|
## 7) Live Dev Server Debugging
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/AuthContext';
|
|||||||
import { UserIcon, DropdownIcon } from '../icons';
|
import { UserIcon, DropdownIcon } from '../icons';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import { THEME_MODES } from '../utils/localSettings';
|
import { THEME_MODES } from '../utils/localSettings';
|
||||||
|
import { SegmentedControl } from './SegmentedControl';
|
||||||
import HamburgerMenu from './HamburgerMenu';
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
import { getPageTitle } from './navigation';
|
import { getPageTitle } from './navigation';
|
||||||
|
|
||||||
@@ -61,30 +62,17 @@ export default function Layout() {
|
|||||||
{isUserDropdownOpen && (
|
{isUserDropdownOpen && (
|
||||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||||
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||||
<div
|
<div className="border-b border-border px-4 py-3">
|
||||||
className="border-b border-border px-4 py-3"
|
|
||||||
role="group"
|
|
||||||
aria-label="Theme mode"
|
|
||||||
>
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
<SegmentedControl
|
||||||
{THEME_MODES.map(mode => (
|
className="w-full"
|
||||||
<button
|
ariaLabel="Theme mode"
|
||||||
key={mode}
|
value={themeMode}
|
||||||
type="button"
|
onChange={setThemeMode}
|
||||||
onClick={() => setThemeMode(mode)}
|
options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
|
||||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
/>
|
||||||
themeMode === mode
|
|
||||||
? 'bg-content text-content-inverse'
|
|
||||||
: 'text-content-muted hover:bg-surface hover:text-content'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="py-1"
|
className="py-1"
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ function MyComponent() {
|
|||||||
- `removeToast(id: string): void`
|
- `removeToast(id: string): void`
|
||||||
- Manually remove a toast by ID
|
- Manually remove a toast by ID
|
||||||
|
|
||||||
- `clearToasts(): void`
|
|
||||||
- Clear all active toasts
|
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -76,22 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
|
|||||||
|
|
||||||
#### `Skeleton`
|
#### `Skeleton`
|
||||||
|
|
||||||
Basic skeleton element with various variants:
|
A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Skeleton } from './components/Skeleton';
|
import { Skeleton } from './components/Skeleton';
|
||||||
|
|
||||||
// Default (rounded rectangle)
|
<Skeleton className="h-8 w-full" />
|
||||||
<Skeleton className="w-full h-8" />
|
<Skeleton className="w-3/4" />
|
||||||
|
|
||||||
// Text variant
|
|
||||||
<Skeleton variant="text" className="w-3/4" />
|
|
||||||
|
|
||||||
// Circular variant (for avatars)
|
|
||||||
<Skeleton variant="circular" width={40} height={40} />
|
|
||||||
|
|
||||||
// Rectangular variant
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `SkeletonTable`
|
#### `SkeletonTable`
|
||||||
|
|||||||
@@ -10,39 +10,67 @@ interface SegmentedControlProps<T extends string> {
|
|||||||
options: SegmentedOption<T>[];
|
options: SegmentedOption<T>[];
|
||||||
value: T;
|
value: T;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
activeClassName: string;
|
variant?: 'pill' | 'unstyled';
|
||||||
inactiveClassName: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
|
activeClassName?: string;
|
||||||
|
inactiveClassName?: string;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pill Variant Defaults - The bordered "segmented" look most call sites want, so they pass only
|
||||||
|
// options/value/onChange. `unstyled` opts out entirely for callers with a bespoke shape (grid, inline text).
|
||||||
|
const PILL = {
|
||||||
|
container: 'inline-flex rounded border border-border bg-surface-muted p-1',
|
||||||
|
button: 'flex-1 rounded px-3 py-1 text-sm font-medium capitalize transition-colors',
|
||||||
|
active: 'bg-content text-content-inverse',
|
||||||
|
inactive: 'text-content-muted hover:bg-surface hover:text-content',
|
||||||
|
};
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
activeClassName,
|
variant = 'pill',
|
||||||
inactiveClassName,
|
|
||||||
className,
|
className,
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
|
activeClassName,
|
||||||
|
inactiveClassName,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
|
const styles =
|
||||||
|
variant === 'pill'
|
||||||
|
? {
|
||||||
|
container: cn(PILL.container, className),
|
||||||
|
button: cn(PILL.button, buttonClassName),
|
||||||
|
active: activeClassName ?? PILL.active,
|
||||||
|
inactive: inactiveClassName ?? PILL.inactive,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
container: className,
|
||||||
|
button: buttonClassName,
|
||||||
|
active: activeClassName,
|
||||||
|
inactive: inactiveClassName,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} role="group" aria-label={ariaLabel}>
|
<div className={styles.container} role="group" aria-label={ariaLabel}>
|
||||||
{options.map(option => (
|
{options.map(option => {
|
||||||
<button
|
const isActive = value === option.value;
|
||||||
key={option.value}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onChange(option.value)}
|
key={option.value}
|
||||||
aria-pressed={value === option.value}
|
type="button"
|
||||||
className={cn(
|
onClick={() => {
|
||||||
buttonClassName,
|
if (!isActive) onChange(option.value);
|
||||||
value === option.value ? activeClassName : inactiveClassName
|
}}
|
||||||
)}
|
aria-pressed={isActive}
|
||||||
>
|
className={cn(styles.button, isActive ? styles.active : styles.inactive)}
|
||||||
{option.label}
|
>
|
||||||
</button>
|
{option.label}
|
||||||
))}
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,10 @@ import { cn } from '../utils/cn';
|
|||||||
|
|
||||||
interface SkeletonProps {
|
interface SkeletonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
animation?: 'pulse' | 'wave' | 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Skeleton({
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
className = '',
|
return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', className)} />;
|
||||||
variant = 'default',
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
animation = 'pulse',
|
|
||||||
}: SkeletonProps) {
|
|
||||||
const baseClasses = 'bg-surface-strong';
|
|
||||||
|
|
||||||
const variantClasses = {
|
|
||||||
default: 'rounded',
|
|
||||||
text: 'h-4 rounded-md',
|
|
||||||
circular: 'rounded-full',
|
|
||||||
rectangular: 'rounded-none',
|
|
||||||
};
|
|
||||||
|
|
||||||
const animationClasses = {
|
|
||||||
pulse: 'animate-pulse',
|
|
||||||
wave: 'animate-wave',
|
|
||||||
none: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
|
||||||
height:
|
|
||||||
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkeletonTableProps {
|
interface SkeletonTableProps {
|
||||||
@@ -65,7 +29,7 @@ export function SkeletonTable({
|
|||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
{Array.from({ length: columns }).map((_, i) => (
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
<th key={i} className="p-3">
|
<th key={i} className="p-3">
|
||||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
<Skeleton className="h-5 w-3/4" />
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -76,10 +40,7 @@ export function SkeletonTable({
|
|||||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
<td key={colIndex} className="p-3">
|
<td key={colIndex} className="p-3">
|
||||||
<Skeleton
|
<Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
|
||||||
variant="text"
|
|
||||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface ToastContextType {
|
|||||||
showError: (message: string, duration?: number) => string;
|
showError: (message: string, duration?: number) => string;
|
||||||
updateToast: (id: string, update: ToastUpdate) => void;
|
updateToast: (id: string, update: ToastUpdate) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
clearToasts: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
@@ -61,13 +60,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearToasts = useCallback(() => {
|
|
||||||
setToasts([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider
|
<ToastContext.Provider
|
||||||
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }}
|
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ToastContainer toasts={toasts} />
|
<ToastContainer toasts={toasts} />
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { Column } from './Table';
|
||||||
|
|
||||||
|
interface DocumentRow {
|
||||||
|
document_id?: string;
|
||||||
|
author?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared "Document" Column - Progress and Activity render the same author/title link to the doc.
|
||||||
|
export const documentColumn: Column<DocumentRow> = {
|
||||||
|
id: 'document',
|
||||||
|
header: 'Document',
|
||||||
|
render: row => (
|
||||||
|
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||||
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page state for a paginated list. Passing `resetKey` (e.g. a search term or filter id) resets
|
||||||
|
* back to page 1 whenever it changes, so a filter change never strands the user on a now-empty page.
|
||||||
|
*/
|
||||||
|
export function usePaginatedList(resetKey?: unknown) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
return { page, setPage };
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ const SWIPE_THRESHOLD = 25;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
|
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
|
||||||
* that clears the pending wheel-cooldown timeout (called from EBookReader.destroy).
|
* (called from EBookReader.destroy) that clears the pending wheel-cooldown timeout and removes
|
||||||
|
* every listener added across rendered sections, so they don't accumulate on re-render.
|
||||||
*/
|
*/
|
||||||
export function registerRenditionGestures(
|
export function registerRenditionGestures(
|
||||||
rendition: EpubRendition,
|
rendition: EpubRendition,
|
||||||
@@ -25,6 +26,7 @@ export function registerRenditionGestures(
|
|||||||
let touchEndX = 0;
|
let touchEndX = 0;
|
||||||
let touchEndY = 0;
|
let touchEndY = 0;
|
||||||
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const listenerCleanups: Array<() => void> = [];
|
||||||
|
|
||||||
const resetWheelCooldown = () => {
|
const resetWheelCooldown = () => {
|
||||||
if (wheelTimeoutId) {
|
if (wheelTimeoutId) {
|
||||||
@@ -68,14 +70,11 @@ export function registerRenditionGestures(
|
|||||||
rendition.hooks.render.register((contents: EpubContents) => {
|
rendition.hooks.render.register((contents: EpubContents) => {
|
||||||
const renderDoc = contents.document;
|
const renderDoc = contents.document;
|
||||||
|
|
||||||
const wakeLockListener = () => {
|
const onWakeLock = () => {
|
||||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||||
};
|
};
|
||||||
renderDoc.addEventListener('click', wakeLockListener);
|
|
||||||
renderDoc.addEventListener('gesturechange', wakeLockListener);
|
|
||||||
renderDoc.addEventListener('touchstart', wakeLockListener);
|
|
||||||
|
|
||||||
renderDoc.addEventListener('click', (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
const windowWidth = window.innerWidth;
|
const windowWidth = window.innerWidth;
|
||||||
const windowHeight = window.innerHeight;
|
const windowHeight = window.innerHeight;
|
||||||
const barPixels = windowHeight * 0.2;
|
const barPixels = windowHeight * 0.2;
|
||||||
@@ -99,9 +98,9 @@ export function registerRenditionGestures(
|
|||||||
} else {
|
} else {
|
||||||
handlers.onCenterTap();
|
handlers.onCenterTap();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
const onWheel = (event: WheelEvent) => {
|
||||||
if (wheelTimeoutId) {
|
if (wheelTimeoutId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,26 +112,36 @@ export function registerRenditionGestures(
|
|||||||
if (event.deltaY < -SWIPE_THRESHOLD) {
|
if (event.deltaY < -SWIPE_THRESHOLD) {
|
||||||
handleSwipeDown();
|
handleSwipeDown();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (event: TouchEvent) => {
|
||||||
|
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
||||||
|
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = (event: TouchEvent) => {
|
||||||
|
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
||||||
|
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
||||||
|
handleGesture();
|
||||||
|
};
|
||||||
|
|
||||||
|
renderDoc.addEventListener('click', onWakeLock);
|
||||||
|
renderDoc.addEventListener('gesturechange', onWakeLock);
|
||||||
|
renderDoc.addEventListener('touchstart', onWakeLock);
|
||||||
|
renderDoc.addEventListener('click', onClick);
|
||||||
|
renderDoc.addEventListener('wheel', onWheel);
|
||||||
|
renderDoc.addEventListener('touchstart', onTouchStart);
|
||||||
|
renderDoc.addEventListener('touchend', onTouchEnd);
|
||||||
|
|
||||||
|
listenerCleanups.push(() => {
|
||||||
|
renderDoc.removeEventListener('click', onWakeLock);
|
||||||
|
renderDoc.removeEventListener('gesturechange', onWakeLock);
|
||||||
|
renderDoc.removeEventListener('touchstart', onWakeLock);
|
||||||
|
renderDoc.removeEventListener('click', onClick);
|
||||||
|
renderDoc.removeEventListener('wheel', onWheel);
|
||||||
|
renderDoc.removeEventListener('touchstart', onTouchStart);
|
||||||
|
renderDoc.removeEventListener('touchend', onTouchEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
|
||||||
'touchstart',
|
|
||||||
(event: TouchEvent) => {
|
|
||||||
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
|
||||||
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
|
||||||
'touchend',
|
|
||||||
(event: TouchEvent) => {
|
|
||||||
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
|
||||||
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
|
||||||
handleGesture();
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -140,5 +149,7 @@ export function registerRenditionGestures(
|
|||||||
clearTimeout(wheelTimeoutId);
|
clearTimeout(wheelTimeoutId);
|
||||||
wheelTimeoutId = null;
|
wheelTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
listenerCleanups.forEach(cleanup => cleanup());
|
||||||
|
listenerCleanups.length = 0;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
|
||||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Activity } from '../generated/model';
|
import type { Activity } 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';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { documentColumn } from '../components/documentColumn';
|
||||||
|
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||||
|
import { formatDuration, formatDateTime } from '../utils/formatters';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
const ACTIVITY_PAGE_SIZE = 25;
|
const ACTIVITY_PAGE_SIZE = 25;
|
||||||
@@ -12,13 +13,9 @@ 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 } = usePaginatedList(documentID);
|
||||||
const limit = ACTIVITY_PAGE_SIZE;
|
const limit = ACTIVITY_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [documentID]);
|
|
||||||
|
|
||||||
const { data, isLoading } = useGetActivity({
|
const { data, isLoading } = useGetActivity({
|
||||||
doc_filter: Boolean(documentID),
|
doc_filter: Boolean(documentID),
|
||||||
document_id: documentID,
|
document_id: documentID,
|
||||||
@@ -29,19 +26,11 @@ export default function ActivityPage() {
|
|||||||
const activities = response?.activities ?? [];
|
const activities = response?.activities ?? [];
|
||||||
|
|
||||||
const columns: Column<Activity>[] = [
|
const columns: Column<Activity>[] = [
|
||||||
{
|
documentColumn,
|
||||||
id: 'document',
|
|
||||||
header: 'Document',
|
|
||||||
render: row => (
|
|
||||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'start_time',
|
id: 'start_time',
|
||||||
header: 'Time',
|
header: 'Time',
|
||||||
render: row => row.start_time || 'N/A',
|
render: row => formatDateTime(row.start_time),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { User } from '../generated/model';
|
|||||||
import { AddIcon, DeleteIcon } from '../icons';
|
import { AddIcon, DeleteIcon } from '../icons';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { formatDate } from '../utils/formatters';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
interface AddUserFormProps {
|
interface AddUserFormProps {
|
||||||
@@ -238,7 +239,12 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at },
|
{
|
||||||
|
id: 'created',
|
||||||
|
header: 'Created',
|
||||||
|
className: 'w-48',
|
||||||
|
render: user => formatDate(user.created_at),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -122,21 +122,20 @@ export default function DocumentPage() {
|
|||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = dataForStatus(docData, 200)?.document;
|
const doc = dataForStatus(docData, 200)?.document;
|
||||||
|
|
||||||
if (!document) {
|
if (!doc) {
|
||||||
return <div className="text-content-muted">Document not found</div>;
|
return <div className="text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const percentage = document.percentage ?? 0;
|
const percentage = doc.percentage ?? 0;
|
||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
const secondsPerPercent = doc.seconds_per_percent || 0;
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
const save = (data: EditDocumentBody): Promise<boolean> =>
|
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||||
runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), {
|
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
||||||
error: 'Failed to save',
|
error: 'Failed to save',
|
||||||
onSuccess: response =>
|
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,13 +144,13 @@ export default function DocumentPage() {
|
|||||||
<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">
|
||||||
<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/${doc.id}/cover`}
|
||||||
alt={`${document.title} cover`}
|
alt={`${doc.title} cover`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{document.filepath && (
|
{doc.filepath && (
|
||||||
<a
|
<a
|
||||||
href={`/reader/${document.id}`}
|
href={`/reader/${doc.id}`}
|
||||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
||||||
>
|
>
|
||||||
Read
|
Read
|
||||||
@@ -162,26 +161,26 @@ export default function DocumentPage() {
|
|||||||
<div className="min-w-[50%] md:mr-2">
|
<div className="min-w-[50%] md:mr-2">
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-content-muted">ISBN-10:</p>
|
<p className="text-content-muted">ISBN-10:</p>
|
||||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
<p className="font-medium">{doc.isbn10 || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-content-muted">ISBN-13:</p>
|
<p className="text-content-muted">ISBN-13:</p>
|
||||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
<p className="font-medium">{doc.isbn13 || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<a
|
<a
|
||||||
href={`/activity?document=${document.id}`}
|
href={`/activity?document=${doc.id}`}
|
||||||
aria-label="Activity"
|
aria-label="Activity"
|
||||||
className={iconButtonClassName}
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
<ActivityIcon size={28} />
|
<ActivityIcon size={28} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{document.filepath ? (
|
{doc.filepath ? (
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/documents/${document.id}/file`}
|
href={`/api/v1/documents/${doc.id}/file`}
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
className={iconButtonClassName}
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
@@ -197,15 +196,11 @@ 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">
|
||||||
<EditableField
|
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
||||||
label="Title"
|
|
||||||
value={document.title}
|
|
||||||
onSave={value => save({ title: value })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditableField
|
<EditableField
|
||||||
label="Author"
|
label="Author"
|
||||||
value={document.author}
|
value={doc.author}
|
||||||
onSave={value => save({ author: value })}
|
onSave={value => save({ author: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -234,9 +229,7 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
||||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||||
@@ -249,8 +242,8 @@ export default function DocumentPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldValue>
|
<FieldValue>
|
||||||
{document.total_time_seconds && document.total_time_seconds > 0
|
{doc.total_time_seconds && doc.total_time_seconds > 0
|
||||||
? formatDuration(document.total_time_seconds)
|
? formatDuration(doc.total_time_seconds)
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</FieldValue>
|
</FieldValue>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -262,7 +255,7 @@ export default function DocumentPage() {
|
|||||||
|
|
||||||
<EditableField
|
<EditableField
|
||||||
label="Description"
|
label="Description"
|
||||||
value={document.description || ''}
|
value={doc.description || ''}
|
||||||
multiline
|
multiline
|
||||||
valueClassName="hyphens-auto text-justify"
|
valueClassName="hyphens-auto text-justify"
|
||||||
onSave={value => save({ description: value })}
|
onSave={value => save({ description: value })}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document } from '../generated/model';
|
import type { Document } from '../generated/model';
|
||||||
@@ -9,6 +9,7 @@ import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
|||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
|
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ function EmptyDocuments({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||||
const [page, setPage] = useState(1);
|
const { page, setPage } = usePaginatedList(debouncedSearch);
|
||||||
const limit = DOCUMENTS_PAGE_SIZE;
|
const limit = DOCUMENTS_PAGE_SIZE;
|
||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||||
@@ -114,10 +115,6 @@ export default function DocumentsPage() {
|
|||||||
const { showWarning } = useToasts();
|
const { showWarning } = useToasts();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
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 documentsResponse = dataForStatus(data, 200);
|
const documentsResponse = dataForStatus(data, 200);
|
||||||
@@ -169,13 +166,9 @@ export default function DocumentsPage() {
|
|||||||
</IconInput>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<SegmentedControl<DocumentsViewMode>
|
<SegmentedControl<DocumentsViewMode>
|
||||||
className="inline-flex rounded border border-border bg-surface p-1"
|
|
||||||
ariaLabel="Document view mode"
|
ariaLabel="Document view mode"
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={setViewMode}
|
onChange={setViewMode}
|
||||||
buttonClassName="rounded px-3 py-1 text-sm font-medium transition-colors"
|
|
||||||
activeClassName="bg-content text-content-inverse"
|
|
||||||
inactiveClassName="text-content-muted hover:bg-surface-muted"
|
|
||||||
options={[
|
options={[
|
||||||
{ value: 'grid', label: 'Grid' },
|
{ value: 'grid', label: 'Grid' },
|
||||||
{ value: 'list', label: 'List' },
|
{ value: 'list', label: 'List' },
|
||||||
|
|||||||
@@ -35,51 +35,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface StreakCardProps {
|
interface StreakCardProps {
|
||||||
window: 'DAY' | 'WEEK';
|
streak: UserStreak;
|
||||||
currentStreak: number;
|
|
||||||
currentStreakStartDate: string;
|
|
||||||
currentStreakEndDate: string;
|
|
||||||
maxStreak: number;
|
|
||||||
maxStreakStartDate: string;
|
|
||||||
maxStreakEndDate: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreakCard({
|
function StreakCard({ streak }: StreakCardProps) {
|
||||||
window,
|
const isWeekly = streak.window === 'WEEK';
|
||||||
currentStreak,
|
|
||||||
currentStreakStartDate,
|
|
||||||
currentStreakEndDate,
|
|
||||||
maxStreak,
|
|
||||||
maxStreakStartDate,
|
|
||||||
maxStreakEndDate,
|
|
||||||
}: StreakCardProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
{isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||||
</p>
|
</p>
|
||||||
<div className="my-6 flex items-end space-x-2">
|
<div className="my-6 flex items-end space-x-2">
|
||||||
<p className="text-5xl font-bold">{currentStreak}</p>
|
<p className="text-5xl font-bold">{streak.current_streak}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
<p>{isWeekly ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||||
<div className="flex items-end text-sm text-content-subtle">
|
<div className="flex items-end text-sm text-content-subtle">
|
||||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
{streak.current_streak_start_date} ➞ {streak.current_streak_end_date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
<div className="flex items-end font-bold">{streak.current_streak}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
<p>{isWeekly ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||||
<div className="flex items-end text-sm text-content-subtle">
|
<div className="flex items-end text-sm text-content-subtle">
|
||||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
{streak.max_streak_start_date} ➞ {streak.max_streak_end_date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end font-bold">{maxStreak}</div>
|
<div className="flex items-end font-bold">{streak.max_streak}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +109,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
{name} Leaderboard
|
{name} Leaderboard
|
||||||
</p>
|
</p>
|
||||||
<SegmentedControl<TimePeriod>
|
<SegmentedControl<TimePeriod>
|
||||||
|
variant="unstyled"
|
||||||
className="flex items-center gap-2 text-xs"
|
className="flex items-center gap-2 text-xs"
|
||||||
ariaLabel={`${name} leaderboard period`}
|
ariaLabel={`${name} leaderboard period`}
|
||||||
value={selectedPeriod}
|
value={selectedPeriod}
|
||||||
@@ -197,16 +186,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<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) => (
|
{streaks?.map((streak: UserStreak) => (
|
||||||
<StreakCard
|
<StreakCard key={streak.window} streak={streak} />
|
||||||
key={streak.window}
|
|
||||||
window={streak.window as 'DAY' | 'WEEK'}
|
|
||||||
currentStreak={streak.current_streak}
|
|
||||||
currentStreakStartDate={streak.current_streak_start_date}
|
|
||||||
currentStreakEndDate={streak.current_streak_end_date}
|
|
||||||
maxStreak={streak.max_streak}
|
|
||||||
maxStreakStartDate={streak.max_streak_start_date}
|
|
||||||
maxStreakEndDate={streak.max_streak_end_date}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ describe('LoginPage', () => {
|
|||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
updateToast: vi.fn(),
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
@@ -115,7 +114,6 @@ describe('LoginPage', () => {
|
|||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
updateToast: vi.fn(),
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Progress } from '../generated/model';
|
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';
|
||||||
|
import { documentColumn } from '../components/documentColumn';
|
||||||
|
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||||
|
import { formatDate } from '../utils/formatters';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
const PROGRESS_PAGE_SIZE = 15;
|
const PROGRESS_PAGE_SIZE = 15;
|
||||||
|
|
||||||
export default function ProgressPage() {
|
export default function ProgressPage() {
|
||||||
const [page, setPage] = useState(1);
|
const { page, setPage } = usePaginatedList();
|
||||||
const limit = PROGRESS_PAGE_SIZE;
|
const limit = PROGRESS_PAGE_SIZE;
|
||||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||||
const response = dataForStatus(data, 200);
|
const response = dataForStatus(data, 200);
|
||||||
const progress = response?.progress ?? [];
|
const progress = response?.progress ?? [];
|
||||||
|
|
||||||
const columns: Column<Progress>[] = [
|
const columns: Column<Progress>[] = [
|
||||||
{
|
documentColumn,
|
||||||
id: 'document',
|
|
||||||
header: 'Document',
|
|
||||||
render: row => (
|
|
||||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'device_name',
|
id: 'device_name',
|
||||||
header: 'Device Name',
|
header: 'Device Name',
|
||||||
@@ -38,7 +31,7 @@ export default function ProgressPage() {
|
|||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'created_at',
|
||||||
header: 'Created At',
|
header: 'Created At',
|
||||||
render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'),
|
render: row => formatDate(row.created_at),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ReaderPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const document = dataForStatus(documentResponse, 200)?.document;
|
const doc = dataForStatus(documentResponse, 200)?.document;
|
||||||
const progress = dataForStatus(progressResponse, 200)?.progress;
|
const progress = dataForStatus(progressResponse, 200)?.progress;
|
||||||
|
|
||||||
const deviceId = defaultDeviceId;
|
const deviceId = defaultDeviceId;
|
||||||
@@ -89,17 +89,17 @@ export default function ReaderPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document?.title) {
|
if (doc?.title) {
|
||||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
document.title = `AnthoLume - Reader - ${doc.title}`;
|
||||||
}
|
}
|
||||||
}, [document?.title]);
|
}, [doc?.title]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTopBarOpen || isBottomBarOpen) {
|
if (isTopBarOpen || isBottomBarOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeElement = window.document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
if (activeElement instanceof HTMLElement) {
|
if (activeElement instanceof HTMLElement) {
|
||||||
activeElement.blur();
|
activeElement.blur();
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export default function ReaderPage() {
|
|||||||
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id || !document) {
|
if (!id || !doc) {
|
||||||
return <div className="p-6 text-content-muted">Document not found</div>;
|
return <div className="p-6 text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,24 +124,24 @@ export default function ReaderPage() {
|
|||||||
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
|
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-start gap-4">
|
<div className="flex min-w-0 items-start gap-4">
|
||||||
<Link to={`/documents/${document.id}`} className="block shrink-0">
|
<Link to={`/documents/${doc.id}`} className="block shrink-0">
|
||||||
<img
|
<img
|
||||||
className="h-28 w-20 rounded object-cover shadow-sm"
|
className="h-28 w-20 rounded object-cover shadow-sm"
|
||||||
src={`/api/v1/documents/${document.id}/cover`}
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
alt={`${document.title} cover`}
|
alt={`${doc.title} cover`}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
||||||
<p className="truncate text-lg font-semibold text-content">{document.title}</p>
|
<p className="truncate text-lg font-semibold text-content">{doc.title}</p>
|
||||||
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
|
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
|
||||||
<p className="truncate text-sm text-content-muted">{document.author}</p>
|
<p className="truncate text-sm text-content-muted">{doc.author}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/documents/${document.id}`}
|
to={`/documents/${doc.id}`}
|
||||||
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
@@ -224,6 +224,7 @@ export default function ReaderPage() {
|
|||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<SegmentedControl<ReaderColorScheme>
|
<SegmentedControl<ReaderColorScheme>
|
||||||
|
variant="unstyled"
|
||||||
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
ariaLabel="Reader theme"
|
ariaLabel="Reader theme"
|
||||||
value={colorScheme}
|
value={colorScheme}
|
||||||
@@ -238,6 +239,7 @@ export default function ReaderPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||||
<SegmentedControl<ReaderFontFamily>
|
<SegmentedControl<ReaderFontFamily>
|
||||||
|
variant="unstyled"
|
||||||
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
ariaLabel="Reader font"
|
ariaLabel="Reader font"
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ describe('RegisterPage', () => {
|
|||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
updateToast: vi.fn(),
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
@@ -116,7 +115,6 @@ describe('RegisterPage', () => {
|
|||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
updateToast: vi.fn(),
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
|
|||||||
import { inputClassName } from '../components/TextInput';
|
import { inputClassName } from '../components/TextInput';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon, BookIcon } from '../icons';
|
import { Search2Icon, BookIcon } from '../icons';
|
||||||
|
import { formatDate } from '../utils/formatters';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
const searchColumns: Column<SearchItem>[] = [
|
const searchColumns: Column<SearchItem>[] = [
|
||||||
@@ -21,7 +22,7 @@ const searchColumns: Column<SearchItem>[] = [
|
|||||||
id: 'date',
|
id: 'date',
|
||||||
header: 'Date',
|
header: 'Date',
|
||||||
className: 'hidden md:table-cell',
|
className: 'hidden md:table-cell',
|
||||||
render: item => item.upload_date || 'N/A',
|
render: item => formatDate(item.upload_date),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import { useToasts } from '../components/ToastContext';
|
|||||||
import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast, useToastMutation } 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';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
import { dataForStatus } from '../utils/apiResponses';
|
import { dataForStatus } from '../utils/apiResponses';
|
||||||
|
|
||||||
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
|
|
||||||
|
|
||||||
const deviceColumns: Column<Device>[] = [
|
const deviceColumns: Column<Device>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
@@ -22,9 +21,9 @@ const deviceColumns: Column<Device>[] = [
|
|||||||
{
|
{
|
||||||
id: 'last_synced',
|
id: 'last_synced',
|
||||||
header: 'Last Sync',
|
header: 'Last Sync',
|
||||||
render: device => formatDeviceDate(device.last_synced),
|
render: device => formatDateTime(device.last_synced),
|
||||||
},
|
},
|
||||||
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
|
{ id: 'created_at', header: 'Created', render: device => formatDateTime(device.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
||||||
|
|||||||
@@ -11,11 +11,3 @@ export function dataForStatus<TResponse extends ApiResponse, TStatus extends TRe
|
|||||||
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
|
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dataForSuccess<TResponse extends ApiResponse>(
|
|
||||||
response: TResponse | undefined
|
|
||||||
): Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'] | undefined {
|
|
||||||
return response && response.status >= 200 && response.status < 300
|
|
||||||
? (response.data as Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'])
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { formatNumber, formatDuration } from './formatters';
|
import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
|
||||||
|
|
||||||
describe('formatNumber', () => {
|
describe('formatNumber', () => {
|
||||||
it('formats zero', () => {
|
it('formats zero', () => {
|
||||||
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
|
|||||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatDuration', () => {
|
describe('formatDuration', () => {
|
||||||
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
|
|||||||
it('formats days, hours, minutes, and seconds', () => {
|
it('formats days, hours, minutes, and seconds', () => {
|
||||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate / formatDateTime', () => {
|
||||||
|
it('returns N/A for empty or unparseable values', () => {
|
||||||
|
expect(formatDate(undefined)).toBe('N/A');
|
||||||
|
expect(formatDate('')).toBe('N/A');
|
||||||
|
expect(formatDate('not-a-date')).toBe('N/A');
|
||||||
|
expect(formatDateTime(undefined)).toBe('N/A');
|
||||||
|
expect(formatDateTime('not-a-date')).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a valid ISO timestamp to a non-empty, non-N/A string', () => {
|
||||||
|
const date = formatDate('2023-06-15T12:00:00Z');
|
||||||
|
expect(date).not.toBe('N/A');
|
||||||
|
expect(date.length).toBeGreaterThan(0);
|
||||||
|
expect(formatDateTime('2023-06-15T12:00:00Z')).not.toBe('N/A');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ export function formatDuration(seconds: number): string {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toValidDate(value?: string): Date | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local Display Dates - Shared formatters for user-facing timestamps so every list/table renders
|
||||||
|
// them the same way, returning 'N/A' for empty or unparseable values.
|
||||||
|
export function formatDate(value?: string): string {
|
||||||
|
const date = toValidDate(value);
|
||||||
|
return date ? date.toLocaleDateString() : 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string): string {
|
||||||
|
const date = toValidDate(value);
|
||||||
|
return date ? date.toLocaleString() : 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Date - Intentionally UTC (not local): the reading graph buckets activity by UTC day, so
|
||||||
|
// converting to local time could shift a point to the wrong day.
|
||||||
export function formatUtcDate(dateString: string): string {
|
export function formatUtcDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export function useLocalSetting<K extends LocalSettingKey>(
|
|||||||
key: K,
|
key: K,
|
||||||
defaultValue: LocalSettingsMap[K]
|
defaultValue: LocalSettingsMap[K]
|
||||||
) {
|
) {
|
||||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue));
|
const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
|
||||||
|
readLocalSetting(key, defaultValue)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
@@ -145,8 +147,3 @@ export function getReaderDevice(): { id: string; name: string } {
|
|||||||
|
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderDevice(name: string, id?: string): void {
|
|
||||||
writeLocalSetting('readerDeviceName', name);
|
|
||||||
writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user