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 // Check access
const isOwner = session?.user?.id === project.ownerId; const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0; const isMember = project.members.length > 0;
const isPublicOrLink = project.visibility !== 'PRIVATE'; const isPublic = project.visibility === 'PUBLIC';
// Check workspace membership // Check workspace membership
let isWorkspaceMember = false; 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'); 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'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react'; import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; 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() { export default function NewVideoPage() {
const router = useRouter(); const router = useRouter();
@@ -17,17 +17,45 @@ export default function NewVideoPage() {
const projectId = params.projectId as string; const projectId = params.projectId as string;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
const [videoUrl, setVideoUrl] = useState(''); const [videoUrl, setVideoUrl] = useState('');
const [videoSource, setVideoSource] = useState<VideoSource | null>(null); const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
const [urlError, setUrlError] = useState(''); const [urlError, setUrlError] = useState('');
const [submitError, setSubmitError] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
description: '', 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) => { const handleUrlChange = (url: string) => {
setVideoUrl(url); setVideoUrl(url);
setUrlError(''); setUrlError('');
setSubmitError('');
if (!url.trim()) { if (!url.trim()) {
setVideoSource(null); setVideoSource(null);
@@ -54,24 +82,36 @@ export default function NewVideoPage() {
} }
setIsLoading(true); setIsLoading(true);
setSubmitError('');
try { try {
// TODO: Implement actual video creation const thumbnailUrl = getThumbnailUrl(videoSource, 'large');
// const response = await fetch(`/api/projects/${projectId}/videos`, { const title = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// ...formData,
// ...videoSource,
// }),
// });
// Simulate API call const response = await fetch(`/api/projects/${projectId}/videos`, {
await new Promise(resolve => setTimeout(resolve, 500)); 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}`); router.push(`/projects/${projectId}`);
} catch (error) { } catch (error) {
console.error('Failed to add video:', error); console.error('Failed to add video:', error);
setSubmitError('An unexpected error occurred');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -80,7 +120,7 @@ export default function NewVideoPage() {
const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null; const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null;
return ( return (
<div className="container max-w-2xl py-8"> <div className="container max-w-2xl mx-auto py-8">
<div className="mb-6"> <div className="mb-6">
<Link <Link
href={`/projects/${projectId}`} href={`/projects/${projectId}`}
@@ -116,7 +156,6 @@ export default function NewVideoPage() {
/> />
</div> </div>
{/* URL validation feedback */}
{urlError && ( {urlError && (
<p className="text-sm text-destructive flex items-center gap-1"> <p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
@@ -128,6 +167,7 @@ export default function NewVideoPage() {
<p className="text-sm text-green-600 flex items-center gap-1"> <p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected {videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
{isFetchingMeta && ' — fetching metadata...'}
</p> </p>
)} )}
</div> </div>
@@ -151,9 +191,9 @@ export default function NewVideoPage() {
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
id="title" 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} value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
disabled={isLoading} disabled={isLoading}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -168,12 +208,19 @@ export default function NewVideoPage() {
id="description" id="description"
placeholder="Add context about this video..." placeholder="Add context about this video..."
value={formData.description} value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={3} rows={3}
disabled={isLoading} disabled={isLoading}
/> />
</div> </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"> <div className="flex gap-3">
<Button type="submit" disabled={isLoading || !videoSource}> <Button type="submit" disabled={isLoading || !videoSource}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+2 -2
View File
@@ -44,9 +44,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const project = comment.version.video.project; const project = comment.version.video.project;
const isOwner = session?.user?.id === project.ownerId; const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0; const isMember = project.members.length > 0;
const isPublicOrLink = project.visibility !== 'PRIVATE'; const isPublic = project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublicOrLink) { if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }); return NextResponse.json({ error: 'Access denied' }, { status: 403 });
} }
@@ -46,9 +46,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Check access // Check access
const isOwner = session?.user?.id === video.project.ownerId; const isOwner = session?.user?.id === video.project.ownerId;
const isMember = video.project.members.length > 0; const isMember = video.project.members.length > 0;
const isPublicOrLink = video.project.visibility !== 'PRIVATE'; const isPublic = video.project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublicOrLink) { if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }); return NextResponse.json({ error: 'Access denied' }, { status: 403 });
} }
@@ -27,9 +27,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const isOwner = session?.user?.id === video.project.ownerId; const isOwner = session?.user?.id === video.project.ownerId;
const isMember = video.project.members.length > 0; const isMember = video.project.members.length > 0;
const isPublicOrLink = video.project.visibility !== 'PRIVATE'; const isPublic = video.project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublicOrLink) { if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }); return NextResponse.json({ error: 'Access denied' }, { status: 403 });
} }
+2 -2
View File
@@ -24,9 +24,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const isOwner = session?.user?.id === project.ownerId; const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0; const isMember = project.members.length > 0;
const isPublicOrLink = project.visibility !== 'PRIVATE'; const isPublic = project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublicOrLink) { if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }); return NextResponse.json({ error: 'Access denied' }, { status: 403 });
} }
@@ -34,9 +34,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const project = version.video.project; const project = version.video.project;
const isOwner = session?.user?.id === project.ownerId; const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0; const isMember = project.members.length > 0;
const isPublicOrLink = project.visibility !== 'PRIVATE'; const isPublic = project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublicOrLink) { if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }); return NextResponse.json({ error: 'Access denied' }, { status: 403 });
} }
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
type RouteParams = { params: Promise<{ videoId: string }> };
// GET /api/watch/[videoId] - Public watch endpoint (no projectId needed)
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { videoId } = await params;
const video = await db.video.findUnique({
where: { id: videoId },
include: {
project: {
include: {
members: { where: { userId: session?.user?.id || '' } },
},
},
versions: {
orderBy: { versionNumber: 'desc' },
include: {
comments: {
orderBy: { timestamp: 'asc' },
where: { parentId: null },
include: {
author: { select: { id: true, name: true, image: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, image: true } },
},
},
},
},
_count: { select: { comments: true } },
},
},
},
});
if (!video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
// Check access
const isOwner = session?.user?.id === video.project.ownerId;
const isMember = video.project.members.length > 0;
const isPublic = video.project.visibility === 'PUBLIC';
if (!isOwner && !isMember && !isPublic) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
return NextResponse.json(video);
} catch (error) {
console.error('Error fetching video:', error);
return NextResponse.json(
{ error: 'Failed to fetch video' },
{ status: 500 }
);
}
}
+385 -290
View File
@@ -9,7 +9,6 @@ import {
Pause, Pause,
Volume2, Volume2,
VolumeX, VolumeX,
Maximize,
MessageSquare, MessageSquare,
Mic, Mic,
Send, Send,
@@ -18,13 +17,12 @@ import {
Circle, Circle,
ChevronDown, ChevronDown,
MoreVertical, MoreVertical,
User,
SkipBack, SkipBack,
SkipForward SkipForward,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -36,66 +34,51 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Mock data interface Version {
const mockVideo = { id: string;
id: 'v1', versionNumber: number;
title: 'Main Product Walkthrough', versionLabel: string | null;
description: 'Complete walkthrough of the new product features', providerId: string;
projectId: '1', videoId: string;
projectName: 'Product Demo v2', originalUrl: string;
versions: [ title: string | null;
{ id: 'ver3', number: 3, label: 'Final Cut', isActive: true }, thumbnailUrl: string | null;
{ id: 'ver2', number: 2, label: 'Review Round 2', isActive: false }, duration: number | null;
{ id: 'ver1', number: 1, label: 'First Draft', isActive: false }, isActive: boolean;
], _count: { comments: number };
currentVersion: { }
id: 'ver3',
number: 3,
label: 'Final Cut',
providerId: 'youtube',
videoId: 'dQw4w9WgXcQ', // Sample video
duration: 342, // 5:42
},
};
const mockComments = [ interface Comment {
{ id: string;
id: 'c1', content: string | null;
content: 'The transition here feels a bit abrupt. Can we add a fade?', timestamp: number;
timestamp: 45.5, voiceUrl: string | null;
author: { name: 'Sarah Chen', image: null }, voiceDuration: number | null;
createdAt: '2 hours ago', isResolved: boolean;
isResolved: false, createdAt: string;
replies: [ author: { id: string; name: string | null; image: string | null } | null;
{ guestName: string | null;
id: 'c1r1', replies: {
content: 'Good catch! I\'ll smooth that out in the next version.', id: string;
author: { name: 'Mike Johnson', image: null }, content: string | null;
createdAt: '1 hour ago', createdAt: string;
}, author: { id: string; name: string | null; image: string | null } | null;
], guestName: string | null;
}, }[];
{ }
id: 'c2',
content: 'Love this section! The pacing is perfect.', interface VideoData {
timestamp: 120, id: string;
author: { name: 'Alex Rivera', image: null }, title: string;
createdAt: '5 hours ago', description: string | null;
isResolved: true, projectId: string;
replies: [], project: {
}, name: string;
{ ownerId: string;
id: 'c3', members: { role: string }[];
content: 'Can we add some background music here?', };
timestamp: 200, versions: (Version & { comments: Comment[] })[];
voiceUrl: '/mock-voice.mp3', // Mock voice comment }
voiceDuration: 8.5,
author: { name: 'Jordan Lee', image: null },
createdAt: '1 day ago',
isResolved: false,
replies: [],
},
];
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -103,32 +86,63 @@ function formatTime(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
} }
export default function VideoPage() { export default function WatchPage() {
const params = useParams(); const params = useParams();
const videoId = params.videoId as string; const videoId = params.videoId as string;
// In real app, fetch projectId from video data
const projectId = mockVideo.projectId;
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<YT.Player | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(mockVideo.currentVersion.duration);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null); const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [comments, setComments] = useState(mockComments);
const [showResolved, setShowResolved] = useState(false); const [showResolved, setShowResolved] = useState(false);
// Fetch video data
useEffect(() => {
async function fetchVideo() {
try {
const res = await fetch(`/api/watch/${videoId}`);
if (!res.ok) {
setError('Video not found or access denied');
setLoading(false);
return;
}
const data = await res.json();
setVideo(data);
const active = data.versions.find((v: Version) => v.isActive) || data.versions[0];
if (active) setActiveVersionId(active.id);
} catch {
setError('Failed to load video');
} finally {
setLoading(false);
}
}
fetchVideo();
}, [videoId]);
const activeVersion = video?.versions.find((v) => v.id === activeVersionId);
const comments = activeVersion?.comments || [];
const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
const duration = activeVersion?.duration || 300;
// Load YouTube iframe API // Load YouTube iframe API
useEffect(() => { useEffect(() => {
// Load YouTube iframe API script if (!activeVersion || activeVersion.providerId !== 'youtube') return;
if (!window.YT) { if (!window.YT) {
const tag = document.createElement('script'); const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api'; tag.src = 'https://www.youtube.com/iframe_api';
@@ -136,31 +150,28 @@ export default function VideoPage() {
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
} }
// Initialize player when API is ready const initPlayer = () => {
const onYouTubeIframeAPIReady = () => { if (!iframeRef.current) return;
playerRef.current = new window.YT.Player(iframeRef.current, { playerRef.current = new YT.Player(iframeRef.current, {
events: { events: {
onReady: () => { onReady: () => setIsReady(true),
setIsReady(true); onStateChange: (event: YT.OnStateChangeEvent) => {
setDuration(playerRef.current.getDuration() || mockVideo.currentVersion.duration); setIsPlaying(event.data === YT.PlayerState.PLAYING);
},
onStateChange: (event: any) => {
setIsPlaying(event.data === window.YT.PlayerState.PLAYING);
}, },
}, },
}); });
}; };
if (window.YT && window.YT.Player) { if (window.YT?.Player) {
onYouTubeIframeAPIReady(); initPlayer();
} else { } else {
window.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = initPlayer;
} }
return () => { return () => {
window.onYouTubeIframeAPIReady = undefined; window.onYouTubeIframeAPIReady = undefined;
}; };
}, []); }, [activeVersion]);
// Update current time periodically // Update current time periodically
useEffect(() => { useEffect(() => {
@@ -175,8 +186,6 @@ export default function VideoPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isReady, isDragging]); }, [isReady, isDragging]);
const filteredComments = comments.filter(c => showResolved || !c.isResolved);
const handlePlayPause = useCallback(() => { const handlePlayPause = useCallback(() => {
if (!playerRef.current) return; if (!playerRef.current) return;
if (isPlaying) { if (isPlaying) {
@@ -193,28 +202,37 @@ export default function VideoPage() {
} }
}, []); }, []);
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineClick = useCallback(
if (!timelineRef.current) return; (e: React.MouseEvent<HTMLDivElement>) => {
const rect = timelineRef.current.getBoundingClientRect(); if (!timelineRef.current) return;
const x = e.clientX - rect.left; const rect = timelineRef.current.getBoundingClientRect();
const percentage = Math.max(0, Math.min(1, x / rect.width)); const x = e.clientX - rect.left;
const newTime = percentage * duration; const percentage = Math.max(0, Math.min(1, x / rect.width));
handleSeekToTimestamp(newTime); const newTime = percentage * duration;
}, [duration, handleSeekToTimestamp]); handleSeekToTimestamp(newTime);
},
[duration, handleSeekToTimestamp]
);
const handleTimelineMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineMouseDown = useCallback(
setIsDragging(true); (e: React.MouseEvent<HTMLDivElement>) => {
handleTimelineClick(e); setIsDragging(true);
}, [handleTimelineClick]); handleTimelineClick(e);
},
[handleTimelineClick]
);
const handleTimelineMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineMouseMove = useCallback(
if (!isDragging || !timelineRef.current) return; (e: React.MouseEvent<HTMLDivElement>) => {
const rect = timelineRef.current.getBoundingClientRect(); if (!isDragging || !timelineRef.current) return;
const x = e.clientX - rect.left; const rect = timelineRef.current.getBoundingClientRect();
const percentage = Math.max(0, Math.min(1, x / rect.width)); const x = e.clientX - rect.left;
const newTime = percentage * duration; const percentage = Math.max(0, Math.min(1, x / rect.width));
setCurrentTime(newTime); const newTime = percentage * duration;
}, [isDragging, duration]); setCurrentTime(newTime);
},
[isDragging, duration]
);
const handleTimelineMouseUp = useCallback(() => { const handleTimelineMouseUp = useCallback(() => {
if (isDragging) { if (isDragging) {
@@ -233,37 +251,117 @@ export default function VideoPage() {
setIsMuted(!isMuted); setIsMuted(!isMuted);
}, [isMuted]); }, [isMuted]);
const handleSkip = useCallback((seconds: number) => { const handleSkip = useCallback(
const newTime = Math.max(0, Math.min(duration, currentTime + seconds)); (seconds: number) => {
handleSeekToTimestamp(newTime); const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
}, [currentTime, duration, handleSeekToTimestamp]); handleSeekToTimestamp(newTime);
},
[currentTime, duration, handleSeekToTimestamp]
);
const handleAddComment = useCallback(() => { const handleAddComment = useCallback(async () => {
if (!commentText.trim() && !isRecording) return; if (!commentText.trim() || !activeVersion) return;
setIsSubmittingComment(true);
const newComment = { try {
id: `c${Date.now()}`, const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
content: commentText, method: 'POST',
timestamp: selectedTimestamp ?? currentTime, headers: { 'Content-Type': 'application/json' },
author: { name: 'You', image: null }, body: JSON.stringify({
createdAt: 'Just now', content: commentText,
isResolved: false, timestamp: selectedTimestamp ?? currentTime,
replies: [], }),
}; });
setComments(prev => [...prev, newComment]); if (res.ok) {
setCommentText(''); const newComment = await res.json();
setSelectedTimestamp(null); setVideo((prev) => {
}, [commentText, currentTime, selectedTimestamp, isRecording]); if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? { ...v, comments: [...v.comments, { ...newComment, replies: newComment.replies || [] }] }
: v
),
};
});
setCommentText('');
setSelectedTimestamp(null);
}
} catch (err) {
console.error('Failed to add comment:', err);
} finally {
setIsSubmittingComment(false);
}
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]);
const handleResolveComment = useCallback((commentId: string) => { const handleResolveComment = useCallback(
setComments(prev => prev.map(c => async (commentId: string, currentlyResolved: boolean) => {
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c try {
)); const res = await fetch(`/api/comments/${commentId}`, {
}, []); method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
// Hide YouTube controls, enable JS API if (res.ok) {
const embedUrl = `https://www.youtube.com/embed/${mockVideo.currentVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
: v
),
};
});
}
} catch (err) {
console.error('Failed to resolve comment:', err);
}
},
[activeVersionId]
);
const getEmbedUrl = (version: Version) => {
if (version.providerId === 'youtube') {
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
}
if (version.providerId === 'vimeo') {
return `https://player.vimeo.com/video/${version.videoId}`;
}
return version.originalUrl;
};
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 || !activeVersion) {
return (
<div className="h-screen flex items-center justify-center bg-background">
<div className="text-center">
<p className="text-muted-foreground mb-4">{error || 'Video not found'}</p>
<Button asChild variant="outline">
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
);
}
const embedUrl = getEmbedUrl(activeVersion);
return ( return (
<div <div
@@ -271,7 +369,6 @@ export default function VideoPage() {
onMouseUp={handleTimelineMouseUp} onMouseUp={handleTimelineMouseUp}
onMouseLeave={() => isDragging && handleTimelineMouseUp()} onMouseLeave={() => isDragging && handleTimelineMouseUp()}
> >
{/* Main Content - Full Width Layout */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Video Area */} {/* Video Area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
@@ -279,7 +376,7 @@ export default function VideoPage() {
<div className="shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50"> <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"> <div className="flex items-center gap-3">
<Link <Link
href={`/projects/${projectId}`} href={`/projects/${video.projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
<ArrowLeft className="h-4 w-4 mr-1" /> <ArrowLeft className="h-4 w-4 mr-1" />
@@ -287,8 +384,8 @@ export default function VideoPage() {
</Link> </Link>
<Separator orientation="vertical" className="h-5" /> <Separator orientation="vertical" className="h-5" />
<div className="min-w-0"> <div className="min-w-0">
<span className="text-sm font-medium">{mockVideo.title}</span> <span className="text-sm font-medium">{video.title}</span>
<span className="text-xs text-muted-foreground ml-2"> {mockVideo.projectName}</span> <span className="text-xs text-muted-foreground ml-2"> {video.project.name}</span>
</div> </div>
</div> </div>
@@ -296,18 +393,29 @@ export default function VideoPage() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">v{mockVideo.currentVersion.number}</Badge> <Badge variant="secondary" className="mr-2">
{mockVideo.currentVersion.label} v{activeVersion.versionNumber}
</Badge>
{activeVersion.versionLabel || `Version ${activeVersion.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" /> <ChevronDown className="h-4 w-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{mockVideo.versions.map((version) => ( {video.versions.map((version) => (
<DropdownMenuItem key={version.id}> <DropdownMenuItem
<Badge variant={version.isActive ? 'default' : 'secondary'} className="mr-2"> key={version.id}
v{version.number} onClick={() => setActiveVersionId(version.id)}
>
<Badge
variant={version.id === activeVersionId ? 'default' : 'secondary'}
className="mr-2"
>
v{version.versionNumber}
</Badge> </Badge>
{version.label} {version.versionLabel || `Version ${version.versionNumber}`}
<span className="ml-auto text-xs text-muted-foreground">
{version._count.comments} comments
</span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -329,10 +437,12 @@ export default function VideoPage() {
/> />
{/* Play/Pause overlay indicator */} {/* Play/Pause overlay indicator */}
<div className={cn( <div
"absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity", className={cn(
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-100" 'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity',
)}> isPlaying ? 'opacity-0 group-hover:opacity-100' : '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">
{isPlaying ? ( {isPlaying ? (
<Pause className="h-8 w-8 text-white" /> <Pause className="h-8 w-8 text-white" />
@@ -348,17 +458,8 @@ export default function VideoPage() {
<div className="shrink-0 px-4 py-3 bg-background border-t"> <div className="shrink-0 px-4 py-3 bg-background border-t">
{/* Control buttons */} {/* Control buttons */}
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Button <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePlayPause}>
variant="ghost" {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
size="icon"
className="h-8 w-8"
onClick={handlePlayPause}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4 ml-0.5" />
)}
</Button> </Button>
<Button <Button
@@ -379,17 +480,8 @@ export default function VideoPage() {
<SkipForward className="h-4 w-4" /> <SkipForward className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleMuteToggle}>
variant="ghost" {isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
size="icon"
className="h-8 w-8"
onClick={handleMuteToggle}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button> </Button>
<span className="text-xs text-muted-foreground ml-2 tabular-nums"> <span className="text-xs text-muted-foreground ml-2 tabular-nums">
@@ -404,8 +496,6 @@ export default function VideoPage() {
onMouseDown={handleTimelineMouseDown} onMouseDown={handleTimelineMouseDown}
onMouseMove={handleTimelineMouseMove} onMouseMove={handleTimelineMouseMove}
> >
{/* Buffered/loaded indicator could go here */}
{/* Progress bar */} {/* Progress bar */}
<div <div
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none" className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
@@ -427,8 +517,8 @@ export default function VideoPage() {
handleSeekToTimestamp(comment.timestamp); handleSeekToTimestamp(comment.timestamp);
}} }}
className={cn( className={cn(
"absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10", 'absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10',
comment.isResolved ? "bg-green-500" : "bg-cyan-400" comment.isResolved ? 'bg-green-500' : 'bg-cyan-400'
)} )}
style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }} style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }}
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`} title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
@@ -438,25 +528,20 @@ export default function VideoPage() {
</div> </div>
</div> </div>
{/* Comments Sidebar - Fixed Right */} {/* Comments Sidebar */}
<div className="w-80 shrink-0 border-l bg-card flex flex-col overflow-hidden"> <div className="w-80 shrink-0 border-l bg-card flex flex-col overflow-hidden">
{/* Comments Header */}
<div className="shrink-0 flex items-center justify-between p-4 border-b"> <div className="shrink-0 flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span> <span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge> <Badge variant="secondary">{comments.length}</Badge>
</div> </div>
<Button <Button variant="ghost" size="sm" onClick={() => setShowResolved(!showResolved)}>
variant="ghost"
size="sm"
onClick={() => setShowResolved(!showResolved)}
>
{showResolved ? 'Hide' : 'Show'} Resolved {showResolved ? 'Hide' : 'Show'} Resolved
</Button> </Button>
</div> </div>
{/* Comments List - Scrollable */} {/* Comments List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3"> <div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? ( {filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
@@ -467,126 +552,134 @@ export default function VideoPage() {
) : ( ) : (
filteredComments filteredComments
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
.map((comment) => ( .map((comment) => {
<div const authorName =
key={comment.id} comment.author?.name || comment.guestName || 'Anonymous';
className={cn( return (
"group rounded-lg border p-3 transition-colors hover:bg-accent/50", <div
comment.isResolved && "opacity-60" key={comment.id}
)} className={cn(
> 'group rounded-lg border p-3 transition-colors hover:bg-accent/50',
{/* Comment Header */} comment.isResolved && 'opacity-60'
<div className="flex items-start justify-between gap-2 mb-2"> )}
<div className="flex items-center gap-2"> >
<Avatar className="h-6 w-6"> <div className="flex items-start justify-between gap-2 mb-2">
<AvatarImage src={comment.author.image ?? undefined} /> <div className="flex items-center gap-2">
<AvatarFallback className="text-xs"> <Avatar className="h-6 w-6">
{comment.author.name.charAt(0)} <AvatarImage src={comment.author?.image ?? undefined} />
</AvatarFallback> <AvatarFallback className="text-xs">
</Avatar> {authorName.charAt(0)}
<span className="text-sm font-medium">{comment.author.name}</span> </AvatarFallback>
</div> </Avatar>
<span className="text-sm font-medium">{authorName}</span>
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => handleSeekToTimestamp(comment.timestamp)} onClick={() => handleSeekToTimestamp(comment.timestamp)}
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10" className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10"
> >
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{formatTime(comment.timestamp)} {formatTime(comment.timestamp)}
</button> </button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => handleResolveComment(comment.id)} onClick={() =>
> handleResolveComment(comment.id, comment.isResolved)
{comment.isResolved ? ( }
<CheckCircle2 className="h-4 w-4 text-green-500" /> >
) : ( {comment.isResolved ? (
<Circle className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
)} ) : (
</Button> <Circle className="h-4 w-4" />
<DropdownMenu> )}
<DropdownMenuTrigger asChild> </Button>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100"> <DropdownMenu>
<MoreVertical className="h-4 w-4" /> <DropdownMenuTrigger asChild>
</Button> <Button
</DropdownMenuTrigger> variant="ghost"
<DropdownMenuContent align="end"> size="icon"
<DropdownMenuItem>Reply</DropdownMenuItem> className="h-6 w-6 opacity-0 group-hover:opacity-100"
<DropdownMenuItem>Edit</DropdownMenuItem> >
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem> <MoreVertical className="h-4 w-4" />
</DropdownMenuContent> </Button>
</DropdownMenu> </DropdownMenuTrigger>
</div> <DropdownMenuContent align="end">
</div> <DropdownMenuItem>Reply</DropdownMenuItem>
<DropdownMenuItem>Edit</DropdownMenuItem>
{/* Comment Content */} <DropdownMenuItem className="text-destructive">
{comment.content && ( Delete
<p className="text-sm mb-2">{comment.content}</p> </DropdownMenuItem>
)} </DropdownMenuContent>
</DropdownMenu>
{/* Voice Comment */}
{comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<Button size="icon" variant="ghost" className="h-8 w-8">
<Play className="h-4 w-4" />
</Button>
<div className="flex-1 h-1 bg-primary/30 rounded">
<div className="w-0 h-full bg-primary rounded" />
</div> </div>
<span className="text-xs text-muted-foreground">
{formatTime(comment.voiceDuration || 0)}
</span>
</div> </div>
)}
{/* Timestamp & Meta */} {comment.content && <p className="text-sm mb-2">{comment.content}</p>}
<p className="text-xs text-muted-foreground">{comment.createdAt}</p>
{/* Replies */} {comment.voiceUrl && (
{comment.replies.length > 0 && ( <div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<div className="mt-3 pl-3 border-l-2 space-y-2"> <Button size="icon" variant="ghost" className="h-8 w-8">
{comment.replies.map((reply) => ( <Play className="h-4 w-4" />
<div key={reply.id} className="text-sm"> </Button>
<div className="flex items-center gap-2 mb-1"> <div className="flex-1 h-1 bg-primary/30 rounded">
<Avatar className="h-5 w-5"> <div className="w-0 h-full bg-primary rounded" />
<AvatarFallback className="text-xs">
{reply.author.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium text-xs">{reply.author.name}</span>
<span className="text-xs text-muted-foreground">{reply.createdAt}</span>
</div>
<p className="text-sm">{reply.content}</p>
</div> </div>
))} <span className="text-xs text-muted-foreground">
</div> {formatTime(comment.voiceDuration || 0)}
)} </span>
</div> </div>
)) )}
<p className="text-xs text-muted-foreground">
{new Date(comment.createdAt).toLocaleDateString()}
</p>
{comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2">
{comment.replies.map((reply) => {
const replyAuthor =
reply.author?.name || reply.guestName || 'Anonymous';
return (
<div key={reply.id} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-xs">
{replyAuthor.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium text-xs">{replyAuthor}</span>
<span className="text-xs text-muted-foreground">
{new Date(reply.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-sm">{reply.content}</p>
</div>
);
})}
</div>
)}
</div>
);
})
)} )}
</div> </div>
{/* Comment Input - Fixed at Bottom */} {/* Comment Input */}
<div className="shrink-0 p-4 border-t bg-background"> <div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setSelectedTimestamp(currentTime)} onClick={() => setSelectedTimestamp(currentTime)}
className={cn(selectedTimestamp !== null && "border-primary")} className={cn(selectedTimestamp !== null && 'border-primary')}
> >
<Clock className="h-4 w-4 mr-1" /> <Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null {selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)}
? formatTime(selectedTimestamp)
: formatTime(currentTime)
}
</Button> </Button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">Pin to this time</span>
Pin to this time
</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -606,22 +699,24 @@ export default function VideoPage() {
<Button <Button
size="icon" size="icon"
onClick={handleAddComment} onClick={handleAddComment}
disabled={!commentText.trim()} disabled={!commentText.trim() || isSubmittingComment}
> >
<Send className="h-4 w-4" /> {isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button
size="icon" size="icon"
variant={isRecording ? "destructive" : "outline"} variant={isRecording ? 'destructive' : 'outline'}
onClick={() => setIsRecording(!isRecording)} onClick={() => setIsRecording(!isRecording)}
> >
<Mic className={cn("h-4 w-4", isRecording && "animate-pulse")} /> <Mic className={cn('h-4 w-4', isRecording && 'animate-pulse')} />
</Button> </Button>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
+Enter to submit
</p>
</div> </div>
</div> </div>
</div> </div>
+328 -58
View File
@@ -1,21 +1,48 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { import {
Play, Play,
MessageSquare, MessageSquare,
Clock, Clock,
MoreVertical MoreVertical,
Loader2,
Link as LinkIcon,
AlertCircle,
CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
interface VideoCardProps { interface VideoCardProps {
video: { video: {
@@ -31,65 +58,308 @@ interface VideoCardProps {
} }
export function VideoCard({ video, projectId }: VideoCardProps) { export function VideoCard({ video, projectId }: VideoCardProps) {
const router = useRouter();
// Edit dialog
const [showEditDialog, setShowEditDialog] = useState(false);
const [editTitle, setEditTitle] = useState(video.title);
const [editDescription, setEditDescription] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [editError, setEditError] = useState('');
// Add Version dialog
const [showVersionDialog, setShowVersionDialog] = useState(false);
const [versionUrl, setVersionUrl] = useState('');
const [versionLabel, setVersionLabel] = useState('');
const [versionSource, setVersionSource] = useState<VideoSource | null>(null);
const [versionUrlError, setVersionUrlError] = useState('');
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
// Delete dialog
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleEdit = async () => {
setIsSaving(true);
setEditError('');
try {
const res = await fetch(`/api/projects/${projectId}/videos/${video.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editTitle.trim(),
description: editDescription.trim() || null,
}),
});
if (!res.ok) {
const data = await res.json();
setEditError(data.error || 'Failed to update video');
return;
}
setShowEditDialog(false);
router.refresh();
} catch {
setEditError('An unexpected error occurred');
} finally {
setIsSaving(false);
}
};
const handleVersionUrlChange = (url: string) => {
setVersionUrl(url);
setVersionUrlError('');
if (!url.trim()) {
setVersionSource(null);
return;
}
const source = parseVideoUrl(url);
if (source) {
setVersionSource(source);
} else {
setVersionSource(null);
if (url.length > 10) setVersionUrlError('Unsupported URL');
}
};
const handleCreateVersion = async () => {
if (!versionSource) return;
setIsCreatingVersion(true);
try {
const meta = await fetchVideoMetadata(versionSource);
const thumbnailUrl = getThumbnailUrl(versionSource, 'large');
const res = await fetch(`/api/projects/${projectId}/videos/${video.id}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoUrl: versionSource.originalUrl,
providerId: versionSource.providerId,
providerVideoId: versionSource.videoId,
versionLabel: versionLabel.trim() || null,
thumbnailUrl,
duration: meta?.duration || null,
setActive: true,
}),
});
if (res.ok) {
setShowVersionDialog(false);
setVersionUrl('');
setVersionLabel('');
setVersionSource(null);
router.refresh();
}
} catch (err) {
console.error('Failed to create version:', err);
} finally {
setIsCreatingVersion(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
const res = await fetch(`/api/projects/${projectId}/videos/${video.id}`, {
method: 'DELETE',
});
if (res.ok) {
setShowDeleteDialog(false);
router.refresh();
}
} catch (err) {
console.error('Failed to delete video:', err);
} finally {
setIsDeleting(false);
}
};
return ( return (
<Card className="group overflow-hidden transition-colors hover:bg-accent/50 cursor-pointer"> <>
<Link href={`/watch/${video.id}`}> <Card className="group overflow-hidden transition-colors hover:bg-accent/50 cursor-pointer">
{/* Thumbnail */} <Link href={`/projects/${projectId}/videos/${video.id}`}>
<div className="relative aspect-video bg-muted overflow-hidden"> {/* Thumbnail */}
<img <div className="relative aspect-video bg-muted overflow-hidden">
src={video.thumbnailUrl} <img
alt={video.title} src={video.thumbnailUrl}
className="object-cover w-full h-full transition-transform group-hover:scale-105" alt={video.title}
/> className="object-cover w-full h-full transition-transform group-hover:scale-105"
<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 className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
</div> <Play className="h-12 w-12 text-white" fill="white" />
<Badge className="absolute bottom-2 right-2 bg-black/70">
{video.duration}
</Badge>
</div>
</Link>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<Link href={`/watch/${video.id}`} className="min-w-0 flex-1">
<h3 className="font-medium truncate">{video.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Badge variant="secondary" className="text-xs">
v{video.currentVersion}
</Badge>
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{video.commentCount}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{video.lastUpdated}
</span>
</div> </div>
</Link> <Badge className="absolute bottom-2 right-2 bg-black/70">{video.duration}</Badge>
</div>
</Link>
<DropdownMenu> <CardContent className="p-4">
<DropdownMenuTrigger asChild> <div className="flex items-start justify-between gap-2">
<Button <Link href={`/projects/${projectId}/videos/${video.id}`} className="min-w-0 flex-1">
variant="ghost" <h3 className="font-medium truncate">{video.title}</h3>
size="icon" <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
className="h-8 w-8 shrink-0" <span className="flex items-center gap-1">
onClick={(e) => e.stopPropagation()} <Badge variant="secondary" className="text-xs">
> v{video.currentVersion}
<MoreVertical className="h-4 w-4" /> </Badge>
</Button> </span>
</DropdownMenuTrigger> <span className="flex items-center gap-1">
<DropdownMenuContent align="end"> <MessageSquare className="h-3.5 w-3.5" />
<DropdownMenuItem>Edit</DropdownMenuItem> {video.commentCount}
<DropdownMenuItem>Add Version</DropdownMenuItem> </span>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem> <span className="flex items-center gap-1">
</DropdownMenuContent> <Clock className="h-3.5 w-3.5" />
</DropdownMenu> {video.lastUpdated}
</div> </span>
</CardContent> </div>
</Card> </Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
Add Version
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={() => setShowDeleteDialog(true)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Video</DialogTitle>
<DialogDescription>Update the video title and description.</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-2">
<Label>Title</Label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={3}
disabled={isSaving}
/>
</div>
{editError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{editError}
</p>
)}
<Button onClick={handleEdit} disabled={!editTitle.trim() || isSaving} className="w-full">
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Changes
</Button>
</div>
</DialogContent>
</Dialog>
{/* Add Version Dialog */}
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Version</DialogTitle>
<DialogDescription>
Upload a new version of &quot;{video.title}&quot;. The new version will become active.
</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={versionUrl}
onChange={(e) => handleVersionUrlChange(e.target.value)}
className="pl-10"
disabled={isCreatingVersion}
/>
</div>
{versionUrlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{versionUrlError}
</p>
)}
{versionSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{versionSource.providerId.charAt(0).toUpperCase() +
versionSource.providerId.slice(1)}{' '}
video detected
</p>
)}
</div>
<div className="space-y-2">
<Label>Version Label (optional)</Label>
<Input
placeholder="e.g. Final Cut, Review Round 2"
value={versionLabel}
onChange={(e) => setVersionLabel(e.target.value)}
disabled={isCreatingVersion}
/>
</div>
<Button
onClick={handleCreateVersion}
disabled={!versionSource || isCreatingVersion}
className="w-full"
>
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add Version {video.currentVersion + 1}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{video.title}&quot;?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this video, all its versions, and all comments. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
} }
+3
View File
@@ -24,6 +24,9 @@ declare namespace YT {
getCurrentTime(): number; getCurrentTime(): number;
getDuration(): number; getDuration(): number;
getPlayerState(): PlayerState; getPlayerState(): PlayerState;
setPlaybackRate(suggestedRate: number): void;
getPlaybackRate(): number;
getAvailablePlaybackRates(): number[];
destroy(): void; destroy(): void;
} }