Refactor new doc list
This commit is contained in:
parent
1f2ebb7ba2
commit
80bc0b0006
8 changed files with 694 additions and 527 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||||
import { DocumentList } from '@/components/DocumentList';
|
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
|
|
||||||
|
|
@ -1,523 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useCallback, useState, useEffect } from 'react';
|
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
|
||||||
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,
|
|
||||||
Popover,
|
|
||||||
PopoverPanel,
|
|
||||||
Button,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB';
|
|
||||||
import {
|
|
||||||
DocumentType,
|
|
||||||
DocumentListDocument,
|
|
||||||
Folder,
|
|
||||||
DocumentListState,
|
|
||||||
SortBy,
|
|
||||||
SortDirection
|
|
||||||
} from '@/types/documents';
|
|
||||||
|
|
||||||
type DocumentToDelete = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: DocumentType;
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
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';
|
|
||||||
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,
|
|
||||||
removePDFDocument: removePDF,
|
|
||||||
isPDFLoading,
|
|
||||||
epubDocs,
|
|
||||||
removeEPUBDocument: removeEPUB,
|
|
||||||
isEPUBLoading,
|
|
||||||
} = useDocuments();
|
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('name');
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
|
||||||
const [draggedDoc, setDraggedDoc] = useState<DocumentListDocument | null>(null);
|
|
||||||
const [dropTargetDoc, setDropTargetDoc] = useState<DocumentListDocument | null>(null);
|
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
|
||||||
const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null);
|
|
||||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
const [showHint, setShowHint] = useState(true);
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
setIsInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadState();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const saveState = async () => {
|
|
||||||
const state: DocumentListState = {
|
|
||||||
sortBy,
|
|
||||||
sortDirection,
|
|
||||||
folders,
|
|
||||||
collapsedFolders: Array.from(collapsedFolders),
|
|
||||||
showHint
|
|
||||||
};
|
|
||||||
await saveDocumentListState(state);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
|
||||||
saveState();
|
|
||||||
}
|
|
||||||
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]);
|
|
||||||
|
|
||||||
const allDocuments: DocumentListDocument[] = [
|
|
||||||
...pdfDocs.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
name: doc.name,
|
|
||||||
size: doc.size,
|
|
||||||
lastModified: doc.lastModified,
|
|
||||||
type: 'pdf' as const,
|
|
||||||
})),
|
|
||||||
...epubDocs.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
name: doc.name,
|
|
||||||
size: doc.size,
|
|
||||||
lastModified: doc.lastModified,
|
|
||||||
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: 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]);
|
|
||||||
|
|
||||||
const handleDragStart = (doc: DocumentListDocument) => {
|
|
||||||
// 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: 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDropTargetDoc(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent, targetDoc: DocumentListDocument) => {
|
|
||||||
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);
|
|
||||||
setShowHint(false);
|
|
||||||
}, [pendingFolderDocs, newFolderName]);
|
|
||||||
|
|
||||||
const handleFolderNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
createFolder();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setPendingFolderDocs(null);
|
|
||||||
setNewFolderName('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!documentToDelete) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (documentToDelete.type === 'pdf') {
|
|
||||||
await removePDF(documentToDelete.id);
|
|
||||||
} 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, removePDF, removeEPUB]);
|
|
||||||
|
|
||||||
const toggleFolderCollapse = (folderId: string) => {
|
|
||||||
setCollapsedFolders((prev: Set<string>) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(folderId)) {
|
|
||||||
next.delete(folderId);
|
|
||||||
} else {
|
|
||||||
next.add(folderId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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: DocumentListDocument) => (
|
|
||||||
<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 truncate hover:bg-base rounded-lg p-1 pr-4 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{doc.type === 'pdf' ? (
|
|
||||||
<PDFIcon />
|
|
||||||
) : (
|
|
||||||
<EPUBIcon />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
|
||||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add this helper function before renderFolder
|
|
||||||
const calculateFolderSize = (documents: DocumentListDocument[]) => {
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allDocuments.length === 0) {
|
|
||||||
return <div className="w-full text-center text-muted">No documents uploaded yet</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{showHint && allDocuments.length > 1 && (
|
|
||||||
<div className="flex items-center justify-between bg-offbase rounded-lg px-3 py-2 text-sm">
|
|
||||||
<p className="text-sm">Drag files on top of each other to make folders</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowHint(false)}
|
|
||||||
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
|
||||||
aria-label="Dismiss hint"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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">
|
|
||||||
{pendingFolderDocs && (
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center">
|
|
||||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
|
|
||||||
<PopoverPanel
|
|
||||||
static
|
|
||||||
className="relative bg-background p-6 rounded-lg shadow-lg max-w-sm w-full"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">Create New Folder</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
onKeyDown={handleFolderNameKeyDown}
|
|
||||||
placeholder="Enter folder name"
|
|
||||||
className="w-full rounded-lg bg-base 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 or Escape to cancel</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverPanel>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input } from '@headlessui/react';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
||||||
|
|
@ -180,7 +180,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">OpenAI API Key</label>
|
<label className="block text-sm font-medium text-foreground">OpenAI API Key</label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||||
|
|
@ -190,7 +190,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">OpenAI API Base URL</label>
|
<label className="block text-sm font-medium text-foreground">OpenAI API Base URL</label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={localBaseUrl}
|
value={localBaseUrl}
|
||||||
onChange={(e) => setLocalBaseUrl(e.target.value)}
|
onChange={(e) => setLocalBaseUrl(e.target.value)}
|
||||||
|
|
|
||||||
68
src/components/doclist/CreateFolderDialog.tsx
Normal file
68
src/components/doclist/CreateFolderDialog.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react';
|
||||||
|
|
||||||
|
interface CreateFolderDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
folderName: string;
|
||||||
|
onFolderNameChange: (name: string) => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateFolderDialog({
|
||||||
|
isOpen,
|
||||||
|
folderName,
|
||||||
|
onFolderNameChange,
|
||||||
|
onKeyDown,
|
||||||
|
onClose,
|
||||||
|
}: CreateFolderDialogProps) {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
|
||||||
|
Create New Folder
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={folderName}
|
||||||
|
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"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-muted">Press Enter to create or Escape to cancel</p>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/doclist/DocumentFolder.tsx
Normal file
128
src/components/doclist/DocumentFolder.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } 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;
|
||||||
|
dropTargetDoc: DocumentListDocument | null;
|
||||||
|
onDragStart: (doc: DocumentListDocument) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onDragOver: (e: React.DragEvent, doc: DocumentListDocument) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
onDrop: (e: React.DragEvent, doc: DocumentListDocument) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateFolderSize = (documents: DocumentListDocument[]) => {
|
||||||
|
return documents.reduce((total, doc) => total + doc.size, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentFolder({
|
||||||
|
folder,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onDelete,
|
||||||
|
sortedDocuments,
|
||||||
|
onDocumentDelete,
|
||||||
|
draggedDoc,
|
||||||
|
dropTargetDoc,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
}: DocumentFolderProps) {
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsHovering(true);
|
||||||
|
if (draggedDoc && !draggedDoc.folderId) {
|
||||||
|
onDragOver(e, { ...draggedDoc, folderId: folder.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsHovering(false);
|
||||||
|
onDragLeave();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsHovering(false);
|
||||||
|
if (!draggedDoc || draggedDoc.folderId) return;
|
||||||
|
onDrop(e, { ...draggedDoc, folderId: folder.id });
|
||||||
|
}}
|
||||||
|
className={`rounded-lg p-2 transition-all bg-offbase shadow hover:shadow-md ${
|
||||||
|
isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-between ${isCollapsed ? 'mb-0' : 'mb-2'}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h3 className="text-lg px-1 font-semibold">{folder.name}</h3>
|
||||||
|
<Button
|
||||||
|
onClick={onDelete}
|
||||||
|
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={() => onToggleCollapse(folder.id)}
|
||||||
|
className="p-1 hover:bg-offbase rounded-lg transition-colors"
|
||||||
|
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||||
|
>
|
||||||
|
<ChevronIcon
|
||||||
|
className={`w-5 h-5 transform transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedDocuments.map(doc => (
|
||||||
|
<DocumentListItem
|
||||||
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
doc={doc}
|
||||||
|
onDelete={onDocumentDelete}
|
||||||
|
dragEnabled={true}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
isDropTarget={dropTargetDoc?.id === doc.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCollapsed && (
|
||||||
|
<p className="text-xs px-1 text-left text-muted">
|
||||||
|
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
||||||
|
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
src/components/doclist/DocumentList.tsx
Normal file
364
src/components/doclist/DocumentList.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState, useEffect } 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 { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB';
|
||||||
|
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 { Button } from '@headlessui/react';
|
||||||
|
|
||||||
|
type DocumentToDelete = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: DocumentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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';
|
||||||
|
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<SortBy>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||||
|
const [draggedDoc, setDraggedDoc] = useState<DocumentListDocument | null>(null);
|
||||||
|
const [dropTargetDoc, setDropTargetDoc] = useState<DocumentListDocument | null>(null);
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null);
|
||||||
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [showHint, setShowHint] = useState(true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
pdfDocs,
|
||||||
|
removePDFDocument: removePDF,
|
||||||
|
isPDFLoading,
|
||||||
|
epubDocs,
|
||||||
|
removeEPUBDocument: removeEPUB,
|
||||||
|
isEPUBLoading,
|
||||||
|
} = useDocuments();
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saveState = async () => {
|
||||||
|
const state: DocumentListState = {
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
folders,
|
||||||
|
collapsedFolders: Array.from(collapsedFolders),
|
||||||
|
showHint
|
||||||
|
};
|
||||||
|
await saveDocumentListState(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]);
|
||||||
|
|
||||||
|
const allDocuments: DocumentListDocument[] = [
|
||||||
|
...pdfDocs.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
size: doc.size,
|
||||||
|
lastModified: doc.lastModified,
|
||||||
|
type: 'pdf' as const,
|
||||||
|
})),
|
||||||
|
...epubDocs.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
size: doc.size,
|
||||||
|
lastModified: doc.lastModified,
|
||||||
|
type: 'epub' as const,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!documentToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (documentToDelete.type === 'pdf') {
|
||||||
|
await removePDF(documentToDelete.id);
|
||||||
|
} 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, removePDF, removeEPUB]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((doc: DocumentListDocument) => {
|
||||||
|
if (!doc.folderId) {
|
||||||
|
setDraggedDoc(doc);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDraggedDoc(null);
|
||||||
|
setDropTargetDoc(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.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: React.DragEvent, targetDoc: DocumentListDocument) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return;
|
||||||
|
|
||||||
|
// If target has a folderId, we're dropping into an existing folder
|
||||||
|
if (targetDoc.folderId) {
|
||||||
|
const targetFolder = folders.find(f => f.id === targetDoc.folderId);
|
||||||
|
if (targetFolder && !targetFolder.documents.some(d => d.id === draggedDoc.id)) {
|
||||||
|
setFolders(prev => prev.map(f => {
|
||||||
|
if (f.id === targetDoc.folderId) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
documents: [...f.documents, { ...draggedDoc, folderId: f.id }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new folder when dropping on an unfoldered document
|
||||||
|
setPendingFolderDocs({
|
||||||
|
source: draggedDoc,
|
||||||
|
target: targetDoc
|
||||||
|
});
|
||||||
|
setNewFolderName('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedDoc(null);
|
||||||
|
setDropTargetDoc(null);
|
||||||
|
}, [draggedDoc, folders]);
|
||||||
|
|
||||||
|
const toggleFolderCollapse = useCallback((folderId: string) => {
|
||||||
|
setCollapsedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(folderId)) {
|
||||||
|
next.delete(folderId);
|
||||||
|
} else {
|
||||||
|
next.add(folderId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setShowHint(false);
|
||||||
|
}, [pendingFolderDocs, newFolderName]);
|
||||||
|
|
||||||
|
const handleFolderNameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
createFolder();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setPendingFolderDocs(null);
|
||||||
|
setNewFolderName('');
|
||||||
|
}
|
||||||
|
}, [createFolder]);
|
||||||
|
|
||||||
|
if (isPDFLoading || isEPUBLoading) {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unfolderedDocuments = allDocuments.filter(
|
||||||
|
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<div className="w-full mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-2 sm:mb-4">
|
||||||
|
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
|
||||||
|
<SortControls
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortByChange={setSortBy}
|
||||||
|
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{showHint && allDocuments.length > 1 && (
|
||||||
|
<div className="flex items-center justify-between bg-background rounded-lg px-3 py-2 text-sm shadow hover:shadow-md transition-shadow">
|
||||||
|
<p className="text-sm">Drag files on top of each other to make folders</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowHint(false)}
|
||||||
|
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||||
|
aria-label="Dismiss hint"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{folders.map(folder => (
|
||||||
|
<DocumentFolder
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
isCollapsed={collapsedFolders.has(folder.id)}
|
||||||
|
onToggleCollapse={toggleFolderCollapse}
|
||||||
|
onDelete={() => setFolders(prev => prev.filter(f => f.id !== folder.id))}
|
||||||
|
sortedDocuments={sortDocuments(folder.documents)}
|
||||||
|
onDocumentDelete={setDocumentToDelete}
|
||||||
|
draggedDoc={draggedDoc}
|
||||||
|
dropTargetDoc={dropTargetDoc}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
|
||||||
|
{sortDocuments(unfolderedDocuments).map(doc => (
|
||||||
|
<DocumentListItem
|
||||||
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
doc={doc}
|
||||||
|
onDelete={setDocumentToDelete}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
isDropTarget={dropTargetDoc?.id === doc.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateFolderDialog
|
||||||
|
isOpen={pendingFolderDocs !== null}
|
||||||
|
onClose={() => setPendingFolderDocs(null)}
|
||||||
|
folderName={newFolderName}
|
||||||
|
onFolderNameChange={setNewFolderName}
|
||||||
|
onKeyDown={handleFolderNameKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/doclist/DocumentListItem.tsx
Normal file
71
src/components/doclist/DocumentListItem.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@headlessui/react';
|
||||||
|
import { PDFIcon, EPUBIcon } from '@/components/icons/Icons';
|
||||||
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
|
||||||
|
interface DocumentListItemProps {
|
||||||
|
doc: DocumentListDocument;
|
||||||
|
onDelete: (doc: DocumentListDocument) => void;
|
||||||
|
dragEnabled?: boolean;
|
||||||
|
onDragStart?: (doc: DocumentListDocument) => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
onDragOver?: (e: React.DragEvent, doc: DocumentListDocument) => void;
|
||||||
|
onDragLeave?: () => void;
|
||||||
|
onDrop?: (e: React.DragEvent, doc: DocumentListDocument) => void;
|
||||||
|
isDropTarget?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentListItem({
|
||||||
|
doc,
|
||||||
|
onDelete,
|
||||||
|
dragEnabled = true,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
isDropTarget = false,
|
||||||
|
}: DocumentListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable={dragEnabled && !doc.folderId}
|
||||||
|
onDragStart={() => onDragStart?.(doc)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(e) => onDragOver?.(e, doc)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop?.(e, doc)}
|
||||||
|
className={`
|
||||||
|
w-full
|
||||||
|
${!doc.folderId && isDropTarget ? 'ring-2 ring-accent 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 truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{doc.type === 'pdf' ? <PDFIcon /> : <EPUBIcon />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
||||||
|
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||||
|
<p className="text-xs sm:text-sm text-muted truncate">
|
||||||
|
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(doc)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/doclist/SortControls.tsx
Normal file
59
src/components/doclist/SortControls.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react';
|
||||||
|
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
|
import { SortBy, SortDirection } from '@/types/documents';
|
||||||
|
|
||||||
|
interface SortControlsProps {
|
||||||
|
sortBy: SortBy;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
onSortByChange: (value: SortBy) => void;
|
||||||
|
onSortDirectionChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortControls({
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
onSortByChange,
|
||||||
|
onSortDirectionChange,
|
||||||
|
}: 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
onClick={onSortDirectionChange}
|
||||||
|
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{directionLabel}
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||||
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue