'use client'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { useDocuments } from '@/contexts/DocumentContext'; import type { DocumentListDocument, DocumentListState, Folder, IconSize, SidebarFilter, SortBy, SortDirection, ViewMode, } from '@/types/documents'; import { getDocumentListState, getDocumentRecentlyOpenedMap, saveDocumentListState, } from '@/lib/client/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; import { IconButton } from '@/components/ui'; import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, useDocumentSelection, } from './dnd/DocumentSelectionContext'; import { documentIdentityKey, type DocumentDragItem } from './dnd/dndTypes'; import { FinderWindow, useIsNarrow } from './window/FinderWindow'; import { FinderToolbar } from './window/FinderToolbar'; import { FinderSidebar } from './window/FinderSidebar'; import { FinderStatusBar } from './window/FinderStatusBar'; import { IconsView } from './views/IconsView'; import { ListView } from './views/ListView'; import { GalleryView } from './views/GalleryView'; let cachedDocumentListState: DocumentListState | null = null; type DocumentToDelete = { id: string; name: string; type: DocumentListDocument['type']; }; const DEFAULT_STATE: Required< Pick< DocumentListState, 'sortBy' | 'sortDirection' | 'folders' | 'collapsedFolders' | 'showHint' > > & { viewMode: ViewMode; iconSize: IconSize; sidebarWidth: number; sidebarFilter: SidebarFilter; sidebarCollapsed: boolean; } = { sortBy: 'name', sortDirection: 'asc', folders: [], collapsedFolders: [], showHint: true, viewMode: 'icons', iconSize: 'md', sidebarWidth: 220, sidebarFilter: 'all', sidebarCollapsed: false, }; function normalizeViewMode(stored: DocumentListState['viewMode']): ViewMode { if (stored === 'grid' || stored === undefined) return 'icons'; if (stored === 'list') return 'list'; if (stored === 'gallery') return 'gallery'; return 'icons'; } function generateDefaultFolderName( doc1: DocumentListDocument, doc2: DocumentListDocument, ): string { const words1 = doc1.name.toLowerCase().split(/[\s\-_.]+/); const words2 = doc2.name.toLowerCase().split(/[\s\-_.]+/); const common = words1.filter((w) => words2.includes(w)); const significant = common.find((w) => w.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); } const timestamp = new Date().toISOString().slice(0, 10); return `Folder ${timestamp}`; } function sortDocs( docs: DocumentListDocument[], sortBy: SortBy, direction: SortDirection, ): DocumentListDocument[] { const sorted = [...docs].sort((a, b) => { switch (sortBy) { case 'name': return a.name.localeCompare(b.name); case 'type': return a.type.localeCompare(b.type); case 'size': return a.size - b.size; default: return a.lastModified - b.lastModified; } }); return direction === 'asc' ? sorted : sorted.reverse(); } interface DocumentListInnerProps { brand?: ReactNode; appActions?: ReactNode; } function SidebarUploadLoader({ totalFiles, completedFiles, currentFileName, }: { totalFiles: number; completedFiles: number; phase: 'uploading'; currentFileName: string | null; }) { const progress = totalFiles > 0 ? Math.min(100, Math.round((completedFiles / totalFiles) * 100)) : 0; const radius = 7; const stroke = 2; const size = 18; const normalizedRadius = radius - stroke / 2; const circumference = 2 * Math.PI * normalizedRadius; const dashOffset = circumference - (progress / 100) * circumference; return (
Uploading {completedFiles}/{totalFiles}
{progress}%
{currentFileName && (

{currentFileName}

)}
); } function DocumentListStateLoader() { return (
); } function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const cachedState = cachedDocumentListState; const [sortBy, setSortBy] = useState(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); const [sortDirection, setSortDirection] = useState( cachedState?.sortDirection ?? DEFAULT_STATE.sortDirection, ); const [viewMode, setViewMode] = useState( normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode), ); const [iconSize, setIconSize] = useState(cachedState?.iconSize ?? DEFAULT_STATE.iconSize); const [folders, setFolders] = useState(cachedState?.folders ?? DEFAULT_STATE.folders); const [showHint, setShowHint] = useState(cachedState?.showHint ?? true); const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth); const [sidebarFilter, setSidebarFilter] = useState(cachedState?.sidebarFilter ?? 'all'); const [sidebarOpen, setSidebarOpen] = useState(!(cachedState?.sidebarCollapsed ?? false)); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); const [activeUploadBatches, setActiveUploadBatches] = useState>({}); const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); const [isInitialized, setIsInitialized] = useState(cachedState !== null); const [documentToDelete, setDocumentToDelete] = useState(null); const [pendingMerge, setPendingMerge] = useState< | { sources: DocumentListDocument[]; target: DocumentListDocument } | null >(null); const [newFolderName, setNewFolderName] = useState(''); const [manualFolderPrompt, setManualFolderPrompt] = useState(false); const [clearFoldersPrompt, setClearFoldersPrompt] = useState(false); const isNarrow = useIsNarrow(); const selection = useDocumentSelection(); const { pdfDocs, isPDFLoading, epubDocs, isEPUBLoading, htmlDocs, deleteDocument, isHTMLLoading, } = useDocuments(); // Load saved state. useEffect(() => { let cancelled = false; (async () => { const saved = await getDocumentListState(); if (cancelled) return; if (saved) { cachedDocumentListState = saved; setSortBy(saved.sortBy); setSortDirection(saved.sortDirection); setFolders(saved.folders ?? []); setShowHint(saved.showHint ?? true); setViewMode(normalizeViewMode(saved.viewMode)); setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize); setSidebarWidth(saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth); setSidebarFilter(saved.sidebarFilter ?? 'all'); setSidebarOpen(!(saved.sidebarCollapsed ?? false)); } else { cachedDocumentListState = null; setSortBy(DEFAULT_STATE.sortBy); setSortDirection(DEFAULT_STATE.sortDirection); setFolders(DEFAULT_STATE.folders); setShowHint(DEFAULT_STATE.showHint); setViewMode(DEFAULT_STATE.viewMode); setIconSize(DEFAULT_STATE.iconSize); setSidebarWidth(DEFAULT_STATE.sidebarWidth); setSidebarFilter(DEFAULT_STATE.sidebarFilter); setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed); } setIsInitialized(true); })(); return () => { cancelled = true; }; }, []); // Persist. useEffect(() => { if (!isInitialized) return; const state: DocumentListState = { sortBy, sortDirection, folders, collapsedFolders: [], showHint, viewMode, iconSize, sidebarWidth, sidebarFilter, sidebarCollapsed: !sidebarOpen, }; cachedDocumentListState = state; void saveDocumentListState(state); }, [ sortBy, sortDirection, folders, showHint, viewMode, iconSize, sidebarWidth, sidebarFilter, sidebarOpen, isInitialized, ]); // Mobile drawer should never auto-open from persisted desktop state. useEffect(() => { if (!isNarrow) return; setMobileSidebarOpen(false); }, [isNarrow]); // Build the union document list. const rawDocuments: DocumentListDocument[] = useMemo( () => [ ...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })), ...epubDocs.map((d) => ({ ...d, type: 'epub' as const })), ...htmlDocs.map((d) => ({ ...d, type: 'html' as const })), ], [pdfDocs, epubDocs, htmlDocs], ); const rawDocumentIdsKey = useMemo( () => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'), [rawDocuments], ); const recentlyOpenedById = useLiveQuery, Record>( async () => { try { return await getDocumentRecentlyOpenedMap(); } catch (err) { console.warn('Failed to load recently opened cache metadata:', err); return {}; } }, [rawDocumentIdsKey], {}, ); const allDocuments: DocumentListDocument[] = useMemo( () => rawDocuments.map((doc) => ({ ...doc, recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0, })), [rawDocuments, recentlyOpenedById], ); const allDocumentsById = useMemo(() => { const map = new Map(); for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc); return map; }, [allDocuments]); const foldersWithLiveDocs = useMemo( () => folders.map((folder) => ({ ...folder, documents: folder.documents .map((d) => allDocumentsById.get(documentIdentityKey(d))) .filter((d): d is DocumentListDocument => Boolean(d)) .map((d) => ({ ...d, folderId: folder.id })), })), [folders, allDocumentsById], ); const folderNameById = useMemo( () => foldersWithLiveDocs.reduce>((acc, folder) => { acc[folder.id] = folder.name; return acc; }, {}), [foldersWithLiveDocs], ); const folderIdByDocId = useMemo(() => { const map = new Map(); for (const folder of foldersWithLiveDocs) { for (const doc of folder.documents) map.set(documentIdentityKey(doc), folder.id); } return map; }, [foldersWithLiveDocs]); const allDocumentsWithFolder = useMemo( () => allDocuments.map((doc) => ({ ...doc, folderId: folderIdByDocId.get(documentIdentityKey(doc)), })), [allDocuments, folderIdByDocId], ); // Filter based on sidebar selection + search query. const visibleDocuments = useMemo(() => { const q = query.trim().toLowerCase(); let docs = allDocumentsWithFolder; if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf'); else if (sidebarFilter === 'epub') docs = docs.filter((d) => d.type === 'epub'); else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html'); else if (sidebarFilter === 'recents') { docs = [...docs] .filter((d) => (d.recentlyOpenedAt ?? 0) > 0) .sort((a, b) => (b.recentlyOpenedAt ?? 0) - (a.recentlyOpenedAt ?? 0)) .slice(0, 20); } else if (sidebarFilter.startsWith('folder:')) { const fid = sidebarFilter.slice('folder:'.length); const folder = foldersWithLiveDocs.find((f) => f.id === fid); docs = folder ? folder.documents .map((d) => allDocumentsById.get(documentIdentityKey(d))) .filter((d): d is DocumentListDocument => Boolean(d)) .map((d) => ({ ...d, folderId: fid })) : []; } if (q) docs = docs.filter((d) => d.name.toLowerCase().includes(q)); return docs; }, [allDocumentsWithFolder, sidebarFilter, query, foldersWithLiveDocs, allDocumentsById]); // Apply sort. const sortedVisible = useMemo(() => { if (sidebarFilter === 'recents') return visibleDocuments; return sortDocs(visibleDocuments, sortBy, sortDirection); }, [visibleDocuments, sidebarFilter, sortBy, sortDirection]); const counts = useMemo( () => ({ all: allDocuments.length, pdf: pdfDocs.length, epub: epubDocs.length, html: htmlDocs.length, }), [allDocuments.length, pdfDocs.length, epubDocs.length, htmlDocs.length], ); // --- Actions --- const handleDelete = useCallback(async () => { if (!documentToDelete) return; try { await deleteDocument(documentToDelete.id); setFolders((prev) => prev.map((f) => ({ ...f, documents: f.documents.filter( (d) => !(d.id === documentToDelete.id && d.type === documentToDelete.type), ), })), ); setDocumentToDelete(null); } catch (err) { console.error('Failed to remove document:', err); } }, [deleteDocument, documentToDelete]); const handleDeleteDoc = useCallback((doc: DocumentListDocument) => { setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }); }, []); const handleDropOnFolder = useCallback( (folderId: string, item: DocumentDragItem) => { setFolders((prev) => prev.map((f) => { if (f.id !== folderId) { // Remove the dropped docs from any other folder they were in. return { ...f, documents: f.documents.filter( (d) => !item.items.some((it) => it.id === d.id && it.type === d.type), ), }; } const existingIdentities = new Set(f.documents.map((d) => documentIdentityKey(d))); const newDocs = item.docs .filter((d) => !existingIdentities.has(documentIdentityKey(d))) .map((d) => ({ ...d, folderId })); return { ...f, documents: [...f.documents, ...newDocs] }; }), ); setSidebarFilter(`folder:${folderId}`); selection.clear(); }, [selection], ); const handleMergeIntoFolder = useCallback( (sources: DocumentListDocument[], target: DocumentListDocument) => { if (target.folderId) return; const targetKey = documentIdentityKey(target); const filtered = sources.filter((s) => documentIdentityKey(s) !== targetKey && !s.folderId); if (filtered.length === 0) return; setPendingMerge({ sources: filtered, target }); setNewFolderName(''); }, [], ); const createFolderFromPending = useCallback(() => { if (!pendingMerge) return; const name = newFolderName.trim() || generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target); const folderId = `folder-${Date.now()}`; setFolders((prev) => [ ...prev, { id: folderId, name, documents: [ ...pendingMerge.sources.map((d) => ({ ...d, folderId })), { ...pendingMerge.target, folderId }, ], }, ]); setPendingMerge(null); setNewFolderName(''); setShowHint(false); setSidebarFilter(`folder:${folderId}`); selection.clear(); }, [pendingMerge, newFolderName, selection]); const createManualFolder = useCallback(() => { const name = newFolderName.trim() || `New Folder`; const folderId = `folder-${Date.now()}`; setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]); setNewFolderName(''); setManualFolderPrompt(false); setSidebarFilter(`folder:${folderId}`); }, [newFolderName]); const handleDeleteFolder = useCallback((folderId: string) => { setFolders((prev) => prev.filter((f) => f.id !== folderId)); if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all'); }, [sidebarFilter]); const handleClearFolders = useCallback(() => { setFolders([]); if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all'); setClearFoldersPrompt(false); selection.clear(); }, [selection, sidebarFilter]); // Status bar summary. const summary = useMemo(() => { const parts: string[] = []; if (counts.pdf) parts.push(`${counts.pdf} PDF${counts.pdf === 1 ? '' : 's'}`); if (counts.epub) parts.push(`${counts.epub} EPUB${counts.epub === 1 ? '' : 's'}`); if (counts.html) parts.push(`${counts.html} Text${counts.html === 1 ? ' Doc' : ' Docs'}`); return parts.join(' • '); }, [counts]); const totalBytes = useMemo( () => allDocuments.reduce((acc, d) => acc + d.size, 0), [allDocuments], ); const visibleSelectedCount = useMemo( () => sortedVisible.reduce((count, doc) => count + (selection.isSelected(doc) ? 1 : 0), 0), [sortedVisible, selection], ); const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading; const handleUploadBatchChange = useCallback((state: UploadBatchState) => { setActiveUploadBatches((prev) => { if (!state.isActive) { if (!prev[state.uploaderId]) return prev; const next = { ...prev }; delete next[state.uploaderId]; return next; } return { ...prev, [state.uploaderId]: state }; }); }, []); const sidebarUploadState = useMemo(() => { const batches = Object.values(activeUploadBatches); if (batches.length === 0) return null; const totalFiles = batches.reduce((sum, batch) => sum + batch.totalFiles, 0); const completedFiles = batches.reduce((sum, batch) => sum + batch.completedFiles, 0); const currentFileName = batches.find((batch) => batch.currentFileName)?.currentFileName ?? null; return { totalFiles, completedFiles, phase: 'uploading' as const, currentFileName }; }, [activeUploadBatches]); const fallbackViewMode: ViewMode = viewMode; const effectiveSidebarOpen = isNarrow ? mobileSidebarOpen : sidebarOpen; return ( setSortDirection((p) => (p === 'asc' ? 'desc' : 'asc')) } query={query} onQueryChange={setQuery} onToggleSidebar={() => isNarrow ? setMobileSidebarOpen((p) => !p) : setSidebarOpen((p) => !p) } isSidebarOpen={effectiveSidebarOpen} showSortControls={sidebarFilter !== 'recents'} leftSlot={brand} /> } sidebar={ { setNewFolderName(''); setManualFolderPrompt(true); }} onClearFolders={() => setClearFoldersPrompt(true)} onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} topSlot={( setIsUploadDialogOpen(true)} /> )} bottomSlot={(
{sidebarUploadState && ( )} {appActions}
)} onRowAction={() => { if (isNarrow) setMobileSidebarOpen(false); }} /> } statusBar={ } sidebarOpen={effectiveSidebarOpen} onRequestSidebarClose={() => { if (isNarrow) setMobileSidebarOpen(false); }} > {!isLoading && showHint && allDocuments.length > 1 && (

Drag files onto each other to make folders. Drop into the sidebar to move.

setShowHint(false)} size="xs" className="h-6 w-6" aria-label="Dismiss hint" >
)} {isLoading ? (
{isInitialized ? ( ) : ( )}
) : allDocuments.length === 0 ? (
) : ( {fallbackViewMode === 'icons' && ( )} {fallbackViewMode === 'list' && ( { setSortBy(b); setSortDirection(d); }} onDeleteDoc={handleDeleteDoc} onMergeIntoFolder={handleMergeIntoFolder} /> )} {fallbackViewMode === 'gallery' && ( )} )} setPendingMerge(null)} folderName={newFolderName} onFolderNameChange={setNewFolderName} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); createFolderFromPending(); } else if (e.key === 'Escape') { setPendingMerge(null); setNewFolderName(''); } }} /> setManualFolderPrompt(false)} folderName={newFolderName} onFolderNameChange={setNewFolderName} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); createManualFolder(); } else if (e.key === 'Escape') { setManualFolderPrompt(false); setNewFolderName(''); } }} /> setDocumentToDelete(null)} onConfirm={handleDelete} title="Delete Document" message={`Are you sure you want to delete ${documentToDelete?.name ?? 'this document'}?`} confirmText="Delete" isDangerous /> setClearFoldersPrompt(false)} onConfirm={handleClearFolders} title="Remove All Folders" message="Remove all folders? This will not delete documents." confirmText="Remove Folders" isDangerous /> setIsUploadDialogOpen(false)} onUploadBatchChange={handleUploadBatchChange} />
); } export function DocumentList({ brand, appActions, }: { brand?: ReactNode; appActions?: ReactNode; } = {}) { return ( ); }