Files
AnthoLume/frontend/src/pages/DocumentsPage.tsx
T
evan 09daf6d511
continuous-integration/drone/pr Build is failing
cleanup api
2026-07-03 20:48:29 -04:00

250 lines
8.3 KiB
TypeScript

import { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components';
import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters';
import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { usePaginatedList } from '../hooks/usePaginatedList';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
const DOCUMENTS_PAGE_SIZE = 9;
interface DocumentItemProps {
doc: Document;
layout: 'grid' | 'list';
}
function DocumentItem({ doc, layout }: DocumentItemProps) {
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
const documentPath = `/documents/${doc.id}`;
const title = doc.title || 'Unknown';
const actions = (
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`} aria-label={`View activity for ${title}`}>
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`} aria-label={`Download ${title}`}>
<DownloadIcon size={20} />
</a>
) : (
<DownloadIcon size={20} disabled />
)}
</div>
);
const fields = [
{ label: 'Title', value: title, link: true },
{ label: 'Author', value: doc.author || 'Unknown' },
{ label: 'Progress', value: `${percentage}%` },
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
];
const fieldList = (
<div
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
>
{fields.map(field => (
<div key={field.label}>
<p className="text-content-subtle">{field.label}</p>
{field.link ? (
<Link to={documentPath} className="font-medium hover:underline">
{field.value}
</Link>
) : (
<p className="font-medium">{field.value}</p>
)}
</div>
))}
</div>
);
if (layout === 'grid') {
return (
<div className="flex size-full gap-4 rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
<Link to={documentPath} className="my-auto h-48 min-w-fit" aria-label={`Open ${title}`}>
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={title}
/>
</Link>
<div className="flex w-full flex-col justify-between gap-4">
{fieldList}
{actions}
</div>
</div>
);
}
return (
<div className="rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{fieldList}
{actions}
</div>
</div>
);
}
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() {
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
const { page, setPage } = usePaginatedList(debouncedSearch);
const limit = DOCUMENTS_PAGE_SIZE;
const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
const fileInputRef = useRef<HTMLInputElement>(null);
const { showWarning } = useToasts();
const toastMutationOptions = useMutationWithToast();
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument();
const documentsResponse = data;
const docs = documentsResponse?.documents;
const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page;
const uploadDocument = (file: File | undefined) => {
if (!file) return;
if (!file.name.endsWith('.epub')) {
showWarning('Please upload an EPUB file');
return;
}
createMutation.mutate(
{ data: { document_file: file } },
toastMutationOptions({
success: 'Document uploaded successfully!',
error: 'Failed to upload document',
onSuccess: () => {
setUploadMode(false);
refetch();
},
})
);
};
const handleCancelUpload = () => {
setUploadMode(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<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 flex-col gap-4 lg:flex-row">
<div className="flex w-full grow flex-col">
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
<TextInput
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search Author / Title"
name="search"
/>
</IconInput>
</div>
<SegmentedControl<DocumentsViewMode>
ariaLabel="Document view mode"
value={viewMode}
onChange={setViewMode}
options={[
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' },
]}
/>
</div>
</div>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{isLoading ? (
<LoadingState className="col-span-full min-h-48" />
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
) : (
<EmptyDocuments className="col-span-full" />
)}
</div>
) : (
<div className="flex flex-col gap-4">
{isLoading ? (
<LoadingState className="min-h-48" />
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
) : (
<EmptyDocuments />
)}
</div>
)}
<Pagination
page={page}
previousPage={previousPage}
nextPage={nextPage}
total={documentsResponse?.total}
limit={limit}
onPageChange={setPage}
/>
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
{uploadMode && (
<div className="absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200">
<div className="flex flex-col gap-2">
<input
type="file"
accept=".epub"
id="document_file"
name="document_file"
ref={fileInputRef}
/>
<button
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
type="button"
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])}
>
Upload File
</button>
</div>
<button
type="button"
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
onClick={handleCancelUpload}
>
Cancel Upload
</button>
</div>
)}
<button
type="button"
onClick={() => setUploadMode(!uploadMode)}
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
aria-label="Upload document"
>
<UploadIcon size={34} className="text-content-inverse" />
</button>
</div>
</div>
);
}