'use client'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as tus from 'tus-js-client'; import { toast } from 'sonner'; import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog'; import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/video-page/bunny-preview-player'; import { AssetListSection } from '@/components/video-page/asset-list-section'; import type { VideoAsset } from '@/components/video-page/types'; import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils'; import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cn } from '@/lib/utils'; const MAX_AUDIO_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB const MAX_AUDIO_UPLOAD_SIZE_MESSAGE = 'File too large. Maximum size is 10MB.'; function formatTime(seconds: number): string { const s = Math.floor(seconds); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } type UploadAudioResponse = { data?: { url?: string }; error?: string; }; function getAudioUploadValidationError(file: Blob): string | null { if (file.size > MAX_AUDIO_UPLOAD_SIZE) { return MAX_AUDIO_UPLOAD_SIZE_MESSAGE; } return null; } async function readUploadAudioResponse(response: Response): Promise { const raw = await response.text().catch(() => ''); const trimmed = raw.trim(); if (!trimmed) return null; try { return JSON.parse(trimmed) as UploadAudioResponse; } catch { if (trimmed.startsWith('<')) return null; return { error: trimmed }; } } interface AssetsPaneProps { videoId: string; assets: VideoAsset[]; isLoadingAssets: boolean; isCreatingAsset: boolean; activeDeleteAssetId: string | null; activeDownloadAssetId: string | null; canUploadAssets: boolean; canDownloadAssets: boolean; getGuestUploadToken: (intent: 'image' | 'audio') => Promise; createAsset: (payload: { provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO'; displayName?: string; sourceUrl: string; providerVideoId?: string; thumbnailUrl?: string; uploadToken?: string; }) => Promise; deleteAsset: (assetId: string) => Promise; downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise; hasMoreAssets: boolean; isLoadingMoreAssets: boolean; loadMoreAssets: () => Promise; highlightedAssetId: string | null; onHighlightedAssetHandled: () => void; } export const AssetsPane = memo(function AssetsPane({ videoId, assets, isLoadingAssets, isCreatingAsset, activeDeleteAssetId, activeDownloadAssetId, canUploadAssets, canDownloadAssets, getGuestUploadToken, createAsset, deleteAsset, downloadAsset, hasMoreAssets, isLoadingMoreAssets, loadMoreAssets, highlightedAssetId, onHighlightedAssetHandled, }: AssetsPaneProps) { const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image'); const [imageTitle, setImageTitle] = useState(''); const [pendingImageFile, setPendingImageFile] = useState(null); const [youtubeUrl, setYoutubeUrl] = useState(''); const [youtubeTitle, setYoutubeTitle] = useState(''); const [bunnyTitle, setBunnyTitle] = useState(''); const [isUploadingBunny, setIsUploadingBunny] = useState(false); const [bunnyProgress, setBunnyProgress] = useState(0); const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState>({}); const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState>({}); const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState>({}); const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState>({}); const [previewImage, setPreviewImage] = useState(null); const [previewImageTitle, setPreviewImageTitle] = useState(null); const [selectedAsset, setSelectedAsset] = useState(null); const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const [focusedAssetId, setFocusedAssetId] = useState(null); const bunnyPreviewPlayerRef = useRef(null); const youtubeIframeRef = useRef(null); const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false }); const imageInputRef = useRef(null); const bunnyInputRef = useRef(null); // Voice recording state const [voiceTitle, setVoiceTitle] = useState(''); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioBlobUrl, setAudioBlobUrl] = useState(null); const [pendingAudioFile, setPendingAudioFile] = useState(null); const [isUploadingVoice, setIsUploadingVoice] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); // Drag-drop state const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); // Audio playback for asset preview dialog and recording preview const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia(); const sortedAssets = useMemo(() => { return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); }, [assets]); useEffect(() => { if (!highlightedAssetId) return; const element = document.getElementById(`asset-card-${highlightedAssetId}`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setFocusedAssetId(highlightedAssetId); window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500); } onHighlightedAssetHandled(); }, [highlightedAssetId, onHighlightedAssetHandled]); useEffect(() => { if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; const sendYouTubeCommand = (func: string, args: unknown[] = []) => { const iframe = youtubeIframeRef.current; if (!iframe?.contentWindow) return; iframe.contentWindow.postMessage(JSON.stringify({ event: 'command', func, args, }), '*'); }; const onMessage = (event: MessageEvent) => { if (!selectedAsset || selectedAsset.provider !== 'YOUTUBE') return; if (typeof event.data !== 'string') return; let parsed: unknown; try { parsed = JSON.parse(event.data); } catch { return; } const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info; if (!info) return; if (typeof info.currentTime === 'number') { youtubePreviewStateRef.current.currentTime = info.currentTime; } if (typeof info.playerState === 'number') { youtubePreviewStateRef.current.isPlaying = info.playerState === 1; } if (typeof info.muted === 'boolean') { youtubePreviewStateRef.current.isMuted = info.muted; } }; const onKeyDown = (event: KeyboardEvent) => { if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return; const target = event.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']); if (!handledKeys.has(event.code)) return; event.preventDefault(); event.stopPropagation(); if (event.code === 'Escape') { setSelectedAsset(null); return; } if (selectedAsset.provider === 'BUNNY') { switch (event.code) { case 'Space': case 'KeyK': bunnyPreviewPlayerRef.current?.togglePlayPause(); break; case 'ArrowLeft': case 'KeyJ': bunnyPreviewPlayerRef.current?.seekBy(-10); break; case 'ArrowRight': case 'KeyL': bunnyPreviewPlayerRef.current?.seekBy(10); break; case 'KeyM': bunnyPreviewPlayerRef.current?.toggleMute(); break; } return; } if (selectedAsset.provider === 'YOUTUBE') { switch (event.code) { case 'Space': case 'KeyK': { const isPlaying = youtubePreviewStateRef.current.isPlaying; sendYouTubeCommand(isPlaying ? 'pauseVideo' : 'playVideo'); youtubePreviewStateRef.current.isPlaying = !isPlaying; break; } case 'ArrowLeft': case 'KeyJ': { const next = Math.max(0, youtubePreviewStateRef.current.currentTime - 10); sendYouTubeCommand('seekTo', [next, true]); youtubePreviewStateRef.current.currentTime = next; break; } case 'ArrowRight': case 'KeyL': { const next = youtubePreviewStateRef.current.currentTime + 10; sendYouTubeCommand('seekTo', [next, true]); youtubePreviewStateRef.current.currentTime = next; break; } case 'KeyM': { const isMuted = youtubePreviewStateRef.current.isMuted; sendYouTubeCommand(isMuted ? 'unMute' : 'mute'); youtubePreviewStateRef.current.isMuted = !isMuted; break; } } } }; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('message', onMessage); return () => { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('message', onMessage); }; }, [selectedAsset]); useEffect(() => { if (!selectedAsset || selectedAsset.provider !== 'BUNNY') return; if (bunnyReadyByAssetId[selectedAsset.id]) return; setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true })); }, [bunnyReadyByAssetId, selectedAsset]); const handleImageUpload = useCallback(async (file: File) => { if (!file) return; const imageError = await validateImageFile(file); if (imageError) { toast.error(imageError); return; } try { const formData = new FormData(); formData.append('image', file); formData.append('videoId', videoId); const guestUploadToken = await getGuestUploadToken('image'); if (guestUploadToken) formData.append('uploadToken', guestUploadToken); const uploadRes = await fetch('/api/upload/image', { method: 'POST', body: formData, }); const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null; const uploadedImageUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedImageUrl) { toast.error(uploadPayload?.error || 'Failed to upload image'); return; } await createAsset({ provider: 'R2_IMAGE', sourceUrl: uploadedImageUrl, displayName: imageTitle.trim() || file.name, }); if (imageInputRef.current) imageInputRef.current.value = ''; setImageTitle(''); setPendingImageFile(null); } catch (error) { console.error('Failed to upload image asset:', error); toast.error('Failed to upload image'); } }, [videoId, getGuestUploadToken, createAsset, imageTitle]); const handleImageFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const imageError = await validateImageFile(file); if (imageError) { toast.error(imageError); return; } setPendingImageFile(file); toast.success('Image attached. Click Upload Image to send.'); }; const handleImagePaste = async (event: React.ClipboardEvent) => { if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; const pastedImage = extractPastedImageFile(event.clipboardData); if (!pastedImage) return; event.preventDefault(); const imageError = await validateImageFile(pastedImage); if (imageError) { toast.error(imageError); return; } setPendingImageFile(pastedImage); toast.success('Image attached from clipboard. Click Upload Image to send.'); }; const handleCreateYoutubeAsset = async () => { if (!youtubeUrl.trim()) return; const created = await createAsset({ provider: 'YOUTUBE', sourceUrl: youtubeUrl.trim(), displayName: youtubeTitle.trim() || undefined, }); if (created) { setYoutubeUrl(''); setYoutubeTitle(''); } }; const handleBunnyFileUpload = useCallback(async (file: File) => { if (!file.type.startsWith('video/')) { toast.error('Please select a video file'); return; } let uploadedVideoId: string | null = null; let uploadToken: string | null = null; try { setIsUploadingBunny(true); setBunnyProgress(0); const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }), }); const initPayload = (await initRes.json().catch(() => null)) as { data?: { videoId: string; libraryId: string; signature: string; expirationTime: number; uploadToken: string; }; error?: string; } | null; if (!initRes.ok || !initPayload?.data) { toast.error(initPayload?.error || 'Failed to initialize Bunny upload'); return; } const initData = initPayload.data; uploadedVideoId = initData.videoId; uploadToken = initData.uploadToken; await new Promise((resolve, reject) => { const upload = new tus.Upload(file, { endpoint: 'https://video.bunnycdn.com/tusupload', retryDelays: [0, 3000, 5000, 10000, 20000], headers: { AuthorizationSignature: initData.signature, AuthorizationExpire: initData.expirationTime.toString(), VideoId: initData.videoId, LibraryId: initData.libraryId, }, metadata: { filetype: file.type, title: file.name, }, onError: (error) => reject(error), onProgress: (bytesUploaded, bytesTotal) => { const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0; setBunnyProgress(Math.min(100, Math.max(0, percentage))); }, onSuccess: () => resolve(), }); upload.start(); }); const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`; const thumbnailUrl = bunnyCdnHostname ? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg` : undefined; const createdAsset = await createAsset({ provider: 'BUNNY', sourceUrl, providerVideoId: initData.videoId, uploadToken: initData.uploadToken, thumbnailUrl, displayName: bunnyTitle.trim() || file.name, }); if (!createdAsset) { throw new Error('Failed to finalize Bunny asset'); } setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false })); setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true })); if (bunnyInputRef.current) bunnyInputRef.current.value = ''; setBunnyTitle(''); } catch (error) { console.error('Failed to upload Bunny asset:', error); toast.error('Failed to upload Bunny video'); if (uploadedVideoId && uploadToken) { await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }), }).catch(() => undefined); } } finally { setIsUploadingBunny(false); setBunnyProgress(0); } }, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]); const handleBunnyUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; await handleBunnyFileUpload(file); }; const handleBunnyThumbnailError = (assetId: string) => { const alreadyReady = !!bunnyReadyByAssetId[assetId]; setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: true })); if (!alreadyReady) { setBunnyProcessingByAssetId((prev) => (prev[assetId] ? prev : { ...prev, [assetId]: true })); setBunnyReadyByAssetId((prev) => ({ ...prev, [assetId]: false })); } window.setTimeout(() => { setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() })); setBunnyThumbnailLoadErrorByAssetId((prev) => ({ ...prev, [assetId]: false })); }, 10000); }; const handleBunnyThumbnailLoad = (assetId: string) => { setBunnyThumbnailLoadErrorByAssetId((prev) => { if (!prev[assetId]) return prev; return { ...prev, [assetId]: false }; }); }; const startRecording = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; const recorder = new MediaRecorder(stream, { mimeType }); audioChunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; recorder.onstop = () => { const blob = new Blob(audioChunksRef.current, { type: mimeType }); setAudioBlob(blob); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); }); stream.getTracks().forEach((t) => t.stop()); }; mediaRecorderRef.current = recorder; recorder.start(100); setIsRecording(true); setRecordingTime(0); recordingTimerRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000); } catch { toast.error('Could not access microphone'); } }, []); const stopRecording = useCallback(() => { mediaRecorderRef.current?.stop(); mediaRecorderRef.current = null; setIsRecording(false); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } }, []); const cancelRecording = useCallback(() => { mediaRecorderRef.current?.stop(); mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop()); mediaRecorderRef.current = null; setIsRecording(false); setRecordingTime(0); setAudioBlob(null); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setPendingAudioFile(null); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } }, []); const handleVoiceUpload = useCallback(async () => { const uploadSource = pendingAudioFile ?? audioBlob; if (!uploadSource) return; const validationError = getAudioUploadValidationError(uploadSource); if (validationError) { toast.error(validationError); return; } setIsUploadingVoice(true); try { const formData = new FormData(); if (pendingAudioFile) { formData.append('audio', pendingAudioFile); } else { formData.append('audio', audioBlob!, 'recording.webm'); } formData.append('videoId', videoId); const guestUploadToken = await getGuestUploadToken('audio'); if (guestUploadToken) formData.append('uploadToken', guestUploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData, }); const uploadPayload = await readUploadAudioResponse(uploadRes); const uploadedUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedUrl) { toast.error( uploadPayload?.error || (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) || 'Failed to upload voice recording' ); return; } const fallbackName = pendingAudioFile ? pendingAudioFile.name.replace(/\.[^/.]+$/, '') : 'Voice Recording'; await createAsset({ provider: 'R2_AUDIO', sourceUrl: uploadedUrl, displayName: voiceTitle.trim() || fallbackName, }); setVoiceTitle(''); setAudioBlob(null); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setPendingAudioFile(null); } catch (error) { console.error('Failed to upload voice asset:', error); toast.error(error instanceof Error ? error.message : 'Failed to upload voice recording'); } finally { setIsUploadingVoice(false); } }, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!canUploadAssets) return; dragCounterRef.current += 1; if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); }, [canUploadAssets]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current -= 1; if (dragCounterRef.current === 0) setIsDragOver(false); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); if (!canUploadAssets) return; const file = Array.from(e.dataTransfer.files)[0]; if (!file) return; if (file.type.startsWith('image/')) { const imageError = await validateImageFile(file); if (imageError) { toast.error(imageError); return; } // Stage the file so the user can optionally set a name before uploading setUploadTab('image'); setPendingImageFile(file); } else if (file.type.startsWith('video/')) { // Videos upload immediately (large files, no staging) setUploadTab('bunny'); await handleBunnyFileUpload(file); } else if (file.type.startsWith('audio/')) { const audioError = getAudioUploadValidationError(file); if (audioError) { toast.error(audioError); return; } // Stage the file so the user can optionally set a name before uploading setUploadTab('voice'); setPendingAudioFile(file); } else { toast.error('Unsupported file type. Drop an image, video, or audio file.'); } }, [canUploadAssets, handleBunnyFileUpload]); const renderAssetPreview = (asset: VideoAsset) => { if (asset.kind === 'AUDIO') { return (
Voice Recording
); } if (asset.kind === 'IMAGE') { const imageSrc = asset.thumbnailUrl || asset.sourceUrl; return (
{imageSrc ? ( // eslint-disable-next-line @next/next/no-img-element {asset.displayName} ) : ( )}
); } if (asset.provider === 'YOUTUBE' && asset.providerVideoId) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} {asset.displayName}
); } const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0; const isProcessing = !!bunnyProcessingByAssetId[asset.id]; const isReadyToPlay = !!bunnyReadyByAssetId[asset.id]; const hasThumbnailLoadError = !!bunnyThumbnailLoadErrorByAssetId[asset.id]; const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null; const showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError; return (
{showThumbnailImage ? ( // eslint-disable-next-line @next/next/no-img-element handleBunnyThumbnailLoad(asset.id)} onError={() => handleBunnyThumbnailError(asset.id)} /> ) : isReadyToPlay ? (
Ready to play
) : ( )} {isProcessing && !isReadyToPlay && (
Processing...
)}
); }; const handleOpenAsset = (asset: VideoAsset) => { if (asset.kind === 'IMAGE') { if (!asset.sourceUrl) { toast.error('Preview is unavailable for this asset'); return; } setPreviewImage(asset.sourceUrl); setPreviewImageTitle(asset.displayName); return; } if (asset.kind === 'AUDIO') { setSelectedAsset(asset); return; } if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) { setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true })); } setSelectedAsset(asset); }; const selectedBunnyAssetId = selectedAsset?.provider === 'BUNNY' ? selectedAsset.id : null; const isSelectedBunnyProcessing = selectedBunnyAssetId ? !!bunnyProcessingByAssetId[selectedBunnyAssetId] && !bunnyReadyByAssetId[selectedBunnyAssetId] : false; return (
Assets {assets.length}
{canUploadAssets ? (
{isDragOver && (
Drop to upload
)} setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}> Image YouTube Video Voice {uploadTab === 'image' && (
setImageTitle(event.target.value)} />

If set, this name will be used in @asset mentions.

Tip: you can paste an image here with Ctrl/Cmd+V.

{pendingImageFile ? (
Attached: {pendingImageFile.name}
) : null}
)} {uploadTab === 'youtube' && (
setYoutubeUrl(event.target.value)} /> setYoutubeTitle(event.target.value)} />
)} {uploadTab === 'bunny' && (
setBunnyTitle(event.target.value)} />

If set, this name will be used in @asset mentions.

{isUploadingBunny && (
)}
)} {uploadTab === 'voice' && (
setVoiceTitle(e.target.value)} disabled={isRecording || isUploadingVoice} /> {pendingAudioFile ? (
Attached: {pendingAudioFile.name}
) : isRecording ? (
Recording {String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
) : audioBlob ? (
{playingVoiceId === 'recording-preview' ? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}` : formatTime(recordingTime)} {playingVoiceId === 'recording-preview' && ( )}
) : ( )}

Or drag an audio file anywhere onto this panel.

)}
) : (
You do not have permission to upload assets.
)} void downloadAsset(asset, preference)} onDeleteAsset={(assetId) => void deleteAsset(assetId)} onLoadMoreAssets={() => void loadMoreAssets()} renderAssetPreview={renderAssetPreview} /> { setPreviewImage(null); setPreviewImageTitle(null); }} /> { if (!open) { stopVoice(); setSelectedAsset(null); } }}> {selectedAsset?.displayName || 'Voice Recording'} {selectedAsset?.sourceUrl ? (
{playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'} {playingVoiceId === selectedAsset?.id && ( )}
) : (

Audio preview unavailable.

)}
!open && setSelectedAsset(null)}> setSelectedAsset(null)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') { event.preventDefault(); setSelectedAsset(null); } }} > {selectedAsset?.displayName || 'Video Preview'}
e.stopPropagation()}>

{selectedAsset?.displayName || 'Video Preview'}

{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? ( ) : null} {selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? ( void downloadAsset(selectedAsset, 'original')}> Original void downloadAsset(selectedAsset, 'compressed')}> Compressed ) : null}
{selectedAsset ? ( selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (