fix and update
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||||
|
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { ActionTile } from '@/components/id/action-tile';
|
import { ActionTile } from '@/components/id/action-tile';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
@@ -19,6 +20,8 @@ import { useRequireAuth } from '@/hooks/use-require-auth';
|
|||||||
import {
|
import {
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
indexDocumentsByType,
|
indexDocumentsByType,
|
||||||
|
parseDocumentPhotos,
|
||||||
|
parseMetadata,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
} from '@/lib/document-catalog';
|
} from '@/lib/document-catalog';
|
||||||
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
|
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
|
||||||
@@ -320,14 +323,21 @@ export default function DataPage() {
|
|||||||
) : (
|
) : (
|
||||||
documents.map((document) => {
|
documents.map((document) => {
|
||||||
const config = getDocumentType(document.type);
|
const config = getDocumentType(document.type);
|
||||||
|
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||||
return (
|
return (
|
||||||
<Row
|
<div key={document.id}>
|
||||||
key={document.id}
|
<Row
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title={config?.label ?? document.type}
|
title={config?.label ?? document.type}
|
||||||
text={document.number}
|
text={document.number}
|
||||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||||
/>
|
/>
|
||||||
|
{photoKeys.length > 0 && userId && token ? (
|
||||||
|
<div className="border-b border-[#eceef4] px-1 pb-4">
|
||||||
|
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ChevronRight, Plus } from 'lucide-react';
|
import { ChevronRight, Plus } from 'lucide-react';
|
||||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||||
|
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
DOCUMENT_CATEGORIES,
|
DOCUMENT_CATEGORIES,
|
||||||
DOCUMENT_TYPES,
|
DOCUMENT_TYPES,
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
|
parseDocumentPhotos,
|
||||||
|
parseMetadata,
|
||||||
QUICK_DOCUMENT_TYPES,
|
QUICK_DOCUMENT_TYPES,
|
||||||
indexDocumentsByType,
|
indexDocumentsByType,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
@@ -93,22 +96,29 @@ export default function DocumentsPage() {
|
|||||||
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const existing = documentsByType.get(item.type);
|
const existing = documentsByType.get(item.type);
|
||||||
|
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
||||||
key={item.type}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCreate(item.type)}
|
onClick={() => openCreate(item.type)}
|
||||||
className="flex w-full items-center gap-4 border-b border-white/70 px-4 py-4 text-left last:border-b-0 hover:bg-white/60"
|
className="flex w-full items-center gap-4 px-4 py-4 text-left hover:bg-white/60"
|
||||||
>
|
>
|
||||||
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium">{item.label}</div>
|
<div className="font-medium">{item.label}</div>
|
||||||
{existing ? <div className="truncate text-sm text-[#667085]">{existing.number}</div> : null}
|
{existing ? <div className="truncate text-sm text-[#667085]">{existing.number}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
||||||
</button>
|
</button>
|
||||||
|
{existing && photoKeys.length > 0 && user && token ? (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Loader2, Trash2 } from 'lucide-react';
|
|||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
||||||
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
||||||
|
|
||||||
interface DocumentPhotoGalleryProps {
|
interface DocumentPhotoGalleryProps {
|
||||||
@@ -13,9 +14,19 @@ interface DocumentPhotoGalleryProps {
|
|||||||
storageKeys: string[];
|
storageKeys: string[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onRemove?: (storageKey: string) => void;
|
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 { isPinLocked } = useAuth();
|
||||||
const [urls, setUrls] = React.useState<Record<string, string>>({});
|
const [urls, setUrls] = React.useState<Record<string, string>>({});
|
||||||
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
|
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] }))
|
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
|
||||||
.filter((item) => Boolean(item.url));
|
.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;
|
if (storageKeys.length === 0) return null;
|
||||||
|
|
||||||
|
const isStrip = layout === 'strip';
|
||||||
|
|
||||||
return (
|
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) => (
|
{storageKeys.map((storageKey) => (
|
||||||
<div key={storageKey} className="relative">
|
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||||
<DocumentPhotoThumbnail
|
<DocumentPhotoThumbnail
|
||||||
url={urls[storageKey]}
|
url={urls[storageKey]}
|
||||||
loading={loadingKeys.has(storageKey)}
|
loading={loadingKeys.has(storageKey)}
|
||||||
onClick={() => {
|
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||||
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
|
onClick={() => openViewer(storageKey)}
|
||||||
if (resolvedIndex >= 0) {
|
|
||||||
setViewerIndex(resolvedIndex);
|
|
||||||
setViewerOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{!readOnly && onRemove ? (
|
{!readOnly && onRemove ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||||
aria-label="Удалить фото"
|
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';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DocumentFullscreenViewerProps {
|
interface DocumentFullscreenViewerProps {
|
||||||
@@ -13,6 +16,11 @@ interface DocumentFullscreenViewerProps {
|
|||||||
|
|
||||||
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
|
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
|
||||||
const [index, setIndex] = React.useState(initialIndex);
|
const [index, setIndex] = React.useState(initialIndex);
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) setIndex(initialIndex);
|
if (open) setIndex(initialIndex);
|
||||||
@@ -29,50 +37,101 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [images.length, onOpenChange, open]);
|
}, [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];
|
const current = images[index];
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[300] flex flex-col bg-black/95">
|
<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="flex items-center justify-between px-4 py-3 text-white">
|
||||||
<span className="text-sm text-white/80">
|
<div className="min-w-0">
|
||||||
{index + 1} / {images.length}
|
<p className="truncate text-sm font-medium">Фото документа</p>
|
||||||
</span>
|
<p className="text-xs text-white/60">
|
||||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-white/10" aria-label="Закрыть">
|
{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" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{images.length > 1 ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={index === 0}
|
size="icon"
|
||||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
variant="ghost"
|
||||||
className="absolute left-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
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="Предыдущее фото"
|
aria-label="Предыдущее фото"
|
||||||
|
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-6 w-6" />
|
<ChevronLeft className="h-6 w-6" />
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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 ? (
|
{images.length > 1 ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={index === images.length - 1}
|
size="icon"
|
||||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
variant="ghost"
|
||||||
className="absolute right-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
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="Следующее фото"
|
aria-label="Следующее фото"
|
||||||
|
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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
|
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 ? (
|
{!loading && url ? (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
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 { 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 { apiFetch, UserDocument } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -68,22 +69,29 @@ export function UserDocumentsDialog({
|
|||||||
const config = getDocumentType(document.type);
|
const config = getDocumentType(document.type);
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={document.id}>
|
||||||
key={document.id}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
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]"
|
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)}>
|
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium">{config.label}</div>
|
<div className="font-medium">{config.label}</div>
|
||||||
<div className="truncate text-sm text-[#667085]">{document.number}</div>
|
<div className="truncate text-sm text-[#667085]">{document.number}</div>
|
||||||
</div>
|
</div>
|
||||||
<FileText className="h-4 w-4 text-[#a8adbc]" />
|
<FileText className="h-4 w-4 text-[#a8adbc]" />
|
||||||
</button>
|
</button>
|
||||||
|
{photoKeys.length > 0 && token ? (
|
||||||
|
<div className="mt-2 px-1">
|
||||||
|
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
extractFilesFromDataTransfer,
|
extractFilesFromDataTransfer,
|
||||||
useChatMediaComposer
|
useChatMediaComposer
|
||||||
} from '@/components/chat/chat-media-compose-dialog';
|
} from '@/components/chat/chat-media-compose-dialog';
|
||||||
|
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
|
||||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||||
@@ -28,6 +29,7 @@ import { scrollToChatMessage } from '@/components/chat/replied-message-preview';
|
|||||||
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { UserAvatar } from '@/components/id/user-avatar';
|
import { UserAvatar } from '@/components/id/user-avatar';
|
||||||
|
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { useRealtime } from '@/components/notifications/realtime-provider';
|
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -74,6 +76,8 @@ import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
|||||||
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||||
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
|
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
|
||||||
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
|
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
|
||||||
|
import { useChatRoomMedia } from '@/hooks/use-chat-room-media';
|
||||||
|
import { collectChatImageMessages } from '@/lib/chat-media-album';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface PresignedUploadResponse {
|
interface PresignedUploadResponse {
|
||||||
@@ -87,7 +91,7 @@ function formatMessageTime(value: string) {
|
|||||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniChatImageMessage({ message, token }: { message: ChatMessage; token: string }) {
|
function MiniChatEncryptedImageMessage({ message, token, onOpen }: { message: ChatMessage; token: string; onOpen?: () => void }) {
|
||||||
const [src, setSrc] = useState<string | null>(null);
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number };
|
const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number };
|
||||||
@@ -146,12 +150,19 @@ function MiniChatImageMessage({ message, token }: { message: ChatMessage; token:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<button
|
||||||
src={src}
|
type="button"
|
||||||
alt={message.content?.trim() || metadata.fileName || 'Фото в чате'}
|
className="block overflow-hidden rounded-2xl shadow-sm"
|
||||||
className="max-h-[260px] w-[190px] rounded-2xl object-cover shadow-sm"
|
onClick={onOpen}
|
||||||
style={{ aspectRatio: metadata.width && metadata.height ? `${metadata.width} / ${metadata.height}` : undefined }}
|
disabled={!onOpen}
|
||||||
/>
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={message.content?.trim() || metadata.fileName || 'Фото в чате'}
|
||||||
|
className="max-h-[260px] w-[190px] object-cover"
|
||||||
|
style={{ aspectRatio: metadata.width && metadata.height ? `${metadata.width} / ${metadata.height}` : undefined }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +213,7 @@ export function MiniFamilyChat() {
|
|||||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const mediaComposer = useChatMediaComposer();
|
const mediaComposer = useChatMediaComposer();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||||
@@ -213,6 +225,13 @@ export function MiniFamilyChat() {
|
|||||||
() => messages.filter((message) => !message.isDeleted),
|
() => messages.filter((message) => !message.isDeleted),
|
||||||
[messages]
|
[messages]
|
||||||
);
|
);
|
||||||
|
const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]);
|
||||||
|
const { mediaUrls } = useChatRoomMedia(token, activeRoom?.id ?? null, visibleMessages);
|
||||||
|
|
||||||
|
function openImageLightbox(message: ChatMessage) {
|
||||||
|
const index = chatImageMessages.findIndex((item) => item.id === message.id);
|
||||||
|
if (index >= 0) setLightboxIndex(index);
|
||||||
|
}
|
||||||
const pinnedMessage = useMemo(
|
const pinnedMessage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
visibleMessages
|
visibleMessages
|
||||||
@@ -361,6 +380,7 @@ export function MiniFamilyChat() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setBotMessages([]);
|
setBotMessages([]);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
|
setLightboxIndex(null);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
setEmojiOpen(false);
|
setEmojiOpen(false);
|
||||||
setPollOpen(false);
|
setPollOpen(false);
|
||||||
@@ -1173,9 +1193,58 @@ export function MiniFamilyChat() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : message.storageKey && message.type === 'IMAGE' && message.isEncrypted && token ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MiniChatEncryptedImageMessage
|
||||||
|
message={message}
|
||||||
|
token={token}
|
||||||
|
onOpen={() => openImageLightbox(message)}
|
||||||
|
/>
|
||||||
|
{visibleText?.trim() ? (
|
||||||
|
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
|
<ChatEmojiContent content={visibleText} size={18} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : message.storageKey && message.type === 'IMAGE' && token ? (
|
) : message.storageKey && message.type === 'IMAGE' && token ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<MiniChatImageMessage message={message} token={token} />
|
{(() => {
|
||||||
|
const media = message.storageKey ? mediaUrls[message.storageKey] : undefined;
|
||||||
|
const meta = parseMessageMetadata(message.metadataJson) as {
|
||||||
|
fileName?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
if (!media?.blobUrl || media.status === 'loading') {
|
||||||
|
const ratio =
|
||||||
|
meta.width && meta.height
|
||||||
|
? Math.max(0.65, Math.min(1.35, meta.height / meta.width))
|
||||||
|
: 0.72;
|
||||||
|
return (
|
||||||
|
<div className="w-[190px]" style={{ height: Math.round(190 * ratio) }}>
|
||||||
|
<MediaImageSkeleton className="h-full w-full min-h-0" rounded="2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block overflow-hidden rounded-2xl shadow-sm"
|
||||||
|
onClick={() => openImageLightbox(message)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={media.blobUrl}
|
||||||
|
alt={message.content?.trim() || meta.fileName || 'Фото в чате'}
|
||||||
|
className="max-h-[260px] w-[190px] object-cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio:
|
||||||
|
meta.width && meta.height ? `${meta.width} / ${meta.height}` : undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{visibleText?.trim() ? (
|
{visibleText?.trim() ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
|
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
<ChatEmojiContent content={visibleText} size={18} />
|
<ChatEmojiContent content={visibleText} size={18} />
|
||||||
@@ -1494,6 +1563,14 @@ export function MiniFamilyChat() {
|
|||||||
authToken={token}
|
authToken={token}
|
||||||
onClose={() => setMiniAppUrl(null)}
|
onClose={() => setMiniAppUrl(null)}
|
||||||
/>
|
/>
|
||||||
|
<ChatImageLightbox
|
||||||
|
open={lightboxIndex !== null}
|
||||||
|
images={chatImageMessages}
|
||||||
|
index={lightboxIndex ?? 0}
|
||||||
|
mediaUrls={mediaUrls}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
95
apps/frontend/hooks/use-chat-room-media.ts
Normal file
95
apps/frontend/hooks/use-chat-room-media.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ChatMessage } from '@/lib/api';
|
||||||
|
import { fetchChatMediaUrl } from '@/lib/api';
|
||||||
|
import { createBlobObjectUrl, fetchAuthenticatedMediaBlob, revokeBlobObjectUrl } from '@/lib/authenticated-media';
|
||||||
|
import { parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||||
|
|
||||||
|
export type ChatRoomMediaEntry = {
|
||||||
|
blobUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
status?: 'loading' | 'ready' | 'error';
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChatRoomMedia(token: string | null, roomId: string | null, messages: ChatMessage[]) {
|
||||||
|
const [mediaUrls, setMediaUrls] = useState<Record<string, ChatRoomMediaEntry>>({});
|
||||||
|
const mediaUrlsRef = useRef<Record<string, ChatRoomMediaEntry>>({});
|
||||||
|
const inFlightRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mediaUrlsRef.current = mediaUrls;
|
||||||
|
}, [mediaUrls]);
|
||||||
|
|
||||||
|
const loadMedia = useCallback(
|
||||||
|
async (message: ChatMessage, force = false) => {
|
||||||
|
if (!token || !roomId || !message.storageKey || message.isDeleted || message.isEncrypted) return;
|
||||||
|
const storageKey = message.storageKey;
|
||||||
|
const existing = mediaUrlsRef.current[storageKey];
|
||||||
|
if (!force && existing?.status === 'ready' && existing.blobUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inFlightRef.current.has(storageKey) && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightRef.current.add(storageKey);
|
||||||
|
setMediaUrls((current) => ({
|
||||||
|
...current,
|
||||||
|
[storageKey]: { ...current[storageKey], status: 'loading', errorMessage: undefined }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||||
|
const fileName = meta.fileName || message.content || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access = await fetchChatMediaUrl(roomId, storageKey, token, fileName);
|
||||||
|
const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, message.mimeType);
|
||||||
|
const blobUrl = createBlobObjectUrl(blob);
|
||||||
|
setMediaUrls((current) => {
|
||||||
|
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[storageKey]: {
|
||||||
|
blobUrl,
|
||||||
|
fileName,
|
||||||
|
mimeType: message.mimeType,
|
||||||
|
status: 'ready'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setMediaUrls((current) => ({
|
||||||
|
...current,
|
||||||
|
[storageKey]: {
|
||||||
|
...current[storageKey],
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
inFlightRef.current.delete(storageKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[roomId, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !roomId) return;
|
||||||
|
messages.forEach((message) => {
|
||||||
|
if (message.storageKey && message.type === 'IMAGE') {
|
||||||
|
void loadMedia(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [loadMedia, messages, roomId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { mediaUrls, loadMedia };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user