feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)

This commit is contained in:
Yusuf İpek
2026-02-25 18:34:03 +03:00
parent 6eea327083
commit 9ce033d306
32 changed files with 3524 additions and 231 deletions
+60
View File
@@ -23,6 +23,7 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
import { CommentComposer } from '@/components/video-page/comment-composer';
import { CommentsPane } from '@/components/video-page/comments-pane';
import { AssetsPane } from '@/components/video-page/assets-pane';
import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
import type {
@@ -34,6 +35,7 @@ import type {
VideoPageHeaderActions,
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
function formatTime(seconds: number): string {
const totalSeconds = Math.floor(seconds);
@@ -92,6 +94,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toggleVoiceSpeed,
} = useCommentMedia();
const [showResolved, setShowResolved] = useState(false);
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
const [highlightedAssetId, setHighlightedAssetId] = useState<string | null>(null);
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
@@ -139,6 +143,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const isGuest = video ? !video.isAuthenticated : false;
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
const normalizedGuestName = guestName.trim();
const canUploadAssets = !!video?.canUploadAssets;
const canDownloadAssets = !!video?.canDownloadAssets;
const {
assets,
isLoadingAssets,
isCreatingAsset,
activeDeleteAssetId,
activeDownloadAssetId,
hasMoreAssets,
isLoadingMoreAssets,
loadMoreAssets,
createAsset,
deleteAsset,
downloadAsset,
getGuestUploadToken,
} = useVideoAssets({
videoId,
isAuthenticated: !!video?.isAuthenticated,
canUploadAssets,
canDownloadAssets,
guestName: normalizedGuestName,
});
const {
showVersionDialog,
@@ -181,6 +208,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setShowResolved(prev => !prev);
}, []);
const handleAssetMentionClick = useCallback((assetId: string) => {
setActiveSidePane('assets');
setHighlightedAssetId(assetId);
}, []);
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
activeVersionId,
showResolved,
@@ -191,6 +223,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const currentUserName = video?.currentUserName || null;
const canResolveComments = !!video?.canResolveComments;
const canRequestApproval = !!video?.canRequestApproval;
const canShareVideo = !!video?.canShareVideo;
const {
requests: approvalRequests,
@@ -655,6 +688,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onCreateVersion={headerActions.onCreateVersion}
onOpenCompare={headerActions.onOpenCompare}
canRequestApproval={canRequestApproval}
canShareVideo={canShareVideo}
hasPendingApprovalRequest={!!activePendingRequest}
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
onOpenApprovalsPanel={handleOpenApprovalsPanel}
@@ -779,6 +813,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
isSubmittingReply={isSubmittingReply}
isUploadingReplyAudio={isUploadingReplyAudio}
isUploadingReplyImage={isUploadingReplyImage}
assets={assets}
onAssetMentionClick={handleAssetMentionClick}
activePane={activeSidePane}
setActivePane={setActiveSidePane}
assetsPane={(
<AssetsPane
videoId={videoId}
assets={assets}
isLoadingAssets={isLoadingAssets}
isCreatingAsset={isCreatingAsset}
activeDeleteAssetId={activeDeleteAssetId}
activeDownloadAssetId={activeDownloadAssetId}
canUploadAssets={canUploadAssets}
canDownloadAssets={canDownloadAssets}
getGuestUploadToken={getGuestUploadToken}
createAsset={createAsset}
deleteAsset={deleteAsset}
downloadAsset={downloadAsset}
hasMoreAssets={hasMoreAssets}
isLoadingMoreAssets={isLoadingMoreAssets}
loadMoreAssets={loadMoreAssets}
highlightedAssetId={highlightedAssetId}
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
/>
)}
composer={(
<CommentComposer
isRecording={isRecording}
@@ -816,6 +875,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
canManageTags={!!video.canManageTags}
projectId={projectId}
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
assets={assets}
/>
)}
/>
@@ -0,0 +1,180 @@
'use client';
import { memo, type ReactNode } from 'react';
import { Download, ExternalLink, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { VideoAsset } from '@/components/video-page/types';
interface AssetListSectionProps {
assets: VideoAsset[];
isLoadingAssets: boolean;
focusedAssetId: string | null;
bunnyProcessingByAssetId: Record<string, boolean>;
activeDownloadAssetId: string | null;
activeDeleteAssetId: string | null;
canDownloadAssets: boolean;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
onViewAsset: (asset: VideoAsset) => void;
onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
onDeleteAsset: (assetId: string) => void;
onLoadMoreAssets: () => void;
renderAssetPreview: (asset: VideoAsset) => ReactNode;
}
export const AssetListSection = memo(function AssetListSection({
assets,
isLoadingAssets,
focusedAssetId,
bunnyProcessingByAssetId,
activeDownloadAssetId,
activeDeleteAssetId,
canDownloadAssets,
hasMoreAssets,
isLoadingMoreAssets,
onViewAsset,
onDownloadAsset,
onDeleteAsset,
onLoadMoreAssets,
renderAssetPreview,
}: AssetListSectionProps) {
if (isLoadingAssets) {
return (
<div className="text-sm text-muted-foreground flex items-center gap-2 py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading assets...
</div>
);
}
if (assets.length === 0) {
return (
<div className="text-sm text-muted-foreground py-4 text-center border rounded-lg">
No assets uploaded yet.
</div>
);
}
return (
<div className="space-y-2">
{assets.map((asset) => (
<div
key={asset.id}
id={`asset-card-${asset.id}`}
className={cn(
'rounded-lg border p-2 flex gap-3 transition-colors',
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
)}
>
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
{renderAssetPreview(asset)}
</button>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium truncate">{asset.displayName}</p>
<div className="flex items-center gap-1 shrink-0">
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? (
<Badge variant="secondary" className="text-[10px] gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</Badge>
) : null}
</div>
</div>
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
</p>
<div className="pt-1 flex items-center gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="View asset"
aria-label="View asset"
disabled={asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
)
)}
{asset.canDelete && (
<Button
size="icon"
variant="destructive"
className="h-7 w-7"
title="Delete asset"
aria-label="Delete asset"
disabled={activeDeleteAssetId === asset.id}
onClick={() => onDeleteAsset(asset.id)}
>
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
)}
</div>
</div>
</div>
))}
{hasMoreAssets ? (
<Button
variant="outline"
className="w-full"
disabled={isLoadingMoreAssets}
onClick={onLoadMoreAssets}
>
{isLoadingMoreAssets ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
{isLoadingMoreAssets ? 'Loading more...' : 'Load more'}
</Button>
) : null}
</div>
);
});
+742
View File
@@ -0,0 +1,742 @@
'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, 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';
interface AssetsPaneProps {
videoId: string;
assets: VideoAsset[];
isLoadingAssets: boolean;
isCreatingAsset: boolean;
activeDeleteAssetId: string | null;
activeDownloadAssetId: string | null;
canUploadAssets: boolean;
canDownloadAssets: boolean;
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
createAsset: (payload: {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
}) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
loadMoreAssets: () => Promise<void>;
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<File | null>(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<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
const imageInputRef = useRef<HTMLInputElement>(null);
const bunnyInputRef = useRef<HTMLInputElement>(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]);
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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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<void>((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 = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`;
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');
}
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) => {
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: true }));
window.setTimeout(() => {
setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false }));
}, 10000);
};
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
return (
<div className="h-24 w-36 rounded border bg-black/20 flex items-center justify-center overflow-hidden">
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageSrc} alt={asset.displayName} className="h-full w-full object-contain" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
);
}
if (asset.provider === 'YOUTUBE' && asset.providerVideoId) {
return (
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.thumbnailUrl || `https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`}
alt={asset.displayName}
className="h-full w-full object-contain"
/>
</div>
);
}
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
return (
<div className="h-24 w-36 rounded border overflow-hidden bg-muted relative flex items-center justify-center">
{thumbnailSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbnailSrc}
alt={asset.displayName}
className="h-full w-full object-cover"
onError={() => handleBunnyThumbnailError(asset.id)}
/>
) : (
<FileVideo className="h-6 w-6 text-muted-foreground" />
)}
{isProcessing && (
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-white" />
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
</div>
)}
</div>
);
};
const handleOpenAsset = (asset: VideoAsset) => {
const isBunnyProcessing = asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id];
if (isBunnyProcessing) {
toast.info('This Bunny asset is still processing.');
return;
}
if (asset.kind === 'IMAGE') {
if (!asset.sourceUrl) {
toast.error('Preview is unavailable for this asset');
return;
}
setPreviewImage(asset.sourceUrl);
setPreviewImageTitle(asset.displayName);
return;
}
setSelectedAsset(asset);
};
return (
<div className="space-y-4" onPaste={handleImagePaste}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">Assets</span>
<Badge variant="secondary">{assets.length}</Badge>
</div>
</div>
{canUploadAssets ? (
<div className="rounded-lg border p-3 space-y-3">
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger>
<TabsTrigger value="bunny">Video</TabsTrigger>
</TabsList>
</Tabs>
{uploadTab === 'image' && (
<div className="space-y-2">
<Input
placeholder="Optional name for mentions/tagging"
value={imageTitle}
onChange={(event) => setImageTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<p className="text-xs text-muted-foreground">Tip: you can paste an image here with Ctrl/Cmd+V.</p>
{pendingImageFile ? (
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
<span className="truncate">Attached: {pendingImageFile.name}</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2"
onClick={() => {
setPendingImageFile(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
Clear
</Button>
</div>
) : null}
<Button
variant="outline"
className="w-full"
disabled={isCreatingAsset}
onClick={() => {
if (pendingImageFile) {
void handleImageUpload(pendingImageFile);
return;
}
imageInputRef.current?.click();
}}
>
<UploadCloud className="h-4 w-4 mr-2" />
{pendingImageFile ? 'Upload Image' : 'Select Image'}
</Button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFileChange}
/>
</div>
)}
{uploadTab === 'youtube' && (
<div className="space-y-2">
<Input
placeholder="https://youtube.com/watch?v=..."
value={youtubeUrl}
onChange={(event) => setYoutubeUrl(event.target.value)}
/>
<Input
placeholder="Optional display name"
value={youtubeTitle}
onChange={(event) => setYoutubeTitle(event.target.value)}
/>
<Button
className="w-full"
disabled={isCreatingAsset || !youtubeUrl.trim()}
onClick={handleCreateYoutubeAsset}
>
<Youtube className="h-4 w-4 mr-2" />
Add YouTube Asset
</Button>
</div>
)}
{uploadTab === 'bunny' && (
<div className="space-y-2">
<Input
placeholder="Optional name for mentions/tagging"
value={bunnyTitle}
onChange={(event) => setBunnyTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<Button
variant="outline"
className="w-full"
disabled={isUploadingBunny || isCreatingAsset}
onClick={() => bunnyInputRef.current?.click()}
>
{isUploadingBunny ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
</Button>
<input
ref={bunnyInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleBunnyUpload}
/>
{isUploadingBunny && (
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
<div className="bg-primary h-2 rounded-full" style={{ width: `${bunnyProgress}%` }} />
</div>
)}
</div>
)}
</div>
) : (
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
You do not have permission to upload assets.
</div>
)}
<AssetListSection
assets={sortedAssets}
isLoadingAssets={isLoadingAssets}
focusedAssetId={focusedAssetId}
bunnyProcessingByAssetId={bunnyProcessingByAssetId}
activeDownloadAssetId={activeDownloadAssetId}
activeDeleteAssetId={activeDeleteAssetId}
canDownloadAssets={canDownloadAssets}
hasMoreAssets={hasMoreAssets}
isLoadingMoreAssets={isLoadingMoreAssets}
onViewAsset={handleOpenAsset}
onDownloadAsset={(asset, preference) => void downloadAsset(asset, preference)}
onDeleteAsset={(assetId) => void deleteAsset(assetId)}
onLoadMoreAssets={() => void loadMoreAssets()}
renderAssetPreview={renderAssetPreview}
/>
<ImagePreviewDialog
previewImage={previewImage}
title={previewImageTitle}
downloadFileName={previewImageTitle}
canDownload={canDownloadAssets}
onClose={() => {
setPreviewImage(null);
setPreviewImageTitle(null);
}}
/>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none rounded-none flex items-center justify-center"
onClick={() => setSelectedAsset(null)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
setSelectedAsset(null);
}
}}
>
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={selectedAsset?.displayName || undefined}>
{selectedAsset?.displayName || 'Video Preview'}
</p>
{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0"
>
<a
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
target="_blank"
rel="noopener noreferrer"
>
Open on YouTube
</a>
</Button>
) : null}
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Download Bunny video"
aria-label="Download Bunny video"
disabled={
activeDownloadAssetId === selectedAsset.id
|| !!bunnyProcessingByAssetId[selectedAsset.id]
}
>
{activeDownloadAssetId === selectedAsset.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedAsset(null)}
>
<span className="sr-only">Close</span>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 w-full p-2 sm:p-4">
{selectedAsset ? (
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
<iframe
ref={youtubeIframeRef}
className="w-full h-full"
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
title={selectedAsset.displayName}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
) : (
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]}
/>
)
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
});
@@ -0,0 +1,270 @@
'use client';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface BunnyPreviewPlayerProps {
providerVideoId: string | null;
isProcessing: boolean;
}
export interface BunnyPreviewPlayerHandle {
togglePlayPause: () => void;
seekBy: (seconds: number) => void;
toggleMute: () => void;
}
const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
function resolveBunnyCdnHostname(): string {
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
try {
const parsed = new URL(configured);
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
} catch {
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
}
}
function formatTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return '0:00';
const total = Math.floor(value);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryAttemptRef = useRef(0);
const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loadError, setLoadError] = useState(false);
const playlistUrl = useMemo(() => {
if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
}, [providerVideoId]);
useEffect(() => {
const video = videoRef.current;
if (!video || !playlistUrl) return;
let destroyed = false;
let usingHlsJs = false;
const clearRetry = () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
const scheduleRetry = (retryFn: () => void) => {
clearRetry();
retryTimerRef.current = setTimeout(() => {
if (!destroyed) retryFn();
}, 3000);
};
const getRetryUrl = () => {
retryAttemptRef.current += 1;
const separator = playlistUrl.includes('?') ? '&' : '?';
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
};
const onLoadedMetadata = () => {
if (destroyed) return;
setIsReady(true);
setLoadError(false);
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
clearRetry();
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
const onError = () => {
if (destroyed) return;
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
setLoadError(true);
return;
}
setLoadError(false);
scheduleRetry(() => {
if (usingHlsJs && hlsRef.current) {
hlsRef.current.loadSource(getRetryUrl());
hlsRef.current.startLoad(-1);
} else {
video.src = getRetryUrl();
video.load();
}
});
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('error', onError);
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
if (Hls.isSupported()) {
const hls = new Hls();
hlsRef.current = hls;
usingHlsJs = true;
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) hls.loadSource(playlistUrl);
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (destroyed) return;
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
scheduleRetry(() => hls.loadSource(getRetryUrl()));
}
});
} else if (canPlayNativeHls) {
video.src = playlistUrl;
video.load();
} else {
// Defer state update to avoid sync setState directly in effect body.
window.setTimeout(() => {
if (!destroyed) setLoadError(true);
}, 0);
}
return () => {
destroyed = true;
clearRetry();
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('error', onError);
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
video.removeAttribute('src');
video.load();
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsReady(false);
};
}, [playlistUrl]);
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
if (!video || !duration) return;
const rect = event.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(video.currentTime);
};
const togglePlayPause = useCallback(() => {
const video = videoRef.current;
if (!video || !isReady || isProcessing) return;
if (video.paused) void video.play();
else video.pause();
}, [isProcessing, isReady]);
const seekBy = useCallback((seconds: number) => {
const video = videoRef.current;
if (!video || !isReady || isProcessing || !duration) return;
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
setCurrentTime(video.currentTime);
}, [duration, isProcessing, isReady]);
const toggleMute = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const nextMuted = !video.muted;
video.muted = nextMuted;
setIsMuted(nextMuted);
}, []);
useImperativeHandle(ref, () => ({
togglePlayPause,
seekBy,
toggleMute,
}), [seekBy, toggleMute, togglePlayPause]);
return (
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
{(isProcessing || (!isReady && !loadError)) && (
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
<div className="flex items-center gap-2 text-white text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</div>
</div>
)}
{loadError && !isProcessing && (
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
<p className="text-xs text-white/85">Unable to load Bunny preview right now.</p>
</div>
)}
</div>
<div className="shrink-0 border-t border-white/10 bg-black/70 px-2 py-1.5">
<div className="flex items-center gap-1.5 mb-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:text-white"
disabled={!isReady || isProcessing}
onClick={togglePlayPause}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:text-white"
disabled={!isReady}
onClick={toggleMute}
>
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
</Button>
<span className="text-[11px] text-white/80 tabular-nums ml-1">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div
className={cn(
'relative h-6 rounded bg-white/10 select-none',
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
)}
onClick={seekTo}
>
<div
className="absolute left-0 top-0 h-full rounded bg-cyan-500/40 pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<div
className="absolute top-0 h-full w-1 rounded bg-cyan-400 pointer-events-none"
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/>
</div>
</div>
</div>
);
});
+26 -20
View File
@@ -4,7 +4,6 @@ import { memo, type RefObject } from 'react';
import Link from 'next/link';
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +12,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { AnnotationStroke } from '@/components/annotation-canvas';
import type { CommentTag } from '@/components/video-page/types';
import { MentionTextarea } from '@/components/video-page/mention-textarea';
import type { CommentTag, VideoAsset } from '@/components/video-page/types';
interface CommentComposerProps {
isRecording: boolean;
@@ -51,6 +51,7 @@ interface CommentComposerProps {
canManageTags: boolean;
projectId?: string;
pauseVideoForAnnotation: () => void;
assets: VideoAsset[];
}
export const CommentComposer = memo(function CommentComposer({
@@ -89,6 +90,7 @@ export const CommentComposer = memo(function CommentComposer({
canManageTags,
projectId,
pauseVideoForAnnotation,
assets,
}: CommentComposerProps) {
return (
<div className="shrink-0 p-4 border-t bg-background">
@@ -168,10 +170,11 @@ export const CommentComposer = memo(function CommentComposer({
</div>
)}
<Textarea
<MentionTextarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onChange={setCommentText}
assets={assets}
rows={1}
className="resize-none text-sm"
/>
@@ -222,21 +225,24 @@ export const CommentComposer = memo(function CommentComposer({
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={(e) => handlePaste(e, false)}
/>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-stretch">
<div className="flex-1 min-w-0">
<MentionTextarea
placeholder="Add a comment..."
value={commentText}
onChange={setCommentText}
assets={assets}
rows={6}
className="resize-none text-sm min-h-[180px] w-full"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={(e) => handlePaste(e, false)}
/>
</div>
<div className="flex flex-col gap-1 self-end">
<Button
size="icon"
onClick={handleAddComment}
@@ -329,7 +335,7 @@ export const CommentComposer = memo(function CommentComposer({
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p>
</>
)}
</div>
@@ -0,0 +1,90 @@
'use client';
import React from 'react';
import { Image as ImageIcon, Video } from 'lucide-react';
import type { VideoAsset } from '@/components/video-page/types';
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
const ASSET_MENTION_REGEX = /@\[(.+?)\]\(asset:([a-z0-9]+)\)/gi;
interface CommentRichTextProps {
text: string;
onAssetMentionClick?: (assetId: string) => void;
assets?: VideoAsset[];
}
function renderUrls(text: string): React.ReactNode[] {
const parts = text.split(URL_REGEX);
return parts.map((part, index) => {
if (/^https?:\/\/[^\s]+$/.test(part)) {
return (
<a
key={`url-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(event) => event.stopPropagation()}
>
{part}
</a>
);
}
return <React.Fragment key={`txt-${index}`}>{part}</React.Fragment>;
});
}
export function CommentRichText({ text, onAssetMentionClick, assets = [] }: CommentRichTextProps) {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(ASSET_MENTION_REGEX)) {
const mentionIndex = match.index ?? -1;
if (mentionIndex < 0) continue;
if (mentionIndex > lastIndex) {
nodes.push(...renderUrls(text.slice(lastIndex, mentionIndex)));
}
const fallbackLabel = match[1] || 'asset';
const assetId = match[2] || '';
const matchedAsset = assets.find((asset) => asset.id === assetId);
const label = matchedAsset?.displayName || fallbackLabel;
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
nodes.push(
<button
key={`mention-${assetId}-${mentionIndex}`}
type="button"
className="inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-primary hover:bg-primary/20 transition-colors align-middle"
onClick={(event) => {
event.stopPropagation();
if (assetId && onAssetMentionClick) onAssetMentionClick(assetId);
}}
title={label}
>
<span
className={
isVideoAsset
? 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-violet-500/25 px-1 text-[9px] font-semibold tracking-wide text-violet-200'
: 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-emerald-500/25 px-1 text-[9px] font-semibold tracking-wide text-emerald-200'
}
>
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
{isVideoAsset ? 'VID' : 'SS'}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label}
</span>
</button>
);
lastIndex = mentionIndex + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(...renderUrls(text.slice(lastIndex)));
}
return <>{nodes}</>;
}
+119 -55
View File
@@ -1,11 +1,10 @@
'use client';
import { memo, type ReactNode, type RefObject } from 'react';
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,8 +13,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Linkify } from '@/components/linkify';
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
import { MentionTextarea } from '@/components/video-page/mention-textarea';
import { CommentRichText } from '@/components/video-page/comment-rich-text';
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types';
interface CommentsPaneProps {
isMobileCommentsOpen: boolean;
@@ -79,6 +79,11 @@ interface CommentsPaneProps {
isUploadingReplyAudio: boolean;
isUploadingReplyImage: boolean;
composer: ReactNode;
assets: VideoAsset[];
onAssetMentionClick: (assetId: string) => void;
activePane: 'comments' | 'assets';
setActivePane: (pane: 'comments' | 'assets') => void;
assetsPane: ReactNode;
}
export const CommentsPane = memo(function CommentsPane({
@@ -143,6 +148,11 @@ export const CommentsPane = memo(function CommentsPane({
isUploadingReplyAudio,
isUploadingReplyImage,
composer,
assets,
onAssetMentionClick,
activePane,
setActivePane,
assetsPane,
}: CommentsPaneProps) {
return (
<>
@@ -161,52 +171,94 @@ export const CommentsPane = memo(function CommentsPane({
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
isFullscreenMode && !showComments ? 'hidden' : ''
)}>
<div
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
>
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
<Button
variant={activePane === 'comments' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('comments')}
>
<MessageSquare className="h-4 w-4 mr-1" />
Comments
<Badge variant="secondary" className="ml-2">{comments.length}</Badge>
</Button>
<Button
variant={activePane === 'assets' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('assets')}
>
<FolderOpen className="h-4 w-4 mr-1" />
Assets
<Badge variant="secondary" className="ml-2">{assets.length}</Badge>
</Button>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden shrink-0" onClick={() => setIsMobileCommentsOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
{activePane === 'comments' && (
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
<Button
variant={showResolved ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}
>
Resolved
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
aria-label="Download comments"
title="Download comments"
>
{isExportingCsv || isExportingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<ChevronDown className="h-4 w-4 ml-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
>
<Download className="h-4 w-4 mr-2" />
Download CSV
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
<FileText className="h-4 w-4 mr-2" />
Download PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? (
{activePane === 'assets' ? assetsPane : filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
@@ -312,9 +364,10 @@ export const CommentsPane = memo(function CommentsPane({
{isEditing ? (
<div className="mb-2">
<Textarea
<MentionTextarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
@@ -402,7 +455,11 @@ export const CommentsPane = memo(function CommentsPane({
</div>
) : (
<div className="mb-2">
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
{comment.content && (
<p className="text-sm mb-2">
<CommentRichText text={comment.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
</p>
)}
{comment.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
@@ -530,9 +587,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
{isEditingReply ? (
<div className="mb-1">
<Textarea
<MentionTextarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
@@ -567,7 +625,11 @@ export const CommentsPane = memo(function CommentsPane({
</div>
) : (
<div className="mb-1">
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
{reply.content && (
<p className="text-sm">
<CommentRichText text={reply.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
</p>
)}
{reply.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
@@ -689,9 +751,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
)}
<Textarea
<MentionTextarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onChange={setReplyText}
assets={assets}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
@@ -725,9 +788,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
)}
<div className="flex gap-1">
<Textarea
<MentionTextarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onChange={setReplyText}
assets={assets}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
@@ -807,7 +871,7 @@ export const CommentsPane = memo(function CommentsPane({
)}
</div>
{composer}
{activePane === 'comments' ? composer : null}
</div>
</>
);
@@ -14,6 +14,7 @@ import {
import { toast } from 'sonner';
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
@@ -279,13 +280,9 @@ export function useCommentActions({
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
@@ -297,27 +294,21 @@ export function useCommentActions({
}, []);
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
const items = e.clipboardData?.items;
if (!items) return;
const file = extractPastedImageFile(e.clipboardData);
if (!file) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
break;
}
}
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
}, []);
const startRecording = useCallback(async () => {
@@ -0,0 +1,235 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import type { VideoAsset } from '@/components/video-page/types';
type BunnyDownloadPreference = 'original' | 'compressed';
type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
};
interface UseVideoAssetsParams {
videoId: string;
isAuthenticated: boolean;
canUploadAssets: boolean;
canDownloadAssets: boolean;
guestName?: string;
}
interface AssetsListResponse {
data?: {
assets?: VideoAsset[];
pagination?: {
limit?: number;
offset?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
canUploadAssets?: boolean;
canDownloadAssets?: boolean;
};
error?: string;
}
interface AssetCreateResponse {
data?: VideoAsset;
error?: string;
}
const ASSET_PAGE_SIZE = 40;
export function useVideoAssets({
videoId,
isAuthenticated,
canUploadAssets,
canDownloadAssets,
guestName,
}: UseVideoAssetsParams) {
const [assets, setAssets] = useState<VideoAsset[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
const [activeDeleteAssetId, setActiveDeleteAssetId] = useState<string | null>(null);
const [activeDownloadAssetId, setActiveDownloadAssetId] = useState<string | null>(null);
const [hasMoreAssets, setHasMoreAssets] = useState(false);
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
const fetchAssets = useCallback(async () => {
setIsLoadingAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store' });
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to fetch assets');
return;
}
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets(list);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
toast.error('Failed to fetch assets');
} finally {
setIsLoadingAssets(false);
}
}, [videoId]);
const loadMoreAssets = useCallback(async () => {
if (isLoadingMoreAssets || !hasMoreAssets) return;
setIsLoadingMoreAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to load more assets');
return;
}
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
toast.error('Failed to load more assets');
} finally {
setIsLoadingMoreAssets(false);
}
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
useEffect(() => {
void fetchAssets();
}, [fetchAssets]);
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
return null;
}
setIsCreatingAsset(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
}),
});
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
if (!res.ok || !body?.data) {
toast.error(body?.error || 'Failed to create asset');
return null;
}
setAssets((prev) => [body.data!, ...prev]);
setNextAssetsOffset((prev) => prev + 1);
return body.data;
} catch {
toast.error('Failed to create asset');
return null;
} finally {
setIsCreatingAsset(false);
}
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
const deleteAsset = useCallback(async (assetId: string) => {
setActiveDeleteAssetId(assetId);
try {
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
method: 'DELETE',
});
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to delete asset');
return false;
}
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
return true;
} catch {
toast.error('Failed to delete asset');
return false;
} finally {
setActiveDeleteAssetId(null);
}
}, [videoId]);
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
}
if (asset.provider === 'YOUTUBE') {
toast.error('YouTube assets cannot be downloaded');
return;
}
setActiveDownloadAssetId(asset.id);
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
if (!prepareRes.ok) {
toast.error(prepareBody?.error || 'Download is not available');
return;
}
downloadUrl = `${downloadUrl}?source=${preference}`;
}
const a = document.createElement('a');
a.href = downloadUrl;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error('Failed to start download');
} finally {
setActiveDownloadAssetId(null);
}
}, [canDownloadAssets, videoId]);
const getGuestUploadToken = useCallback(async (intent: 'image') => {
if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
const payload = (await response.json().catch(() => null)) as
| { data?: { token?: string }; error?: string }
| null;
const token = payload?.data?.token;
if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload');
}
return token;
}, [isAuthenticated, videoId]);
return {
assets,
isLoadingAssets,
isCreatingAsset,
activeDeleteAssetId,
activeDownloadAssetId,
hasMoreAssets,
isLoadingMoreAssets,
fetchAssets,
loadMoreAssets,
createAsset,
deleteAsset,
downloadAsset,
getGuestUploadToken,
};
}
@@ -521,6 +521,10 @@ export function useVideoPlayer({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.querySelector('[data-slot="dialog-content"]')) {
return;
}
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
+76 -53
View File
@@ -9,71 +9,94 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
interface ImagePreviewDialogProps {
previewImage: string | null;
onClose: () => void;
title?: string | null;
downloadFileName?: string | null;
canDownload?: boolean;
}
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
previewImage,
onClose,
title,
downloadFileName,
canDownload = true,
}: ImagePreviewDialogProps) {
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
return (
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex items-center justify-center rounded-none"
onClick={onClose}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}}
>
<DialogTitle className="sr-only">Image Preview</DialogTitle>
<div className="absolute top-4 right-4 flex gap-3 z-50">
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
if (!previewImage) return;
const response = await fetch(previewImage);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = previewImage.split('/').pop() || 'attachment.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
<DialogTitle className="sr-only">{title || 'Image Preview'}</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={title || undefined}>
{title || 'Image Preview'}
</p>
{canDownload ? (
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
if (!previewImage) return;
const response = await fetch(previewImage);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = resolvedDownloadName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
>
<Download className="h-4 w-4" />
</Button>
) : null}
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
className="relative flex-1 min-h-0 w-full flex items-center justify-center p-2 sm:p-4 cursor-zoom-out"
onClick={onClose}
>
<Download className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-5 w-5" />
</Button>
</div>
<div
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
onClick={onClose}
>
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt="Preview"
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt={title || 'Preview'}
className="max-w-full max-h-full object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,29 @@
'use client';
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
export function validateImageFile(file: File): string | null {
if (!file.type.startsWith('image/')) {
return 'Please select an image file';
}
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
return 'Image must be less than 10MB';
}
return null;
}
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null {
const items = data?.items;
if (!items) return null;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (file) return file;
}
return null;
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { VideoAsset } from '@/components/video-page/types';
type MentionRange = {
start: number;
end: number;
query: string;
};
interface MentionTextareaProps {
value: string;
onChange: (value: string) => void;
assets: VideoAsset[];
placeholder?: string;
rows?: number;
className?: string;
onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
autoFocus?: boolean;
disabled?: boolean;
}
function findMentionRange(text: string, caret: number): MentionRange | null {
const before = text.slice(0, caret);
const atIndex = before.lastIndexOf('@');
if (atIndex < 0) return null;
const charBeforeAt = atIndex === 0 ? ' ' : before[atIndex - 1];
if (charBeforeAt && !/\s/.test(charBeforeAt)) return null;
const query = before.slice(atIndex + 1);
if (query.length === 0) {
return { start: atIndex, end: caret, query: '' };
}
if (/\s|\[|\]|\(|\)/.test(query)) return null;
return { start: atIndex, end: caret, query };
}
export function MentionTextarea({
value,
onChange,
assets,
placeholder,
rows = 2,
className,
onPaste,
onKeyDown,
autoFocus,
disabled,
}: MentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [mentionRange, setMentionRange] = useState<MentionRange | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const searchableAssets = useMemo(() => {
return assets
.map((asset) => ({
asset,
displayNameLower: asset.displayName.toLowerCase(),
createdAtMs: Date.parse(asset.createdAt),
}))
.sort((a, b) => b.createdAtMs - a.createdAtMs);
}, [assets]);
const filteredAssets = useMemo(() => {
if (!mentionRange) return [];
const query = mentionRange.query.trim().toLowerCase();
if (!query) return searchableAssets.slice(0, 8).map((entry) => entry.asset);
return searchableAssets
.filter((entry) => entry.displayNameLower.includes(query))
.map((entry) => entry.asset)
.slice(0, 8);
}, [mentionRange, searchableAssets]);
const closeMentions = () => {
setMentionRange(null);
setActiveIndex(0);
};
const insertAssetMention = (asset: VideoAsset) => {
if (!mentionRange || !textareaRef.current) return;
const mentionToken = `@[${asset.displayName}](asset:${asset.id}) `;
const nextValue = `${value.slice(0, mentionRange.start)}${mentionToken}${value.slice(mentionRange.end)}`;
onChange(nextValue);
closeMentions();
const nextCursor = mentionRange.start + mentionToken.length;
requestAnimationFrame(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(nextCursor, nextCursor);
});
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = event.target.value;
onChange(nextValue);
const caret = event.target.selectionStart ?? nextValue.length;
const range = findMentionRange(nextValue, caret);
setMentionRange(range);
setActiveIndex(0);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
if (mentionRange && filteredAssets.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex((prev) => (prev + 1) % filteredAssets.length);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex((prev) => (prev - 1 + filteredAssets.length) % filteredAssets.length);
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const selected = filteredAssets[Math.min(activeIndex, filteredAssets.length - 1)];
if (selected) insertAssetMention(selected);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMentions();
return;
}
}
onKeyDown?.(event);
};
return (
<div className="relative">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className={className}
onPaste={onPaste}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
disabled={disabled}
onBlur={() => {
window.setTimeout(closeMentions, 120);
}}
/>
{mentionRange && filteredAssets.length > 0 && (
<div className="absolute left-0 right-0 bottom-full mb-1 z-30 rounded-md border bg-popover shadow-md overflow-hidden">
{filteredAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
className={cn(
'w-full text-left px-2 py-1.5 text-xs hover:bg-accent transition-colors',
index === activeIndex && 'bg-accent'
)}
onMouseDown={(event) => {
event.preventDefault();
insertAssetMention(asset);
}}
>
<span className="font-medium">@{asset.displayName}</span>
<span className="ml-2 text-muted-foreground">{asset.provider}</span>
</button>
))}
</div>
)}
</div>
);
}
+20
View File
@@ -18,6 +18,23 @@ export interface CommentTag {
color: string;
}
export interface VideoAsset {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName: string;
sourceUrl: string | null;
providerVideoId: string | null;
thumbnailUrl: string | null;
uploadedByUserId: string | null;
uploadedByGuestName: string | null;
createdAt: string;
updatedAt: string;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
canDelete: boolean;
}
export interface ApprovalDecision {
id: string;
approverId: string;
@@ -111,6 +128,9 @@ export interface VideoData {
canManageTags?: boolean;
canResolveComments?: boolean;
canRequestApproval?: boolean;
canShareVideo?: boolean;
canUploadAssets?: boolean;
canDownloadAssets?: boolean;
}
export interface BunnyQualityOption {
+14 -5
View File
@@ -61,6 +61,7 @@ interface VideoPageHeaderProps {
onCreateVersion: () => void;
onOpenCompare: () => void;
canRequestApproval: boolean;
canShareVideo: boolean;
hasPendingApprovalRequest: boolean;
onOpenApprovalRequest: () => void;
onOpenApprovalsPanel: () => void;
@@ -107,6 +108,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onCreateVersion,
onOpenCompare,
canRequestApproval,
canShareVideo,
hasPendingApprovalRequest,
onOpenApprovalRequest,
onOpenApprovalsPanel,
@@ -231,17 +233,24 @@ export const VideoPageHeader = memo(function VideoPageHeader({
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<Button variant="outline" size="sm" className="w-7 px-0 self-center">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
{canShareVideo ? (
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={onOpenApprovalRequest}
disabled={!canRequestApproval}