feat(video-card): add edit, versioning, and delete functionality with dialogs

- Implemented edit dialog for updating video title and description.
- Added functionality to create new video versions with URL validation.
- Included delete confirmation dialog for video removal.
- Enhanced UI with loading indicators and error handling.
- Updated video card layout for better user interaction.

feat(youtube): extend YT namespace with playback rate methods

- Added methods to set and get playback rate.
- Included method to retrieve available playback rates.
This commit is contained in:
Yusuf İpek
2026-02-07 08:17:25 +03:00
parent 6e95f667e3
commit 0228020041
13 changed files with 1982 additions and 704 deletions
@@ -92,7 +92,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
// Check access
const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0;
const isPublicOrLink = project.visibility !== 'PRIVATE';
const isPublic = project.visibility === 'PUBLIC';
// Check workspace membership
let isWorkspaceMember = false;
@@ -116,7 +116,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
}
}
if (!isOwner && !isMember && !isPublicOrLink && !isWorkspaceMember) {
if (!isOwner && !isMember && !isPublic && !isWorkspaceMember) {
redirect('/dashboard');
}
@@ -0,0 +1,281 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams, useSearchParams } from 'next/navigation';
import {
ArrowLeft,
MessageSquare,
ChevronDown,
Loader2,
GitCompareArrows,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface Version {
id: string;
versionNumber: number;
versionLabel: string | null;
providerId: string;
videoId: string;
originalUrl: string;
title: string | null;
thumbnailUrl: string | null;
duration: number | null;
isActive: boolean;
_count: { comments: number };
}
interface VideoData {
id: string;
title: string;
description: string | null;
projectId: string;
project: {
name: string;
};
versions: Version[];
}
function getEmbedUrl(version: Version) {
if (version.providerId === 'youtube') {
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1`;
}
if (version.providerId === 'vimeo') {
return `https://player.vimeo.com/video/${version.videoId}`;
}
return version.originalUrl;
}
export default function CompareVersionsPage() {
const params = useParams();
const searchParams = useSearchParams();
const projectId = params.projectId as string;
const videoId = params.videoId as string;
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [leftVersionId, setLeftVersionId] = useState<string | null>(null);
const [rightVersionId, setRightVersionId] = useState<string | null>(null);
useEffect(() => {
async function fetchVideo() {
try {
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
if (!res.ok) {
setError('Failed to load video');
setLoading(false);
return;
}
const data = await res.json();
setVideo(data);
// Set initial versions from query params or defaults
const leftParam = searchParams.get('left');
const rightParam = searchParams.get('right');
if (data.versions.length >= 2) {
// Sort versions ascending for comparison (older on left, newer on right)
const sorted = [...data.versions].sort(
(a: Version, b: Version) => a.versionNumber - b.versionNumber
);
setLeftVersionId(
leftParam && sorted.find((v: Version) => v.id === leftParam)
? leftParam
: sorted[sorted.length - 2].id
);
setRightVersionId(
rightParam && sorted.find((v: Version) => v.id === rightParam)
? rightParam
: sorted[sorted.length - 1].id
);
} else if (data.versions.length === 1) {
setLeftVersionId(data.versions[0].id);
setRightVersionId(data.versions[0].id);
}
} catch {
setError('Failed to load video');
} finally {
setLoading(false);
}
}
fetchVideo();
}, [projectId, videoId, searchParams]);
const leftVersion = video?.versions.find((v) => v.id === leftVersionId);
const rightVersion = video?.versions.find((v) => v.id === rightVersionId);
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !video || video.versions.length < 2) {
return (
<div className="h-screen flex items-center justify-center bg-background">
<div className="text-center">
<p className="text-muted-foreground mb-4">
{error || 'Need at least 2 versions to compare'}
</p>
<Button asChild variant="outline">
<Link href={`/projects/${projectId}/videos/${videoId}`}>Back to Video</Link>
</Button>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50">
<div className="flex items-center gap-3">
<Link
href={`/projects/${projectId}/videos/${videoId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Video
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-2">
<GitCompareArrows className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Compare Versions</span>
<span className="text-xs text-muted-foreground"> {video.title}</span>
</div>
</div>
</div>
{/* Side-by-side comparison */}
<div className="flex-1 flex overflow-hidden">
{/* Left panel */}
<div className="flex-1 flex flex-col border-r">
<div className="shrink-0 flex items-center justify-between p-3 border-b bg-muted/30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">
v{leftVersion?.versionNumber}
</Badge>
{leftVersion?.versionLabel || `Version ${leftVersion?.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{video.versions.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => setLeftVersionId(v.id)}
disabled={v.id === rightVersionId}
>
<Badge
variant={v.id === leftVersionId ? 'default' : 'secondary'}
className="mr-2"
>
v{v.versionNumber}
</Badge>
{v.versionLabel || `Version ${v.versionNumber}`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{leftVersion?._count.comments || 0} comments
</div>
</div>
<div className="flex-1 bg-black flex items-center justify-center p-2">
{leftVersion && (
<iframe
src={getEmbedUrl(leftVersion)}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)}
</div>
{leftVersion?.duration && (
<div className="shrink-0 px-3 py-2 border-t text-xs text-muted-foreground text-center">
Duration: {Math.floor(leftVersion.duration / 60)}:
{(leftVersion.duration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col">
<div className="shrink-0 flex items-center justify-between p-3 border-b bg-muted/30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">
v{rightVersion?.versionNumber}
</Badge>
{rightVersion?.versionLabel || `Version ${rightVersion?.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{video.versions.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => setRightVersionId(v.id)}
disabled={v.id === leftVersionId}
>
<Badge
variant={v.id === rightVersionId ? 'default' : 'secondary'}
className="mr-2"
>
v{v.versionNumber}
</Badge>
{v.versionLabel || `Version ${v.versionNumber}`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{rightVersion?._count.comments || 0} comments
</div>
</div>
<div className="flex-1 bg-black flex items-center justify-center p-2">
{rightVersion && (
<iframe
src={getEmbedUrl(rightVersion)}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)}
</div>
{rightVersion?.duration && (
<div className="shrink-0 px-3 py-2 border-t text-xs text-muted-foreground text-center">
Duration: {Math.floor(rightVersion.duration / 60)}:
{(rightVersion.duration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
@@ -9,31 +9,59 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { parseVideoUrl, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
export default function NewVideoPage() {
const router = useRouter();
const params = useParams();
const projectId = params.projectId as string;
const [isLoading, setIsLoading] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
const [urlError, setUrlError] = useState('');
const [submitError, setSubmitError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
});
// Auto-fetch metadata when a valid video source is detected
useEffect(() => {
if (!videoSource) return;
let cancelled = false;
setIsFetchingMeta(true);
fetchVideoMetadata(videoSource).then((meta) => {
if (cancelled || !meta) {
setIsFetchingMeta(false);
return;
}
if (!formData.title) {
setFormData((prev) => ({ ...prev, title: meta.title }));
}
setVideoSource((prev) => (prev ? { ...prev, metadata: meta } : prev));
setIsFetchingMeta(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoSource?.videoId, videoSource?.providerId]);
const handleUrlChange = (url: string) => {
setVideoUrl(url);
setUrlError('');
setSubmitError('');
if (!url.trim()) {
setVideoSource(null);
return;
}
const source = parseVideoUrl(url);
if (source) {
setVideoSource(source);
@@ -47,31 +75,43 @@ export default function NewVideoPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!videoSource) {
setUrlError('Please enter a valid video URL');
return;
}
setIsLoading(true);
setSubmitError('');
try {
// TODO: Implement actual video creation
// const response = await fetch(`/api/projects/${projectId}/videos`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// ...formData,
// ...videoSource,
// }),
// });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
const thumbnailUrl = getThumbnailUrl(videoSource, 'large');
const title = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
const response = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: formData.description.trim() || null,
videoUrl: videoSource.originalUrl,
providerId: videoSource.providerId,
videoId: videoSource.videoId,
thumbnailUrl,
duration: videoSource.metadata?.duration || null,
}),
});
if (!response.ok) {
const data = await response.json();
setSubmitError(data.error || 'Failed to add video');
return;
}
router.push(`/projects/${projectId}`);
} catch (error) {
console.error('Failed to add video:', error);
setSubmitError('An unexpected error occurred');
} finally {
setIsLoading(false);
}
@@ -80,9 +120,9 @@ export default function NewVideoPage() {
const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null;
return (
<div className="container max-w-2xl py-8">
<div className="container max-w-2xl mx-auto py-8">
<div className="mb-6">
<Link
<Link
href={`/projects/${projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
@@ -115,19 +155,19 @@ export default function NewVideoPage() {
disabled={isLoading}
/>
</div>
{/* URL validation feedback */}
{urlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{urlError}
</p>
)}
{videoSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
{isFetchingMeta && ' — fetching metadata...'}
</p>
)}
</div>
@@ -151,9 +191,9 @@ export default function NewVideoPage() {
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Video title (will auto-fill from video if empty)"
placeholder={isFetchingMeta ? 'Fetching title...' : 'Video title (will auto-fill from video if empty)'}
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
@@ -168,12 +208,19 @@ export default function NewVideoPage() {
id="description"
placeholder="Add context about this video..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={3}
disabled={isLoading}
/>
</div>
{submitError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{submitError}
</p>
)}
<div className="flex gap-3">
<Button type="submit" disabled={isLoading || !videoSource}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}