From f441b030cfffa387b4b4ddb3df35f66a962b44c2 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Wed, 12 Feb 2025 00:14:57 -0700 Subject: [PATCH] DocumentList: Drag to create folders + sorting --- package-lock.json | 111 ++++++- package.json | 2 + src/app/globals.css | 2 +- src/components/DocumentList.tsx | 506 +++++++++++++++++++++++++++----- 4 files changed, 538 insertions(+), 83 deletions(-) diff --git a/package-lock.json b/package-lock.json index 985871c..cc4af47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openreader-webui", - "version": "0.1.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openreader-webui", - "version": "0.1.0", + "version": "0.1.3", "dependencies": { "@headlessui/react": "^2.2.0", "@types/howler": "^2.2.12", @@ -19,6 +19,8 @@ "openai": "^4.79.1", "pdfjs-dist": "4.8.69", "react": "^19.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", "react-hot-toast": "^2.5.1", @@ -52,6 +54,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", + "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", @@ -1041,6 +1055,24 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@react-stately/utils": { "version": "3.10.5", "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", @@ -2467,6 +2499,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3296,7 +3339,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3864,6 +3906,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/howler": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", @@ -5714,6 +5765,45 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", @@ -5852,6 +5942,15 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5875,6 +5974,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", diff --git a/package.json b/package.json index f28b43d..2d0fcd5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "openai": "^4.79.1", "pdfjs-dist": "4.8.69", "react": "^19.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", "react-hot-toast": "^2.5.1", diff --git a/src/app/globals.css b/src/app/globals.css index 450269a..9548ecf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -16,7 +16,7 @@ html.dark { --background: #111111; --foreground: #ededed; --base: #171717; - --offbase: #4a4a4a; + --offbase: #343434; --accent: #f87171; --muted: #a3a3a3; } diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index f78ec6e..e16a0d1 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -1,10 +1,20 @@ +'use client'; + import Link from 'next/link'; import { Button } from '@headlessui/react'; -import { Transition } from '@headlessui/react'; -import { useState } from 'react'; +import { Popover } from '@headlessui/react'; +import { useCallback, useState } from 'react'; import { useDocuments } from '@/contexts/DocumentContext'; -import { PDFIcon, EPUBIcon } from '@/components/icons/Icons'; +import { PDFIcon, EPUBIcon, ChevronUpDownIcon } from '@/components/icons/Icons'; import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from '@headlessui/react'; type DocumentToDelete = { id: string; @@ -12,6 +22,43 @@ type DocumentToDelete = { type: 'pdf' | 'epub'; }; +type Folder = { + id: string; + name: string; + documents: Array; +}; + +type Document = { + id: string; + name: string; + type: 'pdf' | 'epub'; + size: number; + lastModified: number; + folderId?: string; +}; + +type SortBy = 'name' | 'type' | 'date' | 'size'; +type SortDirection = 'asc' | 'desc'; + +const generateDefaultFolderName = (doc1: Document, doc2: Document) => { + // 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)); + + 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) { + 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() { const { pdfDocs, @@ -22,9 +69,126 @@ export function DocumentList() { isEPUBLoading, } = useDocuments(); + const [sortBy, setSortBy] = useState('date'); + const [sortDirection, setSortDirection] = useState('desc'); + 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: Document, target: Document } | null>(null); + const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); - const handleDelete = async () => { + const allDocuments: Document[] = [ + ...pdfDocs.map(doc => ({ ...doc, type: 'pdf' as const })), + ...epubDocs.map(doc => ({ ...doc, type: 'epub' as const })), + ]; + + const sortOptions: Array<{ value: SortBy; label: string }> = [ + { value: 'name', label: 'Name' }, + { value: 'type', label: 'Type' }, + { value: 'date', label: 'Date' }, + { value: 'size', label: 'Size' }, + ]; + + const sortDocuments = useCallback((docs: Document[]) => { + 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]); + + const handleDragStart = (doc: Document) => { + // Only allow dragging documents that aren't in folders + if (!doc.folderId) { + setDraggedDoc(doc); + } + }; + + const handleDragEnd = () => { + setDraggedDoc(null); + setDropTargetDoc(null); + }; + + const handleDragOver = (e: React.DragEvent, doc: Document) => { + 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); + setMousePosition({ x: e.clientX, y: e.clientY }); + } + } + }; + + const handleDragLeave = () => { + setDropTargetDoc(null); + }; + + const handleDrop = useCallback((e: React.DragEvent, targetDoc: Document) => { + e.preventDefault(); + if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return; + + // Only create new folders for unfoldered documents + if (!targetDoc.folderId) { + setPendingFolderDocs({ + source: draggedDoc, + target: targetDoc + }); + setNewFolderName(''); + } + }, [draggedDoc]); + + const createFolder = useCallback(() => { + if (!pendingFolderDocs) return; + + 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); + }, [pendingFolderDocs, newFolderName]); + + const handleFolderNameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { + e.preventDefault(); + createFolder(); + } + }; + + const handleDelete = useCallback(async () => { if (!documentToDelete) return; try { @@ -33,95 +197,279 @@ export function DocumentList() { } else { await removeEPUB(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) + ) + }))); + setDocumentToDelete(null); } catch (err) { console.error('Failed to remove document:', err); } + }, [documentToDelete]); + + const toggleFolderCollapse = (folderId: string) => { + setCollapsedFolders(prev => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); }; - const allDocuments = [ - ...pdfDocs.map(doc => ({ ...doc, type: 'pdf' as const })), - ...epubDocs.map(doc => ({ ...doc, type: 'epub' as const })), - ].sort((a, b) => b.lastModified - a.lastModified); + const renderViewControls = () => ( +
+
+ + + {sortOptions.find(opt => opt.value === sortBy)?.label} + + + + {sortOptions.map((option) => ( + + `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : '' + } ${selected ? 'font-medium' : ''}` + } + > + {option.label} + + ))} + + +
+ + +
+ ); + + const renderDocument = (doc: Document) => ( +
handleDragStart(doc)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleDragOver(e, doc)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, doc)} + className={` + w-full + ${!doc.folderId && dropTargetDoc?.id === doc.id ? 'ring-2 ring-primary bg-primary/10' : ''} + bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow + `} + > +
+ +
+ {doc.type === 'pdf' ? ( + + ) : ( + + )} +
+
+
+

