From 7a88dabc53862fddeeb7be7df4ef1608fe91a9c0 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 18:59:13 -0600 Subject: [PATCH 01/31] refactor(doclist): redesign document list with new views, DnD, and windowed UI Revamp the document list experience by introducing a Finder-style window interface with multiple views (icons, list, columns, gallery) and a new sidebar filter system. Remove legacy folder and list item components in favor of modular, windowed views. Integrate react-dnd-touch-backend and custom DnD context for improved drag-and-drop, including mobile support. Update uploader and dialog styles for consistency. Extend document types to support new view modes, icon sizing, and sidebar state. --- package.json | 1 + pnpm-lock.yaml | 11 + src/app/(app)/app/page.tsx | 24 +- src/app/(app)/layout.tsx | 4 +- src/components/HomeContent.tsx | 38 +- src/components/doclist/CreateFolderDialog.tsx | 2 +- src/components/doclist/DocumentFolder.tsx | 149 ---- src/components/doclist/DocumentList.tsx | 843 +++++++++++------- src/components/doclist/DocumentListItem.tsx | 176 ---- src/components/doclist/SortControls.tsx | 88 -- .../doclist/dnd/DocumentDndProvider.tsx | 60 ++ .../doclist/dnd/DocumentSelectionContext.tsx | 123 +++ src/components/doclist/dnd/dndTypes.ts | 18 + src/components/doclist/views/ColumnsView.tsx | 241 +++++ src/components/doclist/views/DocumentTile.tsx | 198 ++++ src/components/doclist/views/GalleryView.tsx | 232 +++++ src/components/doclist/views/IconsView.tsx | 195 ++++ src/components/doclist/views/ListView.tsx | 357 ++++++++ .../doclist/window/FinderSidebar.tsx | 237 +++++ .../doclist/window/FinderStatusBar.tsx | 38 + .../doclist/window/FinderToolbar.tsx | 230 +++++ .../doclist/window/FinderWindow.tsx | 100 +++ src/components/doclist/window/finderIcons.tsx | 107 +++ src/components/documents/DocumentUploader.tsx | 40 +- src/types/documents.ts | 13 +- 25 files changed, 2748 insertions(+), 777 deletions(-) delete mode 100644 src/components/doclist/DocumentFolder.tsx delete mode 100644 src/components/doclist/DocumentListItem.tsx delete mode 100644 src/components/doclist/SortControls.tsx create mode 100644 src/components/doclist/dnd/DocumentDndProvider.tsx create mode 100644 src/components/doclist/dnd/DocumentSelectionContext.tsx create mode 100644 src/components/doclist/dnd/dndTypes.ts create mode 100644 src/components/doclist/views/ColumnsView.tsx create mode 100644 src/components/doclist/views/DocumentTile.tsx create mode 100644 src/components/doclist/views/GalleryView.tsx create mode 100644 src/components/doclist/views/IconsView.tsx create mode 100644 src/components/doclist/views/ListView.tsx create mode 100644 src/components/doclist/window/FinderSidebar.tsx create mode 100644 src/components/doclist/window/FinderStatusBar.tsx create mode 100644 src/components/doclist/window/FinderToolbar.tsx create mode 100644 src/components/doclist/window/FinderWindow.tsx create mode 100644 src/components/doclist/window/finderIcons.tsx diff --git a/package.json b/package.json index f509a63..7e7ffb4 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react": "^19.2.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.2.6", "react-dropzone": "^14.4.1", "react-hot-toast": "^2.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cebe16..f15c27b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 + react-dnd-touch-backend: + specifier: ^16.0.1 + version: 16.0.1 react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) @@ -4249,6 +4252,9 @@ packages: react-dnd-html5-backend@16.0.1: resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} + react-dnd-touch-backend@16.0.1: + resolution: {integrity: sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==} + react-dnd@16.0.1: resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} peerDependencies: @@ -9307,6 +9313,11 @@ snapshots: dependencies: dnd-core: 16.0.1 + react-dnd-touch-backend@16.0.1: + dependencies: + '@react-dnd/invariant': 4.0.2 + dnd-core: 16.0.1 + react-dnd@16.0.1(@types/node@20.19.41)(@types/react@19.2.14)(react@19.2.6): dependencies: '@react-dnd/invariant': 4.0.2 diff --git a/src/app/(app)/app/page.tsx b/src/app/(app)/app/page.tsx index 402c51e..e5e5ce4 100644 --- a/src/app/(app)/app/page.tsx +++ b/src/app/(app)/app/page.tsx @@ -1,30 +1,12 @@ -import { Header } from '@/components/Header'; import { HomeContent } from '@/components/HomeContent'; -import { SettingsModal } from '@/components/SettingsModal'; -import { UserMenu } from '@/components/auth/UserMenu'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; export default function Home() { return (
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - -

OpenReader

-
- } - right={ -
- - -
- } - /> -
-
- +
+ +
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 7cb8cb8..c554046 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -34,8 +34,8 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > -
-
{children}
+
+
{children}
( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +

+ OpenReader +

+
+); + +const AppActions = () => ( + <> + + + +); + export function HomeContent() { const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments(); const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0); if (isPDFLoading) { return ( -
- +
+
} right={} /> +
+ +
); } if (totalDocs === 0) { return ( -
- +
+
} right={} /> +
+ +
); } return ( -
- +
+ } appActions={} />
); } 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

