mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user