DocumentList: Drag to create folders + sorting
This commit is contained in:
parent
99dbcd004c
commit
f441b030cf
4 changed files with 538 additions and 83 deletions
111
package-lock.json
generated
111
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ html.dark {
|
|||
--background: #111111;
|
||||
--foreground: #ededed;
|
||||
--base: #171717;
|
||||
--offbase: #4a4a4a;
|
||||
--offbase: #343434;
|
||||
--accent: #f87171;
|
||||
--muted: #a3a3a3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Document>;
|
||||
};
|
||||
|
||||
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<SortBy>('date');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||
const [draggedDoc, setDraggedDoc] = useState<Document | null>(null);
|
||||
const [dropTargetDoc, setDropTargetDoc] = useState<Document | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: Document, target: Document } | null>(null);
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(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<HTMLInputElement>) => {
|
||||
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 = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Listbox value={sortBy} onChange={setSortBy}>
|
||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{sortOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''
|
||||
} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
className="px-1 bg-base hover:bg-offbase rounded"
|
||||
>
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDocument = (doc: Document) => (
|
||||
<div
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
draggable={!doc.folderId} // Only make unfoldered documents draggable
|
||||
onDragStart={() => 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
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center rounded-lg">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
draggable={false}
|
||||
className="flex items-center align-center space-x-4 w-full hover:bg-base rounded-lg p-1 pr-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon />
|
||||
) : (
|
||||
<EPUBIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<span className="text-xs rounded-full bg-muted/20 text-muted uppercase">
|
||||
{doc.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })}
|
||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
onDragOver={(e) => {
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between ${collapsedFolders.has(folder.id) ? 'mb-0': 'mb-2'}`}>
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-lg px-1 font-semibold">{folder.name}</h3>
|
||||
<Button
|
||||
onClick={() => deleteFolder(folder.id)}
|
||||
className="p-1 text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
aria-label="Delete folder"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => toggleFolderCollapse(folder.id)}
|
||||
className="p-1 hover:bg-offbase rounded-lg transition-colors"
|
||||
aria-label={collapsedFolders.has(folder.id) ? "Expand folder" : "Collapse folder"}
|
||||
>
|
||||
<ChevronIcon
|
||||
className={`w-5 h-5 transform transition-transform ${collapsedFolders.has(folder.id) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsedFolders.has(folder.id) && (
|
||||
<div className="space-y-2">
|
||||
{sortDocuments(folder.documents).map(renderDocument)}
|
||||
</div>
|
||||
)}
|
||||
{collapsedFolders.has(folder.id) && (
|
||||
<p className="text-xs px-1 text-left text-muted">
|
||||
{(calculateFolderSize(folder.documents) / 1024 / 1024).toFixed(2)} MB
|
||||
{` • ${folder.documents.length} ${folder.documents.length === 1 ? 'file' : 'files'}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPDFLoading || isEPUBLoading) {
|
||||
return (
|
||||
<div className="w-full text-center text-muted">
|
||||
Loading documents...
|
||||
</div>
|
||||
);
|
||||
return <div className="w-full text-center text-muted">Loading documents...</div>;
|
||||
}
|
||||
|
||||
if (allDocuments.length === 0) {
|
||||
return (
|
||||
<div className="w-full text-center text-muted">
|
||||
No documents uploaded yet
|
||||
</div>
|
||||
);
|
||||
return <div className="w-full text-center text-muted">No documents uploaded yet</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Your Documents</h2>
|
||||
<div className="bg-background rounded-lg shadow p-2 space-y-2">
|
||||
{allDocuments.map((doc) => (
|
||||
<Transition
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
show={true}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<div className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
className="flex items-center space-x-4 flex-1 min-w-0"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon />
|
||||
) : (
|
||||
<EPUBIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<span className="text-xs rounded-full bg-muted/20 text-muted uppercase">
|
||||
{doc.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })}
|
||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
))}
|
||||
</div>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="w-full mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-foreground">Your Documents</h2>
|
||||
{renderViewControls()}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={documentToDelete !== null}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{folders.map(renderFolder)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 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)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover className="fixed z-50">
|
||||
{({ open }) => (
|
||||
<>
|
||||
{pendingFolderDocs && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
left: `${mousePosition.x}px`,
|
||||
top: `${mousePosition.y}px`
|
||||
}}>
|
||||
<Popover.Panel
|
||||
static
|
||||
className="bg-transparent backdrop-blur-md p-4 rounded-lg shadow-lg transform -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">Name your new folder</p>
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p className="text-xs text-muted">Press Enter to create</p>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={documentToDelete !== null}
|
||||
onClose={() => setDocumentToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Document"
|
||||
message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`}
|
||||
confirmText="Delete"
|
||||
isDangerous={true}
|
||||
/>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChevronIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue