+4
-1
@@ -26,7 +26,10 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
|
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
|
||||||
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||||
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||||
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches.
|
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. React Query `isLoading` is initial-load-only (false on background refetches), so a full-page early-return on `isLoading` is fine for pages with no persistent filter form.
|
||||||
|
- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync` → `getResponseError` → toast blocks.
|
||||||
|
- Use `SegmentedControl` for active/inactive toggle groups (view mode, period, reader theme/font) rather than re-implementing `option.map` + ternary class toggling; pass per-call `activeClassName`/`inactiveClassName`.
|
||||||
|
- For a persistent/progress toast that resolves in place (long-running actions), create it with `showInfo(msg, 0)` and finish with `updateToast(id, { message, type, duration })`.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
interface SegmentedOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentedControlProps<T extends string> {
|
||||||
|
options: SegmentedOption<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
activeClassName: string;
|
||||||
|
inactiveClassName: string;
|
||||||
|
className?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
activeClassName,
|
||||||
|
inactiveClassName,
|
||||||
|
className,
|
||||||
|
buttonClassName,
|
||||||
|
ariaLabel,
|
||||||
|
}: SegmentedControlProps<T>) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
import { Toast, ToastType, ToastProps } from './Toast';
|
import { Toast, ToastType, ToastProps } from './Toast';
|
||||||
|
|
||||||
|
interface ToastUpdate {
|
||||||
|
message?: string;
|
||||||
|
type?: ToastType;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToastContextType {
|
interface ToastContextType {
|
||||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||||
showInfo: (message: string, duration?: number) => string;
|
showInfo: (message: string, duration?: number) => string;
|
||||||
showWarning: (message: string, duration?: number) => string;
|
showWarning: (message: string, duration?: number) => string;
|
||||||
showError: (message: string, duration?: number) => string;
|
showError: (message: string, duration?: number) => string;
|
||||||
|
updateToast: (id: string, update: ToastUpdate) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
clearToasts: () => void;
|
clearToasts: () => void;
|
||||||
}
|
}
|
||||||
@@ -49,13 +56,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// In-Place Update - Long-running flows show a persistent toast (duration 0) that resolves into its result toast; changing duration restarts the Toast's auto-dismiss timer.
|
||||||
|
const updateToast = useCallback((id: string, update: ToastUpdate) => {
|
||||||
|
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearToasts = useCallback(() => {
|
const clearToasts = useCallback(() => {
|
||||||
setToasts([]);
|
setToasts([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider
|
<ToastContext.Provider
|
||||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ToastContainer toasts={toasts} />
|
<ToastContainer toasts={toasts} />
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
|||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export { Table, type Column, type TableProps } from './Table';
|
export { Table, type Column, type TableProps } from './Table';
|
||||||
export { IconInput } from './IconInput';
|
export { IconInput } from './IconInput';
|
||||||
|
export { SegmentedControl } from './SegmentedControl';
|
||||||
|
|||||||
@@ -29,3 +29,40 @@ export function useMutationWithToast() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RunToastMutationOptions<T> {
|
||||||
|
error: string;
|
||||||
|
success?: string;
|
||||||
|
onSuccess?: (response: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an
|
||||||
|
* editor open on failure). Runs the action, treats non-2xx as failure, toasts accordingly, and
|
||||||
|
* resolves to `true` only on success.
|
||||||
|
*/
|
||||||
|
export function useToastMutation() {
|
||||||
|
const { showInfo, showError } = useToasts();
|
||||||
|
|
||||||
|
return async function runWithToast<T extends ApiResponseLike>(
|
||||||
|
action: () => Promise<T>,
|
||||||
|
{ error, success, onSuccess }: RunToastMutationOptions<T>
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await action();
|
||||||
|
const message = getResponseError(response);
|
||||||
|
if (message) {
|
||||||
|
showError(`${error}: ${message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onSuccess?.(response);
|
||||||
|
if (success) {
|
||||||
|
showInfo(success);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
showError(`${error}: ${getErrorMessage(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -244,13 +244,13 @@ export class EBookReader {
|
|||||||
const prevPage = this.prevPage.bind(this);
|
const prevPage = this.prevPage.bind(this);
|
||||||
|
|
||||||
this.keyupHandler = (event: KeyboardEvent) => {
|
this.keyupHandler = (event: KeyboardEvent) => {
|
||||||
if ((event.keyCode || event.which) === 37) {
|
if (event.key === 'ArrowLeft') {
|
||||||
void prevPage();
|
void prevPage();
|
||||||
}
|
}
|
||||||
if ((event.keyCode || event.which) === 39) {
|
if (event.key === 'ArrowRight') {
|
||||||
void nextPage();
|
void nextPage();
|
||||||
}
|
}
|
||||||
if ((event.keyCode || event.which) === 84) {
|
if (event.key === 't' || event.key === 'T') {
|
||||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||||
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||||
const colorScheme =
|
const colorScheme =
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface BackupTypes {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const postAdminAction = usePostAdminAction();
|
const postAdminAction = usePostAdminAction();
|
||||||
const { showInfo, showError, removeToast } = useToasts();
|
const { showInfo, showError, updateToast } = useToasts();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||||
@@ -61,8 +61,8 @@ export default function AdminPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!restoreFile) return;
|
if (!restoreFile) return;
|
||||||
|
|
||||||
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator.
|
// Progress Toast - Restore is long-running; a persistent 'started' toast resolves in place into the result toast on completion.
|
||||||
const startedToastId = showInfo('Restore started', 0);
|
const toastId = showInfo('Restore started', 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await postAdminAction.mutateAsync({
|
const response = await postAdminAction.mutateAsync({
|
||||||
@@ -72,18 +72,23 @@ export default function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
removeToast(startedToastId);
|
|
||||||
|
|
||||||
const message = getResponseError(response);
|
const message = getResponseError(response);
|
||||||
if (message) {
|
if (message) {
|
||||||
showError('Restore failed: ' + message);
|
updateToast(toastId, {
|
||||||
|
type: 'error',
|
||||||
|
message: `Restore failed: ${message}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showInfo('Restore completed successfully');
|
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
removeToast(startedToastId);
|
updateToast(toastId, {
|
||||||
showError('Restore failed: ' + getErrorMessage(error));
|
type: 'error',
|
||||||
|
message: `Restore failed: ${getErrorMessage(error)}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ import {
|
|||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import type { EditDocumentBody } from '../generated/model';
|
import type { EditDocumentBody } from '../generated/model';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { getErrorMessage, getResponseError } from '../utils/errors';
|
import { useToastMutation } from '../hooks/useMutationWithToast';
|
||||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
|
||||||
|
|
||||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||||
const popupClassName =
|
|
||||||
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
|
||||||
const editInputClassName =
|
|
||||||
'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600';
|
|
||||||
|
|
||||||
interface EditableFieldProps {
|
interface EditableFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -100,7 +95,7 @@ function EditableField({
|
|||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={e => setDraft(e.target.value)}
|
onChange={e => setDraft(e.target.value)}
|
||||||
className={editInputClassName}
|
className="w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +113,7 @@ export default function DocumentPage() {
|
|||||||
query: { enabled: Boolean(id) },
|
query: { enabled: Boolean(id) },
|
||||||
});
|
});
|
||||||
const editMutation = useEditDocument();
|
const editMutation = useEditDocument();
|
||||||
const { showError } = useToasts();
|
const runWithToast = useToastMutation();
|
||||||
|
|
||||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||||
|
|
||||||
@@ -136,21 +131,12 @@ export default function DocumentPage() {
|
|||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||||
try {
|
runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), {
|
||||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
error: 'Failed to save',
|
||||||
const message = getResponseError(response);
|
onSuccess: response =>
|
||||||
if (message) {
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
|
||||||
showError('Failed to save: ' + message);
|
});
|
||||||
return false;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
showError('Failed to save: ' + getErrorMessage(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative size-full">
|
||||||
@@ -235,7 +221,7 @@ export default function DocumentPage() {
|
|||||||
<InfoIcon size={18} />
|
<InfoIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
|
className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
|
||||||
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ 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';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState, Pagination, TextInput, IconInput } from '../components';
|
import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||||
|
|
||||||
@@ -99,6 +100,16 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyDocuments({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('rounded bg-surface p-6 text-center text-content-muted shadow-lg', className)}
|
||||||
|
>
|
||||||
|
No documents found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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] = useState(1);
|
||||||
@@ -148,13 +159,6 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getViewModeButtonClasses = (mode: DocumentsViewMode) =>
|
|
||||||
`rounded px-3 py-1 text-sm font-medium transition-colors ${
|
|
||||||
viewMode === mode
|
|
||||||
? 'bg-content text-content-inverse'
|
|
||||||
: 'text-content-muted hover:bg-surface-muted'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
@@ -170,22 +174,19 @@ export default function DocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</IconInput>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
<SegmentedControl<DocumentsViewMode>
|
||||||
<button
|
className="inline-flex rounded border border-border bg-surface p-1"
|
||||||
type="button"
|
ariaLabel="Document view mode"
|
||||||
onClick={() => setViewMode('grid')}
|
value={viewMode}
|
||||||
className={getViewModeButtonClasses('grid')}
|
onChange={setViewMode}
|
||||||
>
|
buttonClassName="rounded px-3 py-1 text-sm font-medium transition-colors"
|
||||||
Grid
|
activeClassName="bg-content text-content-inverse"
|
||||||
</button>
|
inactiveClassName="text-content-muted hover:bg-surface-muted"
|
||||||
<button
|
options={[
|
||||||
type="button"
|
{ value: 'grid', label: 'Grid' },
|
||||||
onClick={() => setViewMode('list')}
|
{ value: 'list', label: 'List' },
|
||||||
className={getViewModeButtonClasses('list')}
|
]}
|
||||||
>
|
/>
|
||||||
List
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,9 +197,7 @@ export default function DocumentsPage() {
|
|||||||
) : docs && docs.length > 0 ? (
|
) : docs && docs.length > 0 ? (
|
||||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
|
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
|
||||||
) : (
|
) : (
|
||||||
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
<EmptyDocuments className="col-span-full" />
|
||||||
No documents found.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -208,9 +207,7 @@ export default function DocumentsPage() {
|
|||||||
) : docs && docs.length > 0 ? (
|
) : docs && docs.length > 0 ? (
|
||||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
|
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
<EmptyDocuments />
|
||||||
No documents found.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, SegmentedControl } from '../components';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||||
import type {
|
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
|
||||||
LeaderboardData,
|
|
||||||
LeaderboardEntry,
|
|
||||||
UserStreak,
|
|
||||||
} from '../generated/model';
|
|
||||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
@@ -115,9 +111,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
|
|
||||||
const currentData = data[selectedPeriod];
|
const currentData = data[selectedPeriod];
|
||||||
|
|
||||||
const getPeriodClassName = (period: TimePeriod) =>
|
|
||||||
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||||
@@ -126,20 +119,21 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
<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">
|
||||||
{name} Leaderboard
|
{name} Leaderboard
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<SegmentedControl<TimePeriod>
|
||||||
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
className="flex items-center gap-2 text-xs"
|
||||||
all
|
ariaLabel={`${name} leaderboard period`}
|
||||||
</button>
|
value={selectedPeriod}
|
||||||
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
onChange={setSelectedPeriod}
|
||||||
year
|
buttonClassName="cursor-pointer"
|
||||||
</button>
|
activeClassName="text-content"
|
||||||
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
inactiveClassName="text-content-subtle hover:text-content"
|
||||||
month
|
options={[
|
||||||
</button>
|
{ value: 'all', label: 'all' },
|
||||||
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
{ value: 'year', label: 'year' },
|
||||||
week
|
{ value: 'month', label: 'month' },
|
||||||
</button>
|
{ value: 'week', label: 'week' },
|
||||||
</div>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe('LoginPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
clearToasts: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -112,6 +113,7 @@ describe('LoginPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
clearToasts: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||||
import { LoadingState } from '../components/LoadingState';
|
import { LoadingState } from '../components/LoadingState';
|
||||||
|
import { SegmentedControl } from '../components/SegmentedControl';
|
||||||
import { CloseIcon } from '../icons';
|
import { CloseIcon } from '../icons';
|
||||||
import {
|
import {
|
||||||
READER_COLOR_SCHEMES,
|
READER_COLOR_SCHEMES,
|
||||||
READER_FONT_FAMILIES,
|
READER_FONT_FAMILIES,
|
||||||
|
type ReaderColorScheme,
|
||||||
|
type ReaderFontFamily,
|
||||||
getReaderDevice,
|
getReaderDevice,
|
||||||
useLocalSetting,
|
useLocalSetting,
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
import { useEpubReader } from '../hooks/useEpubReader';
|
import { useEpubReader } from '../hooks/useEpubReader';
|
||||||
|
|
||||||
|
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
|
||||||
|
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
|
||||||
|
const READER_SEGMENT_INACTIVE =
|
||||||
|
'border-border text-content-muted hover:bg-surface-muted hover:text-content';
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||||
@@ -214,42 +222,30 @@ export default function ReaderPage() {
|
|||||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
<SegmentedControl<ReaderColorScheme>
|
||||||
{READER_COLOR_SCHEMES.map(option => (
|
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
<button
|
ariaLabel="Reader theme"
|
||||||
key={option}
|
value={colorScheme}
|
||||||
type="button"
|
onChange={setColorScheme}
|
||||||
onClick={() => setColorScheme(option)}
|
buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`}
|
||||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
activeClassName={READER_SEGMENT_ACTIVE}
|
||||||
colorScheme === option
|
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))}
|
||||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
|
<SegmentedControl<ReaderFontFamily>
|
||||||
{READER_FONT_FAMILIES.map(option => (
|
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
<button
|
ariaLabel="Reader font"
|
||||||
key={option}
|
value={fontFamily}
|
||||||
type="button"
|
onChange={setFontFamily}
|
||||||
onClick={() => setFontFamily(option)}
|
buttonClassName={READER_SEGMENT_BUTTON}
|
||||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
activeClassName={READER_SEGMENT_ACTIVE}
|
||||||
fontFamily === option
|
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))}
|
||||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe('RegisterPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
clearToasts: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -113,6 +114,7 @@ describe('RegisterPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
clearToasts: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user