DocumentList: Drag to create folders + sorting

This commit is contained in:
Richard Roberson 2025-02-12 00:14:57 -07:00
parent 99dbcd004c
commit f441b030cf
4 changed files with 538 additions and 83 deletions

111
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -16,7 +16,7 @@ html.dark {
--background: #111111;
--foreground: #ededed;
--base: #171717;
--offbase: #4a4a4a;
--offbase: #343434;
--accent: #f87171;
--muted: #a3a3a3;
}

View file

@ -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>
);