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,
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user