From c327a3905dbaa4df1ba8cee52caf4db630ce122e Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 3 Jul 2026 16:23:43 -0400 Subject: [PATCH] cleanup 1 --- Makefile | 7 +- frontend/AGENTS.md | 8 +- frontend/src/components/Button.tsx | 15 +- frontend/src/components/Field.tsx | 3 +- frontend/src/components/HamburgerMenu.tsx | 27 +- frontend/src/components/Layout.tsx | 19 +- frontend/src/components/README.md | 68 +-- .../src/components/ReadingHistoryGraph.tsx | 11 +- frontend/src/components/Skeleton.tsx | 124 ----- frontend/src/components/Table.test.tsx | 10 +- frontend/src/components/Table.tsx | 67 +-- frontend/src/components/TextInput.tsx | 15 + frontend/src/components/Toast.tsx | 38 +- frontend/src/components/ToastContext.tsx | 21 +- frontend/src/components/index.ts | 14 +- frontend/src/components/navigation.ts | 46 ++ frontend/src/hooks/useDebounce.test.tsx | 69 --- frontend/src/hooks/useDebounce.ts | 23 - frontend/src/hooks/useDebouncedState.test.tsx | 81 +++ frontend/src/hooks/useDebouncedState.ts | 26 + frontend/src/hooks/useEpubReader.ts | 2 +- frontend/src/hooks/useMutationWithToast.ts | 35 ++ frontend/src/pages/ActivityPage.tsx | 20 +- frontend/src/pages/AdminImportPage.tsx | 13 +- frontend/src/pages/AdminImportResultsPage.tsx | 14 +- frontend/src/pages/AdminLogsPage.tsx | 31 +- frontend/src/pages/AdminPage.tsx | 47 +- frontend/src/pages/AdminUsersPage.tsx | 269 +++++----- frontend/src/pages/AuthFormView.tsx | 105 ++++ frontend/src/pages/ComponentDemoPage.tsx | 156 ------ frontend/src/pages/DocumentPage.tsx | 498 +++++------------- frontend/src/pages/DocumentsPage.tsx | 277 ++++------ frontend/src/pages/HomePage.tsx | 12 +- frontend/src/pages/LoginPage.tsx | 102 +--- frontend/src/pages/ProgressPage.tsx | 21 +- frontend/src/pages/RegisterPage.tsx | 89 +--- frontend/src/pages/SearchPage.test.tsx | 7 +- frontend/src/pages/SearchPage.tsx | 112 ++-- frontend/src/pages/SettingsPage.tsx | 178 ++----- frontend/src/theme/ThemeProvider.tsx | 25 +- frontend/src/utils/download.ts | 57 ++ frontend/src/utils/formatters.ts | 8 + frontend/src/utils/localSettings.ts | 2 +- 43 files changed, 1015 insertions(+), 1757 deletions(-) create mode 100644 frontend/src/components/TextInput.tsx create mode 100644 frontend/src/components/navigation.ts delete mode 100644 frontend/src/hooks/useDebounce.test.tsx delete mode 100644 frontend/src/hooks/useDebounce.ts create mode 100644 frontend/src/hooks/useDebouncedState.test.tsx create mode 100644 frontend/src/hooks/useDebouncedState.ts create mode 100644 frontend/src/hooks/useMutationWithToast.ts create mode 100644 frontend/src/pages/AuthFormView.tsx delete mode 100644 frontend/src/pages/ComponentDemoPage.tsx create mode 100644 frontend/src/utils/download.ts diff --git a/Makefile b/Makefile index cb84f87..040cbd6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend clean tests +.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend dev_noauth clean tests DEV_ENV = GIN_MODE=release \ CONFIG_PATH=./data \ @@ -9,6 +9,8 @@ DEV_ENV = GIN_MODE=release \ COOKIE_AUTH_KEY=1234 \ LOG_LEVEL=debug +DEV_USER ?= evan + build_local: legacy_tailwind go mod download rm -r ./build || true @@ -51,6 +53,9 @@ dev_backend: dev_frontend: cd frontend && pnpm run dev +dev_noauth: + DISABLE_AUTH=true DISABLE_AUTH_USER=$(DEV_USER) $(MAKE) dev + clean: rm -rf ./build diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8cda35d..aa07ca2 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -18,9 +18,15 @@ Also follow the repository root guide at `../AGENTS.md`. - Use local icon components from `src/icons/`. - Do not add external icon libraries. - Prefer generated types from `src/generated/model/` over `any`. +- Unwrap API responses by narrowing on `status` (e.g. `data?.status === 200 ? data.data : undefined`); the generated response types are discriminated unions, so this needs no `as XResponse` cast. +- Type form submit handlers as `SyntheticEvent` (optionally `SyntheticEvent`); `@types/react@19` deprecates `FormEvent`. +- Route mutation success/error feedback through `useMutationWithToast` (`src/hooks/`); error-toast-on-failure is the app-wide pattern (treat non-2xx as failure). +- Shared input styling lives in `TextInput` / the exported `inputClassName` (`src/components/TextInput.tsx`); reuse rather than re-pasting the input class string. +- Render tabular data with the shared `` (`src/components/Table.tsx`). Columns are `{ id, header: ReactNode, render(row, index), className? }` — `id` is decoupled from data, so action columns are first-class. The table owns its own loading skeleton and empty state; don't hand-roll `
` markup for data grids. (Genuinely different widget shapes — e.g. a file-browser or a card-grid — are fine to build bespoke.) +- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`. - Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them. - For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc. -- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches. +- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. - Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first. - Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`. - Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape. diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 0621291..0646a6f 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -1,4 +1,4 @@ -import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react'; +import { ButtonHTMLAttributes, forwardRef } from 'react'; interface BaseButtonProps { variant?: 'default' | 'secondary'; @@ -7,7 +7,6 @@ interface BaseButtonProps { } type ButtonProps = BaseButtonProps & ButtonHTMLAttributes; -type LinkProps = BaseButtonProps & AnchorHTMLAttributes & { href: string }; const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const baseClass = @@ -31,15 +30,3 @@ export const Button = forwardRef( ); Button.displayName = 'Button'; - -export const ButtonLink = forwardRef( - ({ variant = 'default', children, className = '', ...props }, ref) => { - return ( - - {children} - - ); - } -); - -ButtonLink.displayName = 'ButtonLink'; diff --git a/frontend/src/components/Field.tsx b/frontend/src/components/Field.tsx index 589d81a..7769ca4 100644 --- a/frontend/src/components/Field.tsx +++ b/frontend/src/components/Field.tsx @@ -3,10 +3,9 @@ import { ReactNode } from 'react'; interface FieldProps { label: ReactNode; children: ReactNode; - isEditing?: boolean; } -export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) { +export function Field({ label, children }: FieldProps) { return (
{label}
diff --git a/frontend/src/components/HamburgerMenu.tsx b/frontend/src/components/HamburgerMenu.tsx index bc7a663..6c2a12f 100644 --- a/frontend/src/components/HamburgerMenu.tsx +++ b/frontend/src/components/HamburgerMenu.tsx @@ -1,30 +1,9 @@ import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons'; +import { SettingsIcon, GitIcon } from '../icons'; import { useAuth } from '../auth/AuthContext'; import { useGetInfo } from '../generated/anthoLumeAPIV1'; - -interface NavItem { - path: string; - label: string; - icon: React.ElementType; - title: string; -} - -const navItems: NavItem[] = [ - { path: '/', label: 'Home', icon: HomeIcon, title: 'Home' }, - { path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' }, - { path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' }, - { path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' }, - { path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' }, -]; - -const adminSubItems: NavItem[] = [ - { path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' }, - { path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' }, - { path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' }, - { path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' }, -]; +import { navItems, adminNavItems } from './navigation'; function hasPrefix(path: string, prefix: string): boolean { return path.startsWith(prefix); @@ -146,7 +125,7 @@ export default function HamburgerMenu() { {hasPrefix(location.pathname, '/admin') && (
- {adminSubItems.map(item => ( + {adminNavItems.map(item => ( - item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path) - )?.title || 'Home'; + const currentPageTitle = getPageTitle(location.pathname); useEffect(() => { document.title = `AnthoLume - ${currentPageTitle}`; diff --git a/frontend/src/components/README.md b/frontend/src/components/README.md index 8bbeed2..47368f3 100644 --- a/frontend/src/components/README.md +++ b/frontend/src/components/README.md @@ -94,45 +94,6 @@ import { Skeleton } from './components/Skeleton'; ``` -#### `SkeletonText` - -Multiple lines of text skeleton: - -```tsx - - -``` - -#### `SkeletonAvatar` - -Avatar placeholder: - -```tsx - - -``` - -#### `SkeletonCard` - -Card placeholder with optional elements: - -```tsx -// Default card - - -// With avatar - - -// Custom configuration - -``` - #### `SkeletonTable` Table placeholder: @@ -142,33 +103,6 @@ Table placeholder: ``` -#### `SkeletonButton` - -Button placeholder: - -```tsx - - -``` - -#### `PageLoader` - -Full-page loading indicator: - -```tsx - -``` - -#### `InlineLoader` - -Small inline loading spinner: - -```tsx - - - -``` - ## Integration with Table Component The Table component now supports skeleton loading: @@ -200,4 +134,4 @@ The theme is controlled via Tailwind's `dark:` classes, which respond to the sys - `clsx` - Utility for constructing className strings - `tailwind-merge` - Merges Tailwind CSS classes intelligently -- `lucide-react` - Icon library used by Toast component +- Local icon components in `src/icons/` diff --git a/frontend/src/components/ReadingHistoryGraph.tsx b/frontend/src/components/ReadingHistoryGraph.tsx index fa91f50..529693a 100644 --- a/frontend/src/components/ReadingHistoryGraph.tsx +++ b/frontend/src/components/ReadingHistoryGraph.tsx @@ -1,4 +1,5 @@ import type { GraphDataPoint } from '../generated/model'; +import { formatUtcDate } from '../utils/formatters'; interface ReadingHistoryGraphProps { data: GraphDataPoint[]; @@ -147,14 +148,6 @@ export function getSVGGraphData( }; } -function formatDate(dateString: string): string { - const date = new Date(dateString); - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, '0'); - const day = String(date.getUTCDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) { const svgWidth = 800; const svgHeight = 70; @@ -199,7 +192,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) left: '50%', }} > - {formatDate(point.date)} + {formatUtcDate(point.date)} {point.minutes_read} minutes
diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx index 72c25aa..1ba5b6e 100644 --- a/frontend/src/components/Skeleton.tsx +++ b/frontend/src/components/Skeleton.tsx @@ -44,75 +44,6 @@ export function Skeleton({ ); } -interface SkeletonTextProps { - lines?: number; - className?: string; - lineClassName?: string; -} - -export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) { - return ( -
- {Array.from({ length: lines }).map((_, i) => ( - 1 ? 'w-3/4' : 'w-full')} - /> - ))} -
- ); -} - -interface SkeletonAvatarProps { - size?: number | 'sm' | 'md' | 'lg'; - className?: string; -} - -export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) { - const sizeMap = { - sm: 32, - md: 40, - lg: 56, - }; - - const pixelSize = typeof size === 'number' ? size : sizeMap[size]; - - return ; -} - -interface SkeletonCardProps { - className?: string; - showAvatar?: boolean; - showTitle?: boolean; - showText?: boolean; - textLines?: number; -} - -export function SkeletonCard({ - className = '', - showAvatar = false, - showTitle = true, - showText = true, - textLines = 3, -}: SkeletonCardProps) { - return ( -
- {showAvatar && ( -
- -
- - -
-
- )} - {showTitle && } - {showText && } -
- ); -} - interface SkeletonTableProps { rows?: number; columns?: number; @@ -158,58 +89,3 @@ export function SkeletonTable({ ); } - -interface SkeletonButtonProps { - className?: string; - width?: string | number; -} - -export function SkeletonButton({ className = '', width }: SkeletonButtonProps) { - return ( - - ); -} - -interface PageLoaderProps { - message?: string; - className?: string; -} - -export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) { - return ( -
-
-
-
-

{message}

-
- ); -} - -interface InlineLoaderProps { - size?: 'sm' | 'md' | 'lg'; - className?: string; -} - -export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) { - const sizeMap = { - sm: 'h-4 w-4 border-2', - md: 'h-6 w-6 border-[3px]', - lg: 'h-8 w-8 border-4', - }; - - return ( -
-
-
- ); -} - -export { SkeletonTable as SkeletonTableExport }; diff --git a/frontend/src/components/Table.test.tsx b/frontend/src/components/Table.test.tsx index 7d1e08d..32dfa4c 100644 --- a/frontend/src/components/Table.test.tsx +++ b/frontend/src/components/Table.test.tsx @@ -10,12 +10,14 @@ interface TestRow { const columns: Column[] = [ { - key: 'name', + id: 'name', header: 'Name', + render: row => row.name, }, { - key: 'role', + id: 'role', header: 'Role', + render: row => row.role, }, ]; @@ -41,9 +43,9 @@ describe('Table', () => { it('uses a custom render function for column output', () => { const customColumns: Column[] = [ { - key: 'name', + id: 'name', header: 'Name', - render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`, + render: (row, index) => `${index + 1}. ${row.name.toUpperCase()}`, }, ]; diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx index e04aa7b..e37050d 100644 --- a/frontend/src/components/Table.tsx +++ b/frontend/src/components/Table.tsx @@ -1,63 +1,22 @@ -import React from 'react'; -import { Skeleton } from './Skeleton'; -import { cn } from '../utils/cn'; +import { ReactNode } from 'react'; +import { SkeletonTable } from './Skeleton'; -export interface Column { - key: keyof T; - header: string; - render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode; +export interface Column { + id: string; + header: ReactNode; className?: string; + render: (row: T, index: number) => ReactNode; } -export interface TableProps { +export interface TableProps { columns: Column[]; data: T[]; loading?: boolean; - emptyMessage?: string; + emptyMessage?: ReactNode; rowKey?: keyof T | ((row: T) => string); } -function SkeletonTable({ - rows = 5, - columns = 4, - className = '', -}: { - rows?: number; - columns?: number; - className?: string; -}) { - return ( -
-
- - - {Array.from({ length: columns }).map((_, i) => ( - - ))} - - - - {Array.from({ length: rows }).map((_, rowIndex) => ( - - {Array.from({ length: columns }).map((_, colIndex) => ( - - ))} - - ))} - -
- -
- -
- - ); -} - -export function Table({ +export function Table({ columns, data, loading = false, @@ -86,7 +45,7 @@ export function Table({ {columns.map(column => ( {column.header} @@ -106,12 +65,10 @@ export function Table({ {columns.map(column => ( - {column.render - ? column.render(row[column.key], row, index) - : (row[column.key] as React.ReactNode)} + {column.render(row, index)} ))} diff --git a/frontend/src/components/TextInput.tsx b/frontend/src/components/TextInput.tsx new file mode 100644 index 0000000..8abdea8 --- /dev/null +++ b/frontend/src/components/TextInput.tsx @@ -0,0 +1,15 @@ +import { forwardRef, InputHTMLAttributes } from 'react'; +import { cn } from '../utils/cn'; + +export const inputClassName = + 'w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600'; + +type TextInputProps = InputHTMLAttributes; + +export const TextInput = forwardRef( + ({ className, ...props }, ref) => ( + + ) +); + +TextInput.displayName = 'TextInput'; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 18aa29d..14d1dd8 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -11,37 +11,31 @@ export interface ToastProps { onClose?: (id: string) => void; } -const getToastStyles = (_type: ToastType) => { - const baseStyles = - 'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300'; +const baseStyles = + 'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300'; - const typeStyles = { - info: 'border-secondary-500 bg-secondary-100', - warning: 'border-yellow-500 bg-yellow-100', - error: 'border-red-500 bg-red-100', - }; +const typeStyles: Record = { + info: 'border-secondary-500 bg-secondary-100', + warning: 'border-yellow-500 bg-yellow-100', + error: 'border-red-500 bg-red-100', +}; - const iconStyles = { - info: 'text-secondary-700', - warning: 'text-yellow-700', - error: 'text-red-700', - }; +const iconStyles: Record = { + info: 'text-secondary-700', + warning: 'text-yellow-700', + error: 'text-red-700', +}; - const textStyles = { - info: 'text-secondary-900', - warning: 'text-yellow-900', - error: 'text-red-900', - }; - - return { baseStyles, typeStyles, iconStyles, textStyles }; +const textStyles: Record = { + info: 'text-secondary-900', + warning: 'text-yellow-900', + error: 'text-red-900', }; export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) { const [isVisible, setIsVisible] = useState(true); const [isAnimatingOut, setIsAnimatingOut] = useState(false); - const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type); - const handleClose = useCallback(() => { setIsAnimatingOut(true); setTimeout(() => { diff --git a/frontend/src/components/ToastContext.tsx b/frontend/src/components/ToastContext.tsx index 0e12ec7..1963bdc 100644 --- a/frontend/src/components/ToastContext.tsx +++ b/frontend/src/components/ToastContext.tsx @@ -20,34 +20,31 @@ export function ToastProvider({ children }: { children: ReactNode }) { }, []); const showToast = useCallback( - (message: string, _type: ToastType = 'info', _duration?: number): string => { - const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - setToasts(prev => [ - ...prev, - { id, type: _type, message, duration: _duration, onClose: removeToast }, - ]); + (message: string, type: ToastType = 'info', duration?: number): string => { + const id = crypto.randomUUID(); + setToasts(prev => [...prev, { id, type, message, duration, onClose: removeToast }]); return id; }, [removeToast] ); const showInfo = useCallback( - (message: string, _duration?: number) => { - return showToast(message, 'info', _duration); + (message: string, duration?: number) => { + return showToast(message, 'info', duration); }, [showToast] ); const showWarning = useCallback( - (message: string, _duration?: number) => { - return showToast(message, 'warning', _duration); + (message: string, duration?: number) => { + return showToast(message, 'warning', duration); }, [showToast] ); const showError = useCallback( - (message: string, _duration?: number) => { - return showToast(message, 'error', _duration); + (message: string, duration?: number) => { + return showToast(message, 'error', duration); }, [showToast] ); diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index a87615a..0b25b6b 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -2,23 +2,13 @@ export { default as ReadingHistoryGraph } from './ReadingHistoryGraph'; // Toast components -export { Toast } from './Toast'; export { ToastProvider, useToasts } from './ToastContext'; -export type { ToastType, ToastProps } from './Toast'; // Skeleton components -export { - Skeleton, - SkeletonText, - SkeletonAvatar, - SkeletonCard, - SkeletonTable, - SkeletonButton, - PageLoader, - InlineLoader, -} from './Skeleton'; +export { Skeleton, SkeletonTable } from './Skeleton'; export { LoadingState } from './LoadingState'; export { Pagination } from './Pagination'; +export { TextInput } from './TextInput'; // Field components export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; diff --git a/frontend/src/components/navigation.ts b/frontend/src/components/navigation.ts new file mode 100644 index 0000000..68df5cb --- /dev/null +++ b/frontend/src/components/navigation.ts @@ -0,0 +1,46 @@ +import type { ElementType } from 'react'; +import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons'; + +export interface NavItem { + path: string; + label: string; + icon: ElementType; +} + +export const navItems: NavItem[] = [ + { path: '/', label: 'Home', icon: HomeIcon }, + { path: '/documents', label: 'Documents', icon: DocumentsIcon }, + { path: '/progress', label: 'Progress', icon: ActivityIcon }, + { path: '/activity', label: 'Activity', icon: ActivityIcon }, + { path: '/search', label: 'Search', icon: SearchIcon }, +]; + +export const adminNavItems: NavItem[] = [ + { path: '/admin', label: 'General', icon: SettingsIcon }, + { path: '/admin/import', label: 'Import', icon: SettingsIcon }, + { path: '/admin/users', label: 'Users', icon: SettingsIcon }, + { path: '/admin/logs', label: 'Logs', icon: SettingsIcon }, +]; + +// Ordered most-specific-first so prefix matching resolves nested routes correctly. +const pageTitles: { path: string; title: string }[] = [ + { path: '/admin/import-results', title: 'Admin - Import' }, + { path: '/admin/import', title: 'Admin - Import' }, + { path: '/admin/users', title: 'Admin - Users' }, + { path: '/admin/logs', title: 'Admin - Logs' }, + { path: '/admin', title: 'Admin - General' }, + { path: '/documents', title: 'Documents' }, + { path: '/progress', title: 'Progress' }, + { path: '/activity', title: 'Activity' }, + { path: '/search', title: 'Search' }, + { path: '/settings', title: 'Settings' }, + { path: '/', title: 'Home' }, +]; + +export function getPageTitle(pathname: string): string { + return ( + pageTitles.find(item => + item.path === '/' ? pathname === item.path : pathname.startsWith(item.path) + )?.title ?? 'Home' + ); +} diff --git a/frontend/src/hooks/useDebounce.test.tsx b/frontend/src/hooks/useDebounce.test.tsx deleted file mode 100644 index c23db40..0000000 --- a/frontend/src/hooks/useDebounce.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useDebounce } from './useDebounce'; - -describe('useDebounce', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns the initial value immediately', () => { - const { result } = renderHook(({ value, delay }) => useDebounce(value, delay), { - initialProps: { value: 'initial', delay: 300 }, - }); - - expect(result.current).toBe('initial'); - }); - - it('delays updates until the debounce interval has passed', () => { - vi.useFakeTimers(); - - const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { - initialProps: { value: 'initial', delay: 300 }, - }); - - rerender({ value: 'updated', delay: 300 }); - - expect(result.current).toBe('initial'); - - act(() => { - vi.advanceTimersByTime(299); - }); - - expect(result.current).toBe('initial'); - - act(() => { - vi.advanceTimersByTime(1); - }); - - expect(result.current).toBe('updated'); - }); - - it('cancels the previous timer when the value changes again', () => { - vi.useFakeTimers(); - - const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { - initialProps: { value: 'first', delay: 300 }, - }); - - rerender({ value: 'second', delay: 300 }); - - act(() => { - vi.advanceTimersByTime(200); - }); - - rerender({ value: 'third', delay: 300 }); - - act(() => { - vi.advanceTimersByTime(100); - }); - - expect(result.current).toBe('first'); - - act(() => { - vi.advanceTimersByTime(200); - }); - - expect(result.current).toBe('third'); - }); -}); diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts deleted file mode 100644 index 98d0abd..0000000 --- a/frontend/src/hooks/useDebounce.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * Debounces a value by delaying updates until after a specified delay - * @param value The value to debounce - * @param delay The delay in milliseconds - * @returns The debounced value - */ -export function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; -} diff --git a/frontend/src/hooks/useDebouncedState.test.tsx b/frontend/src/hooks/useDebouncedState.test.tsx new file mode 100644 index 0000000..abcd6c6 --- /dev/null +++ b/frontend/src/hooks/useDebouncedState.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedState } from './useDebouncedState'; + +describe('useDebouncedState', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns the initial value for both the input and the debounced value', () => { + const { result } = renderHook(() => useDebouncedState('initial', 300)); + + const [value, , debounced] = result.current; + expect(value).toBe('initial'); + expect(debounced).toBe('initial'); + }); + + it('updates the input immediately but delays the debounced value', () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useDebouncedState('initial', 300)); + + act(() => { + const setValue = result.current[1]; + setValue('updated'); + }); + + expect(result.current[0]).toBe('updated'); + expect(result.current[2]).toBe('initial'); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current[2]).toBe('updated'); + }); + + it('cancels the previous timer when the value changes again', () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useDebouncedState('first', 300)); + + act(() => result.current[1]('second')); + act(() => vi.advanceTimersByTime(200)); + act(() => result.current[1]('third')); + + act(() => vi.advanceTimersByTime(100)); + expect(result.current[2]).toBe('first'); + + act(() => vi.advanceTimersByTime(200)); + expect(result.current[2]).toBe('third'); + }); + + it('flush resolves the debounced value immediately, skipping the debounce window', () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useDebouncedState('initial', 300)); + + act(() => result.current[1]('updated')); + expect(result.current[2]).toBe('initial'); + + act(() => { + const flush = result.current[3]; + flush(); + }); + + expect(result.current[2]).toBe('updated'); + }); + + it('flush with an argument sets both the input and the resolved value', () => { + const { result } = renderHook(() => useDebouncedState('initial', 300)); + + act(() => { + const flush = result.current[3]; + flush('forced'); + }); + + expect(result.current[0]).toBe('forced'); + expect(result.current[2]).toBe('forced'); + }); +}); diff --git a/frontend/src/hooks/useDebouncedState.ts b/frontend/src/hooks/useDebouncedState.ts new file mode 100644 index 0000000..dcafb60 --- /dev/null +++ b/frontend/src/hooks/useDebouncedState.ts @@ -0,0 +1,26 @@ +import { useState, useEffect, useCallback } from 'react'; + +const DEFAULT_DELAY = 300; + +/** + * Owns a search/filter input and a debounced view of it. The fourth value, `flush`, + * resolves the debounced value immediately (e.g. on an explicit submit button) + * without waiting for the debounce window to elapse. + */ +export function useDebouncedState(initialValue: T, delay: number = DEFAULT_DELAY) { + const [value, setValue] = useState(initialValue); + const [debounced, setDebounced] = useState(initialValue); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + // Flush - skip the debounce window so an explicit submit resolves the active value right away. + const flush = useCallback((next?: T) => { + if (next !== undefined) setValue(next); + setDebounced(next !== undefined ? next : value); + }, [value]); + + return [value, setValue, debounced, flush] as const; +} diff --git a/frontend/src/hooks/useEpubReader.ts b/frontend/src/hooks/useEpubReader.ts index 9ff890b..26c73dc 100644 --- a/frontend/src/hooks/useEpubReader.ts +++ b/frontend/src/hooks/useEpubReader.ts @@ -20,7 +20,7 @@ interface UseEpubReaderOptions { } interface UseEpubReaderResult { - viewerRef: (_node: HTMLDivElement | null) => void; + viewerRef: (node: HTMLDivElement | null) => void; isReady: boolean; isLoading: boolean; error: string | null; diff --git a/frontend/src/hooks/useMutationWithToast.ts b/frontend/src/hooks/useMutationWithToast.ts new file mode 100644 index 0000000..4a72c16 --- /dev/null +++ b/frontend/src/hooks/useMutationWithToast.ts @@ -0,0 +1,35 @@ +import { useToasts } from '../components/ToastContext'; +import { getErrorMessage } from '../utils/errors'; + +interface ApiResponse { + status: number; + data: unknown; +} + +interface ToastMutationOptions { + success: string; + error: string; + onSuccess?: () => void; +} + +/** + * Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call, + * centralizing the shared "toast success / toast error / treat non-2xx as failure" pattern. + */ +export function useMutationWithToast() { + const { showInfo, showError } = useToasts(); + + return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) { + return { + onSuccess: (response: ApiResponse) => { + if (response.status < 200 || response.status >= 300) { + showError(`${error}: ${getErrorMessage(response.data)}`); + return; + } + onSuccess?.(); + showInfo(success); + }, + onError: (err: unknown) => showError(`${error}: ${getErrorMessage(err)}`), + }; + }; +} diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index f91d78b..08dfc49 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -6,11 +6,13 @@ import { Pagination } from '../components'; import { Table, type Column } from '../components/Table'; import { formatDuration } from '../utils/formatters'; +const ACTIVITY_PAGE_SIZE = 25; + export default function ActivityPage() { const [searchParams] = useSearchParams(); const documentID = searchParams.get('document') || undefined; const [page, setPage] = useState(1); - const limit = 25; + const limit = ACTIVITY_PAGE_SIZE; useEffect(() => { setPage(1); @@ -27,28 +29,28 @@ export default function ActivityPage() { const columns: Column[] = [ { - key: 'document_id' as const, + id: 'document', header: 'Document', - render: (_value, row) => ( + render: row => ( {row.author || 'Unknown'} - {row.title || 'Unknown'} ), }, { - key: 'start_time' as const, + id: 'start_time', header: 'Time', - render: value => String(value || 'N/A'), + render: row => row.start_time || 'N/A', }, { - key: 'duration' as const, + id: 'duration', header: 'Duration', - render: value => formatDuration(typeof value === 'number' ? value : 0), + render: row => formatDuration(row.duration ?? 0), }, { - key: 'end_percentage' as const, + id: 'end_percentage', header: 'Percent', - render: value => (typeof value === 'number' ? `${value}%` : '0%'), + render: row => (typeof row.end_percentage === 'number' ? `${row.end_percentage}%` : '0%'), }, ]; diff --git a/frontend/src/pages/AdminImportPage.tsx b/frontend/src/pages/AdminImportPage.tsx index 3ece217..33164f5 100644 --- a/frontend/src/pages/AdminImportPage.tsx +++ b/frontend/src/pages/AdminImportPage.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { LoadingState } from '../components'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; -import type { DirectoryItem, DirectoryListResponse } from '../generated/model'; +import type { DirectoryItem } from '../generated/model'; import { getErrorMessage } from '../utils/errors'; import { Button } from '../components/Button'; import { FolderOpenIcon } from '../icons'; @@ -11,6 +13,7 @@ export default function AdminImportPage() { const [selectedDirectory, setSelectedDirectory] = useState(''); const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT'); const { showInfo, showError } = useToasts(); + const navigate = useNavigate(); const { data: directoryData, isLoading } = useGetImportDirectory( currentPath ? { directory: currentPath } : {} @@ -19,7 +22,7 @@ export default function AdminImportPage() { const postImport = usePostImport(); const directoryResponse = - directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null; + directoryData?.status === 200 ? directoryData.data : null; const directories = directoryResponse?.items ?? []; const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data'; @@ -48,9 +51,7 @@ export default function AdminImportPage() { { onSuccess: _response => { showInfo('Import completed successfully'); - setTimeout(() => { - window.location.href = '/admin/import-results'; - }, 1500); + navigate('/admin/import-results'); }, onError: error => { showError('Import failed: ' + getErrorMessage(error)); @@ -64,7 +65,7 @@ export default function AdminImportPage() { }; if (isLoading && !currentPath) { - return
Loading...
; + return ; } if (selectedDirectory) { diff --git a/frontend/src/pages/AdminImportResultsPage.tsx b/frontend/src/pages/AdminImportResultsPage.tsx index 6d0cb73..82048d5 100644 --- a/frontend/src/pages/AdminImportResultsPage.tsx +++ b/frontend/src/pages/AdminImportResultsPage.tsx @@ -1,14 +1,15 @@ import { useGetImportResults } from '../generated/anthoLumeAPIV1'; -import type { ImportResult, ImportResultsResponse } from '../generated/model'; +import { LoadingState } from '../components'; +import type { ImportResult } from '../generated/model'; import { Link } from 'react-router-dom'; export default function AdminImportResultsPage() { const { data: resultsData, isLoading } = useGetImportResults(); const results = - resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : []; + resultsData?.status === 200 ? resultsData.data.results || [] : []; if (isLoading) { - return
Loading...
; + return ; } return ( @@ -33,11 +34,8 @@ export default function AdminImportResultsPage() { ) : ( results.map((result: ImportResult, index: number) => ( - - + + Name: {result.id ? ( { - setActiveFilter(debouncedFilter); - }, [debouncedFilter]); + const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300); const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {}); - const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : []; + const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : []; - const handleFilterSubmit = (e: FormEvent) => { + const handleFilterSubmit = (e: SyntheticEvent) => { e.preventDefault(); - setActiveFilter(filter); + flushFilter(); }; return ( @@ -33,11 +26,11 @@ export default function AdminLogsPage() { - setFilter(e.target.value)} - className="w-full flex-1 appearance-none rounded-none border border-border bg-surface p-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600" + className="p-2" placeholder="JQ Filter" /> @@ -50,13 +43,11 @@ export default function AdminLogsPage() { -
+
{isLoading ? ( ) : ( + // Key By Index - Log lines have no stable id and can repeat; this list is append-only and fully replaced on refetch. logs.map((log, index) => ( {typeof log === 'string' ? log : JSON.stringify(log)} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index fccc11a..a2ccf48 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,8 +1,10 @@ -import { useState, FormEvent } from 'react'; +import { useState, SyntheticEvent } from 'react'; +import { LoadingState } from '../components'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { Button } from '../components/Button'; import { useToasts } from '../components/ToastContext'; import { getErrorMessage } from '../utils/errors'; +import { streamResponseToFile, backupFilename } from '../utils/download'; interface BackupTypes { covers: boolean; @@ -20,7 +22,7 @@ export default function AdminPage() { }); const [restoreFile, setRestoreFile] = useState(null); - const handleBackupSubmit = async (e: FormEvent) => { + const handleBackupSubmit = async (e: SyntheticEvent) => { e.preventDefault(); const backupTypesList: string[] = []; if (backupTypes.covers) backupTypesList.push('COVERS'); @@ -31,6 +33,7 @@ export default function AdminPage() { formData.append('action', 'BACKUP'); backupTypesList.forEach(value => formData.append('backup_types', value)); + // Streaming Fetch - The generated client buffers; the backup can be large, so this endpoint intentionally uses a raw streaming download. const response = await fetch('/api/v1/admin', { method: 'POST', body: formData, @@ -40,43 +43,21 @@ export default function AdminPage() { throw new Error('Backup failed: ' + response.statusText); } - const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`; + const completed = await streamResponseToFile(response, { + suggestedName: backupFilename(), + mimeType: 'application/zip', + extension: '.zip', + }); - if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') { - try { - const handle = await window.showSaveFilePicker({ - suggestedName: filename, - types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], - }); - - const writable = await handle.createWritable(); - const reader = response.body?.getReader(); - if (!reader) throw new Error('Unable to read response'); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - await writable.write(value); - } - - await writable.close(); - showInfo('Backup completed successfully'); - } catch (err) { - if ((err as Error).name !== 'AbortError') { - showError('Backup failed: ' + (err as Error).message); - } - } - } else { - showError( - 'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.' - ); + if (completed) { + showInfo('Backup completed successfully'); } } catch (error) { showError('Backup failed: ' + getErrorMessage(error)); } }; - const handleRestoreSubmit = async (e: FormEvent) => { + const handleRestoreSubmit = async (e: SyntheticEvent) => { e.preventDefault(); if (!restoreFile) return; @@ -125,7 +106,7 @@ export default function AdminPage() { }; if (isLoading) { - return
Loading...
; + return ; } return ( diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx index 0f261a8..c2cfa64 100644 --- a/frontend/src/pages/AdminUsersPage.tsx +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -1,23 +1,27 @@ -import { useState, FormEvent } from 'react'; +import { useState, SyntheticEvent } from 'react'; +import { LoadingState, TextInput } from '../components'; +import { Button } from '../components/Button'; +import { Table, type Column } from '../components/Table'; import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1'; -import type { User, UsersResponse } from '../generated/model'; +import type { User } from '../generated/model'; import { AddIcon, DeleteIcon } from '../icons'; -import { useToasts } from '../components/ToastContext'; -import { getErrorMessage } from '../utils/errors'; +import { useMutationWithToast } from '../hooks/useMutationWithToast'; export default function AdminUsersPage() { const { data: usersData, isLoading, refetch } = useGetUsers({}); const updateUser = useUpdateUser(); - const { showInfo, showError } = useToasts(); + const toastMutationOptions = useMutationWithToast(); const [showAddForm, setShowAddForm] = useState(false); const [newUsername, setNewUsername] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newIsAdmin, setNewIsAdmin] = useState(false); + const [resetUserId, setResetUserId] = useState(null); + const [resetPassword, setResetPassword] = useState(''); - const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : []; + const users = usersData?.status === 200 ? (usersData.data.users ?? []) : []; - const handleCreateUser = (e: FormEvent) => { + const handleCreateUser = (e: SyntheticEvent) => { e.preventDefault(); if (!newUsername || !newPassword) return; @@ -30,22 +34,17 @@ export default function AdminUsersPage() { is_admin: newIsAdmin, }, }, - { - onSuccess: response => { - if (response.status < 200 || response.status >= 300) { - showError('Failed to create user: ' + getErrorMessage(response.data)); - return; - } - - showInfo('User created successfully'); + toastMutationOptions({ + success: 'User created successfully', + error: 'Failed to create user', + onSuccess: () => { setShowAddForm(false); setNewUsername(''); setNewPassword(''); setNewIsAdmin(false); refetch(); }, - onError: error => showError('Failed to create user: ' + getErrorMessage(error)), - } + }) ); }; @@ -54,18 +53,11 @@ export default function AdminUsersPage() { { data: { operation: 'DELETE', user: userId }, }, - { - onSuccess: response => { - if (response.status < 200 || response.status >= 300) { - showError('Failed to delete user: ' + getErrorMessage(response.data)); - return; - } - - showInfo('User deleted successfully'); - refetch(); - }, - onError: error => showError('Failed to delete user: ' + getErrorMessage(error)), - } + toastMutationOptions({ + success: 'User deleted successfully', + error: 'Failed to delete user', + onSuccess: refetch, + }) ); }; @@ -76,18 +68,11 @@ export default function AdminUsersPage() { { data: { operation: 'UPDATE', user: userId, password }, }, - { - onSuccess: response => { - if (response.status < 200 || response.status >= 300) { - showError('Failed to update password: ' + getErrorMessage(response.data)); - return; - } - - showInfo('Password updated successfully'); - refetch(); - }, - onError: error => showError('Failed to update password: ' + getErrorMessage(error)), - } + toastMutationOptions({ + success: 'Password updated successfully', + error: 'Failed to update password', + onSuccess: refetch, + }) ); }; @@ -96,23 +81,80 @@ export default function AdminUsersPage() { { data: { operation: 'UPDATE', user: userId, is_admin: isAdmin }, }, - { - onSuccess: response => { - if (response.status < 200 || response.status >= 300) { - showError('Failed to update admin status: ' + getErrorMessage(response.data)); - return; - } - - showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`); - refetch(); - }, - onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)), - } + toastMutationOptions({ + success: `User permissions updated to ${isAdmin ? 'admin' : 'user'}`, + error: 'Failed to update admin status', + onSuccess: refetch, + }) ); }; + const permissionButtonClass = (active: boolean) => + `rounded-md px-2 py-1 ${ + active + ? 'cursor-default bg-content text-content-inverse' + : 'cursor-pointer bg-surface-strong text-content' + }`; + + const userColumns: Column[] = [ + { + id: 'actions', + className: 'w-12', + header: ( + + ), + render: user => ( + + ), + }, + { id: 'user', header: 'User', render: user => user.id }, + { + id: 'password', + header: 'Password', + render: user => ( + + ), + }, + { + id: 'permissions', + header: 'Permissions', + className: 'text-center', + render: user => ( +
+ + +
+ ), + }, + { id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at }, + ]; + if (isLoading) { - return
Loading...
; + return ; } return ( @@ -153,89 +195,42 @@ export default function AdminUsersPage() {
)} -
- - - - - - - - - - - - {users.length === 0 ? ( - - - - ) : ( - users.map((user: User) => ( - - - - - - - - )) - )} - -
- - User - Password - - Permissions - - Created -
- No Results -
- - -

{user.id}

-
- - - - - -

{user.created_at}

-
-
+ + + {resetUserId && ( +
setResetUserId(null)} + > +
e.stopPropagation()} + onSubmit={e => { + e.preventDefault(); + if (!resetPassword) return; + handleUpdatePassword(resetUserId, resetPassword); + setResetUserId(null); + }} + > +

Reset password for {resetUserId}

+ setResetPassword(e.target.value)} + placeholder="New password" + autoFocus + /> +
+ + +
+ +
+ )} ); } diff --git a/frontend/src/pages/AuthFormView.tsx b/frontend/src/pages/AuthFormView.tsx new file mode 100644 index 0000000..8d0f8ba --- /dev/null +++ b/frontend/src/pages/AuthFormView.tsx @@ -0,0 +1,105 @@ +import { SyntheticEvent, ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button } from '../components/Button'; +import { TextInput } from '../components'; + +interface AuthFormViewProps { + username: string; + password: string; + isLoading: boolean; + onUsernameChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onSubmit: (e: SyntheticEvent) => void | Promise; + + submitLabel: string; + submittingLabel: string; + inputsDisabled?: boolean; + footer: ReactNode; +} + +export function AuthFormView({ + username, + password, + isLoading, + onUsernameChange, + onPasswordChange, + onSubmit, + submitLabel, + submittingLabel, + inputsDisabled = false, + footer, +}: AuthFormViewProps) { + return ( +
+
+
+
+

Welcome.

+
+
+
+ onUsernameChange(e.target.value)} + placeholder="Username" + required + disabled={isLoading || inputsDisabled} + /> +
+
+
+
+ onPasswordChange(e.target.value)} + placeholder="Password" + required + disabled={isLoading || inputsDisabled} + /> +
+
+ + +
{footer}
+
+
+
+
+ AnthoLume +
+
+
+
+ ); +} + +export function authFormFooter( + primaryLink: { to: string; text: string }, + showPrimary: boolean +) { + return ( + <> + {showPrimary && ( +

+ + {primaryLink.text} + +

+ )} +

+ + Offline / Local Mode + +

+ + ); +} diff --git a/frontend/src/pages/ComponentDemoPage.tsx b/frontend/src/pages/ComponentDemoPage.tsx deleted file mode 100644 index b4c61e2..0000000 --- a/frontend/src/pages/ComponentDemoPage.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState } from 'react'; -import { useToasts } from '../components/ToastContext'; -import { - Skeleton, - SkeletonText, - SkeletonAvatar, - SkeletonCard, - SkeletonTable, - SkeletonButton, - PageLoader, - InlineLoader, -} from '../components/Skeleton'; - -export default function ComponentDemoPage() { - const { showInfo, showWarning, showError, showToast } = useToasts(); - const [isLoading, setIsLoading] = useState(false); - - const handleDemoClick = () => { - setIsLoading(true); - showInfo('Starting demo operation...'); - - setTimeout(() => { - setIsLoading(false); - showInfo('Demo operation completed successfully!'); - }, 2000); - }; - - const handleErrorClick = () => { - showError('This is a sample error message'); - }; - - const handleWarningClick = () => { - showWarning('This is a sample warning message', 10000); - }; - - const handleCustomToast = () => { - showToast('Custom toast message', 'info', 3000); - }; - - return ( -
-

UI Components Demo

- -
-

Toast Notifications

-
- - - - -
-
- -
-

Skeleton Loading Components

- -
-
-

Basic Skeletons

-
- - - -
- - -
-
-
- -
-

Skeleton Text

- - -
- -
-

Skeleton Avatar

-
- - - - -
-
- -
-

Skeleton Button

-
- - -
-
-
-
- -
-

Skeleton Cards

-
- - - -
-
- -
-

Skeleton Table

- -
- -
-

Page Loader

- -
- -
-

Inline Loader

-
-
- -

Small

-
-
- -

Medium

-
-
- -

Large

-
-
-
-
- ); -} diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index 28fb37e..e37c479 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -6,119 +6,158 @@ import { useEditDocument, getGetDocumentQueryKey, } from '../generated/anthoLumeAPIV1'; -import { Document } from '../generated/model/document'; +import type { EditDocumentBody } from '../generated/model'; import { formatDuration } from '../utils/formatters'; -import { - DeleteIcon, - ActivityIcon, - SearchIcon, - DownloadIcon, - EditIcon, - InfoIcon, - CloseIcon, - CheckIcon, -} from '../icons'; -import { Field, FieldLabel, FieldValue, FieldActions } from '../components'; +import { getErrorMessage } from '../utils/errors'; +import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; +import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; +import { useToasts } from '../components/ToastContext'; const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content'; const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200'; -const popupInputClassName = 'rounded bg-surface p-2 text-content'; const editInputClassName = 'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500'; +interface EditableFieldProps { + label: string; + value: string; + multiline?: boolean; + valueClassName?: string; + onSave: (value: string) => Promise; +} + +function EditableField({ + label, + value, + multiline = false, + valueClassName, + onSave, +}: EditableFieldProps) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(value); + + const startEdit = () => { + setDraft(value); + setIsEditing(true); + }; + + // Keep Editor Open On Failure - Only close once the save actually succeeds so a failed edit isn't silently lost. + const confirm = async () => { + if (await onSave(draft)) setIsEditing(false); + }; + + return ( + + {label} + + {isEditing ? ( +
+ + +
+ ) : ( + + )} +
+ + } + > + {isEditing ? ( +
+ {multiline ? ( +