fix and update

This commit is contained in:
lendry
2026-07-01 23:27:48 +03:00
parent 2b88e028c6
commit bcdfbc3861
36 changed files with 1504 additions and 320 deletions

View File

@@ -1,17 +1,33 @@
'use client';
import * as React from 'react';
import { Loader2, Trash2 } from 'lucide-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 { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
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';
@@ -22,20 +38,21 @@ export function DocumentPhotoGallery({
userId,
token,
storageKeys,
attachmentMeta = {},
readOnly,
onRemove,
layout = 'grid',
className
}: DocumentPhotoGalleryProps) {
const { isPinLocked } = useAuth();
const [urls, setUrls] = React.useState<Record<string, string>>({});
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) {
setUrls({});
setAttachments([]);
return;
}
@@ -43,34 +60,43 @@ export function DocumentPhotoGallery({
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);
return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) };
const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName);
return {
storageKey,
accessUrl: resolveBrowserMediaUrl(response.accessUrl),
contentType,
fileName
};
} catch {
return { storageKey, accessUrl: '' };
return { storageKey, accessUrl: '', contentType, fileName };
}
})
).then((results) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const item of results) {
if (item.accessUrl) next[item.storageKey] = item.accessUrl;
}
setUrls(next);
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;
};
}, [isPinLocked, storageKeys.join('|'), token, userId]);
const resolvedImages = storageKeys
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
.filter((item) => Boolean(item.url));
}, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]);
function openViewer(storageKey: string) {
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey);
if (resolvedIndex >= 0) {
setViewerIndex(resolvedIndex);
setViewerOpen(true);
@@ -84,67 +110,126 @@ export function DocumentPhotoGallery({
return (
<>
<div
className={cn(
isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3',
className
)}
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) => (
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
<DocumentPhotoThumbnail
url={urls[storageKey]}
loading={loadingKeys.has(storageKey)}
className={isStrip ? 'aspect-square rounded-xl' : undefined}
onClick={() => openViewer(storageKey)}
/>
{!readOnly && onRemove ? (
{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={(event) => {
event.stopPropagation();
onRemove(storageKey);
}}
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
aria-label="Удалить фото"
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]'
)}
>
<Trash2 className="h-3.5 w-3.5" />
<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>
) : null}
{loadingKeys.has(storageKey) ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
</div>
) : null}
</div>
))}
{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>
<DocumentFullscreenViewer
<DocumentAttachmentViewer
open={viewerOpen}
onOpenChange={setViewerOpen}
initialIndex={viewerIndex}
images={resolvedImages}
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, className }: DocumentPhotosInlineProps) {
/** Горизонтальная полоска файлов документа для списков. */
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}