chore(init): scaffold OpenFrame — Next.js + Bun + shadcn

Initialize OpenFrame project with core scaffold and UI foundation.

- Project scaffold: Next.js 16.1 (App Router) + Bun runtime
- UI: shadcn/ui + TailwindCSS; landing, dashboard, project, and auth UIs
- Video support: provider abstraction (YouTube first), video player page with timestamped comments and custom timeline
- Auth & DB: NextAuth skeleton and Prisma schema (Postgres) included
- UX: dark-mode toggle, full-width layouts, comments sidebar, video route moved to /watch/[videoId]
- Dev: added YouTube iframe integration, custom controls, and TypeScript types
- Next steps: DB migrations, API routes (CRUD), real data wiring, voice-recording & sharing
This commit is contained in:
Yusuf İpek
2026-02-05 22:11:20 +03:00
commit 264392c2ec
64 changed files with 7917 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Video, Loader2, Github, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
export default function LoginPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// TODO: Implement actual sign in
// await signIn('credentials', { email, password, redirect: false });
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/dashboard');
} catch (error) {
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
const handleOAuthLogin = async (provider: string) => {
setIsLoading(true);
try {
// TODO: Implement OAuth
// await signIn(provider, { callbackUrl: '/dashboard' });
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/dashboard');
} catch (error) {
console.error('OAuth login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md">
{/* Logo */}
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
<Video className="h-8 w-8 text-primary" />
<span className="font-bold text-2xl">OpenFrame</span>
</Link>
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
{/* OAuth Buttons */}
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => handleOAuthLogin('google')}
disabled={isLoading}
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuthLogin('github')}
disabled={isLoading}
>
<Github className="h-4 w-4 mr-2" />
Continue with GitHub
</Button>
</div>
<div className="relative my-6">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
or continue with email
</span>
</div>
{/* Email Form */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import Link from 'next/link';
import { Plus, FolderOpen, Clock, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
// import { auth } from '@/lib/auth';
// import { redirect } from 'next/navigation';
// Placeholder data - will be replaced with real data from database
const mockProjects = [
{
id: '1',
name: 'Product Demo v2',
description: 'New product walkthrough video for Q1 launch',
videoCount: 3,
lastUpdated: '2 hours ago',
memberCount: 4,
},
{
id: '2',
name: 'Marketing Campaign',
description: 'Social media ads for summer campaign',
videoCount: 8,
lastUpdated: '1 day ago',
memberCount: 2,
},
{
id: '3',
name: 'Tutorial Series',
description: 'Getting started tutorials for new users',
videoCount: 12,
lastUpdated: '3 days ago',
memberCount: 1,
},
];
export default async function DashboardPage() {
// TODO: Uncomment when database is set up
// const session = await auth();
// if (!session) {
// redirect('/login');
// }
return (
<div className="px-6 lg:px-8 py-8 w-full">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground mt-1">
Manage your video projects and collect feedback
</p>
</div>
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
New Project
</Link>
</Button>
</div>
{/* Projects Grid */}
{mockProjects.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{mockProjects.map((project) => (
<Link key={project.id} href={`/projects/${project.id}`}>
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" />
{project.name}
</CardTitle>
<CardDescription className="line-clamp-2">
{project.description}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{project.lastUpdated}
</span>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{project.memberCount}
</span>
<span>{project.videoCount} videos</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
<p className="text-muted-foreground text-center mb-4">
Create your first project to start collecting video feedback
</p>
<Button asChild>
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-2" />
Create Project
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Header } from '@/components/layout';
// import { auth } from '@/lib/auth';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// TODO: Uncomment when database is set up
// const session = await auth();
const mockUser = {
name: 'Demo User',
email: '[email protected]',
image: null,
};
return (
<div className="relative flex min-h-screen flex-col">
<Header user={mockUser} />
<main className="flex-1">{children}</main>
</div>
);
}
@@ -0,0 +1,137 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
ArrowLeft,
Plus,
Settings,
Share2,
Play,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card';
// Mock data - will be replaced with real data
const mockProject = {
id: '1',
name: 'Product Demo v2',
description: 'New product walkthrough video for Q1 launch',
visibility: 'PRIVATE',
videos: [
{
id: 'v1',
title: 'Main Product Walkthrough',
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
currentVersion: 3,
commentCount: 12,
duration: '5:42',
lastUpdated: '2 hours ago',
},
{
id: 'v2',
title: 'Feature Highlight - Dashboard',
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
currentVersion: 1,
commentCount: 5,
duration: '2:18',
lastUpdated: '1 day ago',
},
{
id: 'v3',
title: 'Onboarding Flow',
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
currentVersion: 2,
commentCount: 8,
duration: '3:55',
lastUpdated: '3 days ago',
},
],
};
interface ProjectPageProps {
params: Promise<{ projectId: string }>;
}
export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params;
// TODO: Fetch real project data
const project = mockProject;
if (!project) {
notFound();
}
return (
<div className="px-6 lg:px-8 py-8 w-full">
{/* Back link */}
<div className="mb-6">
<Link
href="/dashboard"
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 Projects
</Link>
</div>
{/* Project Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<Badge variant="outline" className="capitalize">
{project.visibility.toLowerCase()}
</Badge>
</div>
{project.description && (
<p className="text-muted-foreground">{project.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Settings
</Button>
<Button size="sm" asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
</div>
</div>
{/* Videos Grid */}
{project.videos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{project.videos.map((video) => (
<VideoCard key={video.id} video={video} projectId={projectId} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Play className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No videos yet</h3>
<p className="text-muted-foreground text-center mb-4">
Add your first video to start collecting feedback
</p>
<Button asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,9 @@
export default function VideoLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout is empty - no header, no sidebar
// The video page uses full screen space
return <>{children}</>;
}
@@ -0,0 +1,433 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
ArrowLeft,
Play,
Pause,
Volume2,
VolumeX,
Maximize,
MessageSquare,
Mic,
Send,
Clock,
CheckCircle2,
Circle,
ChevronDown,
MoreVertical,
User
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// Mock data
const mockVideo = {
id: 'v1',
title: 'Main Product Walkthrough',
description: 'Complete walkthrough of the new product features',
projectId: '1',
projectName: 'Product Demo v2',
versions: [
{ id: 'ver3', number: 3, label: 'Final Cut', isActive: true },
{ id: 'ver2', number: 2, label: 'Review Round 2', isActive: false },
{ id: 'ver1', number: 1, label: 'First Draft', isActive: false },
],
currentVersion: {
id: 'ver3',
number: 3,
label: 'Final Cut',
providerId: 'youtube',
videoId: 'dQw4w9WgXcQ', // Sample video
duration: 342, // 5:42
},
};
const mockComments = [
{
id: 'c1',
content: 'The transition here feels a bit abrupt. Can we add a fade?',
timestamp: 45.5,
author: { name: 'Sarah Chen', image: null },
createdAt: '2 hours ago',
isResolved: false,
replies: [
{
id: 'c1r1',
content: 'Good catch! I\'ll smooth that out in the next version.',
author: { name: 'Mike Johnson', image: null },
createdAt: '1 hour ago',
},
],
},
{
id: 'c2',
content: 'Love this section! The pacing is perfect.',
timestamp: 120,
author: { name: 'Alex Rivera', image: null },
createdAt: '5 hours ago',
isResolved: true,
replies: [],
},
{
id: 'c3',
content: 'Can we add some background music here?',
timestamp: 200,
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 {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function VideoPage() {
const params = useParams();
const projectId = params.projectId as string;
const videoId = params.videoId as string;
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(mockVideo.currentVersion.duration);
const [isMuted, setIsMuted] = useState(false);
const [commentText, setCommentText] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [comments, setComments] = useState(mockComments);
const [showResolved, setShowResolved] = useState(false);
const filteredComments = comments.filter(c => showResolved || !c.isResolved);
const handleSeekToTimestamp = useCallback((timestamp: number) => {
setCurrentTime(timestamp);
// In real implementation, control iframe player
// window.postMessage to YouTube iframe API
}, []);
const handleAddComment = useCallback(() => {
if (!commentText.trim() && !isRecording) return;
const newComment = {
id: `c${Date.now()}`,
content: commentText,
timestamp: selectedTimestamp ?? currentTime,
author: { name: 'You', image: null },
createdAt: 'Just now',
isResolved: false,
replies: [],
};
setComments(prev => [...prev, newComment]);
setCommentText('');
setSelectedTimestamp(null);
}, [commentText, currentTime, selectedTimestamp, isRecording]);
const handleResolveComment = useCallback((commentId: string) => {
setComments(prev => prev.map(c =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
));
}, []);
const embedUrl = `https://www.youtube.com/embed/${mockVideo.currentVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1`;
return (
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Main Content - Full Width Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Video Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Compact Header Bar */}
<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}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="min-w-0">
<span className="text-sm font-medium">{mockVideo.title}</span>
<span className="text-xs text-muted-foreground ml-2"> {mockVideo.projectName}</span>
</div>
</div>
{/* Version Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">v{mockVideo.currentVersion.number}</Badge>
{mockVideo.currentVersion.label}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{mockVideo.versions.map((version) => (
<DropdownMenuItem key={version.id}>
<Badge variant={version.isActive ? 'default' : 'secondary'} className="mr-2">
v{version.number}
</Badge>
{version.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Video Player - Maximized */}
<div className="flex-1 bg-black flex items-center justify-center p-2">
<div className="relative w-full h-full max-h-full">
<iframe
ref={iframeRef}
src={embedUrl}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
{/* Timeline with comment markers */}
<div className="shrink-0 px-4 py-2 bg-background border-t">
<div className="relative h-8 bg-muted rounded cursor-pointer">
{/* Progress bar */}
<div
className="absolute left-0 top-0 h-full bg-primary/30 rounded"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
{/* Comment markers */}
{comments.map((comment) => (
<button
key={comment.id}
onClick={() => handleSeekToTimestamp(comment.timestamp)}
className={cn(
"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-primary"
)}
style={{ left: `${(comment.timestamp / duration) * 100}%` }}
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
/>
))}
{/* Time display */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground font-medium">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
{/* Comments Sidebar - Fixed Right */}
<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="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowResolved(!showResolved)}
>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
</div>
{/* Comments List - Scrollable */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
<p className="text-sm">Be the first to leave feedback!</p>
</div>
) : (
filteredComments
.sort((a, b) => a.timestamp - b.timestamp)
.map((comment) => (
<div
key={comment.id}
className={cn(
"group rounded-lg border p-3 transition-colors hover:bg-accent/50",
comment.isResolved && "opacity-60"
)}
>
{/* Comment Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={comment.author.image ?? undefined} />
<AvatarFallback className="text-xs">
{comment.author.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{comment.author.name}</span>
</div>
<div className="flex items-center gap-1">
<button
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"
>
<Clock className="h-3 w-3" />
{formatTime(comment.timestamp)}
</button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleResolveComment(comment.id)}
>
{comment.isResolved ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Reply</DropdownMenuItem>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Comment Content */}
{comment.content && (
<p className="text-sm mb-2">{comment.content}</p>
)}
{/* 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>
<span className="text-xs text-muted-foreground">
{formatTime(comment.voiceDuration || 0)}
</span>
</div>
)}
{/* Timestamp & Meta */}
<p className="text-xs text-muted-foreground">{comment.createdAt}</p>
{/* Replies */}
{comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2">
{comment.replies.map((reply) => (
<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">
{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>
)}
</div>
))
)}
</div>
{/* Comment Input - Fixed at Bottom */}
<div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedTimestamp(currentTime)}
className={cn(selectedTimestamp !== null && "border-primary")}
>
<Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null
? formatTime(selectedTimestamp)
: formatTime(currentTime)
}
</Button>
<span className="text-xs text-muted-foreground">
Pin to this time
</span>
</div>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim()}
>
<Send className="h-4 w-4" />
</Button>
<Button
size="icon"
variant={isRecording ? "destructive" : "outline"}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn("h-4 w-4", isRecording && "animate-pulse")} />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
+Enter to submit
</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,191 @@
'use client';
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { parseVideoUrl, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
export default function NewVideoPage() {
const router = useRouter();
const params = useParams();
const projectId = params.projectId as string;
const [isLoading, setIsLoading] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
const [urlError, setUrlError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
});
const handleUrlChange = (url: string) => {
setVideoUrl(url);
setUrlError('');
if (!url.trim()) {
setVideoSource(null);
return;
}
const source = parseVideoUrl(url);
if (source) {
setVideoSource(source);
} else {
setVideoSource(null);
if (url.length > 10) {
setUrlError('Could not recognize this video URL. Currently supported: YouTube, Vimeo');
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!videoSource) {
setUrlError('Please enter a valid video URL');
return;
}
setIsLoading(true);
try {
// TODO: Implement actual video creation
// const response = await fetch(`/api/projects/${projectId}/videos`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// ...formData,
// ...videoSource,
// }),
// });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
router.push(`/projects/${projectId}`);
} catch (error) {
console.error('Failed to add video:', error);
} finally {
setIsLoading(false);
}
};
const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null;
return (
<div className="container max-w-2xl py-8">
<div className="mb-6">
<Link
href={`/projects/${projectId}`}
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 Project
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Add Video</CardTitle>
<CardDescription>
Paste a video link to add it to your project. Currently supports YouTube and Vimeo.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Video URL Input */}
<div className="space-y-2">
<Label htmlFor="url">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
id="url"
placeholder="https://youtube.com/watch?v=..."
value={videoUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="pl-10"
required
disabled={isLoading}
/>
</div>
{/* URL validation feedback */}
{urlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{urlError}
</p>
)}
{videoSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
</p>
)}
</div>
{/* Video Preview */}
{thumbnailUrl && videoSource && (
<div className="space-y-2">
<Label>Preview</Label>
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<img
src={thumbnailUrl}
alt="Video thumbnail"
className="object-cover w-full h-full"
/>
</div>
</div>
)}
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Video title (will auto-fill from video if empty)"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the original video title
</p>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
placeholder="Add context about this video..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
disabled={isLoading}
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={isLoading || !videoSource}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add Video
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
export default function NewProjectPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// TODO: Implement actual project creation
// const response = await fetch('/api/projects', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(formData),
// });
// Simulate API call for now
await new Promise(resolve => setTimeout(resolve, 500));
// Redirect to dashboard on success
router.push('/dashboard');
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="container max-w-2xl py-8">
<div className="mb-6">
<Link
href="/dashboard"
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 Projects
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Create New Project</CardTitle>
<CardDescription>
Set up a new project to organize your videos and collect feedback
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
placeholder="My Awesome Project"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
placeholder="Brief description of what this project is about..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
disabled={isLoading}
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={isLoading || !formData.name.trim()}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Project
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+126
View File
@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.61 0.11 222);
--primary-foreground: oklch(0.98 0.02 201);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.61 0.11 222);
--accent-foreground: oklch(0.98 0.02 201);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.87 0.12 207);
--chart-2: oklch(0.80 0.13 212);
--chart-3: oklch(0.71 0.13 215);
--chart-4: oklch(0.61 0.11 222);
--chart-5: oklch(0.52 0.09 223);
--radius: 0;
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.61 0.11 222);
--sidebar-primary-foreground: oklch(0.98 0.02 201);
--sidebar-accent: oklch(0.61 0.11 222);
--sidebar-accent-foreground: oklch(0.98 0.02 201);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.71 0.13 215);
--primary-foreground: oklch(0.30 0.05 230);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.71 0.13 215);
--accent-foreground: oklch(0.30 0.05 230);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.87 0.12 207);
--chart-2: oklch(0.80 0.13 212);
--chart-3: oklch(0.71 0.13 215);
--chart-4: oklch(0.61 0.11 222);
--chart-5: oklch(0.52 0.09 223);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.80 0.13 212);
--sidebar-primary-foreground: oklch(0.30 0.05 230);
--sidebar-accent: oklch(0.71 0.13 215);
--sidebar-accent-foreground: oklch(0.30 0.05 230);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-sans',
});
export const metadata: Metadata = {
title: "OpenFrame - Video Feedback Platform",
description: "Collect timestamped video feedback with text and voice comments",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={jetbrainsMono.variable} suppressHydrationWarning>
<body className="antialiased min-h-screen bg-background">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Link from 'next/link';
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function HomePage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b">
<div className="px-6 lg:px-8 flex h-16 items-center justify-between w-full">
<Link href="/" className="flex items-center gap-2">
<Video className="h-6 w-6 text-primary" />
<span className="font-bold text-xl">OpenFrame</span>
</Link>
<div className="flex items-center gap-4">
<Button asChild variant="ghost">
<Link href="/login">Sign in</Link>
</Button>
<Button asChild>
<Link href="/dashboard">Get Started</Link>
</Button>
</div>
</div>
</header>
{/* Hero */}
<section className="px-6 lg:px-8 py-24 md:py-32">
<div className="flex flex-col items-center text-center gap-8 max-w-3xl mx-auto">
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
Video feedback,{' '}
<span className="text-primary">reimagined</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
Collect timestamped feedback on your videos with text and voice comments.
Share with your team and clients, iterate faster.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild size="lg">
<Link href="/dashboard">
Start for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="#features">
<Play className="h-4 w-4 mr-2" />
See how it works
</Link>
</Button>
</div>
</div>
</section>
{/* Features */}
<section id="features" className="px-6 lg:px-8 py-24 border-t">
<h2 className="text-3xl font-bold text-center mb-16">
Everything you need for video feedback
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
<FeatureCard
icon={Video}
title="Multiple Sources"
description="Add videos from YouTube, Vimeo, or upload directly. One place for all your video content."
/>
<FeatureCard
icon={MessageSquare}
title="Timestamped Comments"
description="Leave feedback at specific moments. Click any comment to jump to that exact frame."
/>
<FeatureCard
icon={Mic}
title="Voice Comments"
description="Record voice notes for detailed feedback. Sometimes speaking is faster than typing."
/>
<FeatureCard
icon={Share2}
title="Easy Sharing"
description="Generate shareable links for clients and collaborators. No account required to comment."
/>
</div>
</section>
{/* CTA */}
<section className="px-6 lg:px-8 py-24 border-t">
<div className="bg-accent rounded-2xl p-12 text-center">
<h2 className="text-3xl font-bold mb-4">Ready to streamline your video workflow?</h2>
<p className="text-muted-foreground mb-8 max-w-xl mx-auto">
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
</p>
<Button asChild size="lg">
<Link href="/dashboard">
Get started for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
</div>
</section>
{/* Footer */}
<footer className="border-t py-8">
<div className="px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
<div className="flex items-center gap-2">
<Video className="h-5 w-5 text-primary" />
<span className="font-semibold">OpenFrame</span>
</div>
<p className="text-sm text-muted-foreground">
Built with Next.js, shadcn/ui, and
</p>
</div>
</footer>
</div>
);
}
function FeatureCard({
icon: Icon,
title,
description
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center text-center p-6 rounded-lg border bg-card">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="font-semibold mb-2">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}
+630
View File
@@ -0,0 +1,630 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
ArrowLeft,
Play,
Pause,
Volume2,
VolumeX,
Maximize,
MessageSquare,
Mic,
Send,
Clock,
CheckCircle2,
Circle,
ChevronDown,
MoreVertical,
User,
SkipBack,
SkipForward
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// Mock data
const mockVideo = {
id: 'v1',
title: 'Main Product Walkthrough',
description: 'Complete walkthrough of the new product features',
projectId: '1',
projectName: 'Product Demo v2',
versions: [
{ id: 'ver3', number: 3, label: 'Final Cut', isActive: true },
{ id: 'ver2', number: 2, label: 'Review Round 2', isActive: false },
{ id: 'ver1', number: 1, label: 'First Draft', isActive: false },
],
currentVersion: {
id: 'ver3',
number: 3,
label: 'Final Cut',
providerId: 'youtube',
videoId: 'dQw4w9WgXcQ', // Sample video
duration: 342, // 5:42
},
};
const mockComments = [
{
id: 'c1',
content: 'The transition here feels a bit abrupt. Can we add a fade?',
timestamp: 45.5,
author: { name: 'Sarah Chen', image: null },
createdAt: '2 hours ago',
isResolved: false,
replies: [
{
id: 'c1r1',
content: 'Good catch! I\'ll smooth that out in the next version.',
author: { name: 'Mike Johnson', image: null },
createdAt: '1 hour ago',
},
],
},
{
id: 'c2',
content: 'Love this section! The pacing is perfect.',
timestamp: 120,
author: { name: 'Alex Rivera', image: null },
createdAt: '5 hours ago',
isResolved: true,
replies: [],
},
{
id: 'c3',
content: 'Can we add some background music here?',
timestamp: 200,
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 {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function VideoPage() {
const params = useParams();
const videoId = params.videoId as string;
// In real app, fetch projectId from video data
const projectId = mockVideo.projectId;
const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<any>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(mockVideo.currentVersion.duration);
const [isMuted, setIsMuted] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [commentText, setCommentText] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [comments, setComments] = useState(mockComments);
const [showResolved, setShowResolved] = useState(false);
// Load YouTube iframe API
useEffect(() => {
// Load YouTube iframe API script
if (!window.YT) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
}
// Initialize player when API is ready
const onYouTubeIframeAPIReady = () => {
playerRef.current = new window.YT.Player(iframeRef.current, {
events: {
onReady: () => {
setIsReady(true);
setDuration(playerRef.current.getDuration() || mockVideo.currentVersion.duration);
},
onStateChange: (event: any) => {
setIsPlaying(event.data === window.YT.PlayerState.PLAYING);
},
},
});
};
if (window.YT && window.YT.Player) {
onYouTubeIframeAPIReady();
} else {
window.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady;
}
return () => {
window.onYouTubeIframeAPIReady = undefined;
};
}, []);
// Update current time periodically
useEffect(() => {
if (!isReady || !playerRef.current) return;
const interval = setInterval(() => {
if (playerRef.current?.getCurrentTime && !isDragging) {
setCurrentTime(playerRef.current.getCurrentTime());
}
}, 100);
return () => clearInterval(interval);
}, [isReady, isDragging]);
const filteredComments = comments.filter(c => showResolved || !c.isResolved);
const handlePlayPause = useCallback(() => {
if (!playerRef.current) return;
if (isPlaying) {
playerRef.current.pauseVideo();
} else {
playerRef.current.playVideo();
}
}, [isPlaying]);
const handleSeekToTimestamp = useCallback((timestamp: number) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
playerRef.current.seekTo(timestamp, true);
}
}, []);
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
handleSeekToTimestamp(newTime);
}, [duration, handleSeekToTimestamp]);
const handleTimelineMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
handleTimelineClick(e);
}, [handleTimelineClick]);
const handleTimelineMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
setCurrentTime(newTime);
}, [isDragging, duration]);
const handleTimelineMouseUp = useCallback(() => {
if (isDragging) {
handleSeekToTimestamp(currentTime);
setIsDragging(false);
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
const handleMuteToggle = useCallback(() => {
if (!playerRef.current) return;
if (isMuted) {
playerRef.current.unMute();
} else {
playerRef.current.mute();
}
setIsMuted(!isMuted);
}, [isMuted]);
const handleSkip = useCallback((seconds: number) => {
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
handleSeekToTimestamp(newTime);
}, [currentTime, duration, handleSeekToTimestamp]);
const handleAddComment = useCallback(() => {
if (!commentText.trim() && !isRecording) return;
const newComment = {
id: `c${Date.now()}`,
content: commentText,
timestamp: selectedTimestamp ?? currentTime,
author: { name: 'You', image: null },
createdAt: 'Just now',
isResolved: false,
replies: [],
};
setComments(prev => [...prev, newComment]);
setCommentText('');
setSelectedTimestamp(null);
}, [commentText, currentTime, selectedTimestamp, isRecording]);
const handleResolveComment = useCallback((commentId: string) => {
setComments(prev => prev.map(c =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
));
}, []);
// Hide YouTube controls, enable JS API
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`;
return (
<div
className="h-screen flex flex-col bg-background overflow-hidden"
onMouseUp={handleTimelineMouseUp}
onMouseLeave={() => isDragging && handleTimelineMouseUp()}
>
{/* Main Content - Full Width Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Video Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Compact Header Bar */}
<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}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="min-w-0">
<span className="text-sm font-medium">{mockVideo.title}</span>
<span className="text-xs text-muted-foreground ml-2"> {mockVideo.projectName}</span>
</div>
</div>
{/* Version Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">v{mockVideo.currentVersion.number}</Badge>
{mockVideo.currentVersion.label}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{mockVideo.versions.map((version) => (
<DropdownMenuItem key={version.id}>
<Badge variant={version.isActive ? 'default' : 'secondary'} className="mr-2">
v{version.number}
</Badge>
{version.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Video Player - Maximized */}
<div
className="flex-1 bg-black flex items-center justify-center relative cursor-pointer group"
onClick={handlePlayPause}
>
<div className="relative w-full h-full">
<iframe
ref={iframeRef}
src={embedUrl}
className="absolute inset-0 w-full h-full pointer-events-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
{/* Play/Pause overlay indicator */}
<div className={cn(
"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">
{isPlaying ? (
<Pause className="h-8 w-8 text-white" />
) : (
<Play className="h-8 w-8 text-white ml-1" />
)}
</div>
</div>
</div>
</div>
{/* Custom Controls Bar */}
<div className="shrink-0 px-4 py-3 bg-background border-t">
{/* Control buttons */}
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
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
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSkip(-10)}
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSkip(10)}
>
<SkipForward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleMuteToggle}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<span className="text-xs text-muted-foreground ml-2 tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Timeline with comment markers */}
<div
ref={timelineRef}
className="relative h-8 bg-muted rounded cursor-pointer select-none"
onMouseDown={handleTimelineMouseDown}
onMouseMove={handleTimelineMouseMove}
>
{/* Buffered/loaded indicator could go here */}
{/* Progress bar */}
<div
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
{/* Playhead */}
<div
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none"
style={{ left: `calc(${(currentTime / duration) * 100}% - 2px)` }}
/>
{/* Comment markers */}
{comments.map((comment) => (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeekToTimestamp(comment.timestamp);
}}
className={cn(
"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"
)}
style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }}
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
/>
))}
</div>
</div>
</div>
{/* Comments Sidebar - Fixed Right */}
<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="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowResolved(!showResolved)}
>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
</div>
{/* Comments List - Scrollable */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
<p className="text-sm">Be the first to leave feedback!</p>
</div>
) : (
filteredComments
.sort((a, b) => a.timestamp - b.timestamp)
.map((comment) => (
<div
key={comment.id}
className={cn(
"group rounded-lg border p-3 transition-colors hover:bg-accent/50",
comment.isResolved && "opacity-60"
)}
>
{/* Comment Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={comment.author.image ?? undefined} />
<AvatarFallback className="text-xs">
{comment.author.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{comment.author.name}</span>
</div>
<div className="flex items-center gap-1">
<button
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"
>
<Clock className="h-3 w-3" />
{formatTime(comment.timestamp)}
</button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleResolveComment(comment.id)}
>
{comment.isResolved ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Reply</DropdownMenuItem>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Comment Content */}
{comment.content && (
<p className="text-sm mb-2">{comment.content}</p>
)}
{/* 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>
<span className="text-xs text-muted-foreground">
{formatTime(comment.voiceDuration || 0)}
</span>
</div>
)}
{/* Timestamp & Meta */}
<p className="text-xs text-muted-foreground">{comment.createdAt}</p>
{/* Replies */}
{comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2">
{comment.replies.map((reply) => (
<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">
{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>
)}
</div>
))
)}
</div>
{/* Comment Input - Fixed at Bottom */}
<div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedTimestamp(currentTime)}
className={cn(selectedTimestamp !== null && "border-primary")}
>
<Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null
? formatTime(selectedTimestamp)
: formatTime(currentTime)
}
</Button>
<span className="text-xs text-muted-foreground">
Pin to this time
</span>
</div>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim()}
>
<Send className="h-4 w-4" />
</Button>
<Button
size="icon"
variant={isRecording ? "destructive" : "outline"}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn("h-4 w-4", isRecording && "animate-pulse")} />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
+Enter to submit
</p>
</div>
</div>
</div>
</div>
);
}