From 63ad73755dc0e1f5af0efae4c69245e0c9ebab3d Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 22 Mar 2026 12:42:12 -0400 Subject: [PATCH] wip 22 --- AGENTS.md | 1 + api/v1/admin.go | 6 +- frontend/AGENTS.md | 1 + frontend/src/components/Button.tsx | 6 +- frontend/src/icons/BaseIcon.tsx | 6 +- frontend/src/icons/Search2Icon.tsx | 5 +- frontend/src/pages/AdminLogsPage.tsx | 2 +- frontend/src/pages/AdminPage.tsx | 34 +++-- frontend/src/pages/DocumentsPage.tsx | 188 +++++++++++++++++++++------ frontend/src/pages/SearchPage.tsx | 2 +- 10 files changed, 188 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c67779b..5dd1f4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Regenerate: - The Go server embeds `templates/*` and `assets/*`. - Root Tailwind output is built to `assets/style.css`. - Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both. +- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts. ## 5) Frontend diff --git a/api/v1/admin.go b/api/v1/admin.go index 25d23df..f003117 100644 --- a/api/v1/admin.go +++ b/api/v1/admin.go @@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G apiUsers := make([]User, len(users)) for i, user := range users { - createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt) apiUsers[i] = User{ Id: user.ID, Admin: user.Admin, - CreatedAt: createdAt, + CreatedAt: parseTime(user.CreatedAt), } } @@ -493,11 +492,10 @@ func (s *Server) UpdateUser(ctx context.Context, request UpdateUserRequestObject apiUsers := make([]User, len(users)) for i, user := range users { - createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt) apiUsers[i] = User{ Id: user.ID, Admin: user.Admin, - CreatedAt: createdAt, + CreatedAt: parseTime(user.CreatedAt), } } diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 60c9a54..2fcc3d4 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -18,6 +18,7 @@ Also follow the repository root guide at `../AGENTS.md`. - Do not add external icon libraries. - Prefer generated types from `src/generated/model/` over `any`. - 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. ## 3) Generated API client diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index e335555..2a2495d 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes & { h const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const baseClass = - 'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white'; + 'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white disabled:cursor-not-allowed disabled:opacity-50'; if (variant === 'secondary') { - return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`; + return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white disabled:hover:text-white disabled:hover:bg-black`; } - return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100`; + return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:hover:bg-gray-500 dark:disabled:hover:bg-transparent`; }; export const Button = forwardRef( diff --git a/frontend/src/icons/BaseIcon.tsx b/frontend/src/icons/BaseIcon.tsx index 06a9865..a4fa3f1 100644 --- a/frontend/src/icons/BaseIcon.tsx +++ b/frontend/src/icons/BaseIcon.tsx @@ -4,6 +4,7 @@ interface BaseIconProps { size?: number; className?: string; disabled?: boolean; + hoverable?: boolean; viewBox?: string; children: ReactNode; } @@ -12,12 +13,15 @@ export function BaseIcon({ size = 24, className = '', disabled = false, + hoverable = true, viewBox = '0 0 24 24', children, }: BaseIconProps) { const disabledClasses = disabled ? 'text-gray-200 dark:text-gray-600' - : 'hover:text-gray-800 dark:hover:text-gray-100'; + : hoverable + ? 'hover:text-gray-800 dark:hover:text-gray-100' + : ''; return ( +
- + ({ covers: false, @@ -83,26 +83,32 @@ export default function AdminPage() { } }; - const handleRestoreSubmit = (e: FormEvent) => { + const handleRestoreSubmit = async (e: FormEvent) => { e.preventDefault(); if (!restoreFile) return; - postAdminAction.mutate( - { + const startedToastId = showInfo('Restore started', 0); + + try { + const response = await postAdminAction.mutateAsync({ data: { action: 'RESTORE', restore_file: restoreFile, }, - }, - { - onSuccess: () => { - showInfo('Restore completed successfully'); - }, - onError: error => { - showError('Restore failed: ' + getErrorMessage(error)); - }, + }); + + removeToast(startedToastId); + + if (response.status >= 200 && response.status < 300) { + showInfo('Restore completed successfully'); + return; } - ); + + showError('Restore failed: ' + getErrorMessage(response.data)); + } catch (error) { + removeToast(startedToastId); + showError('Restore failed: ' + getErrorMessage(error)); + } }; const handleMetadataMatch = () => { @@ -191,7 +197,7 @@ export default function AdminPage() { />
-
diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 63745d1..d2d9247 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,34 +1,56 @@ -import { useState, FormEvent, useRef, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { useState, useRef, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import type { Document, DocumentsResponse } from '../generated/model'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; -import { Button } from '../components/Button'; import { LoadingState } from '../components'; import { useToasts } from '../components/ToastContext'; import { formatDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; import { getErrorMessage } from '../utils/errors'; +const DOCUMENTS_VIEW_MODE_KEY = 'documents:view-mode'; + +type DocumentViewMode = 'grid' | 'list'; + +function getInitialViewMode(): DocumentViewMode { + if (typeof window === 'undefined') { + return 'grid'; + } + + const storedValue = window.localStorage.getItem(DOCUMENTS_VIEW_MODE_KEY); + return storedValue === 'list' ? 'list' : 'grid'; +} + interface DocumentCardProps { doc: Document; } function DocumentCard({ doc }: DocumentCardProps) { + const navigate = useNavigate(); const percentage = doc.percentage || 0; const totalTimeSeconds = doc.total_time_seconds || 0; return (
-
+
navigate(`/documents/${doc.id}`)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + navigate(`/documents/${doc.id}`); + } + }} + >
- - {doc.title} - + {doc.title}
@@ -57,11 +79,70 @@ function DocumentCard({ doc }: DocumentCardProps) {
- + e.stopPropagation()}> {doc.filepath ? ( - + e.stopPropagation()}> + + + ) : ( + + )} +
+
+
+ ); +} + +interface DocumentListItemProps { + doc: Document; +} + +function DocumentListItem({ doc }: DocumentListItemProps) { + const navigate = useNavigate(); + const percentage = doc.percentage || 0; + const totalTimeSeconds = doc.total_time_seconds || 0; + + return ( +
navigate(`/documents/${doc.id}`)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + navigate(`/documents/${doc.id}`); + } + }} + > +
+
+
+

Title

+

{doc.title || 'Unknown'}

+
+
+

Author

+

{doc.author || 'Unknown'}

+
+
+

Progress

+

{percentage}%

+
+
+

Time Read

+

{formatDuration(totalTimeSeconds)}

+
+
+ +
+ e.stopPropagation()}> + + + {doc.filepath ? ( + e.stopPropagation()}> ) : ( @@ -78,11 +159,16 @@ export default function DocumentsPage() { const [page, setPage] = useState(1); const [limit] = useState(9); const [uploadMode, setUploadMode] = useState(false); + const [viewMode, setViewMode] = useState(getInitialViewMode); const fileInputRef = useRef(null); const { showInfo, showWarning, showError } = useToasts(); const debouncedSearch = useDebounce(search, 300); + useEffect(() => { + window.localStorage.setItem(DOCUMENTS_VIEW_MODE_KEY, viewMode); + }, [viewMode]); + // Reset to page 1 when search changes useEffect(() => { setPage(1); @@ -94,11 +180,6 @@ export default function DocumentsPage() { const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page; const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page; - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - refetch(); - }; - const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -131,13 +212,12 @@ export default function DocumentsPage() { return (
- {/* Search Form */} -
-
+
+
- +
-
- +
+ +
- +
- {/* Document Grid */} -
- {isLoading ? ( - - ) : ( - docs?.map(doc => ) - )} -
+ {viewMode === 'grid' ? ( +
+ {isLoading ? ( + + ) : docs && docs.length > 0 ? ( + docs.map(doc => ) + ) : ( +
+ No documents found. +
+ )} +
+ ) : ( +
+ {isLoading ? ( + + ) : docs && docs.length > 0 ? ( + docs.map(doc => ) + ) : ( +
+ No documents found. +
+ )} +
+ )} - {/* Pagination */}
{previousPage && previousPage > 0 && (