commit 264392c2ec9d2f934048a6a84d18eb3f3cdc145e Author: Yusuf İpek Date: Thu Feb 5 22:11:20 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7a0bd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/lib/generated/prisma + +# Progress (Internal Tracking) +PROGRESS.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..ff87ae0 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e82d823 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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 ( +
+
+ {/* Logo */} + +
+
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..8e9d44e --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+

Projects

+

+ Manage your video projects and collect feedback +

+
+ +
+ + {/* Projects Grid */} + {mockProjects.length > 0 ? ( +
+ {mockProjects.map((project) => ( + + + + + + {project.name} + + + {project.description} + + + +
+ + + {project.lastUpdated} + + + + {project.memberCount} + + {project.videoCount} videos +
+
+
+ + ))} +
+ ) : ( + + + +

No projects yet

+

+ Create your first project to start collecting video feedback +

+ +
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..8db95fa --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -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: 'demo@openframe.dev', + image: null, + }; + + return ( +
+
+
{children}
+
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx new file mode 100644 index 0000000..56dfda4 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -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 ( +
+ {/* Back link */} +
+ + + Back to Projects + +
+ + {/* Project Header */} +
+
+
+

{project.name}

+ + {project.visibility.toLowerCase()} + +
+ {project.description && ( +

{project.description}

+ )} +
+ +
+ + + +
+
+ + {/* Videos Grid */} + {project.videos.length > 0 ? ( +
+ {project.videos.map((video) => ( + + ))} +
+ ) : ( + + + +

No videos yet

+

+ Add your first video to start collecting feedback +

+ +
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/layout.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/layout.tsx new file mode 100644 index 0000000..a3efdc8 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/layout.tsx @@ -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}; +} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx new file mode 100644 index 0000000..7aff83b --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -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(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(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 ( +
+ {/* Main Content - Full Width Layout */} +
+ {/* Video Area */} +
+ {/* Compact Header Bar */} +
+
+ + + Back + + +
+ {mockVideo.title} + • {mockVideo.projectName} +
+
+ + {/* Version Selector */} + + + + + + {mockVideo.versions.map((version) => ( + + + v{version.number} + + {version.label} + + ))} + + +
+ + {/* Video Player - Maximized */} +
+
+