This commit is contained in:
2026-03-22 12:42:12 -04:00
parent 784e53c557
commit 63ad73755d
10 changed files with 188 additions and 63 deletions

View File

@@ -47,6 +47,7 @@ Regenerate:
- The Go server embeds `templates/*` and `assets/*`. - The Go server embeds `templates/*` and `assets/*`.
- Root Tailwind output is built to `assets/style.css`. - 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. - 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 ## 5) Frontend

View File

@@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G
apiUsers := make([]User, len(users)) apiUsers := make([]User, len(users))
for i, user := range users { for i, user := range users {
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
apiUsers[i] = User{ apiUsers[i] = User{
Id: user.ID, Id: user.ID,
Admin: user.Admin, 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)) apiUsers := make([]User, len(users))
for i, user := range users { for i, user := range users {
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
apiUsers[i] = User{ apiUsers[i] = User{
Id: user.ID, Id: user.ID,
Admin: user.Admin, Admin: user.Admin,
CreatedAt: createdAt, CreatedAt: parseTime(user.CreatedAt),
} }
} }

View File

@@ -18,6 +18,7 @@ Also follow the repository root guide at `../AGENTS.md`.
- Do not add external icon libraries. - Do not add external icon libraries.
- Prefer generated types from `src/generated/model/` over `any`. - 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. - 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; avoid early returns that unmount search/filter forms during fetches.
## 3) Generated API client ## 3) Generated API client

View File

@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass = 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') { 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<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(

View File

@@ -4,6 +4,7 @@ interface BaseIconProps {
size?: number; size?: number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
hoverable?: boolean;
viewBox?: string; viewBox?: string;
children: ReactNode; children: ReactNode;
} }
@@ -12,12 +13,15 @@ export function BaseIcon({
size = 24, size = 24,
className = '', className = '',
disabled = false, disabled = false,
hoverable = true,
viewBox = '0 0 24 24', viewBox = '0 0 24 24',
children, children,
}: BaseIconProps) { }: BaseIconProps) {
const disabledClasses = disabled const disabledClasses = disabled
? 'text-gray-200 dark:text-gray-600' ? '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 ( return (
<svg <svg

View File

@@ -4,11 +4,12 @@ interface Search2IconProps {
size?: number; size?: number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
hoverable?: boolean;
} }
export function Search2Icon({ size = 24, className = '', disabled = false }: Search2IconProps) { export function Search2Icon({ size = 24, className = '', disabled = false, hoverable = true }: Search2IconProps) {
return ( return (
<BaseIcon size={size} className={className} disabled={disabled}> <BaseIcon size={size} className={className} disabled={disabled} hoverable={hoverable}>
<rect width="24" height="24" fill="none" /> <rect width="24" height="24" fill="none" />
<path <path
fillRule="evenodd" fillRule="evenodd"

View File

@@ -31,7 +31,7 @@ export default function AdminLogsPage() {
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"> <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<Search2Icon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"

View File

@@ -12,7 +12,7 @@ interface BackupTypes {
export default function AdminPage() { export default function AdminPage() {
const { isLoading } = useGetAdmin(); const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction(); const postAdminAction = usePostAdminAction();
const { showInfo, showError } = useToasts(); const { showInfo, showError, removeToast } = useToasts();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({ const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false, covers: false,
@@ -83,26 +83,32 @@ export default function AdminPage() {
} }
}; };
const handleRestoreSubmit = (e: FormEvent) => { const handleRestoreSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
postAdminAction.mutate( const startedToastId = showInfo('Restore started', 0);
{
try {
const response = await postAdminAction.mutateAsync({
data: { data: {
action: 'RESTORE', action: 'RESTORE',
restore_file: restoreFile, restore_file: restoreFile,
}, },
}, });
{
onSuccess: () => { removeToast(startedToastId);
showInfo('Restore completed successfully');
}, if (response.status >= 200 && response.status < 300) {
onError: error => { showInfo('Restore completed successfully');
showError('Restore failed: ' + getErrorMessage(error)); return;
},
} }
);
showError('Restore failed: ' + getErrorMessage(response.data));
} catch (error) {
removeToast(startedToastId);
showError('Restore failed: ' + getErrorMessage(error));
}
}; };
const handleMetadataMatch = () => { const handleMetadataMatch = () => {
@@ -191,7 +197,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div className="h-10 w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit"> <Button variant="secondary" type="submit" disabled={!restoreFile}>
Restore Restore
</Button> </Button>
</div> </div>

View File

@@ -1,34 +1,56 @@
import { useState, FormEvent, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document, DocumentsResponse } from '../generated/model'; import type { Document, DocumentsResponse } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { Button } from '../components/Button';
import { LoadingState } from '../components'; import { LoadingState } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import { getErrorMessage } from '../utils/errors'; 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 { interface DocumentCardProps {
doc: Document; doc: Document;
} }
function DocumentCard({ doc }: DocumentCardProps) { function DocumentCard({ doc }: DocumentCardProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0; const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0; const totalTimeSeconds = doc.total_time_seconds || 0;
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700"> <div
role="link"
tabIndex={0}
className="flex size-full cursor-pointer gap-4 rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/documents/${doc.id}`);
}
}}
>
<div className="relative my-auto h-48 min-w-fit"> <div className="relative my-auto h-48 min-w-fit">
<Link to={`/documents/${doc.id}`}> <img
<img className="h-full rounded object-cover"
className="h-full rounded object-cover" src={`/api/v1/documents/${doc.id}/cover`}
src={`/api/v1/documents/${doc.id}/cover`} alt={doc.title}
alt={doc.title} />
/>
</Link>
</div> </div>
<div className="flex w-full flex-col justify-around text-sm dark:text-white"> <div className="flex w-full flex-col justify-around text-sm dark:text-white">
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
@@ -57,11 +79,70 @@ function DocumentCard({ doc }: DocumentCardProps) {
</div> </div>
</div> </div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400"> <div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
<Link to={`/activity?document=${doc.id}`}> <Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} /> <ActivityIcon size={20} />
</Link> </Link>
{doc.filepath ? ( {doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`}> <a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
<DownloadIcon size={20} />
</a>
) : (
<DownloadIcon size={20} disabled />
)}
</div>
</div>
</div>
);
}
interface DocumentListItemProps {
doc: Document;
}
function DocumentListItem({ doc }: DocumentListItemProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
return (
<div
role="link"
tabIndex={0}
className="block cursor-pointer rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/documents/${doc.id}`);
}
}}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
<div>
<p className="text-gray-400">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
<div>
<p className="text-gray-400">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
<div>
<p className="text-gray-400">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
<div>
<p className="text-gray-400">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-4 text-gray-500 dark:text-gray-400">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
<DownloadIcon size={20} /> <DownloadIcon size={20} />
</a> </a>
) : ( ) : (
@@ -78,11 +159,16 @@ export default function DocumentsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(9); const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentViewMode>(getInitialViewMode);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts(); const { showInfo, showWarning, showError } = useToasts();
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
window.localStorage.setItem(DOCUMENTS_VIEW_MODE_KEY, viewMode);
}, [viewMode]);
// Reset to page 1 when search changes // Reset to page 1 when search changes
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
@@ -94,11 +180,6 @@ export default function DocumentsPage() {
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page; const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page; const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
refetch();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -131,13 +212,12 @@ export default function DocumentsPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Search Form */} <div className="flex grow flex-col gap-4 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"> <div className="flex flex-col gap-4 lg:flex-row">
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"> <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<Search2Icon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"
@@ -149,24 +229,59 @@ export default function DocumentsPage() {
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <div className="inline-flex rounded border border-gray-300 bg-white p-1 dark:border-gray-600 dark:bg-gray-800">
<Button variant="secondary" type="submit"> <button
Search type="button"
</Button> onClick={() => setViewMode('grid')}
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === 'grid'
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
Grid
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === 'list'
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
List
</button>
</div> </div>
</form> </div>
</div> </div>
{/* Document Grid */} {viewMode === 'grid' ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{isLoading ? ( {isLoading ? (
<LoadingState className="col-span-full min-h-48" /> <LoadingState className="col-span-full min-h-48" />
) : ( ) : docs && docs.length > 0 ? (
docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />) docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
)} ) : (
</div> <div className="col-span-full rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
No documents found.
</div>
)}
</div>
) : (
<div className="flex flex-col gap-4">
{isLoading ? (
<LoadingState className="min-h-48" />
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
) : (
<div className="rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
No documents found.
</div>
)}
</div>
)}
{/* Pagination */}
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white"> <div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
{previousPage && previousPage > 0 && ( {previousPage && previousPage > 0 && (
<button <button
@@ -186,7 +301,6 @@ export default function DocumentsPage() {
)} )}
</div> </div>
{/* Upload Button */}
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full"> <div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
<input <input
type="checkbox" type="checkbox"

View File

@@ -54,7 +54,7 @@ export function SearchPageView({
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"> <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<Search2Icon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"