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 */ + {att.name} + )} + + {/* PDF preview */} + {isPdf && pdfBlobUrl && ( +