openreader/src/components/doclist/DocumentList.tsx

405 lines
14 KiB
TypeScript

'use client';
import { useCallback, useState, useEffect, DragEvent, KeyboardEvent } from 'react';
import { useDocuments } from '@/contexts/DocumentContext';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
import { SortControls } from '@/components/doclist/SortControls';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { Button } from '@headlessui/react';
import { DocumentUploader } from '@/components/documents/DocumentUploader';
type DocumentToDelete = {
id: string;
name: string;
type: DocumentType;
};
const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentListDocument) => {
// Try to find common words between the two document names
const words1 = doc1.name.toLowerCase().split(/[\s-_\.]+/);
const words2 = doc2.name.toLowerCase().split(/[\s-_\.]+/);
const commonWords = words1.filter(word => words2.includes(word));
if (commonWords.length > 0) {
// Use the first common word that's at least 3 characters long
const significant = commonWords.find(word => word.length >= 3);
if (significant) {
if (significant === 'pdf') return 'PDFs';
if (significant === 'epub') return 'EPUBs';
if (significant === 'txt' || significant === 'md') return 'Documents';
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
}
}
// Fallback to a numbered folder
const timestamp = new Date().toISOString().slice(0, 10);
return `Folder ${timestamp}`;
};
export function DocumentList() {
// State hooks
const [sortBy, setSortBy] = useState<SortBy>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [folders, setFolders] = useState<Folder[]>([]);
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
const [draggedDoc, setDraggedDoc] = useState<DocumentListDocument | null>(null);
const [dropTargetDoc, setDropTargetDoc] = useState<DocumentListDocument | null>(null);
const [newFolderName, setNewFolderName] = useState('');
const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const [isInitialized, setIsInitialized] = useState(false);
const [showHint, setShowHint] = useState(true);
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
const {
pdfDocs,
removePDFDocument: removePDF,
isPDFLoading,
epubDocs,
removeEPUBDocument: removeEPUB,
isEPUBLoading,
htmlDocs,
removeHTMLDocument: removeHTML,
isHTMLLoading,
} = useDocuments();
useEffect(() => {
// Load saved state
const loadState = async () => {
const savedState = await getDocumentListState();
if (savedState) {
setSortBy(savedState.sortBy);
setSortDirection(savedState.sortDirection);
setFolders(savedState.folders);
setCollapsedFolders(new Set(savedState.collapsedFolders));
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
setViewMode(savedState.viewMode ?? 'grid');
}
setIsInitialized(true);
};
loadState();
}, []);
useEffect(() => {
const saveState = async () => {
const state: DocumentListState = {
sortBy,
sortDirection,
folders,
collapsedFolders: Array.from(collapsedFolders),
showHint,
viewMode
};
await saveDocumentListState(state);
};
if (isInitialized) { // Prevents saving empty state on first render or back navigation
saveState();
}
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
// Reconcile folder state against the current server-backed document list.
// If a document no longer exists on the server, drop it from folders to avoid stale UI.
useEffect(() => {
if (!isInitialized) return;
const ids = new Set<string>([...pdfDocs, ...epubDocs, ...htmlDocs].map((d) => d.id));
setFolders((prev) =>
prev.map((folder) => ({
...folder,
documents: folder.documents.filter((d) => ids.has(d.id)),
})),
);
}, [isInitialized, pdfDocs, epubDocs, htmlDocs]);
const allDocuments: DocumentListDocument[] = [
...pdfDocs.map((doc) => ({ ...doc, type: 'pdf' as const })),
...epubDocs.map((doc) => ({ ...doc, type: 'epub' as const })),
...htmlDocs.map((doc) => ({ ...doc, type: 'html' as const })),
];
const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
return [...docs].sort((a, b) => {
switch (sortBy) {
case 'name':
return sortDirection === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
case 'type':
return sortDirection === 'asc'
? a.type.localeCompare(b.type)
: b.type.localeCompare(a.type);
case 'size':
return sortDirection === 'asc'
? a.size - b.size
: b.size - a.size;
default:
return sortDirection === 'asc'
? a.lastModified - b.lastModified
: b.lastModified - a.lastModified;
}
});
}, [sortBy, sortDirection]);
const handleDelete = useCallback(async () => {
if (!documentToDelete) return;
try {
if (documentToDelete.type === 'pdf') {
await removePDF(documentToDelete.id);
} else if (documentToDelete.type === 'epub') {
await removeEPUB(documentToDelete.id);
} else if (documentToDelete.type === 'html') {
await removeHTML(documentToDelete.id);
}
// Remove from folders if document is in one
setFolders(prev => prev.map(folder => ({
...folder,
documents: folder.documents.filter(doc =>
!(doc.id === documentToDelete.id && doc.type === documentToDelete.type)
)
})));
setDocumentToDelete(null);
} catch (err) {
console.error('Failed to remove document:', err);
}
}, [documentToDelete, removePDF, removeEPUB, removeHTML]);
const handleDragStart = useCallback((doc: DocumentListDocument) => {
if (!doc.folderId) {
setDraggedDoc(doc);
}
}, []);
const handleDragEnd = useCallback(() => {
setDraggedDoc(null);
setDropTargetDoc(null);
}, []);
const handleDragOver = useCallback((e: DragEvent, doc: DocumentListDocument) => {
e.preventDefault();
if (draggedDoc && draggedDoc.id !== doc.id && !draggedDoc.folderId) {
// Only highlight target if neither document is in a folder
if (!doc.folderId) {
setDropTargetDoc(doc);
}
}
}, [draggedDoc]);
const handleDragLeave = useCallback(() => {
setDropTargetDoc(null);
}, []);
const handleDrop = useCallback((e: DragEvent, targetDoc: DocumentListDocument) => {
e.preventDefault();
console.log('Dropped', draggedDoc?.name, 'on', targetDoc.name);
if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return;
// If target doc is unfoldered, create a new folder
if (!targetDoc.folderId) {
setPendingFolderDocs({
source: draggedDoc,
target: targetDoc
});
setNewFolderName('');
}
setDraggedDoc(null);
setDropTargetDoc(null);
}, [draggedDoc]);
const handleFolderDrop = useCallback((e: DragEvent, folderId: string) => {
e.preventDefault();
if (!draggedDoc || draggedDoc.folderId) return;
// Add document to existing folder
setFolders(folders.map(f => {
if (f.id === folderId && !f.documents.some(d => d.id === draggedDoc.id)) {
return {
...f,
documents: [...f.documents, { ...draggedDoc, folderId }]
};
}
return f;
}));
setDraggedDoc(null);
setDropTargetDoc(null);
}, [draggedDoc, folders]);
const toggleFolderCollapse = useCallback((folderId: string) => {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
const createFolder = useCallback(() => {
if (!pendingFolderDocs) return;
const folderName = newFolderName.trim() ||
generateDefaultFolderName(pendingFolderDocs.source, pendingFolderDocs.target);
const folderId = `folder-${Date.now()}`;
setFolders(prev => [
...prev,
{
id: folderId,
name: folderName,
documents: [
{ ...pendingFolderDocs.source, folderId },
{ ...pendingFolderDocs.target, folderId }
]
}
]);
setPendingFolderDocs(null);
setNewFolderName('');
setDropTargetDoc(null);
setDraggedDoc(null);
setShowHint(false);
}, [pendingFolderDocs, newFolderName]);
const handleFolderNameKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
createFolder();
} else if (e.key === 'Escape') {
setPendingFolderDocs(null);
setNewFolderName('');
}
}, [createFolder]);
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
return <DocumentListSkeleton viewMode={viewMode} />;
}
if (allDocuments.length === 0) {
return <div className="w-full text-center text-muted">No documents uploaded yet</div>;
}
const unfolderedDocuments = allDocuments.filter(
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
);
// Build compact summary (counts per type + total size)
const summaryParts: string[] = [];
if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
return (
<DndProvider backend={HTML5Backend}>
<div className="w-full mx-auto">
<div className="flex items-center justify-between">
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
<SortControls
sortBy={sortBy}
sortDirection={sortDirection}
onSortByChange={setSortBy}
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
<p className="text-xs text-muted mb-2" data-doc-summary>
{summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
</p>
<div className="mb-3">
<DocumentUploader variant="compact" />
</div>
{showHint && allDocuments.length > 1 && (
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm mb-2">
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
<Button
onClick={() => setShowHint(false)}
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss hint"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
)}
<div
className={
viewMode === 'grid'
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
: 'w-full space-y-1'
}
>
{folders.map(folder => (
<DocumentFolder
key={folder.id}
folder={folder}
isCollapsed={collapsedFolders.has(folder.id)}
onToggleCollapse={toggleFolderCollapse}
onDelete={() => setFolders(prev => prev.filter(f => f.id !== folder.id))}
sortedDocuments={sortDocuments(folder.documents)}
onDocumentDelete={setDocumentToDelete}
draggedDoc={draggedDoc}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleFolderDrop}
viewMode={viewMode}
/>
))}
{sortDocuments(unfolderedDocuments).map(doc => (
<DocumentListItem
key={`${doc.type}-${doc.id}`}
doc={doc}
onDelete={setDocumentToDelete}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
isDropTarget={dropTargetDoc?.id === doc.id}
viewMode={viewMode}
/>
))}
</div>
<CreateFolderDialog
isOpen={pendingFolderDocs !== null}
onClose={() => setPendingFolderDocs(null)}
folderName={newFolderName}
onFolderNameChange={setNewFolderName}
onKeyDown={handleFolderNameKeyDown}
/>
<ConfirmDialog
isOpen={documentToDelete !== null}
onClose={() => setDocumentToDelete(null)}
onConfirm={handleDelete}
title="Delete Document"
message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`}
confirmText="Delete"
isDangerous={true}
/>
</div>
</DndProvider>
);
}