Files
IdP/apps/frontend/components/documents/document-photo-viewer.tsx
2026-07-01 23:27:48 +03:00

234 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, Download, X, ZoomIn } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { isImageAttachment, isPdfAttachment } from '@/lib/document-attachments';
import { cn } from '@/lib/utils';
import type { ResolvedDocumentAttachment } from './document-photo-gallery';
interface DocumentAttachmentViewerProps {
attachments: ResolvedDocumentAttachment[];
initialIndex?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DocumentAttachmentViewer({
attachments,
initialIndex = 0,
open,
onOpenChange
}: DocumentAttachmentViewerProps) {
const [index, setIndex] = React.useState(initialIndex);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
if (open) setIndex(initialIndex);
}, [initialIndex, open]);
React.useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onOpenChange(false);
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
if (event.key === 'ArrowRight') setIndex((current) => Math.min(attachments.length - 1, current + 1));
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [attachments.length, onOpenChange, open]);
React.useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
if (!open || attachments.length === 0 || !mounted) return null;
const current = attachments[index];
const downloadUrl = `${current.url}${current.url.includes('?') ? '&' : '?'}download=1`;
return createPortal(
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
<div className="flex items-center justify-between px-4 py-3 text-white">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{current.fileName}</p>
<p className="text-xs text-white/60">
{index + 1} из {attachments.length}
</p>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="text-white hover:bg-white/10"
aria-label="Скачать"
asChild
>
<a href={downloadUrl} target="_blank" rel="noreferrer">
<Download className="h-5 w-5" />
</a>
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="text-white hover:bg-white/10"
aria-label="Закрыть"
onClick={() => onOpenChange(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
{attachments.length > 1 ? (
<Button
type="button"
size="icon"
variant="ghost"
className={cn(
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
index <= 0 && 'pointer-events-none opacity-30'
)}
aria-label="Предыдущий файл"
onClick={() => setIndex((value) => Math.max(0, value - 1))}
>
<ChevronLeft className="h-6 w-6" />
</Button>
) : null}
{isImageAttachment(current.contentType) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.url}
alt={current.fileName}
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
/>
) : isPdfAttachment(current.contentType) ? (
<iframe
title={current.fileName}
src={current.url}
className="h-[calc(100vh-140px)] w-full max-w-5xl rounded-2xl bg-white"
/>
) : (
<div className="max-w-md rounded-[24px] bg-white/10 p-8 text-center text-white backdrop-blur-sm">
<p className="text-lg font-medium">{current.fileName}</p>
<p className="mt-2 text-sm text-white/70">Предпросмотр недоступен для этого типа файла</p>
<Button className="mt-6 rounded-xl" asChild>
<a href={downloadUrl} target="_blank" rel="noreferrer">
Скачать файл
</a>
</Button>
</div>
)}
{attachments.length > 1 ? (
<Button
type="button"
size="icon"
variant="ghost"
className={cn(
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
index >= attachments.length - 1 && 'pointer-events-none opacity-30'
)}
aria-label="Следующий файл"
onClick={() => setIndex((value) => Math.min(attachments.length - 1, value + 1))}
>
<ChevronRight className="h-6 w-6" />
</Button>
) : null}
</div>
{attachments.length > 1 ? (
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
{attachments.map((attachment, attachmentIndex) => (
<button
key={attachment.storageKey}
type="button"
className={cn(
'h-14 min-w-[56px] shrink-0 overflow-hidden rounded-lg border-2 px-2 text-xs text-white',
attachmentIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
)}
onClick={() => setIndex(attachmentIndex)}
>
{isImageAttachment(attachment.contentType) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={attachment.url} alt="" className="h-full w-full object-cover" />
) : (
<span className="flex h-full items-center justify-center">{attachment.fileName.slice(0, 8)}</span>
)}
</button>
))}
</div>
) : null}
</div>,
document.body
);
}
/** @deprecated Используйте DocumentAttachmentViewer */
export function DocumentFullscreenViewer({
images,
initialIndex = 0,
open,
onOpenChange
}: {
images: { storageKey: string; url: string }[];
initialIndex?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const attachments: ResolvedDocumentAttachment[] = images.map((image) => ({
storageKey: image.storageKey,
url: image.url,
contentType: 'image/jpeg',
fileName: 'Фото документа'
}));
return <DocumentAttachmentViewer attachments={attachments} initialIndex={initialIndex} open={open} onOpenChange={onOpenChange} />;
}
interface DocumentPhotoThumbnailProps {
url?: string;
loading?: boolean;
className?: string;
onClick?: () => void;
}
export function DocumentPhotoThumbnail({ url, loading, className, onClick }: DocumentPhotoThumbnailProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group relative aspect-[4/3] overflow-hidden rounded-2xl border border-[#eceef4] bg-[#f4f5f8] transition hover:border-[#20212b]',
className
)}
>
{loading ? <MediaImageSkeleton className="h-full w-full min-h-0" rounded="none" /> : null}
{!loading && url ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="Файл документа" className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
</div>
</>
) : null}
{!loading && !url ? <div className="flex h-full items-center justify-center text-xs text-[#667085]">Нет превью</div> : null}
</button>
);
}