fix and update
This commit is contained in:
@@ -5,6 +5,7 @@ import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
||||
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
||||
|
||||
interface DocumentPhotoGalleryProps {
|
||||
@@ -13,9 +14,19 @@ interface DocumentPhotoGalleryProps {
|
||||
storageKeys: string[];
|
||||
readOnly?: boolean;
|
||||
onRemove?: (storageKey: string) => void;
|
||||
layout?: 'grid' | 'strip';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onRemove }: DocumentPhotoGalleryProps) {
|
||||
export function DocumentPhotoGallery({
|
||||
userId,
|
||||
token,
|
||||
storageKeys,
|
||||
readOnly,
|
||||
onRemove,
|
||||
layout = 'grid',
|
||||
className
|
||||
}: DocumentPhotoGalleryProps) {
|
||||
const { isPinLocked } = useAuth();
|
||||
const [urls, setUrls] = React.useState<Record<string, string>>({});
|
||||
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
|
||||
@@ -58,28 +69,42 @@ export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onR
|
||||
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
|
||||
.filter((item) => Boolean(item.url));
|
||||
|
||||
function openViewer(storageKey: string) {
|
||||
const resolvedIndex = resolvedImages.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="grid grid-cols-2 gap-3">
|
||||
<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) => (
|
||||
<div key={storageKey} className="relative">
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||
<DocumentPhotoThumbnail
|
||||
url={urls[storageKey]}
|
||||
loading={loadingKeys.has(storageKey)}
|
||||
onClick={() => {
|
||||
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
|
||||
if (resolvedIndex >= 0) {
|
||||
setViewerIndex(resolvedIndex);
|
||||
setViewerOpen(true);
|
||||
}
|
||||
}}
|
||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||
onClick={() => openViewer(storageKey)}
|
||||
/>
|
||||
{!readOnly && onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(storageKey)}
|
||||
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="Удалить фото"
|
||||
>
|
||||
@@ -104,3 +129,25 @@ export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onR
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentPhotosInlineProps {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
storageKeys: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Горизонтальная полоска фото документа для списков (страница «Документы», «Данные»). */
|
||||
export function DocumentPhotosInline({ userId, token, storageKeys, className }: DocumentPhotosInlineProps) {
|
||||
if (!storageKeys.length) return null;
|
||||
return (
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={storageKeys}
|
||||
readOnly
|
||||
layout="strip"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DocumentFullscreenViewerProps {
|
||||
@@ -13,6 +16,11 @@ interface DocumentFullscreenViewerProps {
|
||||
|
||||
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
|
||||
const [index, setIndex] = React.useState(initialIndex);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setIndex(initialIndex);
|
||||
@@ -29,50 +37,101 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [images.length, onOpenChange, open]);
|
||||
|
||||
if (!open || images.length === 0) return null;
|
||||
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 || images.length === 0 || !mounted) return null;
|
||||
|
||||
const current = images[index];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex flex-col bg-black/95">
|
||||
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">
|
||||
<span className="text-sm text-white/80">
|
||||
{index + 1} / {images.length}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-white/10" aria-label="Закрыть">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">Фото документа</p>
|
||||
<p className="text-xs text-white/60">
|
||||
{index + 1} из {images.length}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-4 pb-6">
|
||||
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
||||
{images.length > 1 ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||
className="absolute left-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
||||
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>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.url} alt="Фото документа" className="max-h-[calc(100vh-120px)] max-w-full object-contain" />
|
||||
<img
|
||||
src={current.url}
|
||||
alt="Фото документа"
|
||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
||||
/>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
disabled={index === images.length - 1}
|
||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||
className="absolute right-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index >= images.length - 1 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Следующее фото"
|
||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
||||
{images.map((image, imageIndex) => (
|
||||
<button
|
||||
key={image.storageKey}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2',
|
||||
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
)}
|
||||
onClick={() => setIndex(imageIndex)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +152,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc
|
||||
className
|
||||
)}
|
||||
>
|
||||
{loading ? <div className="h-full w-full animate-pulse bg-[#eceef4]" /> : null}
|
||||
{loading ? <MediaImageSkeleton className="h-full w-full min-h-0" rounded="none" /> : null}
|
||||
{!loading && url ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, indexDocumentsByType, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { getDocumentType, indexDocumentsByType, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -68,22 +69,29 @@ export function UserDocumentsDialog({
|
||||
const config = getDocumentType(document.type);
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||
return (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-left transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{config.label}</div>
|
||||
<div className="truncate text-sm text-[#667085]">{document.number}</div>
|
||||
</div>
|
||||
<FileText className="h-4 w-4 text-[#a8adbc]" />
|
||||
</button>
|
||||
<div key={document.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-left transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{config.label}</div>
|
||||
<div className="truncate text-sm text-[#667085]">{document.number}</div>
|
||||
</div>
|
||||
<FileText className="h-4 w-4 text-[#a8adbc]" />
|
||||
</button>
|
||||
{photoKeys.length > 0 && token ? (
|
||||
<div className="mt-2 px-1">
|
||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user