feat: Implement direct video file uploads via Bunny.net and TUS protocol, adding a new API route and UI for file selection.

This commit is contained in:
Yusuf İpek
2026-02-22 09:55:08 +03:00
parent 5547346082
commit 0f24bcfe6c
12 changed files with 1054 additions and 185 deletions
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"gap-2 group/tabs flex data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("text-xs/relaxed flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+29 -13
View File
@@ -2,7 +2,6 @@
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
Play,
@@ -60,6 +59,8 @@ interface VideoCardProps {
export function VideoCard({ video, projectId }: VideoCardProps) {
const router = useRouter();
const [imgError, setImgError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
// Edit dialog
const [showEditDialog, setShowEditDialog] = useState(false);
@@ -181,18 +182,33 @@ export function VideoCard({ video, projectId }: VideoCardProps) {
<Link href={`/projects/${projectId}/videos/${video.id}`}>
{/* Thumbnail */}
<div className="relative aspect-video bg-muted overflow-hidden">
<Image
src={video.thumbnailUrl}
alt={video.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover transition-transform group-hover:scale-105"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMCwsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIhAAAQMDBQADAAAAAAAAAAAAAQIDBAAFEQYSITFBE1FR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQIhMf/aAAwDAQACEQMRAD8Adu3bgZt8NqM2y6sNJCQTjJ+dKz/9k="
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="h-12 w-12 text-white" fill="white" />
</div>
{imgError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<span className="text-xs text-muted-foreground font-medium">Processing...</span>
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
console.error('Thumbnail failed to load. Tried:', (e.target as HTMLImageElement).currentSrc);
setImgError(true);
// Check again after 10 seconds in case Bunny is still processing
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
)}
{!imgError && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="h-12 w-12 text-white" fill="white" />
</div>
)}
<Badge className="absolute bottom-2 right-2 bg-black/70">{video.duration}</Badge>
</div>
</Link>
+367 -98
View File
@@ -78,6 +78,9 @@ import { cn } from '@/lib/utils';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
import { Linkify } from '@/components/linkify';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import * as tus from 'tus-js-client';
import { UploadCloud, FileVideo } from 'lucide-react';
interface Version {
id: string;
@@ -99,6 +102,21 @@ interface CommentTag {
color: string;
}
interface PlayerAdapter {
playVideo: () => void;
pauseVideo: () => void;
seekTo: (time: number, allowSeekAhead?: boolean) => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
getCurrentTime: () => number;
getDuration: () => number;
getPlayerState: () => number;
setPlaybackRate: (rate: number) => void;
destroy: () => void;
off?: (event: string) => void;
}
interface Comment {
id: string;
content: string | null;
@@ -167,7 +185,7 @@ interface VideoPageContentProps {
export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<YT.Player | null>(null);
const playerRef = useRef<YT.Player | PlayerAdapter | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
@@ -278,6 +296,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [newVersionUrlError, setNewVersionUrlError] = useState('');
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
@@ -389,6 +412,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (activeVersion.providerId === 'youtube') {
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
}
if (activeVersion.providerId === 'bunny') {
// Force /embed/ endpoint to ensure player.js integration works correctly and hide native controls
const url = activeVersion.originalUrl.replace('/play/', '/embed/');
return `${url}${url.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
}
try {
const url = new URL(activeVersion.originalUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -441,8 +469,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [isApiLoaded]);
useEffect(() => {
if (!activeVersion || activeVersion.providerId !== 'youtube') return;
if (!isApiLoaded) return;
if (!activeVersion) return;
const isYoutube = activeVersion.providerId === 'youtube';
const isBunny = activeVersion.providerId === 'bunny';
if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return;
setIsReady(false);
setCurrentTime(0);
@@ -451,63 +483,159 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setPlaybackSpeed(1);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
if (isYoutube) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
} else if (isBunny && 'off' in playerRef.current) {
try {
(playerRef.current as PlayerAdapter).off?.('ready');
(playerRef.current as PlayerAdapter).off?.('play');
(playerRef.current as PlayerAdapter).off?.('pause');
(playerRef.current as PlayerAdapter).off?.('timeupdate');
} catch { /* ignore */ }
}
playerRef.current = null;
}
const initPlayer = () => {
if (!iframeRef.current) return;
playerRef.current = new YT.Player(iframeRef.current, {
events: {
onReady: (event: YT.PlayerEvent) => {
setIsReady(true);
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
if (isYoutube) {
playerRef.current = new YT.Player(iframeRef.current, {
events: {
onReady: (event: YT.PlayerEvent) => {
setIsReady(true);
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
if (event.data === YT.PlayerState.PAUSED) {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || 0;
if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) {
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: playerCurrentTime,
duration: playerDuration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on pause:', err));
}
}
if (event.data === YT.PlayerState.PLAYING) {
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
}
},
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
});
} else if (isBunny) {
// dynamically require player.js to avoid SSR window errors
const playerjs = require('player.js');
const player = new playerjs.Player(iframeRef.current);
playerRef.current = player;
// Save progress immediately when video is paused
if (event.data === YT.PlayerState.PAUSED) {
// Get current time and duration directly from player instance, not from React state (which may be stale)
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || 0;
player.on('ready', () => {
setIsReady(true);
player.getDuration((duration: number) => {
if (duration > 0) setVideoDuration(duration);
});
});
if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) {
player.on('play', () => {
setIsPlaying(true);
player.getDuration((duration: number) => {
if (duration > 0) setVideoDuration(duration);
});
});
player.on('pause', () => {
setIsPlaying(false);
player.getCurrentTime((currentTime: number) => {
player.getDuration((duration: number) => {
if (video?.isAuthenticated && currentTime > 0 && activeVersionId) {
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: playerCurrentTime,
duration: playerDuration,
progress: currentTime,
duration: duration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on pause:', err));
}).catch(console.error);
}
}
});
});
});
if (event.data === YT.PlayerState.PLAYING) {
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
}
let cachedTime = 0;
let cachedDuration = videoDuration || 0;
player.on('timeupdate', (data: { seconds: number, duration: number }) => {
cachedTime = data.seconds;
if (data.duration > 0 && data.duration !== cachedDuration) {
cachedDuration = data.duration;
setVideoDuration(data.duration);
}
if (!isDragging) {
setCurrentTime(data.seconds);
}
});
playerRef.current = {
playVideo: () => player.play(),
pauseVideo: () => player.pause(),
seekTo: (time: number) => { player.setCurrentTime(time); },
mute: () => player.mute(),
unMute: () => player.unmute(),
isMuted: () => false,
getCurrentTime: () => cachedTime,
getDuration: () => cachedDuration,
getPlayerState: () => window.YT?.PlayerState?.PLAYING || 1,
setPlaybackRate: (rate: number) => {
try {
if (player && typeof player.setPlaybackRate === 'function') {
player.setPlaybackRate(rate);
}
} catch (e) { console.error('Error setting playback rate on Bunny Stream', e); }
},
},
});
destroy: () => {
try {
player.off('ready');
player.off('play');
player.off('pause');
player.off('timeupdate');
} catch { }
},
off: (event: string) => player.off(event)
};
}
};
const timeout = setTimeout(() => {
if (window.YT?.Player) {
if (isYoutube) {
if (window.YT?.Player) {
initPlayer();
} else {
window.onYouTubeIframeAPIReady = initPlayer;
}
} else if (isBunny) {
initPlayer();
} else {
window.onYouTubeIframeAPIReady = initPlayer;
}
}, 100);
return () => {
clearTimeout(timeout);
window.onYouTubeIframeAPIReady = undefined;
if (isYoutube) {
window.onYouTubeIframeAPIReady = undefined;
}
};
}, [activeVersionId, isApiLoaded]);
}, [activeVersionId, isApiLoaded, video?.isAuthenticated, videoId]);
// Save detected duration to DB if the version doesn't have one stored
useEffect(() => {
@@ -585,22 +713,26 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
// Save progress every 5 seconds while playing
progressSaveTimerRef.current = setInterval(() => {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || 0;
const isYoutube = activeVersion?.providerId === 'youtube';
const isBunny = activeVersion?.providerId === 'bunny';
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
// Save to API - use player duration directly
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: playerCurrentTime,
duration: playerDuration || videoDuration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress:', err));
const save = (playerCurrentTime: number, playerDuration: number) => {
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: playerCurrentTime,
duration: playerDuration || videoDuration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress:', err));
lastSavedProgressRef.current = playerCurrentTime;
}
};
lastSavedProgressRef.current = playerCurrentTime;
if (playerRef.current?.getCurrentTime) {
save(playerRef.current.getCurrentTime(), playerRef.current.getDuration?.() || videoDuration);
}
}, 5000);
@@ -692,15 +824,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
};
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
// Resume playback from saved position
const handleResumeFromSaved = useCallback(() => {
if (savedProgress !== null && playerRef.current?.seekTo) {
playerRef.current.seekTo(savedProgress, true);
if (savedProgress !== null && playerRef.current) {
if (playerRef.current.seekTo) {
playerRef.current.seekTo(savedProgress, true);
}
setCurrentTime(savedProgress);
setShowResumePrompt(false);
setSavedProgress(null);
}
}, [savedProgress]);
}, [savedProgress, activeVersion?.providerId]);
// Dismiss resume prompt
const handleDismissResume = useCallback(() => {
@@ -712,13 +845,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (!isReady || !playerRef.current) return;
const interval = setInterval(() => {
if (playerRef.current?.getCurrentTime && !isDragging) {
setCurrentTime(playerRef.current.getCurrentTime());
if (!isDragging && playerRef.current) {
if (playerRef.current.getCurrentTime) {
setCurrentTime(playerRef.current.getCurrentTime());
}
}
}, 250);
return () => clearInterval(interval);
}, [isReady, isDragging]);
}, [isReady, isDragging, activeVersion?.providerId]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -741,17 +876,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
break;
case 'ArrowLeft':
e.preventDefault();
if (playerRef.current?.seekTo) {
if (playerRef.current) {
const newTime = Math.max(0, currentTime - 5);
playerRef.current.seekTo(newTime, true);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break;
case 'ArrowRight':
e.preventDefault();
if (playerRef.current?.seekTo) {
if (playerRef.current) {
const newTime = Math.min(duration, currentTime + 5);
playerRef.current.seekTo(newTime, true);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break;
@@ -1820,23 +1959,90 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
};
const handleCreateVersion = async () => {
if (!newVersionSource || !propProjectId) return;
if (!propProjectId) return;
setIsCreatingVersion(true);
setNewVersionUploadStatus('');
setNewVersionUploadProgress(0);
try {
const meta = await fetchVideoMetadata(newVersionSource);
const thumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
let finalVideoUrl = '';
let finalProviderId = '';
let finalProviderVideoId = '';
let finalThumbnailUrl: string | null = null;
let finalDuration: number | null = null;
if (newVersionMode === 'url') {
if (!newVersionSource) throw new Error('Invalid URL');
const meta = await fetchVideoMetadata(newVersionSource);
finalVideoUrl = newVersionSource.originalUrl;
finalProviderId = newVersionSource.providerId;
finalProviderVideoId = newVersionSource.videoId;
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
finalDuration = meta?.duration || null;
} else {
if (!newVersionFile) throw new Error('No file selected');
let title = newVersionFile.name;
if (newVersionLabel.trim()) {
title = newVersionLabel.trim();
} else {
title = title.replace(/\.[^/.]+$/, '');
}
setNewVersionUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${propProjectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
await new Promise((resolve, reject) => {
setNewVersionUploadStatus('Uploading video...');
const upload = new tus.Upload(newVersionFile, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expirationTime.toString(),
VideoId: videoId,
LibraryId: libraryId,
},
metadata: {
filetype: newVersionFile.type,
title: title,
},
onError: (error) => reject(new Error('Upload failed: ' + error.message)),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
setNewVersionUploadProgress(Number(percentage));
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
setNewVersionUploadStatus('Processing video...');
resolve(true);
},
});
upload.start();
});
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`;
finalProviderId = 'bunny';
finalProviderVideoId = videoId;
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`;
}
const res = await fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoUrl: newVersionSource.originalUrl,
providerId: newVersionSource.providerId,
providerVideoId: newVersionSource.videoId,
videoUrl: finalVideoUrl,
providerId: finalProviderId,
providerVideoId: finalProviderVideoId,
versionLabel: newVersionLabel.trim() || null,
thumbnailUrl,
duration: meta?.duration || null,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
setActive: true,
}),
});
@@ -1860,9 +2066,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setNewVersionUrl('');
setNewVersionLabel('');
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
}
} catch (err) {
console.error('Failed to create version:', err);
const errorObj = err as Error;
console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version');
} finally {
setIsCreatingVersion(false);
}
@@ -2162,33 +2372,78 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-2">
<Label>Video URL</Label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://youtube.com/watch?v=..."
value={newVersionUrl}
onChange={(e) => handleNewVersionUrlChange(e.target.value)}
className="pl-10"
disabled={isCreatingVersion}
/>
<Tabs value={newVersionMode} onValueChange={(v) => setNewVersionMode(v as 'url' | 'file')} className="mb-2">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="url">Link URL</TabsTrigger>
<TabsTrigger value="file">Upload File</TabsTrigger>
</TabsList>
</Tabs>
{newVersionMode === 'url' ? (
<div className="space-y-2">
<Label>Video URL</Label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://youtube.com/watch?v=..."
value={newVersionUrl}
onChange={(e) => handleNewVersionUrlChange(e.target.value)}
className="pl-10"
disabled={isCreatingVersion}
/>
</div>
{newVersionUrlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{newVersionUrlError}
</p>
)}
{newVersionSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{newVersionSource.providerId.charAt(0).toUpperCase() +
newVersionSource.providerId.slice(1)}{' '}
video detected
</p>
)}
</div>
{newVersionUrlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{newVersionUrlError}
</p>
)}
{newVersionSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{newVersionSource.providerId.charAt(0).toUpperCase() +
newVersionSource.providerId.slice(1)}{' '}
video detected
</p>
)}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="versionFile">Video File</Label>
<div className="flex items-center justify-center w-full">
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{newVersionFile ? (
<>
<FileVideo className="w-8 h-8 mb-2 text-primary" />
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
<p className="text-xs text-muted-foreground">
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</>
) : (
<>
<UploadCloud className="w-8 h-8 mb-2 text-muted-foreground" />
<p className="mb-1 text-sm text-muted-foreground">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
</>
)}
</div>
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
setNewVersionFile(file);
} else {
toast.error('Please select a valid video file');
}
}} disabled={isCreatingVersion} />
</label>
</div>
</div>
)}
<div className="space-y-2">
<Label>Version Label (optional)</Label>
<Input
@@ -2198,9 +2453,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
disabled={isCreatingVersion}
/>
</div>
{newVersionUploadStatus && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
</div>
)}
</div>
)}
<Button
onClick={handleCreateVersion}
disabled={!newVersionSource || isCreatingVersion}
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
className="w-full"
>
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
@@ -2267,7 +2534,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
key={activeVersionId}
ref={iframeRef}
src={embedUrl}
className="absolute inset-0 w-full h-full pointer-events-none"
width="100%"
height="100%"
className="absolute inset-0 w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
@@ -2280,11 +2549,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
: 'opacity-100'
)}
>
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center">
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center relative z-10">
{isPlaying ? (
<Pause className="h-8 w-8 text-white" />
<Pause className="h-8 w-8 text-white relative right-[-1px]" />
) : (
<Play className="h-8 w-8 text-white ml-1" />
<Play className="h-8 w-8 text-white relative left-[2px]" />
)}
</div>
</div>