234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
'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>
|
||
);
|
||
}
|