'use client'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; 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 } from '@/components/documents/DocumentUploader'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, useDocumentSelection, } from './dnd/DocumentSelectionContext'; import 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 { ColumnsView } from './views/ColumnsView'; import { GalleryView } from './views/GalleryView'; 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'; return stored; } 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 DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [sortBy, setSortBy] = useState(DEFAULT_STATE.sortBy); const [sortDirection, setSortDirection] = useState( DEFAULT_STATE.sortDirection, ); const [viewMode, setViewMode] = useState(DEFAULT_STATE.viewMode); const [iconSize, setIconSize] = useState(DEFAULT_STATE.iconSize); const [folders, setFolders] = useState(DEFAULT_STATE.folders); const [showHint, setShowHint] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth); const [sidebarFilter, setSidebarFilter] = useState('all'); const [sidebarOpen, setSidebarOpen] = useState(true); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); const [recentlyOpenedById, setRecentlyOpenedById] = useState>({}); const [isInitialized, setIsInitialized] = useState(false); 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 isNarrow = useIsNarrow(); const selection = useDocumentSelection(); const { pdfDocs, removePDFDocument, isPDFLoading, epubDocs, removeEPUBDocument, isEPUBLoading, htmlDocs, removeHTMLDocument, isHTMLLoading, } = useDocuments(); // Load saved state. useEffect(() => { let cancelled = false; (async () => { const saved = await getDocumentListState(); if (cancelled) return; if (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)); } setIsInitialized(true); })(); return () => { cancelled = true; }; }, []); // Persist. useEffect(() => { if (!isInitialized) return; const state: DocumentListState = { sortBy, sortDirection, folders, collapsedFolders: [], showHint, viewMode, iconSize, sidebarWidth, sidebarFilter, sidebarCollapsed: !sidebarOpen, }; 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) => d.id).sort().join('|'), [rawDocuments], ); useEffect(() => { let cancelled = false; (async () => { try { const recentMap = await getDocumentRecentlyOpenedMap(); if (cancelled) return; setRecentlyOpenedById(recentMap); } catch (err) { console.warn('Failed to load recently opened cache metadata:', err); } })(); return () => { cancelled = true; }; }, [rawDocumentIdsKey]); const allDocuments: DocumentListDocument[] = useMemo( () => rawDocuments.map((doc) => ({ ...doc, recentlyOpenedAt: recentlyOpenedById[doc.id] ?? 0, })), [rawDocuments, recentlyOpenedById], ); const allDocumentsById = useMemo(() => { const map = new Map(); for (const doc of allDocuments) map.set(doc.id, doc); return map; }, [allDocuments]); const foldersWithLiveDocs = useMemo( () => folders.map((folder) => ({ ...folder, documents: folder.documents .map((d) => allDocumentsById.get(d.id)) .filter((d): d is DocumentListDocument => Boolean(d)) .map((d) => ({ ...d, folderId: folder.id })), })), [folders, allDocumentsById], ); const folderIdByDocId = useMemo(() => { const map = new Map(); for (const folder of foldersWithLiveDocs) { for (const doc of folder.documents) map.set(doc.id, folder.id); } return map; }, [foldersWithLiveDocs]); const allDocumentsWithFolder = useMemo( () => allDocuments.map((doc) => ({ ...doc, folderId: folderIdByDocId.get(doc.id), })), [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(d.id)) .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 { if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id); else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id); else if (documentToDelete.type === 'html') await removeHTMLDocument(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); } }, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]); 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.ids.includes(d.id), ), }; } const existingIds = new Set(f.documents.map((d) => d.id)); const newDocs = item.docs .filter((d) => !existingIds.has(d.id)) .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 filtered = sources.filter((s) => s.id !== target.id && !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]); // 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} HTML${counts.html === 1 ? '' : 's'}`); return parts.join(' • '); }, [counts]); const totalBytes = useMemo( () => allDocuments.reduce((acc, d) => acc + d.size, 0), [allDocuments], ); const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading; const fallbackViewMode: ViewMode = viewMode === 'columns' && isNarrow ? 'list' : 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} isNarrow={isNarrow} leftSlot={brand} rightSlot={appActions} /> } sidebar={ { setNewFolderName(''); setManualFolderPrompt(true); }} onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} topSlot={} /> } statusBar={ } sidebarOpen={effectiveSidebarOpen} onSidebarOpenChange={(open) => { if (isNarrow) setMobileSidebarOpen(open); else setSidebarOpen(open); }} > {!isLoading && showHint && allDocuments.length > 1 && (

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

)} {isLoading ? (
) : allDocuments.length === 0 ? (
) : ( {fallbackViewMode === 'icons' && ( )} {fallbackViewMode === 'list' && ( { setSortBy(b); setSortDirection(d); }} onDeleteDoc={handleDeleteDoc} onMergeIntoFolder={handleMergeIntoFolder} /> )} {fallbackViewMode === 'columns' && ( )} {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 />
); } export function DocumentList({ brand, appActions, }: { brand?: ReactNode; appActions?: ReactNode; } = {}) { return ( ); }