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
+46
View File
@@ -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
+36
View File
@@ -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.
+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>
);
}
+1787
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-lyra",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "bold",
"registries": {}
}
+495
View File
@@ -0,0 +1,495 @@
"use client"
import * as React from "react"
import {
Example,
ExampleWrapper,
} from "@/components/example"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { PlusIcon, BluetoothIcon, MoreVerticalIcon, FileIcon, FolderIcon, FolderOpenIcon, FileCodeIcon, MoreHorizontalIcon, FolderSearchIcon, SaveIcon, DownloadIcon, EyeIcon, LayoutIcon, PaletteIcon, SunIcon, MoonIcon, MonitorIcon, UserIcon, CreditCardIcon, SettingsIcon, KeyboardIcon, LanguagesIcon, BellIcon, MailIcon, ShieldIcon, HelpCircleIcon, FileTextIcon, LogOutIcon } from "lucide-react"
export function ComponentExample() {
return (
<ExampleWrapper>
<CardExample />
<FormExample />
</ExampleWrapper>
)
}
function CardExample() {
return (
<Example title="Card" className="items-center justify-center">
<Card className="relative w-full max-w-sm overflow-hidden pt-0">
<div className="bg-primary absolute inset-0 z-30 aspect-video opacity-50 mix-blend-color" />
<img
src="https://images.unsplash.com/photo-1604076850742-4c7221f3101b?q=80&w=1887&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="Photo by mymind on Unsplash"
title="Photo by mymind on Unsplash"
className="relative z-20 aspect-video w-full object-cover brightness-60 grayscale"
/>
<CardHeader>
<CardTitle>Observability Plus is replacing Monitoring</CardTitle>
<CardDescription>
Switch to the improved way to explore your data, with natural
language. Monitoring will no longer be available on the Pro plan in
November, 2025
</CardDescription>
</CardHeader>
<CardFooter>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button>
<PlusIcon data-icon="inline-start" />
Show Dialog
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia>
<BluetoothIcon
/>
</AlertDialogMedia>
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
<AlertDialogDescription>
Do you want to allow the USB accessory to connect to this
device?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Don&apos;t allow</AlertDialogCancel>
<AlertDialogAction>Allow</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Badge variant="secondary" className="ml-auto">
Warning
</Badge>
</CardFooter>
</Card>
</Example>
)
}
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const
function FormExample() {
const [notifications, setNotifications] = React.useState({
email: true,
sms: false,
push: true,
})
const [theme, setTheme] = React.useState("light")
return (
<Example title="Form">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>User Information</CardTitle>
<CardDescription>Please fill in your details below</CardDescription>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVerticalIcon
/>
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>File</DropdownMenuLabel>
<DropdownMenuItem>
<FileIcon
/>
New File
<DropdownMenuShortcut>N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<FolderIcon
/>
New Folder
<DropdownMenuShortcut>N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderOpenIcon
/>
Open Recent
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Recent Projects</DropdownMenuLabel>
<DropdownMenuItem>
<FileCodeIcon
/>
Project Alpha
</DropdownMenuItem>
<DropdownMenuItem>
<FileCodeIcon
/>
Project Beta
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon
/>
More Projects
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>
<FileCodeIcon
/>
Project Gamma
</DropdownMenuItem>
<DropdownMenuItem>
<FileCodeIcon
/>
Project Delta
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<FolderSearchIcon
/>
Browse...
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SaveIcon
/>
Save
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon
/>
Export
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={notifications.email}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
email: checked === true,
})
}
>
<EyeIcon
/>
Show Sidebar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={notifications.sms}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
sms: checked === true,
})
}
>
<LayoutIcon
/>
Show Status Bar
</DropdownMenuCheckboxItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon
/>
Theme
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={theme}
onValueChange={setTheme}
>
<DropdownMenuRadioItem value="light">
<SunIcon
/>
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<MoonIcon
/>
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<MonitorIcon
/>
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuItem>
<UserIcon
/>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon
/>
Billing
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SettingsIcon
/>
Settings
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Preferences</DropdownMenuLabel>
<DropdownMenuItem>
<KeyboardIcon
/>
Keyboard Shortcuts
</DropdownMenuItem>
<DropdownMenuItem>
<LanguagesIcon
/>
Language
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellIcon
/>
Notifications
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>
Notification Types
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={notifications.push}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
push: checked === true,
})
}
>
<BellIcon
/>
Push Notifications
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={notifications.email}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
email: checked === true,
})
}
>
<MailIcon
/>
Email Notifications
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<ShieldIcon
/>
Privacy & Security
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<HelpCircleIcon
/>
Help & Support
</DropdownMenuItem>
<DropdownMenuItem>
<FileTextIcon
/>
Documentation
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
<LogOutIcon
/>
Sign Out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
</CardHeader>
<CardContent>
<form>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="small-form-name">Name</FieldLabel>
<Input
id="small-form-name"
placeholder="Enter your name"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="small-form-role">Role</FieldLabel>
<Select defaultValue="">
<SelectTrigger id="small-form-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="designer">Designer</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<Field>
<FieldLabel htmlFor="small-form-framework">
Framework
</FieldLabel>
<Combobox items={frameworks}>
<ComboboxInput
id="small-form-framework"
placeholder="Select a framework"
required
/>
<ComboboxContent>
<ComboboxEmpty>No frameworks found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
<Field>
<FieldLabel htmlFor="small-form-comments">Comments</FieldLabel>
<Textarea
id="small-form-comments"
placeholder="Add any additional comments"
/>
</Field>
<Field orientation="horizontal">
<Button type="submit">Submit</Button>
<Button variant="outline" type="button">
Cancel
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</Example>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils"
function ExampleWrapper({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className="bg-background w-full">
<div
data-slot="example-wrapper"
className={cn(
"mx-auto grid min-h-screen w-full max-w-5xl min-w-0 content-center items-start gap-8 p-4 pt-2 sm:gap-12 sm:p-6 md:grid-cols-2 md:gap-8 lg:p-12 2xl:max-w-6xl",
className
)}
{...props}
/>
</div>
)
}
function Example({
title,
children,
className,
containerClassName,
...props
}: React.ComponentProps<"div"> & {
title?: string
containerClassName?: string
}) {
return (
<div
data-slot="example"
className={cn(
"mx-auto flex w-full max-w-lg min-w-0 flex-col gap-1 self-stretch lg:max-w-none",
containerClassName
)}
{...props}
>
{title && (
<div className="text-muted-foreground px-1.5 py-2 text-xs font-medium">
{title}
</div>
)}
<div
data-slot="example-content"
className={cn(
"bg-background text-foreground flex min-w-0 flex-1 flex-col items-start gap-6 border border-dashed p-4 sm:p-6 *:[div:not([class*='w-'])]:w-full",
className
)}
>
{children}
</div>
</div>
)
}
export { ExampleWrapper, Example }
+173
View File
@@ -0,0 +1,173 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Video,
FolderOpen,
Plus,
Settings,
LogOut,
User,
Menu
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { ThemeToggle } from '@/components/theme-toggle';
import { cn } from '@/lib/utils';
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ href: '/dashboard', label: 'Projects', icon: FolderOpen },
];
interface HeaderProps {
user?: {
name?: string | null;
email?: string | null;
image?: string | null;
} | null;
}
export function Header({ user }: HeaderProps) {
const pathname = usePathname();
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-6 lg:px-8 flex h-14 items-center w-full">
{/* Mobile menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden mr-2">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<nav className="flex flex-col gap-2 mt-4">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-6">
<Video className="h-6 w-6 text-primary" />
<span className="font-bold text-lg hidden sm:inline-block">OpenFrame</span>
</Link>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
<Button asChild size="sm" className="hidden sm:flex">
<Link href="/projects/new">
<Plus className="h-4 w-4 mr-1" />
New Project
</Link>
</Button>
<Button asChild size="icon" variant="ghost" className="sm:hidden">
<Link href="/projects/new">
<Plus className="h-5 w-5" />
</Link>
</Button>
<ThemeToggle />
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image ?? undefined} alt={user.name ?? ''} />
<AvatarFallback>
{user.name?.charAt(0).toUpperCase() ?? 'U'}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{user.name && <p className="font-medium">{user.name}</p>}
{user.email && (
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user.email}
</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="h-4 w-4 mr-2" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/api/auth/signout">
<LogOut className="h-4 w-4 mr-2" />
Sign out
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button asChild variant="outline" size="sm">
<Link href="/login">
<User className="h-4 w-4 mr-1" />
Sign in
</Link>
</Button>
)}
</div>
</div>
</header>
);
}
+1
View File
@@ -0,0 +1 @@
export { Header } from './header';
+11
View File
@@ -0,0 +1,11 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="h-4 w-4 mr-2" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="h-4 w-4 mr-2" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<span className="h-4 w-4 mr-2">💻</span>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-none p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-none sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"rounded-full aspect-square size-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-none",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+94
View File
@@ -0,0 +1,94 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-sm font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+294
View File
@@ -0,0 +1,294 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-none shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 data-empty:p-0 overflow-y-auto overscroll-contain",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn("text-muted-foreground hidden w-full justify-center py-2 text-center text-xs group-data-empty/combobox-content:flex", className)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn("dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-none border bg-transparent bg-clip-padding px-2.5 py-1 text-xs transition-colors focus-within:ring-1 has-aria-invalid:ring-1 has-data-[slot=combobox-chip]:px-1", className)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-none px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn(
"min-w-16 flex-1 outline-none",
className
)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+155
View File
@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-1 text-left flex flex-col", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-2 py-2 text-xs data-inset:pl-7", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-none shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+227
View File
@@ -0,0 +1,227 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn("gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm", className)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
className
)}
{...props}
/>
)
}
const fieldVariants = cva("data-[invalid=true]:text-destructive gap-2 group/field flex w-full", {
variants: {
orientation: {
vertical:
"flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
})
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative", className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-xs font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-none border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-1 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground h-auto gap-2 py-1.5 text-xs font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-none [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end":
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"gap-2 text-xs shadow-none flex items-center",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-xs [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors file:h-6 file:text-xs file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-xs file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
export { Label }
+186
View File
@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs transition-colors select-none focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-none shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 h-px pointer-events-none", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 text-xs/relaxed duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("gap-0.5 p-4 flex flex-col", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground text-sm font-medium", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors focus-visible:ring-1 aria-invalid:ring-1 md:text-xs placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Textarea }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-none px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 rotate-45 rounded-none bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+95
View File
@@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import {
Play,
MessageSquare,
Clock,
MoreVertical
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface VideoCardProps {
video: {
id: string;
title: string;
thumbnailUrl: string;
currentVersion: number;
commentCount: number;
duration: string;
lastUpdated: string;
};
projectId: string;
}
export function VideoCard({ video, projectId }: VideoCardProps) {
return (
<Card className="group overflow-hidden transition-colors hover:bg-accent/50 cursor-pointer">
<Link href={`/watch/${video.id}`}>
{/* Thumbnail */}
<div className="relative aspect-video bg-muted overflow-hidden">
<img
src={video.thumbnailUrl}
alt={video.title}
className="object-cover w-full h-full transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="h-12 w-12 text-white" fill="white" />
</div>
<Badge className="absolute bottom-2 right-2 bg-black/70">
{video.duration}
</Badge>
</div>
</Link>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<Link href={`/watch/${video.id}`} className="min-w-0 flex-1">
<h3 className="font-medium truncate">{video.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Badge variant="secondary" className="text-xs">
v{video.currentVersion}
</Badge>
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{video.commentCount}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{video.lastUpdated}
</span>
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Add Version</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+68
View File
@@ -0,0 +1,68 @@
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { db } from '@/lib/db';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
// Google OAuth - uncomment and add credentials when ready
// Google({
// clientId: process.env.GOOGLE_CLIENT_ID,
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// }),
// GitHub OAuth - uncomment and add credentials when ready
// GitHub({
// clientId: process.env.GITHUB_ID,
// clientSecret: process.env.GITHUB_SECRET,
// }),
// Email/Password - for development, add proper provider in production
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// TODO: Implement proper credential validation
// This is a placeholder for development
if (!credentials?.email) {
return null;
}
// In production, verify password hash here
const user = await db.user.findUnique({
where: { email: credentials.email as string },
});
return user;
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
// signUp: '/register',
// error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
if (token.sub && session.user) {
session.user.id = token.sub;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id;
}
return token;
},
},
});
+38
View File
@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
// In development without a database, we'll create a mock-friendly client
// For production or when DATABASE_URL is set, use the real adapter
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.warn('DATABASE_URL not set - database features will not work');
// Return a client that will throw clear errors when used
return new PrismaClient({
// This will fail on actual DB operations but allows imports to work
adapter: new PrismaPg(new Pool({ connectionString: 'postgresql://localhost:5432/dummy' })),
});
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
}
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
export default db;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+55
View File
@@ -0,0 +1,55 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// Direct video URL patterns (for future self-hosted videos)
const DIRECT_VIDEO_PATTERNS = [
/\.(mp4|webm|ogg|mov)(\?.*)?$/i,
];
export const directProvider: VideoProvider = {
id: 'direct',
name: 'Direct Upload',
icon: 'Upload',
canHandle(url: string): boolean {
// Check for common video extensions or our own domain
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
// For direct uploads, the "videoId" is the full URL
// In production, this would be a storage key/path
if (this.canHandle(url)) {
return url;
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
// For direct videos, we'll use HTML5 video player
// The videoId IS the URL for direct uploads
const params = new URLSearchParams();
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
// For direct uploads, thumbnail would be generated server-side
// Return a placeholder for now
return '/placeholder-video-thumbnail.png';
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
// For direct uploads, metadata would be stored in our database
// This is a placeholder implementation
const filename = videoId.split('/').pop() || 'Video';
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
return {
title: nameWithoutExt,
thumbnailUrl: this.getThumbnailUrl(videoId),
};
},
};
+129
View File
@@ -0,0 +1,129 @@
// Video Provider Registry - Central place to manage all video providers
import { youtubeProvider } from './youtube';
import { vimeoProvider } from './vimeo';
import { directProvider } from './direct';
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
// Export types
export * from './types';
// Registry of all available providers
const providers: VideoProvider[] = [
youtubeProvider,
vimeoProvider,
directProvider,
];
// Provider lookup map for quick access
const providerMap = new Map<string, VideoProvider>(
providers.map(p => [p.id, p])
);
/**
* Detect which provider can handle a given URL
*/
export function detectProvider(url: string): VideoProvider | null {
for (const provider of providers) {
if (provider.canHandle(url)) {
return provider;
}
}
return null;
}
/**
* Get a provider by its ID
*/
export function getProvider(providerId: VideoProviderType): VideoProvider | null {
return providerMap.get(providerId) ?? null;
}
/**
* Get all available providers
*/
export function getAllProviders(): VideoProvider[] {
return [...providers];
}
/**
* Parse a video URL and return a VideoSource object
*/
export function parseVideoUrl(url: string): VideoSource | null {
const provider = detectProvider(url);
if (!provider) {
return null;
}
const videoId = provider.extractVideoId(url);
if (!videoId) {
return null;
}
return {
providerId: provider.id as VideoProviderType,
videoId,
originalUrl: url,
};
}
/**
* Fetch metadata for a video source
*/
export async function fetchVideoMetadata(source: VideoSource): Promise<VideoMetadata | null> {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
try {
return await provider.getMetadata(source.videoId);
} catch (error) {
console.error('Failed to fetch video metadata:', error);
return null;
}
}
/**
* Get embed URL for a video source
*/
export function getEmbedUrl(source: VideoSource, options?: Parameters<VideoProvider['getEmbedUrl']>[1]): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
return provider.getEmbedUrl(source.videoId, options);
}
/**
* Get thumbnail URL for a video source
*/
export function getThumbnailUrl(source: VideoSource, size?: Parameters<VideoProvider['getThumbnailUrl']>[1]): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
return provider.getThumbnailUrl(source.videoId, size);
}
/**
* Check if a URL is a valid video URL from any supported provider
*/
export function isValidVideoUrl(url: string): boolean {
return detectProvider(url) !== null;
}
/**
* Get the provider icon name for UI display
*/
export function getProviderIcon(providerId: VideoProviderType): string {
const provider = getProvider(providerId);
return provider?.icon ?? 'Video';
}
+49
View File
@@ -0,0 +1,49 @@
// Video Provider Types - Future-proof abstraction for multiple video sources
export interface VideoMetadata {
title: string;
description?: string;
thumbnailUrl: string;
duration?: number; // in seconds
author?: string;
authorUrl?: string;
uploadDate?: Date;
}
export interface VideoProvider {
id: string;
name: string;
icon: string; // Lucide icon name
// URL handling
canHandle(url: string): boolean;
extractVideoId(url: string): string | null;
// Embed and display
getEmbedUrl(videoId: string, options?: EmbedOptions): string;
getThumbnailUrl(videoId: string, size?: ThumbnailSize): string;
// Metadata fetching
getMetadata(videoId: string): Promise<VideoMetadata>;
}
export interface EmbedOptions {
autoplay?: boolean;
startTime?: number; // in seconds
controls?: boolean;
loop?: boolean;
muted?: boolean;
}
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
// Supported provider types - extend as we add more
export type VideoProviderType = 'youtube' | 'vimeo' | 'direct';
// Video source stored in database
export interface VideoSource {
providerId: VideoProviderType;
videoId: string;
originalUrl: string;
metadata?: VideoMetadata;
}
+90
View File
@@ -0,0 +1,90 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// Vimeo URL patterns
const VIMEO_PATTERNS = [
/vimeo\.com\/(\d+)/,
/vimeo\.com\/video\/(\d+)/,
/player\.vimeo\.com\/video\/(\d+)/,
/vimeo\.com\/channels\/\w+\/(\d+)/,
/vimeo\.com\/groups\/\w+\/videos\/(\d+)/,
];
export const vimeoProvider: VideoProvider = {
id: 'vimeo',
name: 'Vimeo',
icon: 'Video', // Lucide doesn't have Vimeo icon
canHandle(url: string): boolean {
return VIMEO_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
for (const pattern of VIMEO_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
if (options.autoplay) params.set('autoplay', '1');
if (options.startTime) params.set('t', `${Math.floor(options.startTime)}s`);
if (options.loop) params.set('loop', '1');
if (options.muted) params.set('muted', '1');
// Better UX options
params.set('byline', '0');
params.set('portrait', '0');
params.set('title', '0');
const queryString = params.toString();
return `https://player.vimeo.com/video/${videoId}${queryString ? `?${queryString}` : ''}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
// Vimeo requires API call to get thumbnail, return placeholder
// In production, cache these after fetching metadata
const sizeMap: Record<ThumbnailSize, number> = {
small: 200,
medium: 400,
large: 640,
maxres: 1280,
};
// This is a placeholder - actual thumbnail comes from metadata API
return `https://vumbnail.com/${videoId}_${sizeMap[size]}.jpg`;
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
try {
const response = await fetch(
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
);
if (!response.ok) {
throw new Error('Failed to fetch video metadata');
}
const data = await response.json();
return {
title: data.title,
description: data.description,
thumbnailUrl: data.thumbnail_url,
duration: data.duration,
author: data.author_name,
authorUrl: data.author_url,
uploadDate: data.upload_date ? new Date(data.upload_date) : undefined,
};
} catch (error) {
return {
title: 'Vimeo Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
}
},
};
+87
View File
@@ -0,0 +1,87 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// YouTube URL patterns
const YOUTUBE_PATTERNS = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
];
export const youtubeProvider: VideoProvider = {
id: 'youtube',
name: 'YouTube',
icon: 'Youtube',
canHandle(url: string): boolean {
return YOUTUBE_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
for (const pattern of YOUTUBE_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
// Enable JS API for programmatic control
params.set('enablejsapi', '1');
params.set('origin', typeof window !== 'undefined' ? window.location.origin : '');
if (options.autoplay) params.set('autoplay', '1');
if (options.startTime) params.set('start', String(Math.floor(options.startTime)));
if (options.controls === false) params.set('controls', '0');
if (options.loop) params.set('loop', '1');
if (options.muted) params.set('mute', '1');
// Better UX options
params.set('rel', '0'); // Don't show related videos from other channels
params.set('modestbranding', '1'); // Minimal YouTube branding
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
const sizeMap: Record<ThumbnailSize, string> = {
small: 'default', // 120x90
medium: 'mqdefault', // 320x180
large: 'hqdefault', // 480x360
maxres: 'maxresdefault', // 1280x720
};
return `https://img.youtube.com/vi/${videoId}/${sizeMap[size]}.jpg`;
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
// Using oEmbed API - no API key required
// For production, you might want to use YouTube Data API for more data
try {
const response = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
);
if (!response.ok) {
throw new Error('Failed to fetch video metadata');
}
const data = await response.json();
return {
title: data.title,
thumbnailUrl: data.thumbnail_url,
author: data.author_name,
authorUrl: data.author_url,
};
} catch (error) {
// Fallback with minimal data
return {
title: 'YouTube Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
}
},
};
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+52
View File
@@ -0,0 +1,52 @@
{
"name": "openframe",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"nanoid": "^5.1.6",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^3.8.3",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+267
View File
@@ -0,0 +1,267 @@
// Prisma Schema for OpenFrame - Video Feedback Platform
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================
// AUTH MODELS (NextAuth.js compatible)
// ============================================
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
accounts Account[]
sessions Session[]
projects Project[]
comments Comment[]
memberships ProjectMember[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// ============================================
// APPLICATION MODELS
// ============================================
model Project {
id String @id @default(cuid())
name String
description String? @db.Text
slug String @unique // URL-friendly identifier
// Visibility
visibility ProjectVisibility @default(PRIVATE)
// Ownership
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
videos Video[]
members ProjectMember[]
shareLinks ShareLink[]
@@index([ownerId])
@@index([slug])
@@map("projects")
}
enum ProjectVisibility {
PRIVATE // Only owner and members can access
LINK_ONLY // Anyone with link can view
PUBLIC // Listed publicly
}
model ProjectMember {
id String @id @default(cuid())
role ProjectMemberRole @default(VIEWER)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([projectId, userId])
@@index([userId])
@@map("project_members")
}
enum ProjectMemberRole {
VIEWER // Can view and comment
EDITOR // Can add/edit videos
ADMIN // Can manage members and settings
}
model Video {
id String @id @default(cuid())
title String
description String? @db.Text
// Ordering within project
position Int @default(0)
// Project relation
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
versions VideoVersion[]
@@index([projectId])
@@map("videos")
}
model VideoVersion {
id String @id @default(cuid())
versionNumber Int // 1, 2, 3...
versionLabel String? // Optional label like "Final Cut", "Review Draft"
// Video source (future-proof for multiple providers)
providerId String // 'youtube', 'vimeo', 'direct'
videoId String // Provider-specific video ID
originalUrl String // Original URL submitted by user
// Cached metadata
title String?
thumbnailUrl String?
duration Int? // Duration in seconds
// Status
isActive Boolean @default(true) // Currently displayed version
// Parent video
videoParentId String
video Video @relation(fields: [videoParentId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
// Relations
comments Comment[]
@@unique([videoParentId, versionNumber])
@@index([videoParentId])
@@map("video_versions")
}
model Comment {
id String @id @default(cuid())
// Comment content
content String? @db.Text // Text content (null if voice-only)
// Timestamp in video (in seconds, with decimal for precision)
timestamp Float // e.g., 65.5 = 1:05.5
timestampEnd Float? // Optional end timestamp for range comments
// Voice recording (optional)
voiceUrl String? // URL to voice recording file
voiceDuration Float? // Duration of voice recording in seconds
// Threading
parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")
// Status
isResolved Boolean @default(false)
resolvedAt DateTime?
// Author (optional for guest comments)
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
// Guest author info (when authorId is null)
guestName String?
guestEmail String?
// Video version relation
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([versionId])
@@index([parentId])
@@index([authorId])
@@index([timestamp])
@@map("comments")
}
model ShareLink {
id String @id @default(cuid())
token String @unique // Random token for URL
// What is being shared
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Permissions
permission SharePermission @default(VIEW)
// Optional restrictions
expiresAt DateTime? // Link expiration
maxUses Int? // Maximum number of uses
useCount Int @default(0)
password String? // Optional password protection
// Settings
allowGuests Boolean @default(true) // Allow comments without account
// Timestamps
createdAt DateTime @default(now())
@@index([projectId])
@@index([token])
@@map("share_links")
}
enum SharePermission {
VIEW // Can only view
COMMENT // Can view and comment
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+81
View File
@@ -0,0 +1,81 @@
// YouTube IFrame API type declarations
interface Window {
YT: typeof YT;
onYouTubeIframeAPIReady: (() => void) | undefined;
}
declare namespace YT {
class Player {
constructor(
elementId: string | HTMLElement | null,
options?: PlayerOptions
);
playVideo(): void;
pauseVideo(): void;
stopVideo(): void;
seekTo(seconds: number, allowSeekAhead?: boolean): void;
mute(): void;
unMute(): void;
isMuted(): boolean;
setVolume(volume: number): void;
getVolume(): number;
getCurrentTime(): number;
getDuration(): number;
getPlayerState(): PlayerState;
destroy(): void;
}
interface PlayerOptions {
width?: number | string;
height?: number | string;
videoId?: string;
playerVars?: PlayerVars;
events?: PlayerEvents;
}
interface PlayerVars {
autoplay?: 0 | 1;
controls?: 0 | 1;
disablekb?: 0 | 1;
enablejsapi?: 0 | 1;
fs?: 0 | 1;
iv_load_policy?: 1 | 3;
modestbranding?: 0 | 1;
rel?: 0 | 1;
showinfo?: 0 | 1;
start?: number;
end?: number;
}
interface PlayerEvents {
onReady?: (event: PlayerEvent) => void;
onStateChange?: (event: OnStateChangeEvent) => void;
onPlaybackQualityChange?: (event: PlayerEvent) => void;
onPlaybackRateChange?: (event: PlayerEvent) => void;
onError?: (event: OnErrorEvent) => void;
onApiChange?: (event: PlayerEvent) => void;
}
interface PlayerEvent {
target: Player;
}
interface OnStateChangeEvent extends PlayerEvent {
data: PlayerState;
}
interface OnErrorEvent extends PlayerEvent {
data: number;
}
enum PlayerState {
UNSTARTED = -1,
ENDED = 0,
PLAYING = 1,
PAUSED = 2,
BUFFERING = 3,
CUED = 5,
}
}