mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: add media attachment support for admin ticket replies
Admin can now attach photos, videos, and documents when replying to tickets. Upload happens on file selection via existing media upload endpoint. Includes attachment preview with type-specific icons, upload progress, error handling, race condition protection, and proper blob URL lifecycle management. Added video player rendering for video attachments in message thread. Updated ticket list preview to show [media_type] for media-only messages.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -6,6 +6,34 @@ import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '..
|
||||
import { ticketsApi } from '../api/tickets';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
|
||||
interface MediaAttachment {
|
||||
file: File;
|
||||
preview: string;
|
||||
uploading: boolean;
|
||||
fileId?: string;
|
||||
mediaType: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ALLOWED_FILE_TYPES: Record<string, string> = {
|
||||
'image/jpeg': 'photo',
|
||||
'image/png': 'photo',
|
||||
'image/gif': 'photo',
|
||||
'image/webp': 'photo',
|
||||
'video/mp4': 'video',
|
||||
'video/webm': 'video',
|
||||
'video/quicktime': 'video',
|
||||
'application/pdf': 'document',
|
||||
'application/msword': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
|
||||
'text/plain': 'document',
|
||||
'application/zip': 'document',
|
||||
'application/x-rar-compressed': 'document',
|
||||
};
|
||||
|
||||
const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(',');
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function AdminMessageMedia({
|
||||
message,
|
||||
t,
|
||||
@@ -80,6 +108,22 @@ function AdminMessageMedia({
|
||||
);
|
||||
}
|
||||
|
||||
if (message.media_type === 'video') {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<video
|
||||
src={mediaUrl}
|
||||
controls
|
||||
className="max-h-64 max-w-full rounded-lg"
|
||||
preload="metadata"
|
||||
/>
|
||||
{message.media_caption && (
|
||||
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<a
|
||||
@@ -93,9 +137,13 @@ function AdminMessageMedia({
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
{message.media_caption || `Download ${message.media_type}`}
|
||||
</a>
|
||||
@@ -126,6 +174,20 @@ export default function AdminTickets() {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [attachment, setAttachment] = useState<MediaAttachment | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const previewRef = useRef<string | null>(null);
|
||||
const uploadIdRef = useRef(0);
|
||||
|
||||
// Cancel in-flight uploads and cleanup blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
uploadIdRef.current++;
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-ticket-stats'],
|
||||
@@ -149,10 +211,18 @@ export default function AdminTickets() {
|
||||
});
|
||||
|
||||
const replyMutation = useMutation({
|
||||
mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) =>
|
||||
adminApi.replyToTicket(ticketId, message),
|
||||
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'] });
|
||||
@@ -169,10 +239,84 @@ export default function AdminTickets() {
|
||||
},
|
||||
});
|
||||
|
||||
const clearAttachment = () => {
|
||||
uploadIdRef.current++;
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current);
|
||||
previewRef.current = null;
|
||||
}
|
||||
setAttachment(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Revoke any existing blob URL before processing new file
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current);
|
||||
previewRef.current = null;
|
||||
}
|
||||
|
||||
const mediaType = ALLOWED_FILE_TYPES[file.type];
|
||||
if (!mediaType) {
|
||||
setAttachment({
|
||||
file,
|
||||
preview: '',
|
||||
uploading: false,
|
||||
mediaType: 'document',
|
||||
error: t('admin.tickets.invalidFileType'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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) : '';
|
||||
previewRef.current = preview;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedTicketId || !replyText.trim()) return;
|
||||
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText });
|
||||
if (attachment && (attachment.uploading || attachment.error)) return;
|
||||
|
||||
const media = attachment?.fileId
|
||||
? {
|
||||
media_type: attachment.mediaType,
|
||||
media_file_id: attachment.fileId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText, media });
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
@@ -321,7 +465,11 @@ export default function AdminTickets() {
|
||||
{ticketsData?.items.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicketId(ticket.id)}
|
||||
onClick={() => {
|
||||
setSelectedTicketId(ticket.id);
|
||||
setReplyText('');
|
||||
clearAttachment();
|
||||
}}
|
||||
className={`w-full rounded-xl border p-4 text-left transition-all ${
|
||||
selectedTicketId === ticket.id
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
@@ -359,7 +507,12 @@ export default function AdminTickets() {
|
||||
{ticket.last_message.is_from_admin
|
||||
? t('admin.tickets.you')
|
||||
: t('admin.tickets.user')}
|
||||
: {ticket.last_message.message_text.substring(0, 50)}...
|
||||
:{' '}
|
||||
{ticket.last_message.message_text
|
||||
? `${ticket.last_message.message_text.substring(0, 50)}${ticket.last_message.message_text.length > 50 ? '...' : ''}`
|
||||
: ticket.last_message.has_media
|
||||
? `[${ticket.last_message.media_type}]`
|
||||
: '...'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -501,7 +654,9 @@ export default function AdminTickets() {
|
||||
{new Date(msg.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
|
||||
{msg.message_text && (
|
||||
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
|
||||
)}
|
||||
<AdminMessageMedia message={msg} t={t} />
|
||||
</div>
|
||||
))}
|
||||
@@ -517,10 +672,118 @@ export default function AdminTickets() {
|
||||
rows={3}
|
||||
className="input resize-none"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
|
||||
{/* Attachment preview */}
|
||||
{attachment && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-lg border border-dark-700/50 bg-dark-800/50 p-2">
|
||||
{attachment.mediaType === 'photo' && attachment.preview ? (
|
||||
<img
|
||||
src={attachment.preview}
|
||||
alt="Preview"
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-dark-700">
|
||||
<svg
|
||||
className="h-6 w-6 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{attachment.mediaType === 'video' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-dark-200">{attachment.file.name}</div>
|
||||
{attachment.uploading && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-accent-400">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
{t('admin.tickets.uploading')}
|
||||
</div>
|
||||
)}
|
||||
{attachment.error && (
|
||||
<div className="text-xs text-red-400">{attachment.error}</div>
|
||||
)}
|
||||
{attachment.fileId && !attachment.uploading && (
|
||||
<div className="text-xs text-success-400">
|
||||
{t('admin.tickets.uploadComplete')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAttachment}
|
||||
className="shrink-0 rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT_STRING}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!!attachment?.uploading}
|
||||
className="flex items-center gap-2 rounded-lg border border-dark-700/50 px-3 py-2 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200 disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
|
||||
/>
|
||||
</svg>
|
||||
{t('admin.tickets.attachMedia')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!replyText.trim() || replyMutation.isPending}
|
||||
disabled={
|
||||
!replyText.trim() ||
|
||||
replyMutation.isPending ||
|
||||
!!attachment?.uploading ||
|
||||
!!attachment?.error
|
||||
}
|
||||
className="btn-primary"
|
||||
>
|
||||
{replyMutation.isPending ? (
|
||||
|
||||
Reference in New Issue
Block a user