- +
- )} - -
- - {folders.map(folder => ( - setFolders(prev => prev.filter(f => f.id !== folder.id))} - sortedDocuments={sortDocuments(folder.documents)} - onDocumentDelete={setDocumentToDelete} - draggedDoc={draggedDoc} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDrop={handleFolderDrop} - viewMode={viewMode} - /> - ))} - - {sortDocuments(unfolderedDocuments).map(doc => ( - - ))}
+ )} - setPendingFolderDocs(null)} - folderName={newFolderName} - onFolderNameChange={setNewFolderName} - onKeyDown={handleFolderNameKeyDown} + {fallbackViewMode === 'icons' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} /> + )} + {fallbackViewMode === 'list' && ( + { + setSortBy(b); + setSortDirection(d); + }} + collapsedFolders={collapsedFolders} + onToggleCollapse={toggleFolderCollapse} + onDeleteFolder={handleDeleteFolder} + onDeleteDoc={(doc) => + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'columns' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'gallery' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} - setDocumentToDelete(null)} - onConfirm={handleDelete} - title="Delete Document" - message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`} - confirmText="Delete" - isDangerous={true} - /> -
-
+ setPendingMerge(null)} + folderName={newFolderName} + onFolderNameChange={setNewFolderName} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + createFolderFromPending(); + } else if (e.key === 'Escape') { + setPendingMerge(null); + setNewFolderName(''); + } + }} + /> + + setManualFolderPrompt(false)} + folderName={newFolderName} + onFolderNameChange={setNewFolderName} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + createManualFolder(); + } else if (e.key === 'Escape') { + setManualFolderPrompt(false); + setNewFolderName(''); + } + }} + /> + + setDocumentToDelete(null)} + onConfirm={handleDelete} + title="Delete Document" + message={`Are you sure you want to delete ${documentToDelete?.name ?? 'this document'}?`} + confirmText="Delete" + isDangerous + /> + + ); +} + +export function DocumentList({ + brand, + appActions, +}: { + brand?: ReactNode; + appActions?: ReactNode; +} = {}) { + return ( + + + + + ); } diff --git a/src/components/doclist/DocumentListItem.tsx b/src/components/doclist/DocumentListItem.tsx deleted file mode 100644 index 8cb029c..0000000 --- a/src/components/doclist/DocumentListItem.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import Link from 'next/link'; -import { DragEvent, useState } from 'react'; -import { Button } from '@headlessui/react'; -import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; -import { DocumentListDocument } from '@/types/documents'; -import { DocumentPreview } from '@/components/doclist/DocumentPreview'; -import { useAuthSession } from '@/hooks/useAuthSession'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; -import { buttonClass } from '@/components/formPrimitives'; - -interface DocumentListItemProps { - doc: DocumentListDocument; - onDelete: (doc: DocumentListDocument) => void; - dragEnabled?: boolean; - onDragStart?: (doc: DocumentListDocument) => void; - onDragEnd?: () => void; - onDragOver?: (e: DragEvent, doc: DocumentListDocument) => void; - onDragLeave?: () => void; - onDrop?: (e: DragEvent, doc: DocumentListDocument) => void; - isDropTarget?: boolean; - viewMode: 'list' | 'grid'; -} - -export function DocumentListItem({ - doc, - onDelete, - dragEnabled = true, - onDragStart, - onDragEnd, - onDragOver, - onDragLeave, - onDrop, - isDropTarget = false, - viewMode, -}: DocumentListItemProps) { - const [loading, setLoading] = useState(false); - const { authEnabled } = useAuthConfig(); - const { data: session } = useAuthSession(); - const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; - - // Only allow drag and drop interactions for documents not in folders - const isDraggable = dragEnabled && !doc.folderId; - const allowDropTarget = !doc.folderId; - const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); - const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); - - const handleDocumentClick = () => { - setLoading(true); - }; - - return ( -
onDragStart?.(doc)} - onDragEnd={onDragEnd} - onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)} - onDragLeave={() => allowDropTarget && onDragLeave?.()} - onDrop={(e) => allowDropTarget && onDrop?.(e, doc)} - aria-busy={loading} - className={ - viewMode === 'grid' - ? ` - flex w-full min-w-0 flex-col - group border border-offbase rounded-md overflow-hidden - transition-colors duration-150 relative bg-base hover:bg-offbase - ${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''} - ${loading ? 'prism-outline' : ''} - ` - : ` - w-full group - ${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''} - ${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'} - border border-offbase rounded-md p-1 - transition-colors duration-150 relative - ` - } - > - {viewMode === 'grid' ? ( - <> - - - -
- -
- {doc.type === 'pdf' ? ( - - ) : doc.type === 'epub' ? ( - - ) : ( - - )} -
-
-

- {doc.name} -

-

- {(doc.size / 1024 / 1024).toFixed(2)} MB -

-
- - {showDeleteButton && ( - - )} -
- - ) : ( -
- -
- {doc.type === 'pdf' ? ( - - ) : doc.type === 'epub' ? ( - - ) : ( - - )} -
-
-

- {doc.name} -

-

- {(doc.size / 1024 / 1024).toFixed(2)} MB -

-
- - {showDeleteButton && ( - - )} -
- )} -
- ); -} diff --git a/src/components/doclist/SortControls.tsx b/src/components/doclist/SortControls.tsx deleted file mode 100644 index 6d29c99..0000000 --- a/src/components/doclist/SortControls.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react'; -import { ChevronUpDownIcon, ListIcon, GridIcon } from '@/components/icons/Icons'; -import { SortBy, SortDirection } from '@/types/documents'; - -interface SortControlsProps { - sortBy: SortBy; - sortDirection: SortDirection; - onSortByChange: (value: SortBy) => void; - onSortDirectionChange: () => void; - viewMode: 'list' | 'grid'; - onViewModeChange: (mode: 'list' | 'grid') => void; -} - -export function SortControls({ - sortBy, - sortDirection, - onSortByChange, - onSortDirectionChange, - viewMode, - onViewModeChange, -}: SortControlsProps) { - const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [ - { value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' }, - { value: 'type', label: 'Type', up: 'A-Z', down: 'Z-A' }, - { value: 'date', label: 'Date', up: 'Newest', down: 'Oldest' }, - { value: 'size', label: 'Size' , up: 'Smallest', down: 'Largest' }, - ]; - - const currentSort = sortOptions.find(opt => opt.value === sortBy); - const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down; - - const buttonBaseClass = "h-6 flex items-center justify-center bg-base hover:bg-offbase rounded border border-transparent hover:border-offbase text-xs sm:text-sm transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"; - const activeIconClass = "text-accent"; - const inactiveIconClass = "text-muted"; - - return ( -
-
- - -
- -
- -
- -
- - - {sortOptions.find(opt => opt.value === sortBy)?.label} - - - - {sortOptions.map((option) => ( - - `relative cursor-pointer select-none py-1.5 px-2 rounded text-xs ${active ? 'bg-offbase text-accent' : 'text-foreground'} ${selected ? 'font-medium' : ''}` - } - > - {option.label} - - ))} - - -
-
-
- ); -} \ No newline at end of file diff --git a/src/components/doclist/dnd/DocumentDndProvider.tsx b/src/components/doclist/dnd/DocumentDndProvider.tsx new file mode 100644 index 0000000..5f5796a --- /dev/null +++ b/src/components/doclist/dnd/DocumentDndProvider.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { TouchBackend } from 'react-dnd-touch-backend'; + +const TOUCH_QUERY = '(hover: none) and (pointer: coarse)'; + +function detectTouchInitial(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.matchMedia(TOUCH_QUERY).matches; + } catch { + return false; + } +} + +/** + * DnD provider that swaps between HTML5 (mouse/keyboard) and Touch backends + * based on the primary input device. Long-press activates a drag on touch. + * + * react-dnd doesn't allow swapping backends after mount, so the subtree is + * remounted with a `key` when the media query changes. That happens at most + * when the user docks/undocks a tablet — fine in practice. + */ +export function DocumentDndProvider({ children }: { children: ReactNode }) { + const [isTouch, setIsTouch] = useState(detectTouchInitial); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(TOUCH_QUERY); + const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + if (isTouch) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/src/components/doclist/dnd/DocumentSelectionContext.tsx b/src/components/doclist/dnd/DocumentSelectionContext.tsx new file mode 100644 index 0000000..137fbae --- /dev/null +++ b/src/components/doclist/dnd/DocumentSelectionContext.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { DocumentListDocument } from '@/types/documents'; + +type DocKey = string; // `${type}-${id}` + +const docKey = (doc: Pick): DocKey => + `${doc.type}-${doc.id}`; + +interface SelectionContextValue { + selection: ReadonlySet; + isSelected: (doc: Pick) => boolean; + selectionSize: number; + /** Treat the visible-doc order so shift-click range-select can resolve. */ + setVisibleOrder: (docs: DocumentListDocument[]) => void; + /** Click semantics: with shift = range-select, with meta/ctrl = toggle, plain = single-select. */ + select: ( + doc: DocumentListDocument, + opts?: { shift?: boolean; meta?: boolean }, + ) => void; + clear: () => void; + /** Force a precise selection (e.g. on drag start when nothing was selected). */ + replace: (docs: DocumentListDocument[]) => void; + /** Resolve concrete docs for the current selection from the visible-order. */ + getSelectedDocs: () => DocumentListDocument[]; +} + +const SelectionContext = createContext(null); + +export function DocumentSelectionProvider({ children }: { children: ReactNode }) { + const [selection, setSelection] = useState>(() => new Set()); + const [order, setOrder] = useState([]); + const [anchor, setAnchor] = useState(null); + + const isSelected = useCallback( + (doc: Pick) => selection.has(docKey(doc)), + [selection], + ); + + const setVisibleOrder = useCallback((docs: DocumentListDocument[]) => { + setOrder(docs); + }, []); + + const select = useCallback( + (doc, opts) => { + const key = docKey(doc); + if (opts?.shift && anchor && order.length > 0) { + const anchorIdx = order.findIndex((d) => docKey(d) === anchor); + const targetIdx = order.findIndex((d) => docKey(d) === key); + if (anchorIdx >= 0 && targetIdx >= 0) { + const [lo, hi] = + anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]; + const next = new Set(); + for (let i = lo; i <= hi; i++) next.add(docKey(order[i])); + setSelection(next); + return; + } + } + if (opts?.meta) { + setSelection((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + setAnchor(key); + return; + } + setSelection(new Set([key])); + setAnchor(key); + }, + [anchor, order], + ); + + const clear = useCallback(() => { + setSelection(new Set()); + setAnchor(null); + }, []); + + const replace = useCallback((docs: DocumentListDocument[]) => { + const next = new Set(); + for (const d of docs) next.add(docKey(d)); + setSelection(next); + setAnchor(docs[0] ? docKey(docs[0]) : null); + }, []); + + const getSelectedDocs = useCallback(() => { + if (selection.size === 0 || order.length === 0) return []; + return order.filter((d) => selection.has(docKey(d))); + }, [selection, order]); + + const value = useMemo( + () => ({ + selection, + isSelected, + selectionSize: selection.size, + setVisibleOrder, + select, + clear, + replace, + getSelectedDocs, + }), + [selection, isSelected, setVisibleOrder, select, clear, replace, getSelectedDocs], + ); + + return {children}; +} + +export function useDocumentSelection(): SelectionContextValue { + const ctx = useContext(SelectionContext); + if (!ctx) { + throw new Error('useDocumentSelection must be used inside DocumentSelectionProvider'); + } + return ctx; +} diff --git a/src/components/doclist/dnd/dndTypes.ts b/src/components/doclist/dnd/dndTypes.ts new file mode 100644 index 0000000..077690c --- /dev/null +++ b/src/components/doclist/dnd/dndTypes.ts @@ -0,0 +1,18 @@ +import type { DocumentListDocument } from '@/types/documents'; + +export const DND_DOCUMENT = 'openreader/document' as const; + +export interface DocumentDragItem { + /** Doc ids being dragged together (may be a single id). */ + ids: string[]; + /** Concrete doc records for the dragged ids — used for previews and folder hints. */ + docs: DocumentListDocument[]; + /** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */ + fromFolderId?: string; +} + +export type DropTarget = + | { kind: 'document'; id: string } + | { kind: 'folder'; id: string } + | { kind: 'sidebar-folder'; id: string } + | { kind: 'pane'; id: 'unfoldered' }; diff --git a/src/components/doclist/views/ColumnsView.tsx b/src/components/doclist/views/ColumnsView.tsx new file mode 100644 index 0000000..0af734b --- /dev/null +++ b/src/components/doclist/views/ColumnsView.tsx @@ -0,0 +1,241 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; +import type { DocumentListDocument, Folder } from '@/types/documents'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { FolderIcon, ChevronRightSmall } from '../window/finderIcons'; +import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface ColumnsViewProps { + folders: Folder[]; + unfolderedDocs: DocumentListDocument[]; + onDeleteDoc: (doc: DocumentListDocument) => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +type Selected = + | { kind: 'folder'; id: string } + | { kind: 'doc'; doc: DocumentListDocument } + | null; + +function KindIcon({ doc }: { doc: DocumentListDocument }) { + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; +} + +function ColumnDocRow({ + doc, + active, + onClick, + onMergeIntoFolder, +}: { + doc: DocumentListDocument; + active: boolean; + onClick: () => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const selection = useDocumentSelection(); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + + const [{ isDragging }, dragRef] = useDrag(() => ({ + type: DND_DOCUMENT, + item: () => { + const sel = selection.getSelectedDocs(); + const dragging = isSelected && sel.length > 1 ? sel : [doc]; + if (!isSelected) selection.replace([doc]); + return { ids: dragging.map((d) => d.id), docs: dragging, fromFolderId: doc.folderId }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + }), [doc, isSelected]); + + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => !isInFolder && !item.ids.includes(doc.id), + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + return ( +
+ + {doc.name} +
+ ); +} + +function ColumnFolderRow({ + folder, + active, + onClick, + onDropOnFolder, +}: { + folder: Folder; + active: boolean; + onClick: () => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; +}) { + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id), + drop: (item) => onDropOnFolder(folder.id, item), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [folder.id, onDropOnFolder]); + + return ( +
} + onClick={onClick} + className={ + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-[12px] transition-colors duration-200 ease-out cursor-pointer ' + + (active + ? 'bg-accent text-background' + : 'text-foreground hover:bg-offbase') + + (isOver && canDrop ? ' ring-1 ring-accent' : '') + } + > + + {folder.name} + + {folder.documents.length} + + +
+ ); +} + +function Column({ children, title }: { children: React.ReactNode; title?: string }) { + return ( +
+ {title && ( +
+ {title} +
+ )} +
{children}
+
+ ); +} + +export function ColumnsView({ + folders, + unfolderedDocs, + onDeleteDoc, + onDropOnFolder, + onMergeIntoFolder, +}: ColumnsViewProps) { + const { setVisibleOrder } = useDocumentSelection(); + const [selected, setSelected] = useState(null); + + useEffect(() => { + const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs]; + setVisibleOrder(all); + }, [folders, unfolderedDocs, setVisibleOrder]); + + // Drop selection if it no longer exists. + useEffect(() => { + if (!selected) return; + if (selected.kind === 'folder' && !folders.find((f) => f.id === selected.id)) { + setSelected(null); + } + }, [folders, selected]); + + const selectedFolder = + selected?.kind === 'folder' ? folders.find((f) => f.id === selected.id) : undefined; + + return ( +
+ + {folders.map((f) => ( + setSelected({ kind: 'folder', id: f.id })} + onDropOnFolder={onDropOnFolder} + /> + ))} + {unfolderedDocs.map((d) => ( + setSelected({ kind: 'doc', doc: d })} + onMergeIntoFolder={onMergeIntoFolder} + /> + ))} + + + {selectedFolder && ( + + {selectedFolder.documents.length === 0 && ( +

Empty folder

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

+ {selected.doc.name} +

+

+ {selected.doc.type.toUpperCase()} • {(selected.doc.size / 1024 / 1024).toFixed(2)} MB +

+
+ + Open + + +
+
+
+ )} +
+ ); +} diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx new file mode 100644 index 0000000..c9b5b32 --- /dev/null +++ b/src/components/doclist/views/DocumentTile.tsx @@ -0,0 +1,198 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; +import { Button } from '@headlessui/react'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import type { DocumentListDocument, IconSize } from '@/types/documents'; +import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { buttonClass } from '@/components/formPrimitives'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface DocumentTileProps { + doc: DocumentListDocument; + iconSize: IconSize; + onDelete: (doc: DocumentListDocument) => void; + /** Fired when two unfoldered docs are dropped together → caller should open a "create folder" dialog. */ + onMergeIntoFolder: (source: DocumentListDocument[], target: DocumentListDocument) => void; +} + +const SIZE_LABEL: Record = { sm: 'S', md: 'M', lg: 'L', xl: 'XL' }; +void SIZE_LABEL; + +export function DocumentTile({ + doc, + iconSize, + onDelete, + onMergeIntoFolder, +}: DocumentTileProps) { + const [loading, setLoading] = useState(false); + const { authEnabled } = useAuthConfig(); + const { data: session } = useAuthSession(); + const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; + const selection = useDocumentSelection(); + + const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); + const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + + const [{ isDragging }, dragRef, previewRef] = useDrag< + DocumentDragItem, + void, + { isDragging: boolean } + >(() => { + return { + type: DND_DOCUMENT, + item: () => { + // If the dragged doc is selected and there are multiple selected, drag the group. + const selected = selection.getSelectedDocs(); + const dragging = isSelected && selected.length > 1 + ? selected + : [doc]; + // Reflect the actual drag in the selection so visuals match. + if (!isSelected) selection.replace([doc]); + return { + ids: dragging.map((d) => d.id), + docs: dragging, + fromFolderId: doc.folderId, + }; + }, + collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }), + }; + }, [doc, isSelected]); + + const [{ isOver, canDrop }, dropRef] = useDrop< + DocumentDragItem, + void, + { isOver: boolean; canDrop: boolean } + >(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => { + // Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself. + if (isInFolder) return false; + return !item.ids.includes(doc.id); + }, + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const isDropTarget = isOver && canDrop; + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + previewRef(node); + }; + + const handleClick: React.MouseEventHandler = (e) => { + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault(); + selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); + return; + } + selection.replace([doc]); + setLoading(true); + }; + + // Cap any stuck loading flag if the user navigates back. + useEffect(() => { + if (!loading) return; + const t = setTimeout(() => setLoading(false), 2500); + return () => clearTimeout(t); + }, [loading]); + + const sizeClasses: Record = { + sm: 'text-[10px]', + md: 'text-[12px]', + lg: 'text-[13px]', + xl: 'text-[14px]', + }; + + return ( +
+ + + +
+ + + {doc.type === 'pdf' ? ( + + ) : doc.type === 'epub' ? ( + + ) : ( + + )} + + + + {doc.name} + + + {(doc.size / 1024 / 1024).toFixed(2)} MB + + + + {showDeleteButton && ( + + )} +
+
+ ); +} diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx new file mode 100644 index 0000000..1b77cc7 --- /dev/null +++ b/src/components/doclist/views/GalleryView.tsx @@ -0,0 +1,232 @@ +'use client'; + +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 { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { FolderIcon } from '../window/finderIcons'; +import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface GalleryViewProps { + folders: Folder[]; + unfolderedDocs: DocumentListDocument[]; + onDeleteDoc: (doc: DocumentListDocument) => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) { + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; +} + +function GalleryThumb({ + doc, + active, + onClick, + onMergeIntoFolder, +}: { + doc: DocumentListDocument; + active: boolean; + onClick: () => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const selection = useDocumentSelection(); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + + const [{ isDragging }, dragRef] = useDrag(() => ({ + type: DND_DOCUMENT, + item: () => { + const sel = selection.getSelectedDocs(); + const dragging = isSelected && sel.length > 1 ? sel : [doc]; + if (!isSelected) selection.replace([doc]); + return { ids: dragging.map((d) => d.id), docs: dragging, fromFolderId: doc.folderId }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + }), [doc, isSelected]); + + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => !isInFolder && !item.ids.includes(doc.id), + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + return ( +
+
+ +
+
+ + {doc.name} +
+
+ ); +} + +function GalleryFolderThumb({ + folder, + active, + onClick, + onDropOnFolder, +}: { + folder: Folder; + active: boolean; + onClick: () => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; +}) { + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id), + drop: (item) => onDropOnFolder(folder.id, item), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [folder.id, onDropOnFolder]); + + return ( +
} + onClick={onClick} + className={ + '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' : '') + } + > +
+ +
+
+ {folder.name} + {folder.documents.length} items +
+
+ ); +} + +export function GalleryView({ + folders, + unfolderedDocs, + onDeleteDoc, + onDropOnFolder, + onMergeIntoFolder, +}: GalleryViewProps) { + const { setVisibleOrder } = useDocumentSelection(); + const allDocs = useMemo( + () => [...folders.flatMap((f) => f.documents), ...unfolderedDocs], + [folders, unfolderedDocs], + ); + const [activeIdx, setActiveIdx] = useState(0); + + useEffect(() => { + setVisibleOrder(allDocs); + }, [allDocs, setVisibleOrder]); + + useEffect(() => { + if (activeIdx >= allDocs.length) setActiveIdx(Math.max(0, allDocs.length - 1)); + }, [allDocs.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)); + } 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]; + + return ( +
+
+ {activeDoc ? ( +
+
+ +
+
+

+ {activeDoc.name} +

+

+ {activeDoc.type.toUpperCase()} •{' '} + {(activeDoc.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ + Open + + +
+
+ ) : ( +

No documents to show

+ )} +
+ +
+
+ {folders.map((f) => ( + { /* could expand later */ }} + onDropOnFolder={onDropOnFolder} + /> + ))} + {allDocs.map((doc, i) => ( + setActiveIdx(i)} + onMergeIntoFolder={onMergeIntoFolder} + /> + ))} +
+
+
+ ); +} diff --git a/src/components/doclist/views/IconsView.tsx b/src/components/doclist/views/IconsView.tsx new file mode 100644 index 0000000..96e9f76 --- /dev/null +++ b/src/components/doclist/views/IconsView.tsx @@ -0,0 +1,195 @@ +'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 { Button } from '@headlessui/react'; +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[]; + iconSize: IconSize; + collapsedFolders: Set; + onToggleCollapse: (folderId: string) => void; + onDeleteFolder: (folderId: string) => void; + onDeleteDoc: (doc: DocumentListDocument) => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +const COLS: Record = { + sm: 'grid-cols-3 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-8', + md: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6', + lg: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5', + xl: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4', +}; + +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 ( +
} + className={ + 'col-span-full rounded-md border border-offbase overflow-hidden bg-base ' + + (isTarget ? 'ring-1 ring-accent' : '') + } + > +
+ + +
+ +
+ {folder.documents.map((doc) => ( + + ))} +
+
+
+ ); +} + +export function IconsView({ + folders, + unfolderedDocs, + 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]); + + const handleBackgroundClick: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; + clear(); + }; + + return ( +
+
+ {folders.map((folder) => ( + onToggleCollapse(folder.id)} + onDelete={() => onDeleteFolder(folder.id)} + iconSize={iconSize} + onDropOnFolder={onDropOnFolder} + onDeleteDoc={onDeleteDoc} + onMergeIntoFolder={onMergeIntoFolder} + /> + ))} + {unfolderedDocs.map((doc) => ( + + ))} +
+
+ ); +} diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx new file mode 100644 index 0000000..1233184 --- /dev/null +++ b/src/components/doclist/views/ListView.tsx @@ -0,0 +1,357 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; +import { Button, Transition } from '@headlessui/react'; +import type { + DocumentListDocument, + Folder, + SortBy, + SortDirection, +} from '@/types/documents'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { FolderIcon } from '../window/finderIcons'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface ListViewProps { + folders: Folder[]; + unfolderedDocs: DocumentListDocument[]; + sortBy: SortBy; + sortDirection: SortDirection; + onSortChange: (sortBy: SortBy, direction: SortDirection) => void; + collapsedFolders: Set; + onToggleCollapse: (folderId: string) => void; + onDeleteFolder: (folderId: string) => void; + onDeleteDoc: (doc: DocumentListDocument) => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +function formatDate(ms: number): string { + const d = new Date(ms); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function formatSize(bytes: number): string { + const mb = bytes / 1024 / 1024; + return mb >= 1 ? `${mb.toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`; +} + +function KindIcon({ doc }: { doc: DocumentListDocument }) { + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; +} + +function HeaderCell({ + label, + field, + sortBy, + sortDirection, + onSortChange, + className, + align = 'left', +}: { + label: string; + field: SortBy; + sortBy: SortBy; + sortDirection: SortDirection; + onSortChange: (b: SortBy, d: SortDirection) => void; + className?: string; + align?: 'left' | 'right'; +}) { + const active = sortBy === field; + const arrow = active ? (sortDirection === 'asc' ? '↑' : '↓') : ''; + return ( + + ); +} + +function DocRow({ + doc, + isFirstColumnIndented, + onDeleteDoc, + onMergeIntoFolder, +}: { + doc: DocumentListDocument; + isFirstColumnIndented?: boolean; + onDeleteDoc: (d: DocumentListDocument) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const selection = useDocumentSelection(); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + const [loading, setLoading] = useState(false); + const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; + + const [{ isDragging }, dragRef] = useDrag(() => ({ + type: DND_DOCUMENT, + item: () => { + const selected = selection.getSelectedDocs(); + const dragging = isSelected && selected.length > 1 ? selected : [doc]; + if (!isSelected) selection.replace([doc]); + return { + ids: dragging.map((d) => d.id), + docs: dragging, + fromFolderId: doc.folderId, + }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + }), [doc, isSelected]); + + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => !isInFolder && !item.ids.includes(doc.id), + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + useEffect(() => { + if (!loading) return; + const t = setTimeout(() => setLoading(false), 2500); + return () => clearTimeout(t); + }, [loading]); + + const isTarget = isOver && canDrop; + + const handleClick: React.MouseEventHandler = (e) => { + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault(); + selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); + return; + } + selection.replace([doc]); + setLoading(true); + }; + + return ( +
+ + + {doc.name} + + {doc.type} + + {formatSize(doc.size)} + + + {formatDate(doc.lastModified)} + + +
+ ); +} + +function FolderRowGroup({ + folder, + collapsed, + onToggleCollapse, + onDelete, + onDeleteDoc, + onDropOnFolder, + onMergeIntoFolder, +}: { + folder: Folder; + collapsed: boolean; + onToggleCollapse: () => void; + onDelete: () => void; + onDeleteDoc: (d: DocumentListDocument) => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id), + drop: (item) => onDropOnFolder(folder.id, item), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [folder.id, onDropOnFolder]); + + const isTarget = isOver && canDrop; + const totalSize = folder.documents.reduce((acc, d) => acc + d.size, 0); + + return ( +
}> +
+ + Folder + + {formatSize(totalSize)} + + + {folder.documents.length} items + + +
+ +
+ {folder.documents.map((doc) => ( + + ))} +
+
+
+ ); +} + +export function ListView({ + folders, + unfolderedDocs, + 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]); + + const handleBackgroundClick: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; + clear(); + }; + + return ( +
+
+ + + + + +
+
+ {folders.map((folder) => ( + onToggleCollapse(folder.id)} + onDelete={() => onDeleteFolder(folder.id)} + onDeleteDoc={onDeleteDoc} + onDropOnFolder={onDropOnFolder} + onMergeIntoFolder={onMergeIntoFolder} + /> + ))} + {unfolderedDocs.map((doc) => ( + + ))} +
+
+ ); +} diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx new file mode 100644 index 0000000..5298520 --- /dev/null +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useRef, 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 { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface FinderSidebarProps { + filter: SidebarFilter; + onFilterChange: (filter: SidebarFilter) => void; + folders: Folder[]; + counts: { all: number; pdf: number; epub: number; html: number }; + /** When dragging onto a folder row, move dropped docs into that folder. */ + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + /** Width controls (desktop only). */ + width: number; + onWidthChange: (px: number) => void; + topSlot?: ReactNode; +} + +const MIN_WIDTH = 168; +const MAX_WIDTH = 320; + +interface SidebarRowProps { + active: boolean; + onClick: () => void; + icon: ReactNode; + label: string; + count?: number; + trailing?: ReactNode; + isDropTarget?: boolean; +} + +function SidebarRow({ + active, + onClick, + icon, + label, + count, + trailing, + isDropTarget, +}: SidebarRowProps) { + return ( + + ); +} + +function FolderRow({ + 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, + drop: (item) => { + onDropOnFolder(folder.id, item); + }, + canDrop: (item) => { + // Don't accept if all items are already in this folder. + return item.docs.some((d) => d.folderId !== folder.id); + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), [folder.id, onDropOnFolder]); + + const isDropTarget = isOver && canDrop; + return ( +
}> + } + label={folder.name} + count={folder.documents.length} + isDropTarget={isDropTarget} + /> +
+ ); +} + +function SectionLabel({ children, isFirst }: { children: ReactNode; isFirst?: boolean }) { + return ( +

+ {children} +

+ ); +} + +export function FinderSidebar({ + filter, + onFilterChange, + folders, + counts, + onDropOnFolder, + width, + onWidthChange, + topSlot, +}: FinderSidebarProps) { + const startRef = useRef<{ x: number; w: number } | null>(null); + + const onResizeStart = (e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + startRef.current = { x: e.clientX, w: width }; + }; + const onResizeMove = (e: React.PointerEvent) => { + if (!startRef.current) return; + const delta = e.clientX - startRef.current.x; + const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startRef.current.w + delta)); + onWidthChange(next); + }; + const onResizeEnd = (e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + startRef.current = null; + }; + + return ( + + ); +} + diff --git a/src/components/doclist/window/FinderStatusBar.tsx b/src/components/doclist/window/FinderStatusBar.tsx new file mode 100644 index 0000000..62c351f --- /dev/null +++ b/src/components/doclist/window/FinderStatusBar.tsx @@ -0,0 +1,38 @@ +'use client'; + +interface FinderStatusBarProps { + itemCount: number; + selectedCount: number; + totalSize: number; + summary?: string; +} + +function formatSize(bytes: number): string { + const mb = bytes / 1024 / 1024; + if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`; + return `${mb.toFixed(2)} MB`; +} + +export function FinderStatusBar({ + itemCount, + selectedCount, + totalSize, + summary, +}: FinderStatusBarProps) { + return ( +
+ {summary} + + {selectedCount > 0 + ? `${selectedCount} of ${itemCount} selected` + : `${itemCount} item${itemCount === 1 ? '' : 's'}`} + + {formatSize(totalSize)} + +
+ ); +} diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx new file mode 100644 index 0000000..d929043 --- /dev/null +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents'; +import { + IconsViewIcon, + ListViewIcon, + ColumnsViewIcon, + GalleryViewIcon, + SearchIcon, + FolderPlusIcon, + HamburgerIcon, +} from './finderIcons'; +import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import type { ReactNode } from 'react'; + +interface FinderToolbarProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; + iconSize: IconSize; + onIconSizeChange: (size: IconSize) => void; + sortBy: SortBy; + sortDirection: SortDirection; + onSortByChange: (s: SortBy) => void; + onSortDirectionToggle: () => void; + query: string; + onQueryChange: (q: string) => void; + onNewFolder: () => void; + onToggleSidebar: () => void; + /** True when the columns view should be disabled (mobile viewport). */ + isNarrow: boolean; + /** App-level content rendered at the far left (brand/logo). */ + leftSlot?: ReactNode; + /** App-level content rendered at the far right (settings, user menu). */ + rightSlot?: ReactNode; +} + +const VIEW_BUTTONS: Array<{ value: ViewMode; label: string; Icon: typeof IconsViewIcon }> = [ + { value: 'icons', label: 'Icons', Icon: IconsViewIcon }, + { value: 'list', label: 'List', Icon: ListViewIcon }, + { value: 'columns', label: 'Columns', Icon: ColumnsViewIcon }, + { value: 'gallery', label: 'Gallery', Icon: GalleryViewIcon }, +]; + +const SORT_OPTIONS: Array<{ value: SortBy; label: string; asc: string; desc: string }> = [ + { value: 'name', label: 'Name', asc: 'A → Z', desc: 'Z → A' }, + { value: 'type', label: 'Kind', asc: 'A → Z', desc: 'Z → A' }, + { value: 'date', label: 'Modified', asc: 'Oldest', desc: 'Newest' }, + { value: 'size', label: 'Size', asc: 'Smallest', desc: 'Largest' }, +]; + +const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ + { value: 'sm', label: 'S' }, + { value: 'md', label: 'M' }, + { value: 'lg', label: 'L' }, + { value: 'xl', label: 'XL' }, +]; + +// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. +const TOOLBAR_BTN = + 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.02]'; +const TOOLBAR_BTN_INACTIVE = + 'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase'; +const TOOLBAR_BTN_ACTIVE = 'border-accent text-accent bg-offbase'; + +// Pill-grouped segmented control. Outer pill carries the border; inner segments are +// borderless and rely on bg/text color to show active/hover. Sized so the whole pill +// matches the height of a standalone TOOLBAR_BTN. +const PILL = 'inline-flex items-center rounded-md border border-offbase bg-base p-0.5 gap-0.5 shrink-0'; +const PILL_SEGMENT = + 'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out'; +const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent'; +const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent'; + +export function FinderToolbar({ + viewMode, + onViewModeChange, + iconSize, + onIconSizeChange, + sortBy, + sortDirection, + onSortByChange, + onSortDirectionToggle, + query, + onQueryChange, + onNewFolder, + onToggleSidebar, + isNarrow, + leftSlot, + rightSlot, +}: FinderToolbarProps) { + const currentSort = SORT_OPTIONS.find((o) => o.value === sortBy) ?? SORT_OPTIONS[0]; + const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; + + return ( +
+ {leftSlot && ( +
+ {leftSlot} +
+ )} + + + +
+ {VIEW_BUTTONS.map(({ value, label, Icon }) => { + const disabled = value === 'columns' && isNarrow; + const active = viewMode === value; + return ( + + ); + })} +
+ + {viewMode === 'icons' && ( + + )} + +
+ + + + {currentSort.label} + + + + {SORT_OPTIONS.map((opt) => ( + + `cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${ + active ? 'bg-offbase text-accent' : 'text-foreground' + } ${selected ? 'font-semibold' : ''}` + } + > + {opt.label} + + ))} + + +
+ +
+ +
+ + onQueryChange(e.target.value)} + placeholder="Search" + className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted" + /> +
+ + + + {rightSlot && ( +
+ {rightSlot} +
+ )} +
+ ); +} diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx new file mode 100644 index 0000000..ff57a87 --- /dev/null +++ b/src/components/doclist/window/FinderWindow.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment, useEffect, useState, type ReactNode } from 'react'; + +interface FinderWindowProps { + toolbar: ReactNode; + sidebar: ReactNode; + statusBar: ReactNode; + children: ReactNode; + /** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */ + sidebarOpen: boolean; + onSidebarOpenChange: (open: boolean) => void; +} + +const NARROW_QUERY = '(max-width: 767px)'; + +export function useIsNarrow(): boolean { + const [isNarrow, setIsNarrow] = useState(false); + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(NARROW_QUERY); + const update = () => setIsNarrow(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + return isNarrow; +} + +/** + * Finder-style file pane: sidebar + toolbar + content + status bar. + * No separate window chrome — it sits flush under the existing app header. + */ +export function FinderWindow({ + toolbar, + sidebar, + statusBar, + children, + sidebarOpen, + onSidebarOpenChange, +}: FinderWindowProps) { + const isNarrow = useIsNarrow(); + + return ( +
+ {toolbar} + +
+ {!isNarrow && sidebarOpen && ( +
{sidebar}
+ )} + +
+ {children} +
+
+ + {statusBar} + + {/* Mobile drawer */} + + onSidebarOpenChange(false)} + className="relative z-40 md:hidden" + > + +
+ +
+ + onSidebarOpenChange(false)} + > + {sidebar} + + +
+
+
+
+ ); +} diff --git a/src/components/doclist/window/finderIcons.tsx b/src/components/doclist/window/finderIcons.tsx new file mode 100644 index 0000000..b8946a8 --- /dev/null +++ b/src/components/doclist/window/finderIcons.tsx @@ -0,0 +1,107 @@ +import type { SVGProps } from 'react'; + +type IconProps = SVGProps; + +const baseSvg = (props: IconProps) => ({ + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.6, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + width: props.width ?? '1em', + height: props.height ?? '1em', + ...props, +}); + +export const SearchIcon = (props: IconProps) => ( + + + + +); + +export const FolderIcon = (props: IconProps) => ( + + + +); + +export const FolderPlusIcon = (props: IconProps) => ( + + + + +); + +export const SidebarIcon = (props: IconProps) => ( + + + + +); + +export const IconsViewIcon = (props: IconProps) => ( + + + + + + +); + +export const ListViewIcon = (props: IconProps) => ( + + + +); + +export const ColumnsViewIcon = (props: IconProps) => ( + + + + + +); + +export const GalleryViewIcon = (props: IconProps) => ( + + + + + + +); + +export const ChevronRightSmall = (props: IconProps) => ( + + + +); + +export const ChevronLeftSmall = (props: IconProps) => ( + + + +); + +export const HamburgerIcon = (props: IconProps) => ( + + + +); + +export const ClockIcon = (props: IconProps) => ( + + + + +); + +export const HomeIcon = (props: IconProps) => ( + + + + +); diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index b850f4a..efe1afb 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -74,28 +74,46 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume disabled: isUploading || isConverting }); - const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`; - const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3'; + const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ + variant === 'compact' ? 'hover:scale-[1.02]' : '' + } ${ + isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' + } ${className}`; + + const borderBgClass = + variant === 'compact' + ? `${ + isDragActive + ? 'border border-accent bg-offbase text-accent' + : 'border border-dashed border-offbase text-foreground hover:border-accent hover:bg-offbase hover:text-accent' + }` + : `${ + isDragActive + ? 'border-2 border-dashed border-accent bg-base text-foreground' + : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.008]' + }`; + + const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; return (
{variant === 'compact' ? ( -
- +
+ {isUploading ? ( -

Uploading…

+

Uploading…

) : isConverting ? ( -

Converting DOCX…

+

Converting DOCX…

) : ( -
-

- {isDragActive ? 'Drop files here' : 'Drop files or click'} +

+

+ {isDragActive ? 'Drop files here' : 'Upload documents'}

- {error &&

{error}

} + {error &&

{error}

}
)}
diff --git a/src/types/documents.ts b/src/types/documents.ts index 3fa90c7..5d2b61b 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -60,13 +60,24 @@ export interface Folder { export type SortBy = 'name' | 'type' | 'date' | 'size'; export type SortDirection = 'asc' | 'desc'; +export type ViewMode = 'icons' | 'list' | 'columns' | 'gallery'; +export type IconSize = 'sm' | 'md' | 'lg' | 'xl'; + +// Filter applied from the sidebar. +// Examples: 'all', 'recents', 'pdf', 'epub', 'html', or `folder:`. +export type SidebarFilter = string; + export interface DocumentListState { sortBy: SortBy; sortDirection: SortDirection; folders: Folder[]; collapsedFolders: string[]; showHint: boolean; - viewMode?: 'list' | 'grid'; + viewMode?: ViewMode | 'list' | 'grid'; + iconSize?: IconSize; + sidebarWidth?: number; + sidebarFilter?: SidebarFilter; + sidebarCollapsed?: boolean; } export interface LibraryDocument extends BaseDocument { From eb8820d888f8cc12142e37161e0f93d13166715b Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 19:35:03 -0600 Subject: [PATCH 02/31] feat(doclist): enhance document list UI with overlay uploader and improved file size formatting Integrate DocumentUploader in overlay mode to wrap all document views, enabling drag-and-drop uploads across the entire list area. Refine file size formatting to support KB, MB, and GB units in all views and tiles. Standardize button scaling and padding for consistent UI rhythm. Update DocumentPreview and detail panels to display both type and human-readable size. Add overlay drag visual feedback for uploads. These changes improve usability, visual clarity, and consistency in the document list interface. --- src/components/SettingsModal.tsx | 6 +- src/components/auth/UserMenu.tsx | 6 +- src/components/doclist/DocumentList.tsx | 116 +++++++++--------- src/components/doclist/DocumentPreview.tsx | 12 +- src/components/doclist/views/ColumnsView.tsx | 7 +- src/components/doclist/views/DocumentTile.tsx | 92 +++++++++----- src/components/doclist/views/GalleryView.tsx | 6 +- src/components/doclist/views/ListView.tsx | 3 + .../doclist/window/FinderSidebar.tsx | 2 +- .../doclist/window/FinderToolbar.tsx | 3 +- src/components/documents/DocumentUploader.tsx | 39 +++++- 11 files changed, 187 insertions(+), 105 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 4c25005..67b49ec 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -501,11 +501,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { onClick={() => { void requestOpenSettings(); }} - className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.08] ${className}`} + className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`} aria-label="Settings" tabIndex={0} > - + @@ -1447,7 +1447,7 @@ function SettingsChangelogPanel({
{enableUserSignups && ( - @@ -49,7 +49,7 @@ export function UserMenu({ className = '' }: { className?: string }) {
)} - {fallbackViewMode === 'icons' && ( - - setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) - } - onDropOnFolder={handleDropOnFolder} - onMergeIntoFolder={handleMergeIntoFolder} - /> - )} - {fallbackViewMode === 'list' && ( - { - setSortBy(b); - setSortDirection(d); - }} - collapsedFolders={collapsedFolders} - onToggleCollapse={toggleFolderCollapse} - onDeleteFolder={handleDeleteFolder} - onDeleteDoc={(doc) => - setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) - } - onDropOnFolder={handleDropOnFolder} - onMergeIntoFolder={handleMergeIntoFolder} - /> - )} - {fallbackViewMode === 'columns' && ( - - setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) - } - onDropOnFolder={handleDropOnFolder} - onMergeIntoFolder={handleMergeIntoFolder} - /> - )} - {fallbackViewMode === 'gallery' && ( - - setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) - } - onDropOnFolder={handleDropOnFolder} - onMergeIntoFolder={handleMergeIntoFolder} - /> - )} + + {fallbackViewMode === 'icons' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'list' && ( + { + setSortBy(b); + setSortDirection(d); + }} + collapsedFolders={collapsedFolders} + onToggleCollapse={toggleFolderCollapse} + onDeleteFolder={handleDeleteFolder} + onDeleteDoc={(doc) => + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'columns' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'gallery' && ( + + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }) + } + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + )} -
- {isGenerating ? '…' : typeLabel} +
+ {isGenerating + ? '…' + : `${typeLabel} • ${ + doc.size >= 1024 * 1024 * 1024 + ? `${(doc.size / 1024 / 1024 / 1024).toFixed(2)} GB` + : doc.size >= 1024 * 1024 + ? `${(doc.size / 1024 / 1024).toFixed(2)} MB` + : `${(doc.size / 1024).toFixed(1)} KB` + }`}
); diff --git a/src/components/doclist/views/ColumnsView.tsx b/src/components/doclist/views/ColumnsView.tsx index 0af734b..0142433 100644 --- a/src/components/doclist/views/ColumnsView.tsx +++ b/src/components/doclist/views/ColumnsView.tsx @@ -216,7 +216,12 @@ export function ColumnsView({ {selected.doc.name}

- {selected.doc.type.toUpperCase()} • {(selected.doc.size / 1024 / 1024).toFixed(2)} MB + {selected.doc.type.toUpperCase()} •{' '} + {selected.doc.size >= 1024 * 1024 * 1024 + ? `${(selected.doc.size / 1024 / 1024 / 1024).toFixed(2)} GB` + : selected.doc.size >= 1024 * 1024 + ? `${(selected.doc.size / 1024 / 1024).toFixed(2)} MB` + : `${(selected.doc.size / 1024).toFixed(1)} KB`}

= { sm: 'text-[10px]', - md: 'text-[12px]', - lg: 'text-[13px]', - xl: 'text-[14px]', + md: 'text-[11px]', + lg: 'text-[12px]', + xl: 'text-[13px]', + }; + + const bottomPaddingClasses: Record = { + sm: 'px-[7px] py-[4px]', + md: 'px-[8px] py-[5px]', + lg: 'px-[9px] py-[5px]', + xl: 'px-[10px] py-[6px]', + }; + + const linkPaddingClasses: Record = { + sm: 'px-[2px] py-[2px]', + md: 'px-[2px] py-[2px]', + lg: 'px-[2px] py-[2px]', + xl: 'px-[2px] py-[2px]', + }; + + const gapClasses: Record = { + sm: 'gap-1', + md: 'gap-1.5', + lg: 'gap-2', + xl: 'gap-2', + }; + + const fileIconClasses: Record = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', + lg: 'w-3.5 h-3.5', + xl: 'w-4 h-4', + }; + + const trashBtnClasses: Record = { + sm: 'ml-0.5 h-[18px] w-[18px] rounded-sm', + md: 'ml-0.5 h-[21px] w-[21px] rounded-sm', + lg: 'ml-1 h-[23px] w-[23px] rounded', + xl: 'ml-1.5 h-[25px] w-[25px] rounded', + }; + + const trashIconClasses: Record = { + sm: 'w-[10px] h-[10px]', + md: 'w-[11px] h-[11px]', + lg: 'w-[12px] h-[12px]', + xl: 'w-[13px] h-[13px]', }; return ( @@ -140,49 +181,40 @@ export function DocumentTile({ > -
+
- + {doc.type === 'pdf' ? ( - + ) : doc.type === 'epub' ? ( - + ) : ( - + )} - - - {doc.name} - - - {(doc.size / 1024 / 1024).toFixed(2)} MB - + + {doc.name} {showDeleteButton && (
diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx index 1233184..e6bc86a 100644 --- a/src/components/doclist/views/ListView.tsx +++ b/src/components/doclist/views/ListView.tsx @@ -36,6 +36,9 @@ function formatDate(ms: number): string { function formatSize(bytes: number): string { const mb = bytes / 1024 / 1024; + if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } return mb >= 1 ? `${mb.toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`; } diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index 5298520..7de467b 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -47,7 +47,7 @@ function SidebarRow({ type="button" onClick={onClick} className={ - 'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.02] ' + + 'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out 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') + diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index d929043..e40761f 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -58,10 +58,9 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ // Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. const TOOLBAR_BTN = - 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.02]'; + 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]'; const TOOLBAR_BTN_INACTIVE = 'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase'; -const TOOLBAR_BTN_ACTIVE = 'border-accent text-accent bg-offbase'; // Pill-grouped segmented control. Outer pill carries the border; inner segments are // borderless and rely on bg/text color to show active/hover. Sized so the whole pill diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index efe1afb..2ff1766 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, type ReactNode } from 'react'; import { useDropzone } from 'react-dropzone'; import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; @@ -10,10 +10,11 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; interface DocumentUploaderProps { className?: string; - variant?: 'default' | 'compact'; + variant?: 'default' | 'compact' | 'overlay'; + children?: ReactNode; } -export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) { +export function DocumentUploader({ className = '', variant = 'default', children }: DocumentUploaderProps) { const enableDocx = useFeatureFlag('enableDocxConversion'); const { addPDFDocument: addPDF, @@ -71,11 +72,13 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume } : {}) }, multiple: true, - disabled: isUploading || isConverting + disabled: isUploading || isConverting, + noClick: variant === 'overlay', + noKeyboard: variant === 'overlay' }); const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ - variant === 'compact' ? 'hover:scale-[1.02]' : '' + variant === 'compact' ? 'hover:scale-[1.01]' : '' } ${ isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' } ${className}`; @@ -95,6 +98,32 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; + if (variant === 'overlay') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { onClick, onKeyDown, ...rootProps } = getRootProps(); + return ( +
+ + {children} + {isDragActive && ( +
+
+ +

+ Drop files here to upload +

+

+ {enableDocx + ? 'Accepts PDF, EPUB, TXT, MD, or DOCX' + : 'Accepts PDF, EPUB, TXT, or MD'} +

+
+
+ )} +
+ ); + } + return (
Date: Wed, 27 May 2026 19:40:12 -0600 Subject: [PATCH 03/31] cleanup doclist formatting helpers and view props --- src/components/doclist/DocumentList.tsx | 22 ++-- src/components/doclist/DocumentPreview.tsx | 9 +- src/components/doclist/formatSize.ts | 9 ++ src/components/doclist/views/ColumnsView.tsx | 12 +- src/components/doclist/views/DocumentTile.tsx | 110 ++++++++---------- src/components/doclist/views/GalleryView.tsx | 12 +- src/components/doclist/views/ListView.tsx | 13 +-- src/components/documents/DocumentUploader.tsx | 6 +- 8 files changed, 84 insertions(+), 109 deletions(-) create mode 100644 src/components/doclist/formatSize.ts diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 4fd7c2d..0ccbe9e 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -310,6 +310,10 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { } }, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]); + const handleDeleteDoc = useCallback((doc: DocumentListDocument) => { + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }); + }, []); + const handleDropOnFolder = useCallback( (folderId: string, item: DocumentDragItem) => { setFolders((prev) => @@ -478,7 +482,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
); diff --git a/src/components/doclist/formatSize.ts b/src/components/doclist/formatSize.ts new file mode 100644 index 0000000..b3886cf --- /dev/null +++ b/src/components/doclist/formatSize.ts @@ -0,0 +1,9 @@ +export function formatDocumentSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; + } + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + return `${(bytes / 1024).toFixed(1)} KB`; +} diff --git a/src/components/doclist/views/ColumnsView.tsx b/src/components/doclist/views/ColumnsView.tsx index 0142433..507278d 100644 --- a/src/components/doclist/views/ColumnsView.tsx +++ b/src/components/doclist/views/ColumnsView.tsx @@ -7,6 +7,7 @@ import type { DocumentListDocument, Folder } 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'; @@ -216,24 +217,19 @@ export function ColumnsView({ {selected.doc.name}

