From 7a88dabc53862fddeeb7be7df4ef1608fe91a9c0 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 18:59:13 -0600 Subject: [PATCH] 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 {