@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -77,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import { THEME_MODES } from '../utils/localSettings';
|
||||
import { SegmentedControl } from './SegmentedControl';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
import { getPageTitle } from './navigation';
|
||||
|
||||
@@ -61,30 +62,17 @@ export default function Layout() {
|
||||
{isUserDropdownOpen && (
|
||||
<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="border-b border-border px-4 py-3"
|
||||
role="group"
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||
{THEME_MODES.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(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>
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
ariaLabel="Theme mode"
|
||||
value={themeMode}
|
||||
onChange={setThemeMode}
|
||||
options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="py-1"
|
||||
|
||||
@@ -47,9 +47,6 @@ function MyComponent() {
|
||||
- `removeToast(id: string): void`
|
||||
- Manually remove a toast by ID
|
||||
|
||||
- `clearToasts(): void`
|
||||
- Clear all active toasts
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
@@ -76,22 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
|
||||
|
||||
#### `Skeleton`
|
||||
|
||||
Basic skeleton element with various variants:
|
||||
A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from './components/Skeleton';
|
||||
|
||||
// Default (rounded rectangle)
|
||||
<Skeleton className="w-full h-8" />
|
||||
|
||||
// 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} />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="w-3/4" />
|
||||
```
|
||||
|
||||
#### `SkeletonTable`
|
||||
|
||||
@@ -10,39 +10,67 @@ interface SegmentedControlProps<T extends string> {
|
||||
options: SegmentedOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
activeClassName: string;
|
||||
inactiveClassName: string;
|
||||
variant?: 'pill' | 'unstyled';
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
activeClassName?: string;
|
||||
inactiveClassName?: 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>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
activeClassName,
|
||||
inactiveClassName,
|
||||
variant = 'pill',
|
||||
className,
|
||||
buttonClassName,
|
||||
activeClassName,
|
||||
inactiveClassName,
|
||||
ariaLabel,
|
||||
}: 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 (
|
||||
<div className={className} role="group" aria-label={ariaLabel}>
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
className={cn(
|
||||
buttonClassName,
|
||||
value === option.value ? activeClassName : inactiveClassName
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<div className={styles.container} role="group" aria-label={ariaLabel}>
|
||||
{options.map(option => {
|
||||
const isActive = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isActive) onChange(option.value);
|
||||
}}
|
||||
aria-pressed={isActive}
|
||||
className={cn(styles.button, isActive ? styles.active : styles.inactive)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,46 +2,10 @@ import { cn } from '../utils/cn';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
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}
|
||||
/>
|
||||
);
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', className)} />;
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
@@ -65,7 +29,7 @@ export function SkeletonTable({
|
||||
<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" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -76,10 +40,7 @@ export function SkeletonTable({
|
||||
<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'}
|
||||
/>
|
||||
<Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -14,7 +14,6 @@ interface ToastContextType {
|
||||
showError: (message: string, duration?: number) => string;
|
||||
updateToast: (id: string, update: ToastUpdate) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
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)));
|
||||
}, []);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }}
|
||||
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
|
||||
>
|
||||
{children}
|
||||
<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
|
||||
* 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(
|
||||
rendition: EpubRendition,
|
||||
@@ -25,6 +26,7 @@ export function registerRenditionGestures(
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const listenerCleanups: Array<() => void> = [];
|
||||
|
||||
const resetWheelCooldown = () => {
|
||||
if (wheelTimeoutId) {
|
||||
@@ -68,14 +70,11 @@ export function registerRenditionGestures(
|
||||
rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const wakeLockListener = () => {
|
||||
const onWakeLock = () => {
|
||||
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 windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
@@ -99,9 +98,9 @@ export function registerRenditionGestures(
|
||||
} else {
|
||||
handlers.onCenterTap();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
@@ -113,26 +112,36 @@ export function registerRenditionGestures(
|
||||
if (event.deltaY < -SWIPE_THRESHOLD) {
|
||||
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 () => {
|
||||
@@ -140,5 +149,7 @@ export function registerRenditionGestures(
|
||||
clearTimeout(wheelTimeoutId);
|
||||
wheelTimeoutId = null;
|
||||
}
|
||||
listenerCleanups.forEach(cleanup => cleanup());
|
||||
listenerCleanups.length = 0;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
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';
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 25;
|
||||
@@ -12,13 +13,9 @@ const ACTIVITY_PAGE_SIZE = 25;
|
||||
export default function ActivityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const documentID = searchParams.get('document') || undefined;
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList(documentID);
|
||||
const limit = ACTIVITY_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [documentID]);
|
||||
|
||||
const { data, isLoading } = useGetActivity({
|
||||
doc_filter: Boolean(documentID),
|
||||
document_id: documentID,
|
||||
@@ -29,19 +26,11 @@ export default function ActivityPage() {
|
||||
const activities = response?.activities ?? [];
|
||||
|
||||
const columns: Column<Activity>[] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
documentColumn,
|
||||
{
|
||||
id: 'start_time',
|
||||
header: 'Time',
|
||||
render: row => row.start_time || 'N/A',
|
||||
render: row => formatDateTime(row.start_time),
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { User } from '../generated/model';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
interface AddUserFormProps {
|
||||
@@ -238,7 +239,12 @@ export default function AdminUsersPage() {
|
||||
</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) {
|
||||
|
||||
@@ -122,21 +122,20 @@ export default function DocumentPage() {
|
||||
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>;
|
||||
}
|
||||
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const percentage = doc.percentage ?? 0;
|
||||
const secondsPerPercent = doc.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||
runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), {
|
||||
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
||||
error: 'Failed to save',
|
||||
onSuccess: response =>
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
|
||||
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
||||
});
|
||||
|
||||
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">
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
|
||||
{document.filepath && (
|
||||
{doc.filepath && (
|
||||
<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"
|
||||
>
|
||||
Read
|
||||
@@ -162,26 +161,26 @@ export default function DocumentPage() {
|
||||
<div className="min-w-[50%] md:mr-2">
|
||||
<div className="flex gap-1 text-sm">
|
||||
<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 className="flex gap-1 text-sm">
|
||||
<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 className="relative my-auto flex grow justify-between text-content-muted">
|
||||
<a
|
||||
href={`/activity?document=${document.id}`}
|
||||
href={`/activity?document=${doc.id}`}
|
||||
aria-label="Activity"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ActivityIcon size={28} />
|
||||
</a>
|
||||
|
||||
{document.filepath ? (
|
||||
{doc.filepath ? (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
href={`/api/v1/documents/${doc.id}/file`}
|
||||
aria-label="Download"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
@@ -197,15 +196,11 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
<EditableField
|
||||
label="Title"
|
||||
value={document.title}
|
||||
onSave={value => save({ title: value })}
|
||||
/>
|
||||
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
||||
|
||||
<EditableField
|
||||
label="Author"
|
||||
value={document.author}
|
||||
value={doc.author}
|
||||
onSave={value => save({ author: value })}
|
||||
/>
|
||||
|
||||
@@ -234,9 +229,7 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||
<p className="font-medium">
|
||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
||||
</p>
|
||||
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||
@@ -249,8 +242,8 @@ export default function DocumentPage() {
|
||||
}
|
||||
>
|
||||
<FieldValue>
|
||||
{document.total_time_seconds && document.total_time_seconds > 0
|
||||
? formatDuration(document.total_time_seconds)
|
||||
{doc.total_time_seconds && doc.total_time_seconds > 0
|
||||
? formatDuration(doc.total_time_seconds)
|
||||
: 'N/A'}
|
||||
</FieldValue>
|
||||
</Field>
|
||||
@@ -262,7 +255,7 @@ export default function DocumentPage() {
|
||||
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={document.description || ''}
|
||||
value={doc.description || ''}
|
||||
multiline
|
||||
valueClassName="hyphens-auto text-justify"
|
||||
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 { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document } from '../generated/model';
|
||||
@@ -9,6 +9,7 @@ import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
@@ -106,7 +107,7 @@ function EmptyDocuments({ className }: { className?: string }) {
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList(debouncedSearch);
|
||||
const limit = DOCUMENTS_PAGE_SIZE;
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||
@@ -114,10 +115,6 @@ export default function DocumentsPage() {
|
||||
const { showWarning } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||
const createMutation = useCreateDocument();
|
||||
const documentsResponse = dataForStatus(data, 200);
|
||||
@@ -169,13 +166,9 @@ export default function DocumentsPage() {
|
||||
</IconInput>
|
||||
</div>
|
||||
<SegmentedControl<DocumentsViewMode>
|
||||
className="inline-flex rounded border border-border bg-surface p-1"
|
||||
ariaLabel="Document view mode"
|
||||
value={viewMode}
|
||||
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={[
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'list', label: 'List' },
|
||||
|
||||
@@ -35,51 +35,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
|
||||
}
|
||||
|
||||
interface StreakCardProps {
|
||||
window: 'DAY' | 'WEEK';
|
||||
currentStreak: number;
|
||||
currentStreakStartDate: string;
|
||||
currentStreakEndDate: string;
|
||||
maxStreak: number;
|
||||
maxStreakStartDate: string;
|
||||
maxStreakEndDate: string;
|
||||
streak: UserStreak;
|
||||
}
|
||||
|
||||
function StreakCard({
|
||||
window,
|
||||
currentStreak,
|
||||
currentStreakStartDate,
|
||||
currentStreakEndDate,
|
||||
maxStreak,
|
||||
maxStreakStartDate,
|
||||
maxStreakEndDate,
|
||||
}: StreakCardProps) {
|
||||
function StreakCard({ streak }: StreakCardProps) {
|
||||
const isWeekly = streak.window === 'WEEK';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<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">
|
||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
{isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
</p>
|
||||
<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 className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||
<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">
|
||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||
{streak.current_streak_start_date} ➞ {streak.current_streak_end_date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
||||
<div className="flex items-end font-bold">{streak.current_streak}</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||
<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">
|
||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||
{streak.max_streak_start_date} ➞ {streak.max_streak_end_date}
|
||||
</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>
|
||||
@@ -121,6 +109,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<SegmentedControl<TimePeriod>
|
||||
variant="unstyled"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
ariaLabel={`${name} leaderboard period`}
|
||||
value={selectedPeriod}
|
||||
@@ -197,16 +186,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: UserStreak) => (
|
||||
<StreakCard
|
||||
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}
|
||||
/>
|
||||
<StreakCard key={streak.window} streak={streak} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('LoginPage', () => {
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
@@ -115,7 +114,6 @@ describe('LoginPage', () => {
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
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';
|
||||
|
||||
const PROGRESS_PAGE_SIZE = 15;
|
||||
|
||||
export default function ProgressPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList();
|
||||
const limit = PROGRESS_PAGE_SIZE;
|
||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||
const response = dataForStatus(data, 200);
|
||||
const progress = response?.progress ?? [];
|
||||
|
||||
const columns: Column<Progress>[] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
documentColumn,
|
||||
{
|
||||
id: 'device_name',
|
||||
header: 'Device Name',
|
||||
@@ -38,7 +31,7 @@ export default function ProgressPage() {
|
||||
{
|
||||
id: '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),
|
||||
},
|
||||
});
|
||||
const document = dataForStatus(documentResponse, 200)?.document;
|
||||
const doc = dataForStatus(documentResponse, 200)?.document;
|
||||
const progress = dataForStatus(progressResponse, 200)?.progress;
|
||||
|
||||
const deviceId = defaultDeviceId;
|
||||
@@ -89,17 +89,17 @@ export default function ReaderPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document?.title) {
|
||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
||||
if (doc?.title) {
|
||||
document.title = `AnthoLume - Reader - ${doc.title}`;
|
||||
}
|
||||
}, [document?.title]);
|
||||
}, [doc?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopBarOpen || isBottomBarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = window.document.activeElement;
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export default function ReaderPage() {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -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="flex items-start justify-between 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
|
||||
className="h-28 w-20 rounded object-cover shadow-sm"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<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="truncate text-sm text-content-muted">{document.author}</p>
|
||||
<p className="truncate text-sm text-content-muted">{doc.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
Back
|
||||
@@ -224,6 +224,7 @@ export default function ReaderPage() {
|
||||
Theme
|
||||
</p>
|
||||
<SegmentedControl<ReaderColorScheme>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||
ariaLabel="Reader theme"
|
||||
value={colorScheme}
|
||||
@@ -238,6 +239,7 @@ export default function ReaderPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||
<SegmentedControl<ReaderFontFamily>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||
ariaLabel="Reader font"
|
||||
value={fontFamily}
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('RegisterPage', () => {
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
@@ -116,7 +115,6 @@ describe('RegisterPage', () => {
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
|
||||
import { inputClassName } from '../components/TextInput';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon, BookIcon } from '../icons';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const searchColumns: Column<SearchItem>[] = [
|
||||
@@ -21,7 +22,7 @@ const searchColumns: Column<SearchItem>[] = [
|
||||
id: 'date',
|
||||
header: 'Date',
|
||||
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 { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
import { dataForStatus } from '../utils/apiResponses';
|
||||
|
||||
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
|
||||
|
||||
const deviceColumns: Column<Device>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
@@ -22,9 +21,9 @@ const deviceColumns: Column<Device>[] = [
|
||||
{
|
||||
id: 'last_synced',
|
||||
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 }> = [
|
||||
|
||||
@@ -11,11 +11,3 @@ export function dataForStatus<TResponse extends ApiResponse, TStatus extends TRe
|
||||
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
|
||||
: 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 { formatNumber, formatDuration } from './formatters';
|
||||
import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('formats zero', () => {
|
||||
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
|
||||
it('formats days, hours, minutes, and seconds', () => {
|
||||
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(' ');
|
||||
}
|
||||
|
||||
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 {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getUTCFullYear();
|
||||
|
||||
@@ -101,7 +101,9 @@ export function useLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
) {
|
||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue));
|
||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
|
||||
readLocalSetting(key, defaultValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -145,8 +147,3 @@ export function getReaderDevice(): { id: string; name: string } {
|
||||
|
||||
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