- {selected.doc.type.toUpperCase()} •{' '} - {selected.doc.size >= 1024 * 1024 * 1024 - ? `${(selected.doc.size / 1024 / 1024 / 1024).toFixed(2)} GB` - : selected.doc.size >= 1024 * 1024 - ? `${(selected.doc.size / 1024 / 1024).toFixed(2)} MB` - : `${(selected.doc.size / 1024).toFixed(1)} KB`} + {selected.doc.type.toUpperCase()} • {formatDocumentSize(selected.doc.size)}

Open diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index a5d5c58..4263a86 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -20,8 +20,49 @@ interface DocumentTileProps { onMergeIntoFolder: (source: DocumentListDocument[], target: DocumentListDocument) => void; } -const SIZE_LABEL: Record = { sm: 'S', md: 'M', lg: 'L', xl: 'XL' }; -void SIZE_LABEL; +const NAME_SIZE_CLASSES: Record = { + sm: 'text-[10px]', + md: 'text-[11px]', + lg: 'text-[12px]', + xl: 'text-[13px]', +}; + +const BOTTOM_PADDING_CLASSES: Record = { + sm: 'px-[7px] py-[4px]', + md: 'px-[8px] py-[5px]', + lg: 'px-[9px] py-[5px]', + xl: 'px-[10px] py-[6px]', +}; + +const LINK_PADDING_CLASS = 'px-[2px] py-[2px]'; + +const GAP_CLASSES: Record = { + sm: 'gap-1', + md: 'gap-1.5', + lg: 'gap-2', + xl: 'gap-2', +}; + +const FILE_ICON_CLASSES: Record = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', + lg: 'w-3.5 h-3.5', + xl: 'w-4 h-4', +}; + +const TRASH_BTN_CLASSES: Record = { + sm: 'ml-0.5 h-[18px] w-[18px] rounded-sm', + md: 'ml-0.5 h-[21px] w-[21px] rounded-sm', + lg: 'ml-1 h-[23px] w-[23px] rounded', + xl: 'ml-1.5 h-[25px] w-[25px] rounded', +}; + +const TRASH_ICON_CLASSES: Record = { + sm: 'w-[10px] h-[10px]', + md: 'w-[11px] h-[11px]', + lg: 'w-[12px] h-[12px]', + xl: 'w-[13px] h-[13px]', +}; export function DocumentTile({ doc, @@ -108,55 +149,6 @@ export function DocumentTile({ return () => clearTimeout(t); }, [loading]); - const sizeClasses: Record = { - sm: 'text-[10px]', - md: 'text-[11px]', - lg: 'text-[12px]', - xl: 'text-[13px]', - }; - - const bottomPaddingClasses: Record = { - sm: 'px-[7px] py-[4px]', - md: 'px-[8px] py-[5px]', - lg: 'px-[9px] py-[5px]', - xl: 'px-[10px] py-[6px]', - }; - - const linkPaddingClasses: Record = { - sm: 'px-[2px] py-[2px]', - md: 'px-[2px] py-[2px]', - lg: 'px-[2px] py-[2px]', - xl: 'px-[2px] py-[2px]', - }; - - const gapClasses: Record = { - sm: 'gap-1', - md: 'gap-1.5', - lg: 'gap-2', - xl: 'gap-2', - }; - - const fileIconClasses: Record = { - sm: 'w-3 h-3', - md: 'w-3.5 h-3.5', - lg: 'w-3.5 h-3.5', - xl: 'w-4 h-4', - }; - - const trashBtnClasses: Record = { - sm: 'ml-0.5 h-[18px] w-[18px] rounded-sm', - md: 'ml-0.5 h-[21px] w-[21px] rounded-sm', - lg: 'ml-1 h-[23px] w-[23px] rounded', - xl: 'ml-1.5 h-[25px] w-[25px] rounded', - }; - - const trashIconClasses: Record = { - sm: 'w-[10px] h-[10px]', - md: 'w-[11px] h-[11px]', - lg: 'w-[12px] h-[12px]', - xl: 'w-[13px] h-[13px]', - }; - return (
-
+
{doc.type === 'pdf' ? ( - + ) : doc.type === 'epub' ? ( - + ) : ( - + )} onDelete(doc)} - className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${trashBtnClasses[iconSize]}`} + className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${TRASH_BTN_CLASSES[iconSize]}`} aria-label={`Delete ${doc.name}`} > - +