{doc.name}

+ + {doc.type} + +
+

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

+
+ + + +
+
+ ); + + // Add this helper function before renderFolder + const calculateFolderSize = (documents: Document[]) => { + return documents.reduce((total, doc) => total + doc.size, 0); + }; + + const deleteFolder = (folderId: string) => { + setFolders(prev => prev.filter(f => f.id !== folderId)); + }; + + const renderFolder = (folder: Folder) => ( +
{ + e.preventDefault(); + // Only show drop target if dragged doc isn't already in a folder + if (draggedDoc && !draggedDoc.folderId) { + setDropTargetDoc({ ...draggedDoc, folderId: folder.id }); + } + }} + onDragLeave={() => setDropTargetDoc(null)} + onDrop={(e: React.DragEvent) => { + e.preventDefault(); + if (!draggedDoc || draggedDoc.folderId) return; + + // Check if document is already in this folder + if (folder.documents.some(doc => doc.id === draggedDoc.id)) { + return; + } + + setFolders(prev => prev.map(f => { + if (f.id === folder.id) { + return { + ...f, + documents: [...f.documents, { ...draggedDoc, folderId: folder.id }] + }; + } + return f; + })); + setDraggedDoc(null); + setDropTargetDoc(null); + }} + className={`rounded-lg p-2 transition-all bg-offbase shadow-lg transition-shadow ${dropTargetDoc?.folderId === folder.id ? 'ring-2 ring-primary bg-primary/10' : '' + }`} + > +
+
+

{folder.name}

+ +
+ +
+ {!collapsedFolders.has(folder.id) && ( +
+ {sortDocuments(folder.documents).map(renderDocument)} +
+ )} + {collapsedFolders.has(folder.id) && ( +

+ {(calculateFolderSize(folder.documents) / 1024 / 1024).toFixed(2)} MB + {` • ${folder.documents.length} ${folder.documents.length === 1 ? 'file' : 'files'}`} +

+ )} +
+ ); if (isPDFLoading || isEPUBLoading) { - return ( -
- Loading documents... -
- ); + return
Loading documents...
; } if (allDocuments.length === 0) { - return ( -
- No documents uploaded yet -
- ); + return
No documents uploaded yet
; } return ( -
-

Your Documents

-
- {allDocuments.map((doc) => ( - -
- -
- {doc.type === 'pdf' ? ( - - ) : ( - - )} -
-
-
-

{doc.name}

- - {doc.type} - -
-

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

-
- - -
-
- ))} -
+ +
+
+

Your Documents

+ {renderViewControls()} +
- setDocumentToDelete(null)} - onConfirm={handleDelete} - title="Delete Document" - message={`Are you sure you want to delete ${documentToDelete?.name ? documentToDelete.name : 'this document'}? This action cannot be undone.`} - confirmText="Delete" - isDangerous={true} - /> -
+
+ {folders.map(renderFolder)} + +
+ {/* Only show documents that don't have a folderId */} + {sortDocuments(allDocuments.filter(doc => !folders.some(folder => + folder.documents.some(d => d.id === doc.id) + ))).map(renderDocument)} +
+
+ + + {({ open }) => ( + <> + {pendingFolderDocs && ( +
+ +
+

Name your new folder

+ setNewFolderName(e.target.value)} + onKeyDown={handleFolderNameKeyDown} + 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" + autoFocus + /> +

Press Enter to create

+
+
+
+ )} + + )} +
+ + setDocumentToDelete(null)} + onConfirm={handleDelete} + title="Delete Document" + message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`} + confirmText="Delete" + isDangerous={true} + /> +
+ ); } + +const ChevronIcon = ({ className = "w-5 h-5" }) => ( + + + +);