diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 8d071ee..4928084 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -123,7 +123,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [viewMode, setViewMode] = useState(DEFAULT_STATE.viewMode); const [iconSize, setIconSize] = useState(DEFAULT_STATE.iconSize); const [folders, setFolders] = useState(DEFAULT_STATE.folders); - const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); const [showHint, setShowHint] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth); const [sidebarFilter, setSidebarFilter] = useState('all'); @@ -167,7 +166,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { setSortBy(saved.sortBy); setSortDirection(saved.sortDirection); setFolders(saved.folders ?? []); - setCollapsedFolders(new Set(saved.collapsedFolders ?? [])); setShowHint(saved.showHint ?? true); setViewMode(normalizeViewMode(saved.viewMode)); setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize); @@ -189,7 +187,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { sortBy, sortDirection, folders, - collapsedFolders: Array.from(collapsedFolders), + collapsedFolders: [], showHint, viewMode, iconSize, @@ -202,7 +200,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { sortBy, sortDirection, folders, - collapsedFolders, showHint, viewMode, iconSize, @@ -257,22 +254,45 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { [rawDocuments, recentlyOpenedById], ); - // Reconcile folders against server. - useEffect(() => { - if (!isInitialized) return; - const ids = new Set(allDocuments.map((d) => d.id)); - setFolders((prev) => - prev.map((f) => ({ - ...f, - documents: f.documents.filter((d) => ids.has(d.id)), + 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 })), })), - ); - }, [isInitialized, allDocuments]); + [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 visibleAll = useMemo(() => { + const visibleDocuments = useMemo(() => { const q = query.trim().toLowerCase(); - let docs = allDocuments; + 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'); @@ -283,42 +303,23 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { .slice(0, 20); } else if (sidebarFilter.startsWith('folder:')) { const fid = sidebarFilter.slice('folder:'.length); - const folder = folders.find((f) => f.id === fid); - docs = folder ? folder.documents : []; + 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; - }, [allDocuments, sidebarFilter, query, folders]); + }, [allDocumentsWithFolder, sidebarFilter, query, foldersWithLiveDocs, allDocumentsById]); // Apply sort. const sortedVisible = useMemo(() => { - if (sidebarFilter === 'recents') return visibleAll; - return sortDocs(visibleAll, sortBy, sortDirection); - }, [visibleAll, sidebarFilter, sortBy, sortDirection]); - - // Split into folders + unfoldered for the current filter context. - const showAllScope = sidebarFilter === 'all'; - const visibleFolders = useMemo(() => { - if (!showAllScope) return []; - // Within a kind-filter (pdf/epub/html), still show folders that contain matches. - return folders.map((f) => ({ - ...f, - documents: sortDocs( - f.documents.filter((d) => - query ? d.name.toLowerCase().includes(query.toLowerCase()) : true, - ), - sortBy, - sortDirection, - ), - })); - }, [folders, showAllScope, query, sortBy, sortDirection]); - - const unfolderedDocs = useMemo(() => { - if (sidebarFilter === 'recents') return sortedVisible; - const inFolder = new Set(); - folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id))); - return sortedVisible.filter((d) => !inFolder.has(d.id)); - }, [folders, sidebarFilter, sortedVisible]); + if (sidebarFilter === 'recents') return visibleDocuments; + return sortDocs(visibleDocuments, sortBy, sortDirection); + }, [visibleDocuments, sidebarFilter, sortBy, sortDirection]); const counts = useMemo( () => ({ @@ -376,6 +377,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { return { ...f, documents: [...f.documents, ...newDocs] }; }), ); + setSidebarFilter(`folder:${folderId}`); selection.clear(); }, [selection], @@ -412,6 +414,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { setPendingMerge(null); setNewFolderName(''); setShowHint(false); + setSidebarFilter(`folder:${folderId}`); selection.clear(); }, [pendingMerge, newFolderName, selection]); @@ -421,6 +424,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]); setNewFolderName(''); setManualFolderPrompt(false); + setSidebarFilter(`folder:${folderId}`); }, [newFolderName]); const handleDeleteFolder = useCallback((folderId: string) => { @@ -428,15 +432,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all'); }, [sidebarFilter]); - 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; - }); - }, []); - // Status bar summary. const summary = useMemo(() => { const parts: string[] = []; @@ -473,10 +468,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { } query={query} onQueryChange={setQuery} - onNewFolder={() => { - setNewFolderName(''); - setManualFolderPrompt(true); - }} onToggleSidebar={() => isNarrow ? setMobileSidebarOpen((p) => !p) @@ -492,8 +483,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { { + setNewFolderName(''); + setManualFolderPrompt(true); + }} onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} @@ -546,50 +542,36 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { {fallbackViewMode === 'icons' && ( )} {fallbackViewMode === 'list' && ( { setSortBy(b); setSortDirection(d); }} - collapsedFolders={collapsedFolders} - onToggleCollapse={toggleFolderCollapse} - onDeleteFolder={handleDeleteFolder} onDeleteDoc={handleDeleteDoc} - onDropOnFolder={handleDropOnFolder} onMergeIntoFolder={handleMergeIntoFolder} /> )} {fallbackViewMode === 'columns' && ( )} {fallbackViewMode === 'gallery' && ( )} diff --git a/src/components/doclist/views/ColumnsView.tsx b/src/components/doclist/views/ColumnsView.tsx index 67b87ce..4c1ba9c 100644 --- a/src/components/doclist/views/ColumnsView.tsx +++ b/src/components/doclist/views/ColumnsView.tsx @@ -1,29 +1,21 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useDrag, useDrop } from 'react-dnd'; -import type { DocumentListDocument, Folder } from '@/types/documents'; +import type { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; -import { FolderIcon, ChevronRightSmall } from '../window/finderIcons'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { formatDocumentSize } from '@/components/doclist/formatSize'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; interface ColumnsViewProps { - folders: Folder[]; - unfolderedDocs: DocumentListDocument[]; + documents: DocumentListDocument[]; onDeleteDoc: (doc: DocumentListDocument) => void; - onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; } -type Selected = - | { kind: 'folder'; id: string } - | { kind: 'doc'; doc: DocumentListDocument } - | null; - function KindIcon({ doc }: { doc: DocumentListDocument }) { if (doc.type === 'pdf') return ; if (doc.type === 'epub') return ; @@ -90,46 +82,6 @@ function ColumnDocRow({ ); } -function ColumnFolderRow({ - folder, - active, - onClick, - onDropOnFolder, -}: { - folder: Folder; - active: boolean; - onClick: () => void; - onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; -}) { - const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ - accept: DND_DOCUMENT, - canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id), - drop: (item) => onDropOnFolder(folder.id, item), - collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), - }), [folder.id, onDropOnFolder]); - - return ( -
} - onClick={onClick} - className={ - 'group flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out cursor-pointer text-left hover:scale-[1.01] ' + - (active - ? 'border-accent bg-offbase text-accent' - : 'border-transparent text-foreground hover:border-accent hover:bg-offbase hover:text-accent') + - (isOver && canDrop ? ' ring-1 ring-accent' : '') - } - > - - {folder.name} - - {folder.documents.length} - - -
- ); -} - function Column({ children, title }: { children: React.ReactNode; title?: string }) { return (
@@ -144,115 +96,72 @@ function Column({ children, title }: { children: React.ReactNode; title?: string } export function ColumnsView({ - folders, - unfolderedDocs, + documents, onDeleteDoc, - onDropOnFolder, onMergeIntoFolder, }: ColumnsViewProps) { const { setVisibleOrder } = useDocumentSelection(); - const [selected, setSelected] = useState(null); - const allDocs = useMemo( - () => [...folders.flatMap((f) => f.documents), ...unfolderedDocs], - [folders, unfolderedDocs], - ); + const [selectedDoc, setSelectedDoc] = useState(null); useEffect(() => { - setVisibleOrder(allDocs); - }, [allDocs, setVisibleOrder]); + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); - // Keep selection valid and default to the first document when entering this view. useEffect(() => { - if (allDocs.length === 0) { - if (selected !== null) setSelected(null); + if (documents.length === 0) { + if (selectedDoc !== null) setSelectedDoc(null); return; } - if (!selected) { - setSelected({ kind: 'doc', doc: allDocs[0] }); + if (!selectedDoc) { + setSelectedDoc(documents[0]); return; } - if (selected.kind === 'folder') { - if (!folders.find((f) => f.id === selected.id)) { - setSelected({ kind: 'doc', doc: allDocs[0] }); - } - return; - } - - const selectedStillExists = allDocs.some( - (d) => d.id === selected.doc.id && d.type === selected.doc.type, + const selectedStillExists = documents.some( + (d) => d.id === selectedDoc.id && d.type === selectedDoc.type, ); if (!selectedStillExists) { - setSelected({ kind: 'doc', doc: allDocs[0] }); + setSelectedDoc(documents[0]); } - }, [allDocs, folders, selected]); - - const selectedFolder = - selected?.kind === 'folder' ? folders.find((f) => f.id === selected.id) : undefined; + }, [documents, selectedDoc]); return (
- - {folders.map((f) => ( - setSelected({ kind: 'folder', id: f.id })} - onDropOnFolder={onDropOnFolder} - /> - ))} - {unfolderedDocs.map((d) => ( + + {documents.map((doc) => ( setSelected({ kind: 'doc', doc: d })} + key={`${doc.type}-${doc.id}`} + doc={doc} + active={selectedDoc?.id === doc.id && selectedDoc?.type === doc.type} + onClick={() => setSelectedDoc(doc)} onMergeIntoFolder={onMergeIntoFolder} /> ))} - {selectedFolder && ( - - {selectedFolder.documents.length === 0 && ( -

Empty folder

- )} - {selectedFolder.documents.map((d) => ( - setSelected({ kind: 'doc', doc: d })} - onMergeIntoFolder={onMergeIntoFolder} - /> - ))} -
- )} - - {selected?.kind === 'doc' && ( + {selectedDoc && (
- +

- {selected.doc.name} + {selectedDoc.name}

- {selected.doc.type.toUpperCase()} • {formatDocumentSize(selected.doc.size)} + {selectedDoc.type.toUpperCase()} • {formatDocumentSize(selectedDoc.size)}

Open - -
- -
- {folder.documents.map((doc) => ( - - ))} -
-
-
- ); -} - export function IconsView({ - folders, - unfolderedDocs, + documents, iconSize, - collapsedFolders, - onToggleCollapse, - onDeleteFolder, onDeleteDoc, - onDropOnFolder, onMergeIntoFolder, }: IconsViewProps) { const { setVisibleOrder, clear } = useDocumentSelection(); useEffect(() => { - const all: DocumentListDocument[] = [ - ...folders.flatMap((f) => f.documents), - ...unfolderedDocs, - ]; - setVisibleOrder(all); - }, [folders, unfolderedDocs, setVisibleOrder]); + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); const handleBackgroundClick: React.MouseEventHandler = (e) => { if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; @@ -190,24 +61,8 @@ export function IconsView({ onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto p-3" > -
- {folders.map((folder) => ( - onToggleCollapse(folder.id)} - onDelete={() => onDeleteFolder(folder.id)} - iconSize={iconSize} - onDropOnFolder={onDropOnFolder} - onDeleteDoc={onDeleteDoc} - onMergeIntoFolder={onMergeIntoFolder} - /> - ))} - {unfolderedDocs.map((doc) => ( +
+ {documents.map((doc) => ( void; - collapsedFolders: Set; - onToggleCollapse: (folderId: string) => void; - onDeleteFolder: (folderId: string) => void; onDeleteDoc: (doc: DocumentListDocument) => void; - onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; } @@ -84,12 +77,10 @@ function HeaderCell({ function DocRow({ doc, - isFirstColumnIndented, onDeleteDoc, onMergeIntoFolder, }: { doc: DocumentListDocument; - isFirstColumnIndented?: boolean; onDeleteDoc: (d: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; }) { @@ -163,10 +154,7 @@ function DocRow({ href={href} draggable={false} onClick={handleClick} - className={ - 'flex items-center gap-2 min-w-0 px-2 py-1.5 ' + - (isFirstColumnIndented ? 'pl-8' : '') - } + className="flex items-center gap-2 min-w-0 px-2 py-1.5" > {doc.name} @@ -199,118 +187,19 @@ function DocRow({ ); } -function FolderRowGroup({ - folder, - collapsed, - onToggleCollapse, - onDelete, - onDeleteDoc, - onDropOnFolder, - onMergeIntoFolder, -}: { - folder: Folder; - collapsed: boolean; - onToggleCollapse: () => void; - onDelete: () => void; - onDeleteDoc: (d: DocumentListDocument) => void; - onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; - onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; -}) { - const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ - accept: DND_DOCUMENT, - canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id), - drop: (item) => onDropOnFolder(folder.id, item), - collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), - }), [folder.id, onDropOnFolder]); - - const isTarget = isOver && canDrop; - const totalSize = folder.documents.reduce((acc, d) => acc + d.size, 0); - - return ( -
}> -
- - Folder - - {formatDocumentSize(totalSize)} - - - {folder.documents.length} items - - -
- -
- {folder.documents.map((doc) => ( - - ))} -
-
-
- ); -} - export function ListView({ - folders, - unfolderedDocs, + documents, sortBy, sortDirection, onSortChange, - collapsedFolders, - onToggleCollapse, - onDeleteFolder, onDeleteDoc, - onDropOnFolder, onMergeIntoFolder, }: ListViewProps) { const { setVisibleOrder, clear } = useDocumentSelection(); useEffect(() => { - const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs]; - setVisibleOrder(all); - }, [folders, unfolderedDocs, setVisibleOrder]); + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); const handleBackgroundClick: React.MouseEventHandler = (e) => { if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; @@ -327,19 +216,7 @@ export function ListView({
- {folders.map((folder) => ( - onToggleCollapse(folder.id)} - onDelete={() => onDeleteFolder(folder.id)} - onDeleteDoc={onDeleteDoc} - onDropOnFolder={onDropOnFolder} - onMergeIntoFolder={onMergeIntoFolder} - /> - ))} - {unfolderedDocs.map((doc) => ( + {documents.map((doc) => ( void; folders: Folder[]; counts: { all: number; pdf: number; epub: number; html: number }; + onDeleteFolder: (folderId: string) => void; + onNewFolder: () => void; /** When dragging onto a folder row, move dropped docs into that folder. */ onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; /** Width controls (desktop only). */ @@ -29,6 +31,7 @@ interface SidebarRowProps { icon: ReactNode; label: string; count?: number; + countClassName?: string; trailing?: ReactNode; isDropTarget?: boolean; } @@ -39,6 +42,7 @@ function SidebarRow({ icon, label, count, + countClassName, trailing, isDropTarget, }: SidebarRowProps) { @@ -64,7 +68,11 @@ function SidebarRow({ {label} {typeof count === 'number' && count > 0 && ( - {count} + + {count} + )} {trailing} @@ -75,11 +83,13 @@ function FolderRow({ folder, active, onClick, + onDelete, onDropOnFolder, }: { folder: Folder; active: boolean; onClick: () => void; + onDelete: () => void; onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; }) { const [{ isOver, canDrop }, dropRef] = useDrop< @@ -103,15 +113,33 @@ function FolderRow({ const isDropTarget = isOver && canDrop; return ( -
}> +
} + className="group/folder relative" + > } label={folder.name} count={folder.documents.length} + countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6" isDropTarget={isDropTarget} /> +
); } @@ -134,6 +162,8 @@ export function FinderSidebar({ onFilterChange, folders, counts, + onDeleteFolder, + onNewFolder, onDropOnFolder, width, onWidthChange, @@ -205,19 +235,33 @@ export function FinderSidebar({ count={counts.html} /> - {folders.length > 0 && ( - <> - Folders - {folders.map((folder) => ( - onFilterChange(`folder:${folder.id}`)} - onDropOnFolder={onDropOnFolder} - /> - ))} - +
+

+ Folders +

+ +
+ {folders.length === 0 ? ( +

No folders yet

+ ) : ( + folders.map((folder) => ( + onFilterChange(`folder:${folder.id}`)} + onDelete={() => onDeleteFolder(folder.id)} + onDropOnFolder={onDropOnFolder} + /> + )) )}
diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index 053402e..92798ee 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -8,7 +8,6 @@ import { ColumnsViewIcon, GalleryViewIcon, SearchIcon, - FolderPlusIcon, HamburgerIcon, } from './finderIcons'; import { ChevronUpDownIcon } from '@/components/icons/Icons'; @@ -25,7 +24,6 @@ interface FinderToolbarProps { onSortDirectionToggle: () => void; query: string; onQueryChange: (q: string) => void; - onNewFolder: () => void; onToggleSidebar: () => void; isSidebarOpen: boolean; /** True when the columns view should be disabled (mobile viewport). */ @@ -84,7 +82,6 @@ export function FinderToolbar({ onSortDirectionToggle, query, onQueryChange, - onNewFolder, onToggleSidebar, isSidebarOpen, isNarrow, @@ -222,16 +219,6 @@ export function FinderToolbar({ />
- - {rightSlot && (
{rightSlot}