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
|
// 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" />}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 "{video.title}". 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 "{video.title}"?</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user