239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Download, FileText, Loader2, Trash2 } from 'lucide-react';
|
|
import { useAuth } from '@/components/id/auth-provider';
|
|
import {
|
|
attachmentDisplayName,
|
|
attachmentFileExtension,
|
|
inferAttachmentContentType,
|
|
isImageAttachment,
|
|
isPdfAttachment,
|
|
type DocumentAttachmentMeta
|
|
} from '@/lib/document-attachments';
|
|
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
|
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
|
import { cn } from '@/lib/utils';
|
|
import { DocumentAttachmentViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
|
|
|
export type ResolvedDocumentAttachment = {
|
|
storageKey: string;
|
|
url: string;
|
|
contentType: string;
|
|
fileName: string;
|
|
};
|
|
|
|
interface DocumentPhotoGalleryProps {
|
|
userId: string;
|
|
token: string | null;
|
|
storageKeys: string[];
|
|
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
|
readOnly?: boolean;
|
|
onRemove?: (storageKey: string) => void;
|
|
layout?: 'grid' | 'strip';
|
|
className?: string;
|
|
}
|
|
|
|
export function DocumentPhotoGallery({
|
|
userId,
|
|
token,
|
|
storageKeys,
|
|
attachmentMeta = {},
|
|
readOnly,
|
|
onRemove,
|
|
layout = 'grid',
|
|
className
|
|
}: DocumentPhotoGalleryProps) {
|
|
const { isPinLocked } = useAuth();
|
|
const [attachments, setAttachments] = React.useState<ResolvedDocumentAttachment[]>([]);
|
|
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
|
|
const [viewerOpen, setViewerOpen] = React.useState(false);
|
|
const [viewerIndex, setViewerIndex] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
if (!token || storageKeys.length === 0 || isPinLocked) {
|
|
setAttachments([]);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setLoadingKeys(new Set(storageKeys));
|
|
void Promise.all(
|
|
storageKeys.map(async (storageKey) => {
|
|
const meta = attachmentMeta[storageKey];
|
|
const contentType = inferAttachmentContentType(storageKey, meta);
|
|
const fileName = attachmentDisplayName(storageKey, meta);
|
|
try {
|
|
const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName);
|
|
return {
|
|
storageKey,
|
|
accessUrl: resolveBrowserMediaUrl(response.accessUrl),
|
|
contentType,
|
|
fileName
|
|
};
|
|
} catch {
|
|
return { storageKey, accessUrl: '', contentType, fileName };
|
|
}
|
|
})
|
|
).then((results) => {
|
|
if (cancelled) return;
|
|
setAttachments(
|
|
results
|
|
.filter((item) => Boolean(item.accessUrl))
|
|
.map((item) => ({
|
|
storageKey: item.storageKey,
|
|
url: item.accessUrl,
|
|
contentType: item.contentType,
|
|
fileName: item.fileName
|
|
}))
|
|
);
|
|
setLoadingKeys(new Set());
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]);
|
|
|
|
function openViewer(storageKey: string) {
|
|
const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey);
|
|
if (resolvedIndex >= 0) {
|
|
setViewerIndex(resolvedIndex);
|
|
setViewerOpen(true);
|
|
}
|
|
}
|
|
|
|
if (storageKeys.length === 0) return null;
|
|
|
|
const isStrip = layout === 'strip';
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3', className)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{storageKeys.map((storageKey) => {
|
|
const meta = attachmentMeta[storageKey];
|
|
const contentType = inferAttachmentContentType(storageKey, meta);
|
|
const fileName = attachmentDisplayName(storageKey, meta);
|
|
const resolved = attachments.find((item) => item.storageKey === storageKey);
|
|
const loading = loadingKeys.has(storageKey);
|
|
|
|
if (isImageAttachment(contentType)) {
|
|
return (
|
|
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
|
<DocumentPhotoThumbnail
|
|
url={resolved?.url}
|
|
loading={loading}
|
|
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
|
onClick={() => openViewer(storageKey)}
|
|
/>
|
|
{!readOnly && onRemove ? (
|
|
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={storageKey} className={cn('relative', isStrip && 'w-36 shrink-0')}>
|
|
<button
|
|
type="button"
|
|
onClick={() => (resolved ? openViewer(storageKey) : undefined)}
|
|
className={cn(
|
|
'flex h-full min-h-[96px] w-full flex-col items-start justify-between rounded-2xl border border-[#eceef4] bg-white p-3 text-left transition hover:border-[#20212b]',
|
|
isStrip && 'min-h-[80px]'
|
|
)}
|
|
>
|
|
<div className="flex w-full items-start justify-between gap-2">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#f4f5f8] text-[#667085]">
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-5 w-5" />}
|
|
</div>
|
|
<span className="rounded-lg bg-[#20212b] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
|
|
{attachmentFileExtension(contentType, fileName)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 min-w-0">
|
|
<p className="truncate text-sm font-medium text-[#1f2430]">{fileName}</p>
|
|
<p className="mt-0.5 text-xs text-[#667085]">{isPdfAttachment(contentType) ? 'Открыть PDF' : 'Скачать файл'}</p>
|
|
</div>
|
|
</button>
|
|
{resolved ? (
|
|
<a
|
|
href={`${resolved.url}${resolved.url.includes('?') ? '&' : '?'}download=1`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
|
aria-label="Скачать файл"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</a>
|
|
) : null}
|
|
{!readOnly && onRemove ? (
|
|
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} className="right-2 top-2" />
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<DocumentAttachmentViewer
|
|
open={viewerOpen}
|
|
onOpenChange={setViewerOpen}
|
|
initialIndex={viewerIndex}
|
|
attachments={attachments}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RemoveAttachmentButton({
|
|
storageKey,
|
|
onRemove,
|
|
className
|
|
}: {
|
|
storageKey: string;
|
|
onRemove: (storageKey: string) => void;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onRemove(storageKey);
|
|
}}
|
|
className={cn('absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80', className)}
|
|
aria-label="Удалить файл"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface DocumentPhotosInlineProps {
|
|
userId: string;
|
|
token: string | null;
|
|
storageKeys: string[];
|
|
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
|
className?: string;
|
|
}
|
|
|
|
/** Горизонтальная полоска файлов документа для списков. */
|
|
export function DocumentPhotosInline({ userId, token, storageKeys, attachmentMeta, className }: DocumentPhotosInlineProps) {
|
|
if (!storageKeys.length) return null;
|
|
return (
|
|
<DocumentPhotoGallery
|
|
userId={userId}
|
|
token={token}
|
|
storageKeys={storageKeys}
|
|
attachmentMeta={attachmentMeta}
|
|
readOnly
|
|
layout="strip"
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|