From 2b88e028c6b0a7efd44a4c371e721f1f45213585 Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 1 Jul 2026 22:46:04 +0300 Subject: [PATCH] fix and update --- apps/frontend/app/data/page.tsx | 24 ++-- apps/frontend/app/documents/page.tsx | 40 ++++--- .../documents/document-photo-gallery.tsx | 69 ++++++++++-- .../documents/document-photo-viewer.tsx | 103 ++++++++++++++---- .../documents/user-documents-dialog.tsx | 40 ++++--- .../components/family/mini-family-chat.tsx | 93 ++++++++++++++-- apps/frontend/hooks/use-chat-room-media.ts | 95 ++++++++++++++++ 7 files changed, 385 insertions(+), 79 deletions(-) create mode 100644 apps/frontend/hooks/use-chat-room-media.ts diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index 86feefb..9dfaef1 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react'; import { AddressQuickSection } from '@/components/addresses/address-quick-section'; import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { ActionTile } from '@/components/id/action-tile'; import { useAuth } from '@/components/id/auth-provider'; import { IdShell } from '@/components/id/shell'; @@ -19,6 +20,8 @@ import { useRequireAuth } from '@/hooks/use-require-auth'; import { getDocumentType, indexDocumentsByType, + parseDocumentPhotos, + parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog'; import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api'; @@ -320,14 +323,21 @@ export default function DataPage() { ) : ( documents.map((document) => { const config = getDocumentType(document.type); + const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson)); return ( - openDocument(document.type as DocumentTypeCode)} - /> +
+ openDocument(document.type as DocumentTypeCode)} + /> + {photoKeys.length > 0 && userId && token ? ( +
+ +
+ ) : null} +
); }) )} diff --git a/apps/frontend/app/documents/page.tsx b/apps/frontend/app/documents/page.tsx index aa64619..ed777d2 100644 --- a/apps/frontend/app/documents/page.tsx +++ b/apps/frontend/app/documents/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronRight, Plus } from 'lucide-react'; import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { useAuth } from '@/components/id/auth-provider'; import { IdShell } from '@/components/id/shell'; import { useToast } from '@/components/id/toast-provider'; @@ -11,6 +12,8 @@ import { DOCUMENT_CATEGORIES, DOCUMENT_TYPES, getDocumentType, + parseDocumentPhotos, + parseMetadata, QUICK_DOCUMENT_TYPES, indexDocumentsByType, type DocumentTypeCode @@ -93,22 +96,29 @@ export default function DocumentsPage() { {DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => { const Icon = item.icon; const existing = documentsByType.get(item.type); + const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : []; return ( - +
+ + {existing && photoKeys.length > 0 && user && token ? ( +
+ +
+ ) : null} +
); })} diff --git a/apps/frontend/components/documents/document-photo-gallery.tsx b/apps/frontend/components/documents/document-photo-gallery.tsx index 015fffa..0707fe2 100644 --- a/apps/frontend/components/documents/document-photo-gallery.tsx +++ b/apps/frontend/components/documents/document-photo-gallery.tsx @@ -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>({}); const [loadingKeys, setLoadingKeys] = React.useState>(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 ( <> -
+
event.stopPropagation()} + > {storageKeys.map((storageKey) => ( -
+
{ - 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 ? ( +
-
+
{images.length > 1 ? ( - + ) : null} {/* eslint-disable-next-line @next/next/no-img-element */} - Фото документа + Фото документа {images.length > 1 ? ( - + ) : null}
-
+ + {images.length > 1 ? ( +
+ {images.map((image, imageIndex) => ( + + ))} +
+ ) : null} +
, + document.body ); } @@ -93,7 +152,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc className )} > - {loading ?
: null} + {loading ? : null} {!loading && url ? ( <> {/* eslint-disable-next-line @next/next/no-img-element */} diff --git a/apps/frontend/components/documents/user-documents-dialog.tsx b/apps/frontend/components/documents/user-documents-dialog.tsx index 1560b6b..f19f4c4 100644 --- a/apps/frontend/components/documents/user-documents-dialog.tsx +++ b/apps/frontend/components/documents/user-documents-dialog.tsx @@ -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 ( - +
+ + {photoKeys.length > 0 && token ? ( +
+ +
+ ) : null} +
); })}
diff --git a/apps/frontend/components/family/mini-family-chat.tsx b/apps/frontend/components/family/mini-family-chat.tsx index a8e8d9d..3e74054 100644 --- a/apps/frontend/components/family/mini-family-chat.tsx +++ b/apps/frontend/components/family/mini-family-chat.tsx @@ -15,6 +15,7 @@ import { extractFilesFromDataTransfer, useChatMediaComposer } from '@/components/chat/chat-media-compose-dialog'; +import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox'; import { ChatEmojiContent } from '@/components/chat/chat-emoji-content'; import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; 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 { useAuth } from '@/components/id/auth-provider'; import { UserAvatar } from '@/components/id/user-avatar'; +import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton'; import { useToast } from '@/components/id/toast-provider'; import { useRealtime } from '@/components/notifications/realtime-provider'; 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 { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-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'; 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)); } -function MiniChatImageMessage({ message, token }: { message: ChatMessage; token: string }) { +function MiniChatEncryptedImageMessage({ message, token, onOpen }: { message: ChatMessage; token: string; onOpen?: () => void }) { const [src, setSrc] = useState(null); const [failed, setFailed] = useState(false); const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number }; @@ -146,12 +150,19 @@ function MiniChatImageMessage({ message, token }: { message: ChatMessage; token: } return ( - {message.content?.trim() + ); } @@ -202,6 +213,7 @@ export function MiniFamilyChat() { const [chatToolsOpen, setChatToolsOpen] = useState(false); const [chatToolsTab, setChatToolsTab] = useState('search'); const [mediaDropVisible, setMediaDropVisible] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); const mediaComposer = useChatMediaComposer(); const messagesEndRef = useRef(null); 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] ); + 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( () => visibleMessages @@ -361,6 +380,7 @@ export function MiniFamilyChat() { setMessages([]); setBotMessages([]); setDraft(''); + setLightboxIndex(null); exitSelectionMode(); setEmojiOpen(false); setPollOpen(false); @@ -1173,9 +1193,58 @@ export function MiniFamilyChat() { ); })}
+ ) : message.storageKey && message.type === 'IMAGE' && message.isEncrypted && token ? ( +
+ openImageLightbox(message)} + /> + {visibleText?.trim() ? ( +
+ +
+ ) : null} +
) : message.storageKey && message.type === 'IMAGE' && 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 ( +
+ +
+ ); + } + return ( + + ); + })()} {visibleText?.trim() ? (
@@ -1494,6 +1563,14 @@ export function MiniFamilyChat() { authToken={token} onClose={() => setMiniAppUrl(null)} /> + setLightboxIndex(null)} + onIndexChange={setLightboxIndex} + /> ); } diff --git a/apps/frontend/hooks/use-chat-room-media.ts b/apps/frontend/hooks/use-chat-room-media.ts new file mode 100644 index 0000000..5192d92 --- /dev/null +++ b/apps/frontend/hooks/use-chat-room-media.ts @@ -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>({}); + const mediaUrlsRef = useRef>({}); + const inFlightRef = useRef(new Set()); + + 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 }; +}