mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user