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:
@@ -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,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -35,7 +36,11 @@ import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
|||||||
import { getOverdueDays } from "../services/registry-service";
|
import { getOverdueDays } from "../services/registry-service";
|
||||||
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
|
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
|
||||||
import { cn } from "@/shared/lib/utils";
|
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 {
|
interface RegistryEntryDetailProps {
|
||||||
entry: RegistryEntry | null;
|
entry: RegistryEntry | null;
|
||||||
@@ -130,11 +135,14 @@ export function RegistryEntryDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
allEntries,
|
allEntries,
|
||||||
}: RegistryEntryDetailProps) {
|
}: RegistryEntryDetailProps) {
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<string | null>(
|
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const previewableAtts = useMemo(
|
||||||
|
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
|
||||||
|
[entry],
|
||||||
|
);
|
||||||
|
|
||||||
const copyPath = useCallback(async (path: string) => {
|
const copyPath = useCallback(async (path: string) => {
|
||||||
await navigator.clipboard.writeText(path);
|
await navigator.clipboard.writeText(path);
|
||||||
setCopiedPath(path);
|
setCopiedPath(path);
|
||||||
@@ -448,20 +456,26 @@ export function RegistryEntryDetail({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{/* Preview for images */}
|
{/* Preview button (images + PDFs) */}
|
||||||
{att.type.startsWith("image/") &&
|
{att.data &&
|
||||||
att.data &&
|
|
||||||
att.data !== "" &&
|
att.data !== "" &&
|
||||||
att.data !== "__network__" && (
|
att.data !== "__network__" &&
|
||||||
|
(att.type.startsWith("image/") ||
|
||||||
|
att.type === "application/pdf") && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
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"
|
title="Previzualizare"
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Download for files with data */}
|
{/* Download for files with data */}
|
||||||
@@ -493,51 +507,6 @@ export function RegistryEntryDetail({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -648,6 +617,17 @@ export function RegistryEntryDetail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
{/* QuickLook-style attachment preview */}
|
||||||
|
{previewIndex !== null && (
|
||||||
|
<AttachmentPreview
|
||||||
|
key={previewIndex}
|
||||||
|
attachments={previewableAtts}
|
||||||
|
initialIndex={previewIndex}
|
||||||
|
open
|
||||||
|
onClose={() => setPreviewIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user