openreader/src/components/doclist/DocumentList.tsx
Richard R 85aff2c1be refactor(doclist): flatten foldered document structure and remove folder UI
Eliminate folder grouping logic and related UI from all document list views.
Switch to a flat document array model for rendering, selection, and filtering.
Remove folder collapse/expand state and folder drag-and-drop handling. Update
sidebar and toolbar components to remove folder creation and management
controls. Simplify props and internal state across all views for consistency.
2026-05-28 04:36:08 -06:00

640 lines
20 KiB
TypeScript

'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<SortBy>(DEFAULT_STATE.sortBy);
const [sortDirection, setSortDirection] = useState<SortDirection>(
DEFAULT_STATE.sortDirection,
);
const [viewMode, setViewMode] = useState<ViewMode>(DEFAULT_STATE.viewMode);
const [iconSize, setIconSize] = useState<IconSize>(DEFAULT_STATE.iconSize);
const [folders, setFolders] = useState<Folder[]>(DEFAULT_STATE.folders);
const [showHint, setShowHint] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [query, setQuery] = useState('');
const [recentlyOpenedById, setRecentlyOpenedById] = useState<Record<string, number>>({});
const [isInitialized, setIsInitialized] = useState(false);
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(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<string, DocumentListDocument>();
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<string, string>();
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 (
<FinderWindow
toolbar={
<FinderToolbar
viewMode={fallbackViewMode}
onViewModeChange={setViewMode}
iconSize={iconSize}
onIconSizeChange={setIconSize}
sortBy={sortBy}
sortDirection={sortDirection}
onSortByChange={setSortBy}
onSortDirectionToggle={() =>
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={
<FinderSidebar
filter={sidebarFilter}
onFilterChange={setSidebarFilter}
folders={foldersWithLiveDocs}
counts={counts}
onDeleteFolder={handleDeleteFolder}
onNewFolder={() => {
setNewFolderName('');
setManualFolderPrompt(true);
}}
onDropOnFolder={handleDropOnFolder}
width={sidebarWidth}
onWidthChange={setSidebarWidth}
topSlot={<DocumentUploader variant="compact" />}
/>
}
statusBar={
<FinderStatusBar
itemCount={allDocuments.length}
selectedCount={selection.selectionSize}
totalSize={totalBytes}
summary={summary}
/>
}
sidebarOpen={effectiveSidebarOpen}
onSidebarOpenChange={(open) => {
if (isNarrow) setMobileSidebarOpen(open);
else setSidebarOpen(open);
}}
>
{!isLoading && showHint && allDocuments.length > 1 && (
<div className="px-3 pt-3 shrink-0 bg-background">
<div className="flex items-center justify-between bg-base border border-offbase rounded-md px-3 py-1 text-[12px]">
<p className="text-foreground">
Drag files onto each other to make folders. Drop into the sidebar to move.
</p>
<button
type="button"
onClick={() => setShowHint(false)}
className="h-6 w-6 inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base hover:scale-[1.01] rounded transition-all duration-200 ease-out"
aria-label="Dismiss hint"
>
<svg className="w-3.5 h-3.5" 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>
)}
{isLoading ? (
<div className="flex-1 min-h-0 overflow-hidden">
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
</div>
) : allDocuments.length === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
<DocumentUploader className="py-12 w-full max-w-2xl" />
</div>
) : (
<DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col">
{fallbackViewMode === 'icons' && (
<IconsView
documents={sortedVisible}
iconSize={iconSize}
onDeleteDoc={handleDeleteDoc}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'list' && (
<ListView
documents={sortedVisible}
sortBy={sortBy}
sortDirection={sortDirection}
onSortChange={(b, d) => {
setSortBy(b);
setSortDirection(d);
}}
onDeleteDoc={handleDeleteDoc}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'columns' && (
<ColumnsView
documents={sortedVisible}
onDeleteDoc={handleDeleteDoc}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'gallery' && (
<GalleryView
documents={sortedVisible}
onDeleteDoc={handleDeleteDoc}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
</DocumentUploader>
)}
<CreateFolderDialog
isOpen={pendingMerge !== null}
onClose={() => setPendingMerge(null)}
folderName={newFolderName}
onFolderNameChange={setNewFolderName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
createFolderFromPending();
} else if (e.key === 'Escape') {
setPendingMerge(null);
setNewFolderName('');
}
}}
/>
<CreateFolderDialog
isOpen={manualFolderPrompt}
onClose={() => setManualFolderPrompt(false)}
folderName={newFolderName}
onFolderNameChange={setNewFolderName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
createManualFolder();
} else if (e.key === 'Escape') {
setManualFolderPrompt(false);
setNewFolderName('');
}
}}
/>
<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
/>
</FinderWindow>
);
}
export function DocumentList({
brand,
appActions,
}: {
brand?: ReactNode;
appActions?: ReactNode;
} = {}) {
return (
<DocumentDndProvider>
<DocumentSelectionProvider>
<DocumentListInner brand={brand} appActions={appActions} />
</DocumentSelectionProvider>
</DocumentDndProvider>
);
}