-
+
);
}
diff --git a/src/components/doclist/CreateFolderDialog.tsx b/src/components/doclist/CreateFolderDialog.tsx
index f17c295..69385b5 100644
--- a/src/components/doclist/CreateFolderDialog.tsx
+++ b/src/components/doclist/CreateFolderDialog.tsx
@@ -53,7 +53,7 @@ export function CreateFolderDialog({
onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Enter folder name"
- className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
+ className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent"
autoFocus
/>
Press Enter to create or Escape to cancel
diff --git a/src/components/doclist/DocumentFolder.tsx b/src/components/doclist/DocumentFolder.tsx
deleted file mode 100644
index 78ecbb3..0000000
--- a/src/components/doclist/DocumentFolder.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useState, DragEvent } from 'react';
-import { Button, Transition } from '@headlessui/react';
-import { DocumentListItem } from './DocumentListItem';
-import { Folder, DocumentListDocument } from '@/types/documents';
-
-interface DocumentFolderProps {
- folder: Folder;
- isCollapsed: boolean;
- onToggleCollapse: (folderId: string) => void;
- onDelete: () => void;
- sortedDocuments: DocumentListDocument[];
- onDocumentDelete: (doc: DocumentListDocument) => void;
- draggedDoc: DocumentListDocument | null;
- onDragStart: (doc: DocumentListDocument) => void;
- onDragEnd: () => void;
- onDrop: (e: DragEvent, folderId: string) => void;
- viewMode: 'list' | 'grid';
-}
-
-const ChevronIcon = ({ className = "w-4 h-4" }) => (
-
-);
-
-const calculateFolderSize = (documents: DocumentListDocument[]) => {
- return documents.reduce((total, doc) => total + doc.size, 0);
-};
-
-export function DocumentFolder({
- folder,
- isCollapsed,
- onToggleCollapse,
- onDelete,
- sortedDocuments,
- onDocumentDelete,
- draggedDoc,
- onDragStart,
- onDragEnd,
- onDrop,
- viewMode,
-}: DocumentFolderProps) {
- const [isHovering, setIsHovering] = useState(false);
- const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
-
- return (
-
{
- e.preventDefault();
- e.stopPropagation();
- if (draggedDoc && !draggedDoc.folderId) {
- setIsHovering(true);
- }
- }}
- onDragLeave={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsHovering(false);
- }}
- onDrop={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsHovering(false);
- if (!draggedDoc || draggedDoc.folderId) return;
- onDrop(e, folder.id);
- }}
- className={`w-full overflow-hidden rounded-md border border-offbase ${viewMode === 'grid' ? 'col-span-full' : ''} ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
- >
-
-
-
-
-
{folder.name}
-
-
-
-
-
-
-
- {sortedDocuments.map(doc => (
-
- ))}
-
-
-
-
-
- {(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
- {` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
-
-
-
-
-
-
- );
-}
diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx
index 431049b..f785318 100644
--- a/src/components/doclist/DocumentList.tsx
+++ b/src/components/doclist/DocumentList.tsx
@@ -1,410 +1,609 @@
'use client';
-import { useCallback, useState, useEffect, DragEvent, KeyboardEvent } from 'react';
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useDocuments } from '@/contexts/DocumentContext';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
-import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
+import type {
+ DocumentListDocument,
+ DocumentListState,
+ Folder,
+ IconSize,
+ SidebarFilter,
+ SortBy,
+ SortDirection,
+ ViewMode,
+} from '@/types/documents';
import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie';
import { ConfirmDialog } from '@/components/ConfirmDialog';
-import { DocumentListItem } from '@/components/doclist/DocumentListItem';
-import { DocumentFolder } from '@/components/doclist/DocumentFolder';
-import { SortControls } from '@/components/doclist/SortControls';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
-import { Button } from '@headlessui/react';
import { DocumentUploader } from '@/components/documents/DocumentUploader';
-import { buttonClass } from '@/components/formPrimitives';
+import { DocumentDndProvider } from './dnd/DocumentDndProvider';
+import {
+ DocumentSelectionProvider,
+ useDocumentSelection,
+} from './dnd/DocumentSelectionContext';
+import type { DocumentDragItem } from './dnd/dndTypes';
+import { FinderWindow, useIsNarrow } from './window/FinderWindow';
+import { FinderToolbar } from './window/FinderToolbar';
+import { FinderSidebar } from './window/FinderSidebar';
+import { FinderStatusBar } from './window/FinderStatusBar';
+import { IconsView } from './views/IconsView';
+import { ListView } from './views/ListView';
+import { ColumnsView } from './views/ColumnsView';
+import { GalleryView } from './views/GalleryView';
type DocumentToDelete = {
id: string;
name: string;
- type: DocumentType;
+ type: DocumentListDocument['type'];
};
-const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentListDocument) => {
- // Try to find common words between the two document names
- const words1 = doc1.name.toLowerCase().split(/[\s-_\.]+/);
- const words2 = doc2.name.toLowerCase().split(/[\s-_\.]+/);
- const commonWords = words1.filter(word => words2.includes(word));
+const DEFAULT_STATE: Required<
+ Pick<
+ DocumentListState,
+ 'sortBy' | 'sortDirection' | 'folders' | 'collapsedFolders' | 'showHint'
+ >
+> & {
+ viewMode: ViewMode;
+ iconSize: IconSize;
+ sidebarWidth: number;
+ sidebarFilter: SidebarFilter;
+ sidebarCollapsed: boolean;
+} = {
+ sortBy: 'name',
+ sortDirection: 'asc',
+ folders: [],
+ collapsedFolders: [],
+ showHint: true,
+ viewMode: 'icons',
+ iconSize: 'md',
+ sidebarWidth: 220,
+ sidebarFilter: 'all',
+ sidebarCollapsed: false,
+};
- if (commonWords.length > 0) {
- // Use the first common word that's at least 3 characters long
- const significant = commonWords.find(word => word.length >= 3);
- if (significant) {
- if (significant === 'pdf') return 'PDFs';
- if (significant === 'epub') return 'EPUBs';
- if (significant === 'txt' || significant === 'md') return 'Documents';
- return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
- }
+function normalizeViewMode(stored: DocumentListState['viewMode']): ViewMode {
+ if (stored === 'grid' || stored === undefined) return 'icons';
+ if (stored === 'list') return 'list';
+ return stored;
+}
+
+function generateDefaultFolderName(
+ doc1: DocumentListDocument,
+ doc2: DocumentListDocument,
+): string {
+ const words1 = doc1.name.toLowerCase().split(/[\s\-_.]+/);
+ const words2 = doc2.name.toLowerCase().split(/[\s\-_.]+/);
+ const common = words1.filter((w) => words2.includes(w));
+ const significant = common.find((w) => w.length >= 3);
+ if (significant) {
+ if (significant === 'pdf') return 'PDFs';
+ if (significant === 'epub') return 'EPUBs';
+ if (significant === 'txt' || significant === 'md') return 'Documents';
+ return significant.charAt(0).toUpperCase() + significant.slice(1);
}
-
- // Fallback to a numbered folder
const timestamp = new Date().toISOString().slice(0, 10);
return `Folder ${timestamp}`;
-};
+}
-export function DocumentList() {
- // State hooks
- const [sortBy, setSortBy] = useState
('name');
- const [sortDirection, setSortDirection] = useState('asc');
- const [folders, setFolders] = useState([]);
- const [documentToDelete, setDocumentToDelete] = useState(null);
- const [draggedDoc, setDraggedDoc] = useState(null);
- const [dropTargetDoc, setDropTargetDoc] = useState(null);
- const [newFolderName, setNewFolderName] = useState('');
- const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null);
+function sortDocs(
+ docs: DocumentListDocument[],
+ sortBy: SortBy,
+ direction: SortDirection,
+): DocumentListDocument[] {
+ const sorted = [...docs].sort((a, b) => {
+ switch (sortBy) {
+ case 'name':
+ return a.name.localeCompare(b.name);
+ case 'type':
+ return a.type.localeCompare(b.type);
+ case 'size':
+ return a.size - b.size;
+ default:
+ return a.lastModified - b.lastModified;
+ }
+ });
+ return direction === 'asc' ? sorted : sorted.reverse();
+}
+
+interface DocumentListInnerProps {
+ brand?: ReactNode;
+ appActions?: ReactNode;
+}
+
+function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
+ const [sortBy, setSortBy] = useState(DEFAULT_STATE.sortBy);
+ const [sortDirection, setSortDirection] = useState(
+ DEFAULT_STATE.sortDirection,
+ );
+ const [viewMode, setViewMode] = useState(DEFAULT_STATE.viewMode);
+ const [iconSize, setIconSize] = useState(DEFAULT_STATE.iconSize);
+ const [folders, setFolders] = useState(DEFAULT_STATE.folders);
const [collapsedFolders, setCollapsedFolders] = useState>(new Set());
- const [isInitialized, setIsInitialized] = useState(false);
const [showHint, setShowHint] = useState(true);
- const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
+ const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
+ const [sidebarFilter, setSidebarFilter] = useState('all');
+ const [sidebarOpen, setSidebarOpen] = useState(true);
+ const [query, setQuery] = useState('');
+
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ const [documentToDelete, setDocumentToDelete] = useState(null);
+ const [pendingMerge, setPendingMerge] = useState<
+ | { sources: DocumentListDocument[]; target: DocumentListDocument }
+ | null
+ >(null);
+ const [newFolderName, setNewFolderName] = useState('');
+ const [manualFolderPrompt, setManualFolderPrompt] = useState(false);
+
+ const isNarrow = useIsNarrow();
+ const selection = useDocumentSelection();
const {
pdfDocs,
- removePDFDocument: removePDF,
+ removePDFDocument,
isPDFLoading,
epubDocs,
- removeEPUBDocument: removeEPUB,
+ removeEPUBDocument,
isEPUBLoading,
htmlDocs,
- removeHTMLDocument: removeHTML,
+ removeHTMLDocument,
isHTMLLoading,
} = useDocuments();
+ // Load saved state.
useEffect(() => {
- // Load saved state
- const loadState = async () => {
- const savedState = await getDocumentListState();
- if (savedState) {
- setSortBy(savedState.sortBy);
- setSortDirection(savedState.sortDirection);
- setFolders(savedState.folders);
- setCollapsedFolders(new Set(savedState.collapsedFolders));
- setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
- setViewMode(savedState.viewMode ?? 'grid');
+ let cancelled = false;
+ (async () => {
+ const saved = await getDocumentListState();
+ if (cancelled) return;
+ if (saved) {
+ 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);
+ setSidebarWidth(saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
+ setSidebarFilter(saved.sidebarFilter ?? 'all');
+ setSidebarOpen(!(saved.sidebarCollapsed ?? false));
}
setIsInitialized(true);
+ })();
+ return () => {
+ cancelled = true;
};
-
- loadState();
}, []);
- useEffect(() => {
- const saveState = async () => {
- const state: DocumentListState = {
- sortBy,
- sortDirection,
- folders,
- collapsedFolders: Array.from(collapsedFolders),
- showHint,
- viewMode
- };
- await saveDocumentListState(state);
- };
-
- if (isInitialized) { // Prevents saving empty state on first render or back navigation
- saveState();
- }
- }, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
-
- // Reconcile folder state against the current server-backed document list.
- // If a document no longer exists on the server, drop it from folders to avoid stale UI.
+ // Persist.
useEffect(() => {
if (!isInitialized) return;
- const ids = new Set([...pdfDocs, ...epubDocs, ...htmlDocs].map((d) => d.id));
+ const state: DocumentListState = {
+ sortBy,
+ sortDirection,
+ folders,
+ collapsedFolders: Array.from(collapsedFolders),
+ showHint,
+ viewMode,
+ iconSize,
+ sidebarWidth,
+ sidebarFilter,
+ sidebarCollapsed: !sidebarOpen,
+ };
+ void saveDocumentListState(state);
+ }, [
+ sortBy,
+ sortDirection,
+ folders,
+ collapsedFolders,
+ showHint,
+ viewMode,
+ iconSize,
+ sidebarWidth,
+ sidebarFilter,
+ sidebarOpen,
+ isInitialized,
+ ]);
+
+ // Build the union document list.
+ const allDocuments: DocumentListDocument[] = useMemo(
+ () => [
+ ...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })),
+ ...epubDocs.map((d) => ({ ...d, type: 'epub' as const })),
+ ...htmlDocs.map((d) => ({ ...d, type: 'html' as const })),
+ ],
+ [pdfDocs, epubDocs, htmlDocs],
+ );
+
+ // Reconcile folders against server.
+ useEffect(() => {
+ if (!isInitialized) return;
+ const ids = new Set(allDocuments.map((d) => d.id));
setFolders((prev) =>
- prev.map((folder) => ({
- ...folder,
- documents: folder.documents.filter((d) => ids.has(d.id)),
+ prev.map((f) => ({
+ ...f,
+ documents: f.documents.filter((d) => ids.has(d.id)),
})),
);
- }, [isInitialized, pdfDocs, epubDocs, htmlDocs]);
+ }, [isInitialized, allDocuments]);
- const allDocuments: DocumentListDocument[] = [
- ...pdfDocs.map((doc) => ({ ...doc, type: 'pdf' as const })),
- ...epubDocs.map((doc) => ({ ...doc, type: 'epub' as const })),
- ...htmlDocs.map((doc) => ({ ...doc, type: 'html' as const })),
- ];
+ // Filter based on sidebar selection + search query.
+ const visibleAll = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ let docs = allDocuments;
+ if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf');
+ else if (sidebarFilter === 'epub') docs = docs.filter((d) => d.type === 'epub');
+ else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html');
+ else if (sidebarFilter === 'recents') {
+ docs = [...docs]
+ .sort((a, b) => b.lastModified - a.lastModified)
+ .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 : [];
+ }
+ if (q) docs = docs.filter((d) => d.name.toLowerCase().includes(q));
+ return docs;
+ }, [allDocuments, sidebarFilter, query, folders]);
- const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
- return [...docs].sort((a, b) => {
- switch (sortBy) {
- case 'name':
- return sortDirection === 'asc'
- ? a.name.localeCompare(b.name)
- : b.name.localeCompare(a.name);
- case 'type':
- return sortDirection === 'asc'
- ? a.type.localeCompare(b.type)
- : b.type.localeCompare(a.type);
- case 'size':
- return sortDirection === 'asc'
- ? a.size - b.size
- : b.size - a.size;
- default:
- return sortDirection === 'asc'
- ? a.lastModified - b.lastModified
- : b.lastModified - a.lastModified;
- }
- });
- }, [sortBy, sortDirection]);
+ // Apply sort.
+ const sortedVisible = useMemo(
+ () => sortDocs(visibleAll, sortBy, sortDirection),
+ [visibleAll, sortBy, sortDirection],
+ );
+
+ // Split into folders + unfoldered for the current filter context.
+ const showAllScope =
+ sidebarFilter === 'all' || sidebarFilter === 'recents';
+ const visibleFolders = useMemo(() => {
+ if (!showAllScope) return [];
+ // Within a kind-filter (pdf/epub/html), still show folders that contain matches.
+ return folders.map((f) => ({
+ ...f,
+ documents: sortDocs(
+ f.documents.filter((d) =>
+ query ? d.name.toLowerCase().includes(query.toLowerCase()) : true,
+ ),
+ sortBy,
+ sortDirection,
+ ),
+ }));
+ }, [folders, showAllScope, query, sortBy, sortDirection]);
+
+ const unfolderedDocs = useMemo(() => {
+ const inFolder = new Set();
+ folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id)));
+ return sortedVisible.filter((d) => !inFolder.has(d.id));
+ }, [folders, sortedVisible]);
+
+ const counts = useMemo(
+ () => ({
+ all: allDocuments.length,
+ pdf: pdfDocs.length,
+ epub: epubDocs.length,
+ html: htmlDocs.length,
+ }),
+ [allDocuments.length, pdfDocs.length, epubDocs.length, htmlDocs.length],
+ );
+
+ // --- Actions ---
const handleDelete = useCallback(async () => {
if (!documentToDelete) return;
-
try {
- if (documentToDelete.type === 'pdf') {
- await removePDF(documentToDelete.id);
- } else if (documentToDelete.type === 'epub') {
- await removeEPUB(documentToDelete.id);
- } else if (documentToDelete.type === 'html') {
- await removeHTML(documentToDelete.id);
- }
-
- // Remove from folders if document is in one
- setFolders(prev => prev.map(folder => ({
- ...folder,
- documents: folder.documents.filter(doc =>
- !(doc.id === documentToDelete.id && doc.type === documentToDelete.type)
- )
- })));
-
+ if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id);
+ else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id);
+ else if (documentToDelete.type === 'html') await removeHTMLDocument(documentToDelete.id);
+ setFolders((prev) =>
+ prev.map((f) => ({
+ ...f,
+ documents: f.documents.filter(
+ (d) => !(d.id === documentToDelete.id && d.type === documentToDelete.type),
+ ),
+ })),
+ );
setDocumentToDelete(null);
} catch (err) {
console.error('Failed to remove document:', err);
}
- }, [documentToDelete, removePDF, removeEPUB, removeHTML]);
+ }, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]);
- const handleDragStart = useCallback((doc: DocumentListDocument) => {
- if (!doc.folderId) {
- setDraggedDoc(doc);
- }
- }, []);
+ const handleDropOnFolder = useCallback(
+ (folderId: string, item: DocumentDragItem) => {
+ setFolders((prev) =>
+ prev.map((f) => {
+ if (f.id !== folderId) {
+ // Remove the dropped docs from any other folder they were in.
+ return {
+ ...f,
+ documents: f.documents.filter(
+ (d) => !item.ids.includes(d.id),
+ ),
+ };
+ }
+ const existingIds = new Set(f.documents.map((d) => d.id));
+ const newDocs = item.docs
+ .filter((d) => !existingIds.has(d.id))
+ .map((d) => ({ ...d, folderId }));
+ return { ...f, documents: [...f.documents, ...newDocs] };
+ }),
+ );
+ selection.clear();
+ },
+ [selection],
+ );
- const handleDragEnd = useCallback(() => {
- setDraggedDoc(null);
- setDropTargetDoc(null);
- }, []);
-
- const handleDragOver = useCallback((e: DragEvent, doc: DocumentListDocument) => {
- e.preventDefault();
- if (draggedDoc && draggedDoc.id !== doc.id && !draggedDoc.folderId) {
- // Only highlight target if neither document is in a folder
- if (!doc.folderId) {
- setDropTargetDoc(doc);
- }
- }
- }, [draggedDoc]);
-
- const handleDragLeave = useCallback(() => {
- setDropTargetDoc(null);
- }, []);
-
- const handleDrop = useCallback((e: DragEvent, targetDoc: DocumentListDocument) => {
- e.preventDefault();
- console.log('Dropped', draggedDoc?.name, 'on', targetDoc.name);
-
- if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return;
-
- // If target doc is unfoldered, create a new folder
- if (!targetDoc.folderId) {
- setPendingFolderDocs({
- source: draggedDoc,
- target: targetDoc
- });
+ const handleMergeIntoFolder = useCallback(
+ (sources: DocumentListDocument[], target: DocumentListDocument) => {
+ if (target.folderId) return;
+ const filtered = sources.filter((s) => s.id !== target.id && !s.folderId);
+ if (filtered.length === 0) return;
+ setPendingMerge({ sources: filtered, target });
setNewFolderName('');
- }
+ },
+ [],
+ );
- setDraggedDoc(null);
- setDropTargetDoc(null);
- }, [draggedDoc]);
+ const createFolderFromPending = useCallback(() => {
+ if (!pendingMerge) return;
+ const name =
+ newFolderName.trim() ||
+ generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
+ const folderId = `folder-${Date.now()}`;
+ setFolders((prev) => [
+ ...prev,
+ {
+ id: folderId,
+ name,
+ documents: [
+ ...pendingMerge.sources.map((d) => ({ ...d, folderId })),
+ { ...pendingMerge.target, folderId },
+ ],
+ },
+ ]);
+ setPendingMerge(null);
+ setNewFolderName('');
+ setShowHint(false);
+ selection.clear();
+ }, [pendingMerge, newFolderName, selection]);
- const handleFolderDrop = useCallback((e: DragEvent, folderId: string) => {
- e.preventDefault();
- if (!draggedDoc || draggedDoc.folderId) return;
+ const createManualFolder = useCallback(() => {
+ const name = newFolderName.trim() || `New Folder`;
+ const folderId = `folder-${Date.now()}`;
+ setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
+ setNewFolderName('');
+ setManualFolderPrompt(false);
+ }, [newFolderName]);
- // Add document to existing folder
- setFolders(folders.map(f => {
- if (f.id === folderId && !f.documents.some(d => d.id === draggedDoc.id)) {
- return {
- ...f,
- documents: [...f.documents, { ...draggedDoc, folderId }]
- };
- }
- return f;
- }));
-
- setDraggedDoc(null);
- setDropTargetDoc(null);
- }, [draggedDoc, folders]);
+ const handleDeleteFolder = useCallback((folderId: string) => {
+ setFolders((prev) => prev.filter((f) => f.id !== folderId));
+ 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);
- }
+ if (next.has(folderId)) next.delete(folderId);
+ else next.add(folderId);
return next;
});
}, []);
- const createFolder = useCallback(() => {
- if (!pendingFolderDocs) return;
+ // Status bar summary.
+ const summary = useMemo(() => {
+ const parts: string[] = [];
+ if (counts.pdf) parts.push(`${counts.pdf} PDF${counts.pdf === 1 ? '' : 's'}`);
+ if (counts.epub) parts.push(`${counts.epub} EPUB${counts.epub === 1 ? '' : 's'}`);
+ if (counts.html) parts.push(`${counts.html} HTML${counts.html === 1 ? '' : 's'}`);
+ return parts.join(' • ');
+ }, [counts]);
- const folderName = newFolderName.trim() ||
- generateDefaultFolderName(pendingFolderDocs.source, pendingFolderDocs.target);
-
- const folderId = `folder-${Date.now()}`;
- setFolders(prev => [
- ...prev,
- {
- id: folderId,
- name: folderName,
- documents: [
- { ...pendingFolderDocs.source, folderId },
- { ...pendingFolderDocs.target, folderId }
- ]
- }
- ]);
-
- setPendingFolderDocs(null);
- setNewFolderName('');
- setDropTargetDoc(null);
- setDraggedDoc(null);
- setShowHint(false);
- }, [pendingFolderDocs, newFolderName]);
-
- const handleFolderNameKeyDown = useCallback((e: KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- createFolder();
- } else if (e.key === 'Escape') {
- setPendingFolderDocs(null);
- setNewFolderName('');
- }
- }, [createFolder]);
+ const totalBytes = useMemo(
+ () => allDocuments.reduce((acc, d) => acc + d.size, 0),
+ [allDocuments],
+ );
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
- return ;
+ return ;
}
if (allDocuments.length === 0) {
- return No documents uploaded yet
;
+ return (
+
+
+
+ );
}
- const unfolderedDocuments = allDocuments.filter(
- doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
- );
-
- // Build compact summary (counts per type + total size)
- const summaryParts: string[] = [];
- if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
- if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
- if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
- const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
+ const fallbackViewMode: ViewMode =
+ viewMode === 'columns' && isNarrow ? 'list' : viewMode;
return (
-
-
-
-
Your Documents
- setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
- viewMode={viewMode}
- onViewModeChange={setViewMode}
- />
-
-
-
- {summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
-
-
-
-
-
- {showHint && allDocuments.length > 1 && (
-
-
Drag files on top of each other to make folders
-