From dcce341b8a68b3a4cf6c06274338e06e5b7fe914 Mon Sep 17 00:00:00 2001
From: AI Assistant
Date: Sat, 28 Feb 2026 19:33:40 +0200
Subject: [PATCH] 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)
---
.../components/attachment-preview.tsx | 462 ++++++++++++++++++
.../components/registry-entry-detail.tsx | 90 ++--
2 files changed, 497 insertions(+), 55 deletions(-)
create mode 100644 src/modules/registratura/components/attachment-preview.tsx
diff --git a/src/modules/registratura/components/attachment-preview.tsx b/src/modules/registratura/components/attachment-preview.tsx
new file mode 100644
index 0000000..44f8ba7
--- /dev/null
+++ b/src/modules/registratura/components/attachment-preview.tsx
@@ -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(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(
+ `${att.name}
`,
+ );
+ 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 (
+
+ {/* ── Top bar ── */}
+
+
+
+
+
+ {att.name}
+
+
+ {(att.size / 1024).toFixed(0)} KB • {fileExt}
+ {hasMultiple && (
+
+ {currentIndex + 1} / {attachments.length}
+
+ )}
+
+
+
+
+
+ {/* Zoom controls (images only) */}
+ {isImage && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ {/* ── Content area ── */}
+
1 && "cursor-grab",
+ isPanning && "cursor-grabbing",
+ )}
+ onWheel={handleWheel}
+ onMouseDown={handleMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleMouseUp}
+ onMouseLeave={handleMouseUp}
+ >
+ {/* Navigation arrows */}
+ {hasMultiple && (
+ <>
+
+
+ >
+ )}
+
+ {/* Image preview */}
+ {isImage && (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ )}
+
+ {/* PDF preview */}
+ {isPdf && pdfBlobUrl && (
+
+ )}
+
+ {/* PDF without blob fallback */}
+ {isPdf && !pdfBlobUrl && (
+
+
+
+ Previzualizarea PDF nu este disponibilă.
+
+
+
+ )}
+
+ {/* Unsupported type fallback */}
+ {!isImage && !isPdf && (
+
+
+
+ Previzualizare indisponibilă pentru {fileExt}.
+
+
+
+ )}
+
+
+ {/* ── Bottom thumbnails (when multiple) ── */}
+ {hasMultiple && (
+
+ {attachments.map((a, idx) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/modules/registratura/components/registry-entry-detail.tsx b/src/modules/registratura/components/registry-entry-detail.tsx
index f1ffe6e..3fe4cb5 100644
--- a/src/modules/registratura/components/registry-entry-detail.tsx
+++ b/src/modules/registratura/components/registry-entry-detail.tsx
@@ -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(
- null,
- );
+ const [previewIndex, setPreviewIndex] = useState(null);
const [copiedPath, setCopiedPath] = useState(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({
- {/* 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") && (
)}
{/* Download for files with data */}
@@ -493,51 +507,6 @@ export function RegistryEntryDetail({
),
)}
-
- {/* Image preview modal */}
- {previewAttachment && (
-
- {(() => {
- const att = entry.attachments.find(
- (a) => a.id === previewAttachment,
- );
- if (
- !att ||
- !att.type.startsWith("image/") ||
- !att.data ||
- att.data === "" ||
- att.data === "__network__"
- )
- return (
-
- Previzualizare indisponibilă (fișierul nu conține
- date inline).
-
- );
- return (
-
-
-
{att.name}
-
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-

-
- );
- })()}
-
- )}
)}
@@ -648,6 +617,17 @@ export function RegistryEntryDetail({
)}
+ {/* QuickLook-style attachment preview */}
+ {previewIndex !== null && (
+ setPreviewIndex(null)}
+ />
+ )}
+
);