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:
parent
d59e911ca2
commit
85aff2c1be
7 changed files with 176 additions and 580 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue