Fix bugs, convert thumbnail to png, add context menu
Some checks failed
Automated Container Build / build-and-push (push) Failing after 2s
CI / Backend (Python) (push) Failing after 9s
CI / Frontend (TypeScript) (push) Failing after 4m46s

This commit is contained in:
Elijah 2026-06-10 19:06:28 -07:00
parent c545d4b17d
commit eb8a302222
37 changed files with 1916 additions and 137 deletions

View file

@ -0,0 +1,136 @@
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, toggleSelection, isLoading, error, 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-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border-2 ${
isSelected ? 'border-indigo-500 shadow-md ring-2 ring-indigo-500/20' : 'border-transparent hover:border-indigo-200 dark:hover:border-indigo-800'
}`}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: isSelected ? 1 : undefined }}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(doc.id)}
className="w-5 h-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer shadow-sm"
/>
</div>
<Link
to={`/editor/${doc.id}`}
className="relative aspect-[3/4] bg-gray-100 dark:bg-gray-900 overflow-hidden"
onClick={(e) => {
if (isTrash) e.preventDefault() // disable link in trash
}}
>
<img
src={thumbnailUrl}
alt={doc.title}
className={`w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 ${isTrash ? 'grayscale opacity-70' : ''}`}
loading="lazy"
onError={(e) => {
// Fallback if no thumbnail
(e.target as HTMLImageElement).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="%239ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>'
;(e.target as HTMLImageElement).className = 'w-1/2 h-1/2 absolute top-1/4 left-1/4 opacity-30 object-contain'
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
</Link>
<div className="p-4 flex flex-col">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={doc.title}>
{doc.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })}
</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
{(doc.size_bytes / 1024 / 1024).toFixed(1)} MB
</p>
</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={() => deleteDocument(contextMenu.docId)}
onRestore={() => restoreDocument(contextMenu.docId)}
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
/>
)}
</>
)
}