'use client'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import * as tus from 'tus-js-client'; import { toast } from 'sonner'; import { Download, FileVideo, Image as ImageIcon, Loader2, Play, UploadCloud, 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 { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; interface AssetsPaneProps { videoId: string; assets: VideoAsset[]; isLoadingAssets: boolean; isCreatingAsset: boolean; activeDeleteAssetId: string | null; activeDownloadAssetId: string | null; canUploadAssets: boolean; canDownloadAssets: boolean; getGuestUploadToken: (intent: 'image') => Promise; createAsset: (payload: { provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY'; 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'>('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); 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 = async (file: File) => { if (!file) return; const imageError = 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'); } }; const handleImageFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const imageError = validateImageFile(file); if (imageError) { toast.error(imageError); return; } setPendingImageFile(file); toast.success('Image attached. Click Upload Image to send.'); }; const handleImagePaste = (event: React.ClipboardEvent) => { if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; const pastedImage = extractPastedImageFile(event.clipboardData); if (!pastedImage) return; const imageError = validateImageFile(pastedImage); if (imageError) { toast.error(imageError); return; } event.preventDefault(); 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 handleBunnyUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; 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); } }; 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 renderAssetPreview = (asset: VideoAsset) => { 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.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 ? (
setUploadTab(value as 'image' | 'youtube' | 'bunny')}> Image YouTube Video {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 && (
)}
)}
) : (
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); }} /> !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 ? (