From 6d3010b6212f41a484dd507535d1dafa28a9160b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 08:45:15 +0300 Subject: [PATCH] feat: multi-media attachments, linkify URLs, shared MessageMediaGrid - Add media_items array support to ticket messages (types, API clients) - Create shared MessageMediaGrid component with photo grid, fullscreen viewer, keyboard nav, video/document rendering - Replace per-page AdminMessageMedia/MessageMedia with MessageMediaGrid - Add linkifyText util (DOMPurify-sanitized) for auto-linking URLs - Support multi-file upload (up to 10) in AdminTickets and Support pages - Remove unused ticketsApi import from AdminUserDetail --- src/api/admin.ts | 14 +- src/api/tickets.ts | 9 +- src/components/tickets/MessageMediaGrid.tsx | 256 +++++++++++ src/pages/AdminTickets.tsx | 444 ++++++++------------ src/pages/AdminUserDetail.tsx | 34 +- src/pages/Support.tsx | 427 ++++++++----------- src/types/index.ts | 7 + src/utils/linkify.ts | 28 ++ 8 files changed, 666 insertions(+), 553 deletions(-) create mode 100644 src/components/tickets/MessageMediaGrid.tsx create mode 100644 src/utils/linkify.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index b2960ce..2497974 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -8,6 +8,12 @@ export interface AdminTicketUser { last_name: string | null; } +export interface AdminTicketMediaItem { + type: 'photo' | 'video' | 'document'; + file_id: string; + caption?: string | null; +} + export interface AdminTicketMessage { id: number; message_text: string; @@ -16,6 +22,7 @@ export interface AdminTicketMessage { media_type: string | null; media_file_id: string | null; media_caption: string | null; + media_items?: AdminTicketMediaItem[] | null; created_at: string; } @@ -118,7 +125,12 @@ export const adminApi = { replyToTicket: async ( ticketId: number, message: string, - media?: { media_type?: string; media_file_id?: string; media_caption?: string }, + media?: { + media_type?: string; + media_file_id?: string; + media_caption?: string; + media_items?: AdminTicketMediaItem[]; + }, ): Promise => { const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message, diff --git a/src/api/tickets.ts b/src/api/tickets.ts index 1cb4405..2794127 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -1,5 +1,11 @@ import apiClient from './client'; -import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'; +import type { + Ticket, + TicketDetail, + TicketMessage, + TicketMediaItem, + PaginatedResponse, +} from '../types'; // Media upload response type interface MediaUploadResponse { @@ -14,6 +20,7 @@ interface MediaParams { media_type?: string; media_file_id?: string; media_caption?: string; + media_items?: TicketMediaItem[]; } export const ticketsApi = { diff --git a/src/components/tickets/MessageMediaGrid.tsx b/src/components/tickets/MessageMediaGrid.tsx new file mode 100644 index 0000000..7ccb1c0 --- /dev/null +++ b/src/components/tickets/MessageMediaGrid.tsx @@ -0,0 +1,256 @@ +import { useState, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ticketsApi } from '../../api/tickets'; + +export interface MediaItem { + type: string; + file_id: string; + caption?: string | null; +} + +interface MessageLike { + has_media?: boolean; + media_type?: string | null; + media_file_id?: string | null; + media_caption?: string | null; + media_items?: MediaItem[] | null; +} + +/** + * Normalize message media into a unified list. + * If media_items is present, use it. Otherwise fall back to legacy single-media fields. + */ +function getItems(message: MessageLike): MediaItem[] { + if (message.media_items && message.media_items.length > 0) { + return message.media_items; + } + if (message.media_file_id && message.media_type) { + return [ + { + type: message.media_type, + file_id: message.media_file_id, + caption: message.media_caption, + }, + ]; + } + return []; +} + +export function MessageMediaGrid({ + message, + translateError = 'Failed to load image', +}: { + message: MessageLike; + translateError?: string; +}) { + const items = getItems(message); + const photoItems = items.filter((i) => i.type === 'photo'); + const otherItems = items.filter((i) => i.type !== 'photo'); + + const [fullscreenIndex, setFullscreenIndex] = useState(null); + + const openFullscreen = useCallback((idx: number) => setFullscreenIndex(idx), []); + const closeFullscreen = useCallback(() => setFullscreenIndex(null), []); + + // Escape + arrow keys for fullscreen nav + useEffect(() => { + if (fullscreenIndex === null) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setFullscreenIndex(null); + else if (e.key === 'ArrowLeft' && fullscreenIndex > 0) + setFullscreenIndex(fullscreenIndex - 1); + else if (e.key === 'ArrowRight' && fullscreenIndex < photoItems.length - 1) + setFullscreenIndex(fullscreenIndex + 1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [fullscreenIndex, photoItems.length]); + + // Lock body scroll while fullscreen overlay is open (mobile mainly). + useEffect(() => { + if (fullscreenIndex === null) return undefined; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [fullscreenIndex]); + + // All hooks have been called — safe to early-return now. + if (items.length === 0) return null; + + // Grid layout based on photo count + let gridClass = ''; + if (photoItems.length === 1) { + gridClass = 'grid-cols-1'; + } else if (photoItems.length === 2) { + gridClass = 'grid-cols-2'; + } else if (photoItems.length === 3) { + gridClass = 'grid-cols-3'; + } else { + gridClass = 'grid-cols-2'; // 4+ → 2x2 + } + + const visiblePhotos = photoItems.slice(0, 4); + const hiddenCount = photoItems.length - visiblePhotos.length; + + return ( +
+ {photoItems.length > 0 && ( +
+ {visiblePhotos.map((item, visIdx) => { + // visIdx is always the correct index into photoItems (visible prefix) + const originalIdx = visIdx; + const isLastVisible = visIdx === visiblePhotos.length - 1 && hiddenCount > 0; + return ( + + ); + })} +
+ )} + + {/* Non-photo media rendered inline */} + {otherItems.map((item) => { + const mediaUrl = ticketsApi.getMediaUrl(item.file_id); + if (item.type === 'video') { + return ( +
+
+ ); + } + return ( + + + + + {item.caption || `Download ${item.type}`} + + ); + })} + + {fullscreenIndex !== null && + photoItems[fullscreenIndex] && + createPortal( +
+ + + {photoItems.length > 1 && ( + <> + + +
+ {fullscreenIndex + 1} / {photoItems.length} +
+ + )} + +
+ {photoItems[fullscreenIndex].caption e.stopPropagation()} + /> +
+
, + document.body, + )} + + {/* Fallback: error state */} + {photoItems.length === 0 && otherItems.length === 0 && ( +
{translateError}
+ )} +
+ ); +} diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 5ae505f..80ed5cd 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -1,12 +1,16 @@ import { useState, useRef, useEffect } from 'react'; +import logger from '../utils/logger'; +import { linkifyText } from '../utils/linkify'; +import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'; +import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { usePlatform } from '../platform/hooks/usePlatform'; interface MediaAttachment { + id: string; file: File; preview: string; uploading: boolean; @@ -34,123 +38,6 @@ const ALLOWED_FILE_TYPES: Record = { const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(','); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -function AdminMessageMedia({ - message, - t, -}: { - message: AdminTicketMessage; - t: (key: string) => string; -}) { - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const [showFullImage, setShowFullImage] = useState(false); - - if (!message.has_media || !message.media_file_id) { - return null; - } - - const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id); - - if (message.media_type === 'photo') { - return ( -
- {!imageLoaded && !imageError && ( -
-
-
- )} - {imageError ? ( -
- {t('support.imageLoadFailed')} -
- ) : ( - {message.media_caption setImageLoaded(true)} - onError={() => setImageError(true)} - onClick={() => setShowFullImage(true)} - /> - )} - {message.media_caption && ( -

{message.media_caption}

- )} - {showFullImage && ( -
setShowFullImage(false)} - > - - {message.media_caption -
- )} -
- ); - } - - if (message.media_type === 'video') { - return ( -
-
- ); - } - - return ( - - ); -} - // BackIcon const BackIcon = () => ( (null); const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); + const [isReplying, setIsReplying] = useState(false); + const [replyError, setReplyError] = useState(null); const [page, setPage] = useState(1); - const [attachment, setAttachment] = useState(null); + const [attachments, setAttachments] = useState([]); const fileInputRef = useRef(null); - const previewRef = useRef(null); const uploadIdRef = useRef(0); - // Cancel in-flight uploads and cleanup blob URL on unmount + // Track all created blob URLs for cleanup on unmount + const blobUrlsRef = useRef>(new Set()); + useEffect(() => { const uploadRef = uploadIdRef; - const prevRef = previewRef; + const urls = blobUrlsRef; return () => { uploadRef.current++; - if (prevRef.current) { - URL.revokeObjectURL(prevRef.current); - } + urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); @@ -212,25 +100,6 @@ export default function AdminTickets() { enabled: !!selectedTicketId, }); - const replyMutation = useMutation({ - mutationFn: ({ - ticketId, - message, - media, - }: { - ticketId: number; - message: string; - media?: { media_type?: string; media_file_id?: string; media_caption?: string }; - }) => adminApi.replyToTicket(ticketId, message, media), - onSuccess: () => { - setReplyText(''); - clearAttachment(); - queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); - queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); - queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); - }, - }); - const statusMutation = useMutation({ mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) => adminApi.updateTicketStatus(ticketId, status), @@ -241,84 +110,116 @@ export default function AdminTickets() { }, }); - const clearAttachment = () => { + const clearAttachments = () => { uploadIdRef.current++; - if (previewRef.current) { - URL.revokeObjectURL(previewRef.current); - previewRef.current = null; - } - setAttachment(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + setAttachments((prev) => { + prev.forEach((a) => { + if (a.preview) { + URL.revokeObjectURL(a.preview); + blobUrlsRef.current.delete(a.preview); + } + }); + return []; + }); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const removeAttachment = (idx: number) => { + setAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }); }; const handleFileSelect = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const files = Array.from(e.target.files || []); + if (!files.length) return; + if (fileInputRef.current) fileInputRef.current.value = ''; - // Revoke any existing blob URL before processing new file - if (previewRef.current) { - URL.revokeObjectURL(previewRef.current); - previewRef.current = null; - } + const remaining = 10 - attachments.length; + const toAdd = files.slice(0, remaining); - const mediaType = ALLOWED_FILE_TYPES[file.type]; - if (!mediaType) { - setAttachment({ - file, - preview: '', - uploading: false, - mediaType: 'document', - error: t('admin.tickets.invalidFileType'), - }); - return; - } + for (const file of toAdd) { + const mediaType = ALLOWED_FILE_TYPES[file.type]; + if (!mediaType) continue; + if (file.size > MAX_FILE_SIZE) continue; - if (file.size > MAX_FILE_SIZE) { - setAttachment({ - file, - preview: '', - uploading: false, - mediaType, - error: t('admin.tickets.fileTooLarge'), - }); - return; - } + const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; + if (preview) blobUrlsRef.current.add(preview); + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const entry: MediaAttachment = { id, file, preview, uploading: true, mediaType }; + const uploadToken = uploadIdRef.current; - const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; - previewRef.current = preview; + setAttachments((prev) => [...prev, entry]); - const currentUploadId = ++uploadIdRef.current; - setAttachment({ file, preview, uploading: true, mediaType }); - - try { - const result = await ticketsApi.uploadMedia(file, mediaType); - if (uploadIdRef.current !== currentUploadId) return; // stale upload - setAttachment((prev) => - prev ? { ...prev, uploading: false, fileId: result.file_id } : null, - ); - } catch { - if (uploadIdRef.current !== currentUploadId) return; // stale upload - setAttachment((prev) => - prev ? { ...prev, uploading: false, error: t('admin.tickets.uploadFailed') } : null, - ); + // Upload in background; ignore the result if user cleared/unmounted in the meantime. + (async () => { + try { + const result = await ticketsApi.uploadMedia(file, mediaType); + if (uploadIdRef.current !== uploadToken) return; + setAttachments((prev) => + prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), + ); + } catch { + if (uploadIdRef.current !== uploadToken) return; + setAttachments((prev) => + prev.map((a) => + a.id === id ? { ...a, uploading: false, error: t('admin.tickets.uploadFailed') } : a, + ), + ); + } + })(); } }; - const handleReply = (e: React.FormEvent) => { + const handleReply = async (e: React.FormEvent) => { e.preventDefault(); - if (!selectedTicketId || !replyText.trim()) return; - if (attachment && (attachment.uploading || attachment.error)) return; + if (!selectedTicketId) return; + if (attachments.some((a) => a.uploading || a.error)) return; - const media = attachment?.fileId + const readyAttachments = attachments.filter((a) => a.fileId) as Array<{ + fileId: string; + mediaType: string; + }>; + + const hasText = replyText.trim().length > 0; + const hasMedia = readyAttachments.length > 0; + if (!hasText && !hasMedia) return; + + const media = hasMedia ? { - media_type: attachment.mediaType, - media_file_id: attachment.fileId, + media_type: readyAttachments[0].mediaType, + media_file_id: readyAttachments[0].fileId, + media_items: readyAttachments.map((a) => ({ + type: a.mediaType as 'photo' | 'video' | 'document', + file_id: a.fileId, + })), } : undefined; - replyMutation.mutate({ ticketId: selectedTicketId, message: replyText, media }); + setIsReplying(true); + setReplyError(null); + try { + await adminApi.replyToTicket(selectedTicketId, replyText, media); + } catch (err) { + logger.error('Ticket reply failed:', err); + const msg = + err instanceof Error ? err.message : t('admin.tickets.replyFailed', 'Failed to send reply'); + setReplyError(msg); + setIsReplying(false); + return; + } + + setReplyText(''); + clearAttachments(); + setIsReplying(false); + queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); + queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); + queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); }; const getStatusBadge = (status: string) => { @@ -470,7 +371,7 @@ export default function AdminTickets() { onClick={() => { setSelectedTicketId(ticket.id); setReplyText(''); - clearAttachment(); + clearAttachments(); }} className={`w-full rounded-xl border p-4 text-left transition-all ${ selectedTicketId === ticket.id @@ -657,9 +558,12 @@ export default function AdminTickets() {
{msg.message_text && ( -

{msg.message_text}

+

)} - + ))} @@ -675,76 +579,53 @@ export default function AdminTickets() { className="input resize-none" /> - {/* Attachment preview */} - {attachment && ( -

- {attachment.mediaType === 'photo' && attachment.preview ? ( - Preview - ) : ( -
- 0 && ( +
+ {attachments.map((att, idx) => ( +
+ {att.mediaType === 'photo' && att.preview ? ( + Preview + ) : ( +
+ {att.file.name.slice(-6)} +
+ )} + {att.uploading && ( +
+ +
+ )} + {att.error && ( +
+ ! +
+ )} +
- )} -
-
{attachment.file.name}
- {attachment.uploading && ( -
- - {t('admin.tickets.uploading')} -
- )} - {attachment.error && ( -
{attachment.error}
- )} - {attachment.fileId && !attachment.uploading && ( -
- {t('admin.tickets.uploadComplete')} -
- )} -
- + ))}
)} @@ -752,15 +633,22 @@ export default function AdminTickets() { ref={fileInputRef} type="file" accept={ACCEPT_STRING} + multiple onChange={handleFileSelect} className="hidden" /> + {replyError && ( +
+ {replyError} +
+ )} +
-

- {msg.message_text} -

- {msg.has_media && msg.media_file_id && ( -
- {msg.media_type === 'photo' ? ( - {msg.media_caption - ) : ( - - {msg.media_caption || msg.media_type} - - )} - {msg.media_caption && msg.media_type === 'photo' && ( -

{msg.media_caption}

- )} -
+ {msg.message_text && ( +

)} +

))}
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index e70b1d4..495e12e 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -3,15 +3,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { ticketsApi } from '../api/tickets'; +import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { infoApi } from '../api/info'; import { useAuthStore } from '../store/auth'; import { logger } from '../utils/logger'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; -import type { TicketDetail, TicketMessage } from '../types'; +import type { TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { usePlatform } from '@/platform'; +import { linkifyText } from '../utils/linkify'; const log = logger.createLogger('Support'); @@ -49,6 +51,7 @@ const CloseIcon = () => ( // Media attachment state interface MediaAttachment { + id: string; file: File; preview: string; uploading: boolean; @@ -56,97 +59,6 @@ interface MediaAttachment { error?: string; } -// Message media display component -function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string) => string }) { - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const [showFullImage, setShowFullImage] = useState(false); - - if (!message.has_media || !message.media_file_id) { - return null; - } - - const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id); - - if (message.media_type === 'photo') { - return ( - <> -
- {!imageLoaded && !imageError && ( -
-
-
- )} - {imageError ? ( -
- {t('support.imageLoadFailed')} -
- ) : ( - {message.media_caption setImageLoaded(true)} - onError={() => setImageError(true)} - onClick={() => setShowFullImage(true)} - /> - )} - {message.media_caption && ( -

{message.media_caption}

- )} -
- - {/* Full image modal */} - {showFullImage && ( -
setShowFullImage(false)} - > - - {message.media_caption -
- )} - - ); - } - - // For documents/videos - show download link - return ( - - ); -} - export default function Support() { log.debug('Component loaded'); @@ -161,38 +73,35 @@ export default function Support() { const [replyMessage, setReplyMessage] = useState(''); const [rateLimitError, setRateLimitError] = useState(null); - // Media attachment states - const [createAttachment, setCreateAttachment] = useState(null); - const [replyAttachment, setReplyAttachment] = useState(null); + // Media attachment states (multi-upload, up to 10) + const [createAttachments, setCreateAttachments] = useState([]); + const [replyAttachments, setReplyAttachments] = useState([]); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); - const createPreviewRef = useRef(null); - const replyPreviewRef = useRef(null); - // Revoke blob URLs on unmount to prevent memory leaks + const blobUrlsRef = useRef>(new Set()); + useEffect(() => { - const createRef = createPreviewRef; - const replyRef = replyPreviewRef; + const urls = blobUrlsRef; return () => { - if (createRef.current) URL.revokeObjectURL(createRef.current); - if (replyRef.current) URL.revokeObjectURL(replyRef.current); + urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); - const clearCreateAttachment = () => { - if (createPreviewRef.current) { - URL.revokeObjectURL(createPreviewRef.current); - createPreviewRef.current = null; - } - clearCreateAttachment(); + const clearCreateAttachments = () => { + createAttachments.forEach((a) => { + if (a.preview) URL.revokeObjectURL(a.preview); + }); + setCreateAttachments([]); + if (createFileInputRef.current) createFileInputRef.current.value = ''; }; - const clearReplyAttachment = () => { - if (replyPreviewRef.current) { - URL.revokeObjectURL(replyPreviewRef.current); - replyPreviewRef.current = null; - } - clearReplyAttachment(); + const clearReplyAttachments = () => { + replyAttachments.forEach((a) => { + if (a.preview) URL.revokeObjectURL(a.preview); + }); + setReplyAttachments([]); + if (replyFileInputRef.current) replyFileInputRef.current.value = ''; }; // Get support configuration @@ -213,67 +122,49 @@ export default function Support() { enabled: !!selectedTicket, }); - // Handle file selection + // Handle file selection (multi-upload) const handleFileSelect = async ( file: File, - setAttachment: (a: MediaAttachment | null) => void, + setAttachments: React.Dispatch>, ) => { - // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - setAttachment({ - file, - preview: '', - uploading: false, - error: t('support.invalidFileType'), - }); - return; - } + if (!allowedTypes.includes(file.type)) return; + if (file.size > 10 * 1024 * 1024) return; - // Validate file size (10MB) - if (file.size > 10 * 1024 * 1024) { - setAttachment({ - file, - preview: '', - uploading: false, - error: t('support.fileTooLarge'), - }); - return; - } - - // Revoke old blob URL before creating new one - const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef; - if (previewRef.current) URL.revokeObjectURL(previewRef.current); const preview = URL.createObjectURL(file); - previewRef.current = preview; - setAttachment({ file, preview, uploading: true }); + blobUrlsRef.current.add(preview); + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const entry: MediaAttachment = { id, file, preview, uploading: true }; + setAttachments((prev) => (prev.length >= 10 ? prev : [...prev, entry])); try { const result = await ticketsApi.uploadMedia(file, 'photo'); - setAttachment({ - file, - preview, - uploading: false, - fileId: result.file_id, - }); + setAttachments((prev) => + prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), + ); } catch { - setAttachment({ - file, - preview, - uploading: false, - error: t('support.uploadFailed'), - }); + setAttachments((prev) => + prev.map((a) => + a.id === id ? { ...a, uploading: false, error: t('support.uploadFailed') } : a, + ), + ); } }; const createMutation = useMutation({ mutationFn: async () => { - const media = createAttachment?.fileId - ? { - media_type: 'photo', - media_file_id: createAttachment.fileId, - } - : undefined; + const ready = createAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; + const media = + ready.length > 0 + ? { + media_type: 'photo', + media_file_id: ready[0].fileId, + media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), + } + : undefined; return ticketsApi.createTicket(newTitle, newMessage, media); }, onSuccess: (ticket) => { @@ -281,25 +172,28 @@ export default function Support() { setShowCreateForm(false); setNewTitle(''); setNewMessage(''); - clearCreateAttachment(); + clearCreateAttachments(); setSelectedTicket(ticket); }, }); const replyMutation = useMutation({ mutationFn: async () => { - const media = replyAttachment?.fileId - ? { - media_type: 'photo', - media_file_id: replyAttachment.fileId, - } - : undefined; - return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media); + const ready = replyAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; + const media = + ready.length > 0 + ? { + media_type: 'photo', + media_file_id: ready[0].fileId, + media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), + } + : undefined; + await ticketsApi.addMessage(selectedTicket!.id, replyMessage, media); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); - clearReplyAttachment(); + clearReplyAttachments(); }, }); @@ -427,37 +321,50 @@ export default function Support() { ); } - // Attachment preview component - const AttachmentPreview = ({ - attachment, + // Attachments preview component + const AttachmentsPreview = ({ + items, onRemove, }: { - attachment: MediaAttachment; - onRemove: () => void; - }) => ( -
- {attachment.preview && ( - Attachment preview - )} - {attachment.uploading && ( -
-
-
- )} - {attachment.error &&
{attachment.error}
} - -
- ); + items: MediaAttachment[]; + onRemove: (idx: number) => void; + }) => + items.length === 0 ? null : ( +
+ {items.map((att, idx) => ( +
+ {att.preview ? ( + Preview + ) : ( +
+ {att.file.name.slice(-6)} +
+ )} + {att.uploading && ( +
+ +
+ )} + {att.error && ( +
+ ! +
+ )} + +
+ ))} +
+ ); return ( { setShowCreateForm(true); setSelectedTicket(null); - clearCreateAttachment(); + clearCreateAttachments(); }} > @@ -540,7 +447,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); - clearReplyAttachment(); + clearReplyAttachments(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id @@ -629,32 +536,40 @@ export default function Support() { />
- {/* Image attachment for create */} + {/* Image attachments for create */}
{ - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, setCreateAttachment); + const files = Array.from(e.target.files || []); + files.forEach((file) => handleFileSelect(file, setCreateAttachments)); e.target.value = ''; }} /> - {createAttachment ? ( - clearCreateAttachment()} - /> - ) : ( + + setCreateAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }) + } + /> + {createAttachments.length < 10 && ( )}
@@ -668,7 +583,7 @@ export default function Support() {
-
{msg.message_text}
+ {msg.message_text && ( +
+ )} {/* Display media if present */} - +
))}
@@ -763,46 +686,56 @@ export default function Support() { placeholder={t('support.replyPlaceholder')} value={replyMessage} onChange={(e) => setReplyMessage(e.target.value)} - required - minLength={1} maxLength={4000} />
- {/* Image attachment for reply */} + {/* Image attachments for reply */} +
+ { + const files = Array.from(e.target.files || []); + files.forEach((file) => handleFileSelect(file, setReplyAttachments)); + e.target.value = ''; + }} + /> + + setReplyAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }) + } + /> +
-
- { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, setReplyAttachment); - e.target.value = ''; - }} - /> - {replyAttachment ? ( - clearReplyAttachment()} - /> - ) : ( - - )} -
+ {replyAttachments.length < 10 && ( + + )}