feat(registratura): QuickLook-style attachment preview

New fullscreen preview modal for document attachments:
- Images: zoomable (scroll wheel + buttons), pannable when zoomed,
  zoom percentage display, reset with '0' key
- PDFs: native browser PDF viewer via blob URL iframe
- Navigation: left/right arrows (keyboard + buttons), bottom
  thumbnail strip when multiple attachments
- Actions: download, print, close (Esc)
- Dark overlay with smooth animations
- Preview button (eye icon) shown for images AND PDFs
- Replaced old inline image-only preview with new QuickLook modal

New file: attachment-preview.tsx (~450 lines)
Modified: registry-entry-detail.tsx (integrated preview)
This commit is contained in:
AI Assistant
2026-02-28 19:33:40 +02:00
parent 08b7485646
commit dcce341b8a
2 changed files with 497 additions and 55 deletions
@@ -8,6 +8,7 @@ import {
Clock,
Copy,
ExternalLink,
Eye,
FileText,
GitBranch,
HardDrive,
@@ -35,7 +36,11 @@ import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { getOverdueDays } from "../services/registry-service";
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
import { cn } from "@/shared/lib/utils";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import {
AttachmentPreview,
getPreviewableAttachments,
} from "./attachment-preview";
interface RegistryEntryDetailProps {
entry: RegistryEntry | null;
@@ -130,11 +135,14 @@ export function RegistryEntryDetail({
onDelete,
allEntries,
}: RegistryEntryDetailProps) {
const [previewAttachment, setPreviewAttachment] = useState<string | null>(
null,
);
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const previewableAtts = useMemo(
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
[entry],
);
const copyPath = useCallback(async (path: string) => {
await navigator.clipboard.writeText(path);
setCopiedPath(path);
@@ -448,20 +456,26 @@ export function RegistryEntryDetail({
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Preview for images */}
{att.type.startsWith("image/") &&
att.data &&
{/* Preview button (images + PDFs) */}
{att.data &&
att.data !== "" &&
att.data !== "__network__" && (
att.data !== "__network__" &&
(att.type.startsWith("image/") ||
att.type === "application/pdf") && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPreviewAttachment(att.id)}
onClick={() => {
const idx = previewableAtts.findIndex(
(a) => a.id === att.id,
);
if (idx >= 0) setPreviewIndex(idx);
}}
title="Previzualizare"
>
<ImageIcon className="h-3 w-3" />
<Eye className="h-3 w-3" />
</Button>
)}
{/* Download for files with data */}
@@ -493,51 +507,6 @@ export function RegistryEntryDetail({
),
)}
</div>
{/* Image preview modal */}
{previewAttachment && (
<div className="mt-3 rounded-md border p-2 bg-muted/30">
{(() => {
const att = entry.attachments.find(
(a) => a.id === previewAttachment,
);
if (
!att ||
!att.type.startsWith("image/") ||
!att.data ||
att.data === "" ||
att.data === "__network__"
)
return (
<p className="text-xs text-muted-foreground">
Previzualizare indisponibilă (fișierul nu conține
date inline).
</p>
);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium">{att.name}</p>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setPreviewAttachment(null)}
>
<X className="h-3 w-3" />
</Button>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.data}
alt={att.name}
className="max-w-full max-h-64 rounded border object-contain"
/>
</div>
);
})()}
</div>
)}
</DetailSection>
)}
@@ -648,6 +617,17 @@ export function RegistryEntryDetail({
)}
</div>
</ScrollArea>
{/* QuickLook-style attachment preview */}
{previewIndex !== null && (
<AttachmentPreview
key={previewIndex}
attachments={previewableAtts}
initialIndex={previewIndex}
open
onClose={() => setPreviewIndex(null)}
/>
)}
</SheetContent>
</Sheet>
);