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.
This commit is contained in:
Richard R 2026-05-28 04:36:08 -06:00
parent d59e911ca2
commit 85aff2c1be
7 changed files with 176 additions and 580 deletions

View file

@ -123,7 +123,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [viewMode, setViewMode] = useState<ViewMode>(DEFAULT_STATE.viewMode); const [viewMode, setViewMode] = useState<ViewMode>(DEFAULT_STATE.viewMode);
const [iconSize, setIconSize] = useState<IconSize>(DEFAULT_STATE.iconSize); const [iconSize, setIconSize] = useState<IconSize>(DEFAULT_STATE.iconSize);
const [folders, setFolders] = useState<Folder[]>(DEFAULT_STATE.folders); const [folders, setFolders] = useState<Folder[]>(DEFAULT_STATE.folders);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const [showHint, setShowHint] = useState(true); const [showHint, setShowHint] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all'); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all');
@ -167,7 +166,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setSortBy(saved.sortBy); setSortBy(saved.sortBy);
setSortDirection(saved.sortDirection); setSortDirection(saved.sortDirection);
setFolders(saved.folders ?? []); setFolders(saved.folders ?? []);
setCollapsedFolders(new Set(saved.collapsedFolders ?? []));
setShowHint(saved.showHint ?? true); setShowHint(saved.showHint ?? true);
setViewMode(normalizeViewMode(saved.viewMode)); setViewMode(normalizeViewMode(saved.viewMode));
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize); setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
@ -189,7 +187,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sortBy, sortBy,
sortDirection, sortDirection,
folders, folders,
collapsedFolders: Array.from(collapsedFolders), collapsedFolders: [],
showHint, showHint,
viewMode, viewMode,
iconSize, iconSize,
@ -202,7 +200,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sortBy, sortBy,
sortDirection, sortDirection,
folders, folders,
collapsedFolders,
showHint, showHint,
viewMode, viewMode,
iconSize, iconSize,
@ -257,22 +254,45 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[rawDocuments, recentlyOpenedById], [rawDocuments, recentlyOpenedById],
); );
// Reconcile folders against server. const allDocumentsById = useMemo(() => {
useEffect(() => { const map = new Map<string, DocumentListDocument>();
if (!isInitialized) return; for (const doc of allDocuments) map.set(doc.id, doc);
const ids = new Set(allDocuments.map((d) => d.id)); return map;
setFolders((prev) => }, [allDocuments]);
prev.map((f) => ({
...f, const foldersWithLiveDocs = useMemo(
documents: f.documents.filter((d) => ids.has(d.id)), () =>
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],
}, [isInitialized, allDocuments]); );
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. // Filter based on sidebar selection + search query.
const visibleAll = useMemo(() => { const visibleDocuments = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
let docs = allDocuments; let docs = allDocumentsWithFolder;
if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf'); if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf');
else if (sidebarFilter === 'epub') docs = docs.filter((d) => d.type === 'epub'); 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 === 'html') docs = docs.filter((d) => d.type === 'html');
@ -283,42 +303,23 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
.slice(0, 20); .slice(0, 20);
} else if (sidebarFilter.startsWith('folder:')) { } else if (sidebarFilter.startsWith('folder:')) {
const fid = sidebarFilter.slice('folder:'.length); const fid = sidebarFilter.slice('folder:'.length);
const folder = folders.find((f) => f.id === fid); const folder = foldersWithLiveDocs.find((f) => f.id === fid);
docs = folder ? folder.documents : []; 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)); if (q) docs = docs.filter((d) => d.name.toLowerCase().includes(q));
return docs; return docs;
}, [allDocuments, sidebarFilter, query, folders]); }, [allDocumentsWithFolder, sidebarFilter, query, foldersWithLiveDocs, allDocumentsById]);
// Apply sort. // Apply sort.
const sortedVisible = useMemo(() => { const sortedVisible = useMemo(() => {
if (sidebarFilter === 'recents') return visibleAll; if (sidebarFilter === 'recents') return visibleDocuments;
return sortDocs(visibleAll, sortBy, sortDirection); return sortDocs(visibleDocuments, sortBy, sortDirection);
}, [visibleAll, sidebarFilter, sortBy, sortDirection]); }, [visibleDocuments, sidebarFilter, sortBy, sortDirection]);
// Split into folders + unfoldered for the current filter context.
const showAllScope = sidebarFilter === 'all';
const visibleFolders = useMemo<Folder[]>(() => {
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<string>();
folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id)));
return sortedVisible.filter((d) => !inFolder.has(d.id));
}, [folders, sidebarFilter, sortedVisible]);
const counts = useMemo( const counts = useMemo(
() => ({ () => ({
@ -376,6 +377,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
return { ...f, documents: [...f.documents, ...newDocs] }; return { ...f, documents: [...f.documents, ...newDocs] };
}), }),
); );
setSidebarFilter(`folder:${folderId}`);
selection.clear(); selection.clear();
}, },
[selection], [selection],
@ -412,6 +414,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setPendingMerge(null); setPendingMerge(null);
setNewFolderName(''); setNewFolderName('');
setShowHint(false); setShowHint(false);
setSidebarFilter(`folder:${folderId}`);
selection.clear(); selection.clear();
}, [pendingMerge, newFolderName, selection]); }, [pendingMerge, newFolderName, selection]);
@ -421,6 +424,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]); setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
setNewFolderName(''); setNewFolderName('');
setManualFolderPrompt(false); setManualFolderPrompt(false);
setSidebarFilter(`folder:${folderId}`);
}, [newFolderName]); }, [newFolderName]);
const handleDeleteFolder = useCallback((folderId: string) => { const handleDeleteFolder = useCallback((folderId: string) => {
@ -428,15 +432,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all'); if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
}, [sidebarFilter]); }, [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. // Status bar summary.
const summary = useMemo(() => { const summary = useMemo(() => {
const parts: string[] = []; const parts: string[] = [];
@ -473,10 +468,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
} }
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
onNewFolder={() => {
setNewFolderName('');
setManualFolderPrompt(true);
}}
onToggleSidebar={() => onToggleSidebar={() =>
isNarrow isNarrow
? setMobileSidebarOpen((p) => !p) ? setMobileSidebarOpen((p) => !p)
@ -492,8 +483,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
<FinderSidebar <FinderSidebar
filter={sidebarFilter} filter={sidebarFilter}
onFilterChange={setSidebarFilter} onFilterChange={setSidebarFilter}
folders={folders} folders={foldersWithLiveDocs}
counts={counts} counts={counts}
onDeleteFolder={handleDeleteFolder}
onNewFolder={() => {
setNewFolderName('');
setManualFolderPrompt(true);
}}
onDropOnFolder={handleDropOnFolder} onDropOnFolder={handleDropOnFolder}
width={sidebarWidth} width={sidebarWidth}
onWidthChange={setSidebarWidth} onWidthChange={setSidebarWidth}
@ -546,50 +542,36 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
<DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col"> <DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col">
{fallbackViewMode === 'icons' && ( {fallbackViewMode === 'icons' && (
<IconsView <IconsView
folders={visibleFolders} documents={sortedVisible}
unfolderedDocs={unfolderedDocs}
iconSize={iconSize} iconSize={iconSize}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={handleDeleteDoc} onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder} onMergeIntoFolder={handleMergeIntoFolder}
/> />
)} )}
{fallbackViewMode === 'list' && ( {fallbackViewMode === 'list' && (
<ListView <ListView
folders={visibleFolders} documents={sortedVisible}
unfolderedDocs={unfolderedDocs}
sortBy={sortBy} sortBy={sortBy}
sortDirection={sortDirection} sortDirection={sortDirection}
onSortChange={(b, d) => { onSortChange={(b, d) => {
setSortBy(b); setSortBy(b);
setSortDirection(d); setSortDirection(d);
}} }}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={handleDeleteDoc} onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder} onMergeIntoFolder={handleMergeIntoFolder}
/> />
)} )}
{fallbackViewMode === 'columns' && ( {fallbackViewMode === 'columns' && (
<ColumnsView <ColumnsView
folders={visibleFolders} documents={sortedVisible}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={handleDeleteDoc} onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder} onMergeIntoFolder={handleMergeIntoFolder}
/> />
)} )}
{fallbackViewMode === 'gallery' && ( {fallbackViewMode === 'gallery' && (
<GalleryView <GalleryView
folders={visibleFolders} documents={sortedVisible}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={handleDeleteDoc} onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder} onMergeIntoFolder={handleMergeIntoFolder}
/> />
)} )}

View file

@ -1,29 +1,21 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd'; 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 { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { FolderIcon, ChevronRightSmall } from '../window/finderIcons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface ColumnsViewProps { interface ColumnsViewProps {
folders: Folder[]; documents: DocumentListDocument[];
unfolderedDocs: DocumentListDocument[];
onDeleteDoc: (doc: DocumentListDocument) => void; onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
} }
type Selected =
| { kind: 'folder'; id: string }
| { kind: 'doc'; doc: DocumentListDocument }
| null;
function KindIcon({ doc }: { doc: DocumentListDocument }) { function KindIcon({ doc }: { doc: DocumentListDocument }) {
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 text-red-500" />; if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 text-red-500" />;
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 text-blue-500" />; if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 text-blue-500" />;
@ -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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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 (
<div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
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' : '')
}
>
<FolderIcon className={'w-4 h-4 ' + (active ? 'text-accent' : 'text-muted group-hover:text-accent')} />
<span className="truncate flex-1">{folder.name}</span>
<span className={'text-[10px] ' + (active ? 'text-accent' : 'text-muted')}>
{folder.documents.length}
</span>
<ChevronRightSmall className={'w-3 h-3 ' + (active ? 'text-accent' : 'text-muted group-hover:text-accent')} />
</div>
);
}
function Column({ children, title }: { children: React.ReactNode; title?: string }) { function Column({ children, title }: { children: React.ReactNode; title?: string }) {
return ( return (
<div className="w-[260px] shrink-0 h-full bg-base border-r border-offbase overflow-y-auto"> <div className="w-[260px] shrink-0 h-full bg-base border-r border-offbase overflow-y-auto">
@ -144,115 +96,72 @@ function Column({ children, title }: { children: React.ReactNode; title?: string
} }
export function ColumnsView({ export function ColumnsView({
folders, documents,
unfolderedDocs,
onDeleteDoc, onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder, onMergeIntoFolder,
}: ColumnsViewProps) { }: ColumnsViewProps) {
const { setVisibleOrder } = useDocumentSelection(); const { setVisibleOrder } = useDocumentSelection();
const [selected, setSelected] = useState<Selected>(null); const [selectedDoc, setSelectedDoc] = useState<DocumentListDocument | null>(null);
const allDocs = useMemo(
() => [...folders.flatMap((f) => f.documents), ...unfolderedDocs],
[folders, unfolderedDocs],
);
useEffect(() => { useEffect(() => {
setVisibleOrder(allDocs); setVisibleOrder(documents);
}, [allDocs, setVisibleOrder]); }, [documents, setVisibleOrder]);
// Keep selection valid and default to the first document when entering this view.
useEffect(() => { useEffect(() => {
if (allDocs.length === 0) { if (documents.length === 0) {
if (selected !== null) setSelected(null); if (selectedDoc !== null) setSelectedDoc(null);
return; return;
} }
if (!selected) { if (!selectedDoc) {
setSelected({ kind: 'doc', doc: allDocs[0] }); setSelectedDoc(documents[0]);
return; return;
} }
if (selected.kind === 'folder') { const selectedStillExists = documents.some(
if (!folders.find((f) => f.id === selected.id)) { (d) => d.id === selectedDoc.id && d.type === selectedDoc.type,
setSelected({ kind: 'doc', doc: allDocs[0] });
}
return;
}
const selectedStillExists = allDocs.some(
(d) => d.id === selected.doc.id && d.type === selected.doc.type,
); );
if (!selectedStillExists) { if (!selectedStillExists) {
setSelected({ kind: 'doc', doc: allDocs[0] }); setSelectedDoc(documents[0]);
} }
}, [allDocs, folders, selected]); }, [documents, selectedDoc]);
const selectedFolder =
selected?.kind === 'folder' ? folders.find((f) => f.id === selected.id) : undefined;
return ( return (
<div className="flex-1 min-h-0 flex overflow-x-auto overflow-y-hidden"> <div className="flex-1 min-h-0 flex overflow-x-auto overflow-y-hidden">
<Column title="Library"> <Column title="Documents">
{folders.map((f) => ( {documents.map((doc) => (
<ColumnFolderRow
key={f.id}
folder={f}
active={selected?.kind === 'folder' && selected.id === f.id}
onClick={() => setSelected({ kind: 'folder', id: f.id })}
onDropOnFolder={onDropOnFolder}
/>
))}
{unfolderedDocs.map((d) => (
<ColumnDocRow <ColumnDocRow
key={`${d.type}-${d.id}`} key={`${doc.type}-${doc.id}`}
doc={d} doc={doc}
active={selected?.kind === 'doc' && selected.doc.id === d.id} active={selectedDoc?.id === doc.id && selectedDoc?.type === doc.type}
onClick={() => setSelected({ kind: 'doc', doc: d })} onClick={() => setSelectedDoc(doc)}
onMergeIntoFolder={onMergeIntoFolder} onMergeIntoFolder={onMergeIntoFolder}
/> />
))} ))}
</Column> </Column>
{selectedFolder && ( {selectedDoc && (
<Column title={selectedFolder.name}>
{selectedFolder.documents.length === 0 && (
<p className="px-2 py-3 text-[11px] text-muted text-center">Empty folder</p>
)}
{selectedFolder.documents.map((d) => (
<ColumnDocRow
key={`${d.type}-${d.id}`}
doc={d}
active={selected?.kind === 'doc' && selected.doc.id === d.id}
onClick={() => setSelected({ kind: 'doc', doc: d })}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</Column>
)}
{selected?.kind === 'doc' && (
<div className="flex-1 min-w-[280px] h-full bg-background overflow-y-auto p-4"> <div className="flex-1 min-w-[280px] h-full bg-background overflow-y-auto p-4">
<div className="max-w-[360px] mx-auto"> <div className="max-w-[360px] mx-auto">
<div className="rounded-lg overflow-hidden border border-offbase"> <div className="rounded-lg overflow-hidden border border-offbase">
<DocumentPreview doc={selected.doc} /> <DocumentPreview doc={selectedDoc} />
</div> </div>
<h3 className="mt-3 text-[13px] font-semibold text-foreground truncate"> <h3 className="mt-3 text-[13px] font-semibold text-foreground truncate">
{selected.doc.name} {selectedDoc.name}
</h3> </h3>
<p className="text-[11px] text-muted"> <p className="text-[11px] text-muted">
{selected.doc.type.toUpperCase()} {formatDocumentSize(selected.doc.size)} {selectedDoc.type.toUpperCase()} {formatDocumentSize(selectedDoc.size)}
</p> </p>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Link <Link
href={`/${selected.doc.type}/${encodeURIComponent(selected.doc.id)}`} href={`/${selectedDoc.type}/${encodeURIComponent(selectedDoc.id)}`}
className="flex-1 inline-flex items-center justify-center h-8 rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.01] transition-all duration-200 ease-out" className="flex-1 inline-flex items-center justify-center h-8 rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.01] transition-all duration-200 ease-out"
> >
Open Open
</Link> </Link>
<button <button
type="button" type="button"
onClick={() => onDeleteDoc(selected.doc)} onClick={() => onDeleteDoc(selectedDoc)}
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.01] transition-all duration-200 ease-out" className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.01] transition-all duration-200 ease-out"
> >
Delete Delete

View file

@ -3,19 +3,16 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd'; 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 { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { FolderIcon } from '../window/finderIcons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface GalleryViewProps { interface GalleryViewProps {
folders: Folder[]; documents: DocumentListDocument[];
unfolderedDocs: DocumentListDocument[];
onDeleteDoc: (doc: DocumentListDocument) => void; onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
} }
@ -89,84 +86,38 @@ function GalleryThumb({
); );
} }
function GalleryFolderThumb({
folder,
active,
onClick,
onDropOnFolder,
}: {
folder: Folder;
active: boolean;
onClick: () => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
}) {
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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 (
<div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
onClick={onClick}
className={
'shrink-0 cursor-pointer rounded-md overflow-hidden border transition-all duration-200 ease-out snap-start flex flex-col items-center justify-center gap-2 ' +
(active
? 'border-accent ring-1 ring-accent w-[110px]'
: 'border-offbase hover:border-accent hover:scale-[1.01] w-[88px]') +
(isOver && canDrop ? ' ring-1 ring-accent' : '')
}
>
<div className="aspect-[3/4] w-full bg-base flex items-center justify-center">
<FolderIcon className="w-10 h-10 text-accent" />
</div>
<div className="px-1.5 py-1 w-full text-center bg-base">
<span className="text-[10px] text-foreground truncate block">{folder.name}</span>
<span className="text-[9px] text-muted">{folder.documents.length} items</span>
</div>
</div>
);
}
export function GalleryView({ export function GalleryView({
folders, documents,
unfolderedDocs,
onDeleteDoc, onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder, onMergeIntoFolder,
}: GalleryViewProps) { }: GalleryViewProps) {
const { setVisibleOrder } = useDocumentSelection(); const { setVisibleOrder } = useDocumentSelection();
const allDocs = useMemo(
() => [...folders.flatMap((f) => f.documents), ...unfolderedDocs],
[folders, unfolderedDocs],
);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const activeDoc = useMemo(() => documents[activeIdx], [documents, activeIdx]);
useEffect(() => { useEffect(() => {
setVisibleOrder(allDocs); setVisibleOrder(documents);
}, [allDocs, setVisibleOrder]); }, [documents, setVisibleOrder]);
useEffect(() => { useEffect(() => {
if (activeIdx >= allDocs.length) setActiveIdx(Math.max(0, allDocs.length - 1)); if (activeIdx >= documents.length) {
}, [allDocs.length, activeIdx]); setActiveIdx(Math.max(0, documents.length - 1));
}
}, [documents.length, activeIdx]);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target?.closest('input, textarea, [contenteditable]')) return; if (target?.closest('input, textarea, [contenteditable]')) return;
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
setActiveIdx((i) => Math.min(allDocs.length - 1, i + 1)); setActiveIdx((i) => Math.min(documents.length - 1, i + 1));
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
setActiveIdx((i) => Math.max(0, i - 1)); setActiveIdx((i) => Math.max(0, i - 1));
} }
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [allDocs.length]); }, [documents.length]);
const activeDoc = allDocs[activeIdx];
return ( return (
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
@ -207,16 +158,7 @@ export function GalleryView({
<div className="shrink-0 border-t border-offbase bg-base"> <div className="shrink-0 border-t border-offbase bg-base">
<div className="flex gap-2 overflow-x-auto p-2 snap-x snap-mandatory"> <div className="flex gap-2 overflow-x-auto p-2 snap-x snap-mandatory">
{folders.map((f) => ( {documents.map((doc, i) => (
<GalleryFolderThumb
key={f.id}
folder={f}
active={false}
onClick={() => { /* could expand later */ }}
onDropOnFolder={onDropOnFolder}
/>
))}
{allDocs.map((doc, i) => (
<GalleryThumb <GalleryThumb
key={`${doc.type}-${doc.id}`} key={`${doc.type}-${doc.id}`}
doc={doc} doc={doc}

View file

@ -1,29 +1,14 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, type CSSProperties } from 'react';
import { useDrop } from 'react-dnd'; import type { DocumentListDocument, IconSize } from '@/types/documents';
import { Transition } from '@headlessui/react';
import type {
DocumentListDocument,
Folder,
IconSize,
} from '@/types/documents';
import { formatDocumentSize } from '@/components/doclist/formatSize';
import { Button } from '@headlessui/react';
import { DocumentTile } from './DocumentTile'; import { DocumentTile } from './DocumentTile';
import { FolderIcon } from '../window/finderIcons';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface IconsViewProps { interface IconsViewProps {
folders: Folder[]; documents: DocumentListDocument[];
unfolderedDocs: DocumentListDocument[];
iconSize: IconSize; iconSize: IconSize;
collapsedFolders: Set<string>;
onToggleCollapse: (folderId: string) => void;
onDeleteFolder: (folderId: string) => void;
onDeleteDoc: (doc: DocumentListDocument) => void; onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
} }
@ -46,7 +31,7 @@ function responsiveGridTemplate(iconSize: IconSize, itemCount: number): string {
const gridGap = '12px'; const gridGap = '12px';
function gridStyle(iconSize: IconSize, itemCount: number): React.CSSProperties { function gridStyle(iconSize: IconSize, itemCount: number): CSSProperties {
return { return {
gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount), gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount),
gap: gridGap, gap: gridGap,
@ -54,131 +39,17 @@ function gridStyle(iconSize: IconSize, itemCount: number): React.CSSProperties {
}; };
} }
function FolderGridRow({
folder,
collapsed,
onToggleCollapse,
onDelete,
iconSize,
onDropOnFolder,
onDeleteDoc,
onMergeIntoFolder,
}: {
folder: Folder;
collapsed: boolean;
onToggleCollapse: () => void;
onDelete: () => void;
iconSize: IconSize;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onDeleteDoc: (doc: DocumentListDocument) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
}) {
const [{ isOver, canDrop }, dropRef] = useDrop<
DocumentDragItem,
void,
{ isOver: boolean; canDrop: boolean }
>(() => ({
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 totalSize = folder.documents.reduce((acc, d) => acc + d.size, 0);
const isTarget = isOver && canDrop;
return (
<div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className={
'col-span-full rounded-md border border-offbase overflow-hidden bg-base ' +
(isTarget ? 'ring-1 ring-accent' : '')
}
>
<div className="flex items-center justify-between px-2 py-1 bg-offbase border-b border-offbase">
<button
type="button"
onClick={onToggleCollapse}
className="flex items-center gap-1.5 text-[12px] font-semibold text-foreground hover:text-accent transition-colors duration-200 ease-out"
aria-expanded={!collapsed}
>
<FolderIcon className="w-3.5 h-3.5 text-accent" />
{folder.name}
<span className="text-[10px] font-normal text-muted ml-1">
{folder.documents.length} {formatDocumentSize(totalSize)}
</span>
<svg
className={`w-3 h-3 transition-transform ${collapsed ? '-rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Button
onClick={onDelete}
className="p-1 text-muted hover:text-accent hover:bg-base hover:scale-[1.02] rounded-md transition-all duration-200 ease-out"
aria-label={`Delete ${folder.name}`}
>
<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>
<Transition
show={!collapsed}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 max-h-0"
enterTo="opacity-100 max-h-[2000px]"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 max-h-[2000px]"
leaveTo="opacity-0 max-h-0"
>
<div
className="grid p-2"
style={gridStyle(iconSize, folder.documents.length)}
>
{folder.documents.map((doc) => (
<DocumentTile
key={`${doc.type}-${doc.id}`}
doc={doc}
iconSize={iconSize}
onDelete={onDeleteDoc}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</div>
</Transition>
</div>
);
}
export function IconsView({ export function IconsView({
folders, documents,
unfolderedDocs,
iconSize, iconSize,
collapsedFolders,
onToggleCollapse,
onDeleteFolder,
onDeleteDoc, onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder, onMergeIntoFolder,
}: IconsViewProps) { }: IconsViewProps) {
const { setVisibleOrder, clear } = useDocumentSelection(); const { setVisibleOrder, clear } = useDocumentSelection();
useEffect(() => { useEffect(() => {
const all: DocumentListDocument[] = [ setVisibleOrder(documents);
...folders.flatMap((f) => f.documents), }, [documents, setVisibleOrder]);
...unfolderedDocs,
];
setVisibleOrder(all);
}, [folders, unfolderedDocs, setVisibleOrder]);
const handleBackgroundClick: React.MouseEventHandler = (e) => { const handleBackgroundClick: React.MouseEventHandler = (e) => {
if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; if ((e.target as HTMLElement).closest('[data-doc-tile]')) return;
@ -190,24 +61,8 @@ export function IconsView({
onClick={handleBackgroundClick} onClick={handleBackgroundClick}
className="flex-1 min-h-0 overflow-y-auto p-3" className="flex-1 min-h-0 overflow-y-auto p-3"
> >
<div <div className="grid" style={gridStyle(iconSize, documents.length)}>
className="grid" {documents.map((doc) => (
style={gridStyle(iconSize, unfolderedDocs.length)}
>
{folders.map((folder) => (
<FolderGridRow
key={folder.id}
folder={folder}
collapsed={collapsedFolders.has(folder.id)}
onToggleCollapse={() => onToggleCollapse(folder.id)}
onDelete={() => onDeleteFolder(folder.id)}
iconSize={iconSize}
onDropOnFolder={onDropOnFolder}
onDeleteDoc={onDeleteDoc}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
{unfolderedDocs.map((doc) => (
<DocumentTile <DocumentTile
key={`${doc.type}-${doc.id}`} key={`${doc.type}-${doc.id}`}
doc={doc} doc={doc}

View file

@ -3,30 +3,23 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { Button, Transition } from '@headlessui/react'; import { Button } from '@headlessui/react';
import type { import type {
DocumentListDocument, DocumentListDocument,
Folder,
SortBy, SortBy,
SortDirection, SortDirection,
} from '@/types/documents'; } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { FolderIcon } from '../window/finderIcons';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface ListViewProps { interface ListViewProps {
folders: Folder[]; documents: DocumentListDocument[];
unfolderedDocs: DocumentListDocument[];
sortBy: SortBy; sortBy: SortBy;
sortDirection: SortDirection; sortDirection: SortDirection;
onSortChange: (sortBy: SortBy, direction: SortDirection) => void; onSortChange: (sortBy: SortBy, direction: SortDirection) => void;
collapsedFolders: Set<string>;
onToggleCollapse: (folderId: string) => void;
onDeleteFolder: (folderId: string) => void;
onDeleteDoc: (doc: DocumentListDocument) => void; onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
} }
@ -84,12 +77,10 @@ function HeaderCell({
function DocRow({ function DocRow({
doc, doc,
isFirstColumnIndented,
onDeleteDoc, onDeleteDoc,
onMergeIntoFolder, onMergeIntoFolder,
}: { }: {
doc: DocumentListDocument; doc: DocumentListDocument;
isFirstColumnIndented?: boolean;
onDeleteDoc: (d: DocumentListDocument) => void; onDeleteDoc: (d: DocumentListDocument) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
}) { }) {
@ -163,10 +154,7 @@ function DocRow({
href={href} href={href}
draggable={false} draggable={false}
onClick={handleClick} onClick={handleClick}
className={ className="flex items-center gap-2 min-w-0 px-2 py-1.5"
'flex items-center gap-2 min-w-0 px-2 py-1.5 ' +
(isFirstColumnIndented ? 'pl-8' : '')
}
> >
<KindIcon doc={doc} /> <KindIcon doc={doc} />
<span className="truncate">{doc.name}</span> <span className="truncate">{doc.name}</span>
@ -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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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 (
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}>
<div
className={
'grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px] items-center text-[12px] border-b border-offbase bg-base ' +
(isTarget ? 'ring-1 ring-accent ring-inset' : '')
}
>
<button
type="button"
onClick={onToggleCollapse}
className="flex items-center gap-1.5 min-w-0 px-2 py-1.5 text-left font-semibold hover:text-accent transition-colors duration-200 ease-out"
>
<svg
className={`w-3 h-3 text-muted transition-transform ${collapsed ? '-rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
<FolderIcon className="w-3.5 h-3.5 text-accent" />
<span className="truncate">{folder.name}</span>
</button>
<span className="px-2 text-[11px] text-muted">Folder</span>
<span className="px-2 text-[11px] text-muted text-right tabular-nums">
{formatDocumentSize(totalSize)}
</span>
<span className="px-2 text-[11px] text-muted tabular-nums">
{folder.documents.length} items
</span>
<Button
onClick={onDelete}
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out"
aria-label={`Delete ${folder.name}`}
>
<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>
<Transition
show={!collapsed}
enter="transition-all duration-200"
enterFrom="opacity-0 max-h-0"
enterTo="opacity-100 max-h-[2000px]"
leave="transition-all duration-150"
leaveFrom="opacity-100 max-h-[2000px]"
leaveTo="opacity-0 max-h-0"
>
<div>
{folder.documents.map((doc) => (
<DocRow
key={`${doc.type}-${doc.id}`}
doc={doc}
isFirstColumnIndented
onDeleteDoc={onDeleteDoc}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</div>
</Transition>
</div>
);
}
export function ListView({ export function ListView({
folders, documents,
unfolderedDocs,
sortBy, sortBy,
sortDirection, sortDirection,
onSortChange, onSortChange,
collapsedFolders,
onToggleCollapse,
onDeleteFolder,
onDeleteDoc, onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder, onMergeIntoFolder,
}: ListViewProps) { }: ListViewProps) {
const { setVisibleOrder, clear } = useDocumentSelection(); const { setVisibleOrder, clear } = useDocumentSelection();
useEffect(() => { useEffect(() => {
const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs]; setVisibleOrder(documents);
setVisibleOrder(all); }, [documents, setVisibleOrder]);
}, [folders, unfolderedDocs, setVisibleOrder]);
const handleBackgroundClick: React.MouseEventHandler = (e) => { const handleBackgroundClick: React.MouseEventHandler = (e) => {
if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; if ((e.target as HTMLElement).closest('[data-doc-tile]')) return;
@ -327,19 +216,7 @@ export function ListView({
<span /> <span />
</div> </div>
<div> <div>
{folders.map((folder) => ( {documents.map((doc) => (
<FolderRowGroup
key={folder.id}
folder={folder}
collapsed={collapsedFolders.has(folder.id)}
onToggleCollapse={() => onToggleCollapse(folder.id)}
onDelete={() => onDeleteFolder(folder.id)}
onDeleteDoc={onDeleteDoc}
onDropOnFolder={onDropOnFolder}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
{unfolderedDocs.map((doc) => (
<DocRow <DocRow
key={`${doc.type}-${doc.id}`} key={`${doc.type}-${doc.id}`}
doc={doc} doc={doc}

View file

@ -4,7 +4,7 @@ import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents'; import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon } from './finderIcons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface FinderSidebarProps { interface FinderSidebarProps {
@ -12,6 +12,8 @@ interface FinderSidebarProps {
onFilterChange: (filter: SidebarFilter) => void; onFilterChange: (filter: SidebarFilter) => void;
folders: Folder[]; folders: Folder[];
counts: { all: number; pdf: number; epub: number; html: number }; 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. */ /** When dragging onto a folder row, move dropped docs into that folder. */
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
/** Width controls (desktop only). */ /** Width controls (desktop only). */
@ -29,6 +31,7 @@ interface SidebarRowProps {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
count?: number; count?: number;
countClassName?: string;
trailing?: ReactNode; trailing?: ReactNode;
isDropTarget?: boolean; isDropTarget?: boolean;
} }
@ -39,6 +42,7 @@ function SidebarRow({
icon, icon,
label, label,
count, count,
countClassName,
trailing, trailing,
isDropTarget, isDropTarget,
}: SidebarRowProps) { }: SidebarRowProps) {
@ -64,7 +68,11 @@ function SidebarRow({
</span> </span>
<span className="truncate flex-1">{label}</span> <span className="truncate flex-1">{label}</span>
{typeof count === 'number' && count > 0 && ( {typeof count === 'number' && count > 0 && (
<span className="text-[10px] text-muted tabular-nums">{count}</span> <span
className={`text-[10px] text-muted tabular-nums transition-transform duration-200 ease-out ${countClassName ?? ''}`}
>
{count}
</span>
)} )}
{trailing} {trailing}
</button> </button>
@ -75,11 +83,13 @@ function FolderRow({
folder, folder,
active, active,
onClick, onClick,
onDelete,
onDropOnFolder, onDropOnFolder,
}: { }: {
folder: Folder; folder: Folder;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
onDelete: () => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
}) { }) {
const [{ isOver, canDrop }, dropRef] = useDrop< const [{ isOver, canDrop }, dropRef] = useDrop<
@ -103,15 +113,33 @@ function FolderRow({
const isDropTarget = isOver && canDrop; const isDropTarget = isOver && canDrop;
return ( return (
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}> <div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className="group/folder relative"
>
<SidebarRow <SidebarRow
active={active} active={active}
onClick={onClick} onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />} icon={<FolderIcon className="w-3.5 h-3.5" />}
label={folder.name} label={folder.name}
count={folder.documents.length} count={folder.documents.length}
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
isDropTarget={isDropTarget} isDropTarget={isDropTarget}
/> />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-muted opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-offbase transition"
aria-label={`Delete ${folder.name}`}
title={`Delete ${folder.name}`}
>
<svg className="w-3 h-3" 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>
); );
} }
@ -134,6 +162,8 @@ export function FinderSidebar({
onFilterChange, onFilterChange,
folders, folders,
counts, counts,
onDeleteFolder,
onNewFolder,
onDropOnFolder, onDropOnFolder,
width, width,
onWidthChange, onWidthChange,
@ -205,19 +235,33 @@ export function FinderSidebar({
count={counts.html} count={counts.html}
/> />
{folders.length > 0 && ( <div className="px-2 pt-3 pb-1 flex items-center justify-between">
<> <p className="text-[10px] uppercase tracking-[0.08em] text-muted font-semibold">
<SectionLabel>Folders</SectionLabel> Folders
{folders.map((folder) => ( </p>
<FolderRow <button
key={folder.id} type="button"
folder={folder} onClick={onNewFolder}
active={filter === `folder:${folder.id}`} className="inline-flex items-center justify-center h-6 w-6 rounded-md border border-offbase bg-base text-foreground hover:text-accent hover:border-accent hover:bg-offbase transition-all duration-200 ease-out hover:scale-[1.01]"
onClick={() => onFilterChange(`folder:${folder.id}`)} title="New folder"
onDropOnFolder={onDropOnFolder} aria-label="New folder"
/> >
))} <FolderPlusIcon className="w-4 h-4" />
</> </button>
</div>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
) : (
folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDelete={() => onDeleteFolder(folder.id)}
onDropOnFolder={onDropOnFolder}
/>
))
)} )}
</div> </div>

View file

@ -8,7 +8,6 @@ import {
ColumnsViewIcon, ColumnsViewIcon,
GalleryViewIcon, GalleryViewIcon,
SearchIcon, SearchIcon,
FolderPlusIcon,
HamburgerIcon, HamburgerIcon,
} from './finderIcons'; } from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon } from '@/components/icons/Icons';
@ -25,7 +24,6 @@ interface FinderToolbarProps {
onSortDirectionToggle: () => void; onSortDirectionToggle: () => void;
query: string; query: string;
onQueryChange: (q: string) => void; onQueryChange: (q: string) => void;
onNewFolder: () => void;
onToggleSidebar: () => void; onToggleSidebar: () => void;
isSidebarOpen: boolean; isSidebarOpen: boolean;
/** True when the columns view should be disabled (mobile viewport). */ /** True when the columns view should be disabled (mobile viewport). */
@ -84,7 +82,6 @@ export function FinderToolbar({
onSortDirectionToggle, onSortDirectionToggle,
query, query,
onQueryChange, onQueryChange,
onNewFolder,
onToggleSidebar, onToggleSidebar,
isSidebarOpen, isSidebarOpen,
isNarrow, isNarrow,
@ -222,16 +219,6 @@ export function FinderToolbar({
/> />
</div> </div>
<button
type="button"
onClick={onNewFolder}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 shrink-0`}
title="New folder"
>
<FolderPlusIcon className="w-4 h-4" />
<span className="hidden md:inline">New Folder</span>
</button>
{rightSlot && ( {rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5"> <div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot} {rightSlot}