- {activeDoc.type.toUpperCase()} •{' '} - {activeDoc.size >= 1024 * 1024 * 1024 - ? `${(activeDoc.size / 1024 / 1024 / 1024).toFixed(2)} GB` - : activeDoc.size >= 1024 * 1024 - ? `${(activeDoc.size / 1024 / 1024).toFixed(2)} MB` - : `${(activeDoc.size / 1024).toFixed(1)} KB`} + {activeDoc.type.toUpperCase()} • {formatDocumentSize(activeDoc.size)}

Open diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx index e6bc86a..e6bccd4 100644 --- a/src/components/doclist/views/ListView.tsx +++ b/src/components/doclist/views/ListView.tsx @@ -11,6 +11,7 @@ import type { 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'; @@ -34,14 +35,6 @@ function formatDate(ms: number): string { return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } -function formatSize(bytes: number): string { - const mb = bytes / 1024 / 1024; - if (mb >= 1024) { - return `${(mb / 1024).toFixed(2)} GB`; - } - return mb >= 1 ? `${mb.toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`; -} - function KindIcon({ doc }: { doc: DocumentListDocument }) { if (doc.type === 'pdf') return ; if (doc.type === 'epub') return ; @@ -180,7 +173,7 @@ function DocRow({ {doc.type} - {formatSize(doc.size)} + {formatDocumentSize(doc.size)} {formatDate(doc.lastModified)} @@ -259,7 +252,7 @@ function FolderRowGroup({ Folder - {formatSize(totalSize)} + {formatDocumentSize(totalSize)} {folder.documents.length} items diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index 2ff1766..4d2f636 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -7,7 +7,6 @@ import { useDocuments } from '@/contexts/DocumentContext'; import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; - interface DocumentUploaderProps { className?: string; variant?: 'default' | 'compact' | 'overlay'; @@ -93,14 +92,13 @@ export function DocumentUploader({ className = '', variant = 'default', children : `${ isDragActive ? 'border-2 border-dashed border-accent bg-base text-foreground' - : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.008]' + : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.01]' }`; const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; if (variant === 'overlay') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { onClick, onKeyDown, ...rootProps } = getRootProps(); + const rootProps = getRootProps(); return (
From 0470f044ef08a8a9c62ef343ec1594412335fad6 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 19:55:23 -0600 Subject: [PATCH 04/31] refactor(doclist): restructure loading and empty states, update skeletons and columns UI Simplify document list state handling by centralizing loading and empty logic into DocumentList. Remove redundant state management from HomeContent. Refactor DocumentListSkeleton to support all view modes and icon sizes for improved visual consistency during loading. Enhance ColumnsView styling for folder and document rows, improving selection, hover, and drag feedback. Use useMemo for visible order calculation to optimize rendering. These changes streamline the document list rendering flow, unify skeleton loading visuals, and improve the clarity and responsiveness of the columns view interface. --- src/components/HomeContent.tsx | 29 --- src/components/doclist/DocumentList.tsx | 126 ++++++------ .../doclist/DocumentListSkeleton.tsx | 189 ++++++++++++++---- src/components/doclist/views/ColumnsView.tsx | 62 ++++-- 4 files changed, 259 insertions(+), 147 deletions(-) diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index 138f3c3..6cc2be8 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -1,12 +1,8 @@ 'use client'; -import { Header } from '@/components/Header'; -import { DocumentUploader } from '@/components/documents/DocumentUploader'; import { DocumentList } from '@/components/doclist/DocumentList'; -import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; -import { useDocuments } from '@/contexts/DocumentContext'; const Brand = () => (
@@ -26,31 +22,6 @@ const AppActions = () => ( ); export function HomeContent() { - const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments(); - const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0); - - if (isPDFLoading) { - return ( -
-
} right={} /> -
- -
-
- ); - } - - if (totalDocs === 0) { - return ( -
-
} right={} /> -
- -
-
- ); - } - return (
} appActions={} /> diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 0ccbe9e..663120e 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -409,17 +409,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { [allDocuments], ); - if (isPDFLoading || isEPUBLoading || isHTMLLoading) { - return ; - } - - if (allDocuments.length === 0) { - return ( -
- -
- ); - } + const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading; const fallbackViewMode: ViewMode = viewMode === 'columns' && isNarrow ? 'list' : viewMode; @@ -473,7 +463,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { sidebarOpen={sidebarOpen} onSidebarOpenChange={setSidebarOpen} > - {showHint && allDocuments.length > 1 && ( + {!isLoading && showHint && allDocuments.length > 1 && (

@@ -493,57 +483,67 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {

)} - - {fallbackViewMode === 'icons' && ( - - )} - {fallbackViewMode === 'list' && ( - { - setSortBy(b); - setSortDirection(d); - }} - collapsedFolders={collapsedFolders} - onToggleCollapse={toggleFolderCollapse} - onDeleteFolder={handleDeleteFolder} - onDeleteDoc={handleDeleteDoc} - onDropOnFolder={handleDropOnFolder} - onMergeIntoFolder={handleMergeIntoFolder} - /> - )} - {fallbackViewMode === 'columns' && ( - - )} - {fallbackViewMode === 'gallery' && ( - - )} - + {isLoading ? ( +
+ +
+ ) : allDocuments.length === 0 ? ( +
+ +
+ ) : ( + + {fallbackViewMode === 'icons' && ( + + )} + {fallbackViewMode === 'list' && ( + { + setSortBy(b); + setSortDirection(d); + }} + collapsedFolders={collapsedFolders} + onToggleCollapse={toggleFolderCollapse} + onDeleteFolder={handleDeleteFolder} + onDeleteDoc={handleDeleteDoc} + onDropOnFolder={handleDropOnFolder} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'columns' && ( + + )} + {fallbackViewMode === 'gallery' && ( + + )} + + )} = { + sm: 'grid-cols-3 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-8', + md: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6', + lg: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5', + xl: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4', +}; +function IconsSkeleton({ iconSize }: { iconSize: IconSize }) { return ( -
-
-
-
-
-
-
- -
- {placeholders.map((_, index) => ( -
- {viewMode === 'grid' ? ( - <> -
-
-
-
-
- - ) : null} +
+
+ {Array.from({ length: 12 }).map((_, index) => ( +
+
+
+
+
+
))}
); } + +function ListSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} + +function ColumnsSkeleton() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 11 }).map((_, rowIndex) => ( +
+ ))} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function GallerySkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); +} + +export function DocumentListSkeleton({ + viewMode = 'icons', + iconSize = 'md', +}: DocumentListSkeletonProps) { + let body; + if (viewMode === 'list') { + body = ; + } else if (viewMode === 'columns') { + body = ; + } else if (viewMode === 'gallery') { + body = ; + } else { + body = ; + } + + return ( +
+ {body} +
+ ); +} diff --git a/src/components/doclist/views/ColumnsView.tsx b/src/components/doclist/views/ColumnsView.tsx index 507278d..67b87ce 100644 --- a/src/components/doclist/views/ColumnsView.tsx +++ b/src/components/doclist/views/ColumnsView.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import type { DocumentListDocument, Folder } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; @@ -74,15 +74,17 @@ function ColumnDocRow({ data-doc-tile onClick={onClick} className={ - 'flex items-center gap-2 px-2 py-1.5 rounded-md text-[12px] transition-colors duration-200 ease-out cursor-pointer ' + + '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 || isSelected - ? 'bg-accent text-background' - : 'text-foreground hover:bg-offbase') + + ? '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' : '') + (isDragging ? ' opacity-50' : '') } > - + + + {doc.name}
); @@ -111,19 +113,19 @@ function ColumnFolderRow({ ref={dropRef as unknown as React.RefObject} onClick={onClick} className={ - 'flex items-center gap-2 px-2 py-1.5 rounded-md text-[12px] transition-colors duration-200 ease-out cursor-pointer ' + + '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 - ? 'bg-accent text-background' - : 'text-foreground hover:bg-offbase') + + ? 'border-accent bg-offbase text-accent' + : 'border-transparent text-foreground hover:border-accent hover:bg-offbase hover:text-accent') + (isOver && canDrop ? ' ring-1 ring-accent' : '') } > - + {folder.name} - + {folder.documents.length} - +
); } @@ -150,19 +152,41 @@ export function ColumnsView({ }: ColumnsViewProps) { const { setVisibleOrder } = useDocumentSelection(); const [selected, setSelected] = useState(null); + const allDocs = useMemo( + () => [...folders.flatMap((f) => f.documents), ...unfolderedDocs], + [folders, unfolderedDocs], + ); useEffect(() => { - const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs]; - setVisibleOrder(all); - }, [folders, unfolderedDocs, setVisibleOrder]); + setVisibleOrder(allDocs); + }, [allDocs, setVisibleOrder]); - // Drop selection if it no longer exists. + // Keep selection valid and default to the first document when entering this view. useEffect(() => { - if (!selected) return; - if (selected.kind === 'folder' && !folders.find((f) => f.id === selected.id)) { - setSelected(null); + if (allDocs.length === 0) { + if (selected !== null) setSelected(null); + return; } - }, [folders, selected]); + + if (!selected) { + setSelected({ kind: 'doc', doc: allDocs[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, + ); + if (!selectedStillExists) { + setSelected({ kind: 'doc', doc: allDocs[0] }); + } + }, [allDocs, folders, selected]); const selectedFolder = selected?.kind === 'folder' ? folders.find((f) => f.id === selected.id) : undefined; From 53993aa3c690f336ad5b5150f76949f355f20d9b Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 20:11:43 -0600 Subject: [PATCH 05/31] feat(doclist): improve sidebar responsiveness and toolbar interaction in document list Enhance mobile and desktop sidebar behavior by introducing a separate state for mobile sidebar visibility and ensuring it does not persist from desktop state. Update FinderToolbar to visually indicate sidebar open state and make the toolbar sticky for better accessibility. Refine FinderSidebar width handling for improved responsiveness. Remove redundant border in mobile sidebar dialog for a cleaner appearance. These changes provide a more consistent and intuitive sidebar experience across viewports. --- src/components/doclist/DocumentList.tsx | 22 +- .../doclist/window/FinderSidebar.tsx | 7 +- .../doclist/window/FinderToolbar.tsx | 228 +++++++++--------- .../doclist/window/FinderWindow.tsx | 2 +- 4 files changed, 140 insertions(+), 119 deletions(-) diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 663120e..344ccaa 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -124,6 +124,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth); const [sidebarFilter, setSidebarFilter] = useState('all'); const [sidebarOpen, setSidebarOpen] = useState(true); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); const [isInitialized, setIsInitialized] = useState(false); @@ -206,6 +207,12 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { isInitialized, ]); + // Mobile drawer should never auto-open from persisted desktop state. + useEffect(() => { + if (!isNarrow) return; + setMobileSidebarOpen(false); + }, [isNarrow]); + // Build the union document list. const allDocuments: DocumentListDocument[] = useMemo( () => [ @@ -413,6 +420,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const fallbackViewMode: ViewMode = viewMode === 'columns' && isNarrow ? 'list' : viewMode; + const effectiveSidebarOpen = isNarrow ? mobileSidebarOpen : sidebarOpen; return ( setSidebarOpen((p) => !p)} + onToggleSidebar={() => + isNarrow + ? setMobileSidebarOpen((p) => !p) + : setSidebarOpen((p) => !p) + } + isSidebarOpen={effectiveSidebarOpen} isNarrow={isNarrow} leftSlot={brand} rightSlot={appActions} @@ -460,8 +473,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { summary={summary} /> } - sidebarOpen={sidebarOpen} - onSidebarOpenChange={setSidebarOpen} + sidebarOpen={effectiveSidebarOpen} + onSidebarOpenChange={(open) => { + if (isNarrow) setMobileSidebarOpen(open); + else setSidebarOpen(open); + }} > {!isLoading && showHint && allDocuments.length > 1 && (
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index 7de467b..7d5b4a9 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef, type ReactNode } from 'react'; +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'; @@ -158,8 +158,8 @@ export function FinderSidebar({ return ( ); } - diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index e40761f..e98284c 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -27,6 +27,7 @@ interface FinderToolbarProps { onQueryChange: (q: string) => void; onNewFolder: () => void; onToggleSidebar: () => void; + isSidebarOpen: boolean; /** True when the columns view should be disabled (mobile viewport). */ isNarrow: boolean; /** App-level content rendered at the far left (brand/logo). */ @@ -61,6 +62,7 @@ const TOOLBAR_BTN = 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]'; const TOOLBAR_BTN_INACTIVE = 'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase'; +const TOOLBAR_BTN_ACTIVE = 'border-accent bg-offbase text-accent'; // Pill-grouped segmented control. Outer pill carries the border; inner segments are // borderless and rely on bg/text color to show active/hover. Sized so the whole pill @@ -84,6 +86,7 @@ export function FinderToolbar({ onQueryChange, onNewFolder, onToggleSidebar, + isSidebarOpen, isNarrow, leftSlot, rightSlot, @@ -92,138 +95,141 @@ export function FinderToolbar({ const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; return ( -
- {leftSlot && ( -
- {leftSlot} -
- )} +
+
+ {leftSlot && ( +
+ {leftSlot} +
+ )} - + -
- {VIEW_BUTTONS.map(({ value, label, Icon }) => { - const disabled = value === 'columns' && isNarrow; - const active = viewMode === value; - return ( - - ); - })} -
- - {viewMode === 'icons' && ( -