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 { DocumentList } from '@/components/DocumentList';
|
||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
|
||||
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';
|
||||
|
||||
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 { useConfig } from '@/contexts/ConfigContext';
|
||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
||||
|
|
@ -180,7 +180,7 @@ export function SettingsModal() {
|
|||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">OpenAI API Key</label>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||
|
|
@ -190,7 +190,7 @@ export function SettingsModal() {
|
|||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">OpenAI API Base URL</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={localBaseUrl}
|
||||
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