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 [iconSize, setIconSize] = useState<IconSize>(DEFAULT_STATE.iconSize);
const [folders, setFolders] = useState<Folder[]>(DEFAULT_STATE.folders);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const [showHint, setShowHint] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('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<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 })),
})),
);
}, [isInitialized, allDocuments]);
[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 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<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]);
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) {
<FinderSidebar
filter={sidebarFilter}
onFilterChange={setSidebarFilter}
folders={folders}
folders={foldersWithLiveDocs}
counts={counts}
onDeleteFolder={handleDeleteFolder}
onNewFolder={() => {
setNewFolderName('');
setManualFolderPrompt(true);
}}
onDropOnFolder={handleDropOnFolder}
width={sidebarWidth}
onWidthChange={setSidebarWidth}
@ -546,50 +542,36 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
<DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col">
{fallbackViewMode === 'icons' && (
<IconsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
documents={sortedVisible}
iconSize={iconSize}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'list' && (
<ListView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
documents={sortedVisible}
sortBy={sortBy}
sortDirection={sortDirection}
onSortChange={(b, d) => {
setSortBy(b);
setSortDirection(d);
}}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'columns' && (
<ColumnsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
documents={sortedVisible}
onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'gallery' && (
<GalleryView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
documents={sortedVisible}
onDeleteDoc={handleDeleteDoc}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}

View file

@ -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 <PDFIcon className="w-4 h-4 text-red-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 }) {
return (
<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({
folders,
unfolderedDocs,
documents,
onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder,
}: ColumnsViewProps) {
const { setVisibleOrder } = useDocumentSelection();
const [selected, setSelected] = useState<Selected>(null);
const allDocs = useMemo(
() => [...folders.flatMap((f) => f.documents), ...unfolderedDocs],
[folders, unfolderedDocs],
);
const [selectedDoc, setSelectedDoc] = useState<DocumentListDocument | null>(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 (
<div className="flex-1 min-h-0 flex overflow-x-auto overflow-y-hidden">
<Column title="Library">
{folders.map((f) => (
<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) => (
<Column title="Documents">
{documents.map((doc) => (
<ColumnDocRow
key={`${d.type}-${d.id}`}
doc={d}
active={selected?.kind === 'doc' && selected.doc.id === d.id}
onClick={() => 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}
/>
))}
</Column>
{selectedFolder && (
<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' && (
{selectedDoc && (
<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="rounded-lg overflow-hidden border border-offbase">
<DocumentPreview doc={selected.doc} />
<DocumentPreview doc={selectedDoc} />
</div>
<h3 className="mt-3 text-[13px] font-semibold text-foreground truncate">
{selected.doc.name}
{selectedDoc.name}
</h3>
<p className="text-[11px] text-muted">
{selected.doc.type.toUpperCase()} {formatDocumentSize(selected.doc.size)}
{selectedDoc.type.toUpperCase()} {formatDocumentSize(selectedDoc.size)}
</p>
<div className="mt-3 flex gap-2">
<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"
>
Open
</Link>
<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"
>
Delete

View file

@ -3,19 +3,16 @@
import Link from 'next/link';
import { useEffect, useMemo, 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 } 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 GalleryViewProps {
folders: Folder[];
unfolderedDocs: DocumentListDocument[];
documents: DocumentListDocument[];
onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => 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({
folders,
unfolderedDocs,
documents,
onDeleteDoc,
onDropOnFolder,
onMergeIntoFolder,
}: GalleryViewProps) {
const { setVisibleOrder } = useDocumentSelection();
const allDocs = useMemo(
() => [...folders.flatMap((f) => f.documents), ...unfolderedDocs],
[folders, unfolderedDocs],
);
const [activeIdx, setActiveIdx] = useState(0);
const activeDoc = useMemo(() => documents[activeIdx], [documents, activeIdx]);
useEffect(() => {
setVisibleOrder(allDocs);
}, [allDocs, setVisibleOrder]);
setVisibleOrder(documents);
}, [documents, setVisibleOrder]);
useEffect(() => {
if (activeIdx >= allDocs.length) setActiveIdx(Math.max(0, allDocs.length - 1));
}, [allDocs.length, activeIdx]);
if (activeIdx >= documents.length) {
setActiveIdx(Math.max(0, documents.length - 1));
}
}, [documents.length, activeIdx]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target?.closest('input, textarea, [contenteditable]')) return;
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') {
setActiveIdx((i) => Math.max(0, i - 1));
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [allDocs.length]);
const activeDoc = allDocs[activeIdx];
}, [documents.length]);
return (
<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="flex gap-2 overflow-x-auto p-2 snap-x snap-mandatory">
{folders.map((f) => (
<GalleryFolderThumb
key={f.id}
folder={f}
active={false}
onClick={() => { /* could expand later */ }}
onDropOnFolder={onDropOnFolder}
/>
))}
{allDocs.map((doc, i) => (
{documents.map((doc, i) => (
<GalleryThumb
key={`${doc.type}-${doc.id}`}
doc={doc}

View file

@ -1,29 +1,14 @@
'use client';
import { useEffect } from 'react';
import { useDrop } from 'react-dnd';
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 { useEffect, type CSSProperties } from 'react';
import type { DocumentListDocument, IconSize } from '@/types/documents';
import { DocumentTile } from './DocumentTile';
import { FolderIcon } from '../window/finderIcons';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface IconsViewProps {
folders: Folder[];
unfolderedDocs: DocumentListDocument[];
documents: DocumentListDocument[];
iconSize: IconSize;
collapsedFolders: Set<string>;
onToggleCollapse: (folderId: string) => void;
onDeleteFolder: (folderId: string) => void;
onDeleteDoc: (doc: DocumentListDocument) => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
}
@ -46,7 +31,7 @@ function responsiveGridTemplate(iconSize: IconSize, itemCount: number): string {
const gridGap = '12px';
function gridStyle(iconSize: IconSize, itemCount: number): React.CSSProperties {
function gridStyle(iconSize: IconSize, itemCount: number): CSSProperties {
return {
gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount),
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({
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"
>
<div
className="grid"
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) => (
<div className="grid" style={gridStyle(iconSize, documents.length)}>
{documents.map((doc) => (
<DocumentTile
key={`${doc.type}-${doc.id}`}
doc={doc}

View file

@ -3,30 +3,23 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { Button, Transition } from '@headlessui/react';
import { Button } from '@headlessui/react';
import type {
DocumentListDocument,
Folder,
SortBy,
SortDirection,
} from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { formatDocumentSize } from '@/components/doclist/formatSize';
import { FolderIcon } from '../window/finderIcons';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface ListViewProps {
folders: Folder[];
unfolderedDocs: DocumentListDocument[];
documents: DocumentListDocument[];
sortBy: SortBy;
sortDirection: SortDirection;
onSortChange: (sortBy: SortBy, direction: SortDirection) => void;
collapsedFolders: Set<string>;
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"
>
<KindIcon doc={doc} />
<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({
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({
<span />
</div>
<div>
{folders.map((folder) => (
<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) => (
{documents.map((doc) => (
<DocRow
key={`${doc.type}-${doc.id}`}
doc={doc}

View file

@ -4,7 +4,7 @@ import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
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';
interface FinderSidebarProps {
@ -12,6 +12,8 @@ interface FinderSidebarProps {
onFilterChange: (filter: SidebarFilter) => 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({
</span>
<span className="truncate flex-1">{label}</span>
{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}
</button>
@ -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 (
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}>
<div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className="group/folder relative"
>
<SidebarRow
active={active}
onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={folder.name}
count={folder.documents.length}
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
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>
);
}
@ -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 && (
<>
<SectionLabel>Folders</SectionLabel>
{folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDropOnFolder={onDropOnFolder}
/>
))}
</>
<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">
Folders
</p>
<button
type="button"
onClick={onNewFolder}
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]"
title="New folder"
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>

View file

@ -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({
/>
</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 && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot}