151 lines
7.2 KiB
TypeScript
151 lines
7.2 KiB
TypeScript
import { useState } from 'react'
|
|
import { useLibrary } from './useLibrary'
|
|
import { libraryApi } from './api'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { ContextMenu } from './ContextMenu'
|
|
|
|
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
|
const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
|
const navigate = useNavigate()
|
|
|
|
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, docId: string) => {
|
|
e.preventDefault()
|
|
setContextMenu({ x: e.clientX, y: e.clientY, docId })
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex justify-center items-center py-20 text-red-500">
|
|
{error}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (documents.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 dark:border-gray-700 py-24 text-center bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm transition-all hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10">
|
|
<div className="mb-4 text-6xl opacity-50 grayscale hover:grayscale-0 transition-all duration-300">📄</div>
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
{isTrash ? 'Trash is empty' : 'No documents yet'}
|
|
</h2>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{isTrash ? 'Nothing to see here.' : 'Drag & drop a PDF, or click Upload to get started.'}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
|
{documents.map((doc) => {
|
|
const isSelected = selectedIds.has(doc.id)
|
|
const thumbnailUrl = libraryApi.getThumbnailUrl(doc.id)
|
|
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
onContextMenu={(e) => handleContextMenu(e, doc.id)}
|
|
className={`group relative flex flex-col bg-white dark:bg-gray-800 rounded-3xl overflow-hidden shadow-sm hover:shadow-xl hover:shadow-accent-500/10 transition-all duration-300 border border-gray-200 dark:border-gray-700 hover:-translate-y-1 ${
|
|
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700'
|
|
}`}
|
|
>
|
|
<label className="absolute right-3 top-3 z-20 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-white/80 bg-white/90 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 sm:opacity-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => toggleSelection(doc.id)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500"
|
|
aria-label={`Select ${doc.title}`}
|
|
/>
|
|
</label>
|
|
{isSelected && (
|
|
<div className="absolute top-3 left-3 z-20 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
|
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
<Link
|
|
to={`/editor/${doc.id}`}
|
|
className="relative aspect-[3/4] bg-gray-50/50 dark:bg-gray-900/50 overflow-hidden"
|
|
onClick={(e) => {
|
|
if (isTrash) e.preventDefault() // disable link in trash
|
|
}}
|
|
>
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={doc.title}
|
|
className={`w-full h-full object-contain transition-transform duration-500 group-hover:scale-105 p-3 ${isTrash ? 'grayscale opacity-70' : ''}`}
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
// Fallback if no thumbnail
|
|
const target = e.target as HTMLImageElement;
|
|
target.onerror = null;
|
|
target.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="%23cbd5e1" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="9" x2="15" y2="15"></line><line x1="15" y1="9" x2="9" y2="15"></line></svg>';
|
|
}}
|
|
/>
|
|
|
|
{/* Hover overlay */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/40 via-transparent to-gray-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
|
</Link>
|
|
|
|
<div className="p-4 flex flex-col gap-1.5 border-t border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800">
|
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white truncate" title={doc.title}>
|
|
{doc.title}
|
|
</h3>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
|
{formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })}
|
|
</p>
|
|
<p className="text-[10px] font-bold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-md">
|
|
{(doc.size_bytes / 1024 / 1024).toFixed(1)} MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
isTrash={isTrash}
|
|
onClose={() => setContextMenu(null)}
|
|
onOpen={() => navigate(`/editor/${contextMenu.docId}`)}
|
|
onRename={() => {
|
|
const doc = documents.find(d => d.id === contextMenu.docId)
|
|
if (doc) {
|
|
const newTitle = window.prompt("Rename Document:", doc.title)
|
|
if (newTitle && newTitle.trim() !== "" && newTitle !== doc.title) {
|
|
updateDocument(doc.id, { title: newTitle.trim() })
|
|
}
|
|
}
|
|
}}
|
|
onDelete={() => {
|
|
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
|
|
}}
|
|
onRestore={() => restoreDocument(contextMenu.docId)}
|
|
onHardDelete={() => {
|
|
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|