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
@@ -0,0 +1,462 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
ChevronLeft,
ChevronRight,
Download,
FileText,
Minus,
Plus,
Printer,
X,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import type { RegistryAttachment } from "../types";
import { cn } from "@/shared/lib/utils";
interface AttachmentPreviewProps {
/** All previewable attachments (non-NAS, with data) */
attachments: RegistryAttachment[];
/** Currently selected attachment index */
initialIndex: number;
/** Whether the preview is open */
open: boolean;
/** Close handler */
onClose: () => void;
}
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const ZOOM_STEP = 0.25;
/** Check if a MIME type can be previewed */
function isPreviewable(type: string): boolean {
return (
type.startsWith("image/") ||
type === "application/pdf"
);
}
/** Filter to only previewable attachments with inline data */
export function getPreviewableAttachments(
attachments: RegistryAttachment[],
): RegistryAttachment[] {
return attachments.filter(
(a) =>
a.data &&
a.data !== "" &&
a.data !== "__network__" &&
isPreviewable(a.type),
);
}
export function AttachmentPreview({
attachments,
initialIndex,
open,
onClose,
}: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
const navigateTo = useCallback(
(index: number) => {
setCurrentIndex(index);
resetView();
},
[resetView],
);
const att = attachments[currentIndex];
const hasMultiple = attachments.length > 1;
const isImage = att?.type.startsWith("image/");
const isPdf = att?.type === "application/pdf";
// Build blob URL for PDF viewer (more reliable than data: URI in iframes)
const pdfBlobUrl = useMemo(() => {
if (!isPdf || !att?.data) return null;
try {
// data is a data URI like "data:application/pdf;base64,..."
const base64 = att.data.split(",")[1];
if (!base64) return null;
const bytes = atob(base64);
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
const blob = new Blob([arr], { type: "application/pdf" });
return URL.createObjectURL(blob);
} catch {
return null;
}
}, [isPdf, att?.data]);
// Revoke blob URL on cleanup
useEffect(() => {
return () => {
if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
};
}, [pdfBlobUrl]);
// Keyboard navigation
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
onClose();
break;
case "ArrowLeft":
e.preventDefault();
if (hasMultiple)
setCurrentIndex((i) => {
const next = i > 0 ? i - 1 : attachments.length - 1;
resetView();
return next;
});
break;
case "ArrowRight":
e.preventDefault();
if (hasMultiple)
setCurrentIndex((i) => {
const next = i < attachments.length - 1 ? i + 1 : 0;
resetView();
return next;
});
break;
case "+":
case "=":
e.preventDefault();
setZoom((z) => Math.min(z + ZOOM_STEP, ZOOM_MAX));
break;
case "-":
e.preventDefault();
setZoom((z) => Math.max(z - ZOOM_STEP, ZOOM_MIN));
break;
case "0":
e.preventDefault();
setZoom(1);
setPan({ x: 0, y: 0 });
break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, hasMultiple, attachments.length, onClose, resetView]);
// Mouse wheel zoom (images only)
const handleWheel = useCallback(
(e: React.WheelEvent) => {
if (!isImage) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((z) => Math.max(ZOOM_MIN, Math.min(z + delta, ZOOM_MAX)));
},
[isImage],
);
// Pan handling (images only, when zoomed in)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isImage || zoom <= 1) return;
e.preventDefault();
setIsPanning(true);
panStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
},
[isImage, zoom, pan],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanning) return;
setPan({
x: panStart.current.panX + (e.clientX - panStart.current.x),
y: panStart.current.panY + (e.clientY - panStart.current.y),
});
},
[isPanning],
);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const goNext = useCallback(() => {
setCurrentIndex((i) => {
const next = i < attachments.length - 1 ? i + 1 : 0;
resetView();
return next;
});
}, [attachments.length, resetView]);
const goPrev = useCallback(() => {
setCurrentIndex((i) => {
const next = i > 0 ? i - 1 : attachments.length - 1;
resetView();
return next;
});
}, [attachments.length, resetView]);
const handleDownload = useCallback(() => {
if (!att) return;
const a = document.createElement("a");
a.href = att.data;
a.download = att.name;
a.click();
}, [att]);
const handlePrint = useCallback(() => {
if (!att) return;
if (isPdf && pdfBlobUrl) {
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = pdfBlobUrl;
document.body.appendChild(iframe);
iframe.onload = () => {
iframe.contentWindow?.print();
setTimeout(() => document.body.removeChild(iframe), 1000);
};
} else if (isImage) {
const w = window.open("", "_blank");
if (w) {
w.document.write(
`<html><head><title>${att.name}</title></head><body style="margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111"><img src="${att.data}" style="max-width:100%;max-height:100vh;object-fit:contain"/></body></html>`,
);
w.document.close();
w.onload = () => w.print();
}
}
}, [att, isPdf, pdfBlobUrl, isImage]);
if (!open || !att) return null;
const fileExt =
att.name.split(".").pop()?.toUpperCase() ?? att.type.split("/")[1]?.toUpperCase() ?? "FILE";
return (
<div className="fixed inset-0 z-[100] flex flex-col bg-black/90 backdrop-blur-sm animate-in fade-in duration-200">
{/* ── Top bar ── */}
<div className="flex items-center justify-between px-4 py-2.5 bg-black/60 border-b border-white/10 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 text-white/70 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">
{att.name}
</p>
<p className="text-[11px] text-white/50">
{(att.size / 1024).toFixed(0)} KB {fileExt}
{hasMultiple && (
<span className="ml-2">
{currentIndex + 1} / {attachments.length}
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Zoom controls (images only) */}
{isImage && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white/70 hover:text-white hover:bg-white/10"
onClick={() => setZoom((z) => Math.max(z - ZOOM_STEP, ZOOM_MIN))}
title="Micșorează ()"
>
<Minus className="h-4 w-4" />
</Button>
<button
type="button"
className="px-2 text-xs text-white/70 hover:text-white font-mono min-w-[3.5rem] text-center"
onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
title="Resetează zoom (0)"
>
{Math.round(zoom * 100)}%
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white/70 hover:text-white hover:bg-white/10"
onClick={() => setZoom((z) => Math.min(z + ZOOM_STEP, ZOOM_MAX))}
title="Mărește (+)"
>
<Plus className="h-4 w-4" />
</Button>
<div className="w-px h-5 bg-white/20 mx-1" />
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white/70 hover:text-white hover:bg-white/10"
onClick={handlePrint}
title="Printează"
>
<Printer className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white/70 hover:text-white hover:bg-white/10"
onClick={handleDownload}
title="Descarcă"
>
<Download className="h-4 w-4" />
</Button>
<div className="w-px h-5 bg-white/20 mx-1" />
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white/70 hover:text-white hover:bg-white/10"
onClick={onClose}
title="Închide (Esc)"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* ── Content area ── */}
<div
ref={containerRef}
className={cn(
"flex-1 relative overflow-hidden flex items-center justify-center",
isImage && zoom > 1 && "cursor-grab",
isPanning && "cursor-grabbing",
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Navigation arrows */}
{hasMultiple && (
<>
<button
type="button"
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex items-center justify-center w-10 h-10 rounded-full bg-black/50 text-white/70 hover:text-white hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); goPrev(); }}
title="Anterior (←)"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex items-center justify-center w-10 h-10 rounded-full bg-black/50 text-white/70 hover:text-white hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); goNext(); }}
title="Următor (→)"
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
{/* Image preview */}
{isImage && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={att.data}
alt={att.name}
className="select-none transition-transform duration-150"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
maxWidth: zoom <= 1 ? "90%" : undefined,
maxHeight: zoom <= 1 ? "90%" : undefined,
objectFit: "contain",
}}
draggable={false}
/>
)}
{/* PDF preview */}
{isPdf && pdfBlobUrl && (
<iframe
src={pdfBlobUrl}
className="w-full h-full border-0"
title={att.name}
/>
)}
{/* PDF without blob fallback */}
{isPdf && !pdfBlobUrl && (
<div className="text-center space-y-4 p-8">
<FileText className="h-16 w-16 text-white/30 mx-auto" />
<p className="text-white/60 text-sm">
Previzualizarea PDF nu este disponibilă.
</p>
<Button
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
onClick={handleDownload}
>
<Download className="mr-2 h-4 w-4" /> Descarcă fișierul
</Button>
</div>
)}
{/* Unsupported type fallback */}
{!isImage && !isPdf && (
<div className="text-center space-y-4 p-8">
<FileText className="h-16 w-16 text-white/30 mx-auto" />
<p className="text-white/60 text-sm">
Previzualizare indisponibilă pentru {fileExt}.
</p>
<Button
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
onClick={handleDownload}
>
<Download className="mr-2 h-4 w-4" /> Descarcă fișierul
</Button>
</div>
)}
</div>
{/* ── Bottom thumbnails (when multiple) ── */}
{hasMultiple && (
<div className="flex items-center justify-center gap-2 px-4 py-2 bg-black/60 border-t border-white/10 shrink-0 overflow-x-auto">
{attachments.map((a, idx) => (
<button
key={a.id}
type="button"
className={cn(
"shrink-0 rounded border-2 transition-all",
idx === currentIndex
? "border-white/80 ring-1 ring-white/30"
: "border-transparent opacity-50 hover:opacity-80",
)}
onClick={() => navigateTo(idx)}
title={a.name}
>
{a.type.startsWith("image/") ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={a.data}
alt={a.name}
className="h-10 w-14 object-cover rounded"
draggable={false}
/>
) : (
<div className="h-10 w-14 flex items-center justify-center bg-white/10 rounded">
<FileText className="h-4 w-4 text-white/70" />
</div>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -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>
);