mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
chore(init): scaffold OpenFrame — Next.js + Bun + shadcn
Initialize OpenFrame project with core scaffold and UI foundation. - Project scaffold: Next.js 16.1 (App Router) + Bun runtime - UI: shadcn/ui + TailwindCSS; landing, dashboard, project, and auth UIs - Video support: provider abstraction (YouTube first), video player page with timestamped comments and custom timeline - Auth & DB: NextAuth skeleton and Prisma schema (Postgres) included - UX: dark-mode toggle, full-width layouts, comments sidebar, video route moved to /watch/[videoId] - Dev: added YouTube iframe integration, custom controls, and TypeScript types - Next steps: DB migrations, API routes (CRUD), real data wiring, voice-recording & sharing
This commit is contained in:
+46
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Video, Loader2, Github, Mail } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual sign in
|
||||||
|
// await signIn('credentials', { email, password, redirect: false });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthLogin = async (provider: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: Implement OAuth
|
||||||
|
// await signIn(provider, { callbackUrl: '/dashboard' });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth login failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
<Video className="h-8 w-8 text-primary" />
|
||||||
|
<span className="font-bold text-2xl">OpenFrame</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Welcome back</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* OAuth Buttons */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOAuthLogin('google')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOAuthLogin('github')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Github className="h-4 w-4 mr-2" />
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<Separator />
|
||||||
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||||
|
or continue with email
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Form */}
|
||||||
|
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/register" className="text-primary hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||||
|
By continuing, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus, FolderOpen, Clock, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
// import { auth } from '@/lib/auth';
|
||||||
|
// import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
// Placeholder data - will be replaced with real data from database
|
||||||
|
const mockProjects = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Product Demo v2',
|
||||||
|
description: 'New product walkthrough video for Q1 launch',
|
||||||
|
videoCount: 3,
|
||||||
|
lastUpdated: '2 hours ago',
|
||||||
|
memberCount: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Marketing Campaign',
|
||||||
|
description: 'Social media ads for summer campaign',
|
||||||
|
videoCount: 8,
|
||||||
|
lastUpdated: '1 day ago',
|
||||||
|
memberCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Tutorial Series',
|
||||||
|
description: 'Getting started tutorials for new users',
|
||||||
|
videoCount: 12,
|
||||||
|
lastUpdated: '3 days ago',
|
||||||
|
memberCount: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
// TODO: Uncomment when database is set up
|
||||||
|
// const session = await auth();
|
||||||
|
// if (!session) {
|
||||||
|
// redirect('/login');
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage your video projects and collect feedback
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
{mockProjects.length > 0 ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{mockProjects.map((project) => (
|
||||||
|
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||||
|
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-5 w-5 text-primary" />
|
||||||
|
{project.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{project.lastUpdated}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{project.memberCount}
|
||||||
|
</span>
|
||||||
|
<span>{project.videoCount} videos</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Create your first project to start collecting video feedback
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Header } from '@/components/layout';
|
||||||
|
// import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// TODO: Uncomment when database is set up
|
||||||
|
// const session = await auth();
|
||||||
|
const mockUser = {
|
||||||
|
name: 'Demo User',
|
||||||
|
email: '[email protected]',
|
||||||
|
image: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
<Header user={mockUser} />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Play,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { VideoCard } from '@/components/video-card';
|
||||||
|
|
||||||
|
// Mock data - will be replaced with real data
|
||||||
|
const mockProject = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Product Demo v2',
|
||||||
|
description: 'New product walkthrough video for Q1 launch',
|
||||||
|
visibility: 'PRIVATE',
|
||||||
|
videos: [
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
title: 'Main Product Walkthrough',
|
||||||
|
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
||||||
|
currentVersion: 3,
|
||||||
|
commentCount: 12,
|
||||||
|
duration: '5:42',
|
||||||
|
lastUpdated: '2 hours ago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
title: 'Feature Highlight - Dashboard',
|
||||||
|
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
||||||
|
currentVersion: 1,
|
||||||
|
commentCount: 5,
|
||||||
|
duration: '2:18',
|
||||||
|
lastUpdated: '1 day ago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v3',
|
||||||
|
title: 'Onboarding Flow',
|
||||||
|
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
|
||||||
|
currentVersion: 2,
|
||||||
|
commentCount: 8,
|
||||||
|
duration: '3:55',
|
||||||
|
lastUpdated: '3 days ago',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProjectPageProps {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
// TODO: Fetch real project data
|
||||||
|
const project = mockProject;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
|
{/* Back link */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Projects
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{project.visibility.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-muted-foreground">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/projects/${projectId}/videos/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Video
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Videos Grid */}
|
||||||
|
{project.videos.length > 0 ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{project.videos.map((video) => (
|
||||||
|
<VideoCard key={video.id} video={video} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
<Play className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No videos yet</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Add your first video to start collecting feedback
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/projects/${projectId}/videos/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Video
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function VideoLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// This layout is empty - no header, no sidebar
|
||||||
|
// The video page uses full screen space
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
ChevronDown,
|
||||||
|
MoreVertical,
|
||||||
|
User
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const mockVideo = {
|
||||||
|
id: 'v1',
|
||||||
|
title: 'Main Product Walkthrough',
|
||||||
|
description: 'Complete walkthrough of the new product features',
|
||||||
|
projectId: '1',
|
||||||
|
projectName: 'Product Demo v2',
|
||||||
|
versions: [
|
||||||
|
{ id: 'ver3', number: 3, label: 'Final Cut', isActive: true },
|
||||||
|
{ id: 'ver2', number: 2, label: 'Review Round 2', isActive: false },
|
||||||
|
{ id: 'ver1', number: 1, label: 'First Draft', isActive: false },
|
||||||
|
],
|
||||||
|
currentVersion: {
|
||||||
|
id: 'ver3',
|
||||||
|
number: 3,
|
||||||
|
label: 'Final Cut',
|
||||||
|
providerId: 'youtube',
|
||||||
|
videoId: 'dQw4w9WgXcQ', // Sample video
|
||||||
|
duration: 342, // 5:42
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockComments = [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
content: 'The transition here feels a bit abrupt. Can we add a fade?',
|
||||||
|
timestamp: 45.5,
|
||||||
|
author: { name: 'Sarah Chen', image: null },
|
||||||
|
createdAt: '2 hours ago',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'c1r1',
|
||||||
|
content: 'Good catch! I\'ll smooth that out in the next version.',
|
||||||
|
author: { name: 'Mike Johnson', image: null },
|
||||||
|
createdAt: '1 hour ago',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
content: 'Love this section! The pacing is perfect.',
|
||||||
|
timestamp: 120,
|
||||||
|
author: { name: 'Alex Rivera', image: null },
|
||||||
|
createdAt: '5 hours ago',
|
||||||
|
isResolved: true,
|
||||||
|
replies: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c3',
|
||||||
|
content: 'Can we add some background music here?',
|
||||||
|
timestamp: 200,
|
||||||
|
voiceUrl: '/mock-voice.mp3', // Mock voice comment
|
||||||
|
voiceDuration: 8.5,
|
||||||
|
author: { name: 'Jordan Lee', image: null },
|
||||||
|
createdAt: '1 day ago',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const videoId = params.videoId as string;
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(mockVideo.currentVersion.duration);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
|
const [comments, setComments] = useState(mockComments);
|
||||||
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
|
||||||
|
const filteredComments = comments.filter(c => showResolved || !c.isResolved);
|
||||||
|
|
||||||
|
const handleSeekToTimestamp = useCallback((timestamp: number) => {
|
||||||
|
setCurrentTime(timestamp);
|
||||||
|
// In real implementation, control iframe player
|
||||||
|
// window.postMessage to YouTube iframe API
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddComment = useCallback(() => {
|
||||||
|
if (!commentText.trim() && !isRecording) return;
|
||||||
|
|
||||||
|
const newComment = {
|
||||||
|
id: `c${Date.now()}`,
|
||||||
|
content: commentText,
|
||||||
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
|
author: { name: 'You', image: null },
|
||||||
|
createdAt: 'Just now',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setComments(prev => [...prev, newComment]);
|
||||||
|
setCommentText('');
|
||||||
|
setSelectedTimestamp(null);
|
||||||
|
}, [commentText, currentTime, selectedTimestamp, isRecording]);
|
||||||
|
|
||||||
|
const handleResolveComment = useCallback((commentId: string) => {
|
||||||
|
setComments(prev => prev.map(c =>
|
||||||
|
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const embedUrl = `https://www.youtube.com/embed/${mockVideo.currentVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-background overflow-hidden">
|
||||||
|
{/* Main Content - Full Width Layout */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Video Area */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Compact Header Bar */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
<Separator orientation="vertical" className="h-5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium">{mockVideo.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">• {mockVideo.projectName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Badge variant="secondary" className="mr-2">v{mockVideo.currentVersion.number}</Badge>
|
||||||
|
{mockVideo.currentVersion.label}
|
||||||
|
<ChevronDown className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{mockVideo.versions.map((version) => (
|
||||||
|
<DropdownMenuItem key={version.id}>
|
||||||
|
<Badge variant={version.isActive ? 'default' : 'secondary'} className="mr-2">
|
||||||
|
v{version.number}
|
||||||
|
</Badge>
|
||||||
|
{version.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player - Maximized */}
|
||||||
|
<div className="flex-1 bg-black flex items-center justify-center p-2">
|
||||||
|
<div className="relative w-full h-full max-h-full">
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={embedUrl}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline with comment markers */}
|
||||||
|
<div className="shrink-0 px-4 py-2 bg-background border-t">
|
||||||
|
<div className="relative h-8 bg-muted rounded cursor-pointer">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full bg-primary/30 rounded"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Comment markers */}
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<button
|
||||||
|
key={comment.id}
|
||||||
|
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10",
|
||||||
|
comment.isResolved ? "bg-green-500" : "bg-primary"
|
||||||
|
)}
|
||||||
|
style={{ left: `${(comment.timestamp / duration) * 100}%` }}
|
||||||
|
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Time display */}
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground font-medium">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Sidebar - Fixed Right */}
|
||||||
|
<div className="w-80 shrink-0 border-l bg-card flex flex-col overflow-hidden">
|
||||||
|
{/* Comments Header */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between p-4 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span className="font-medium">Comments</span>
|
||||||
|
<Badge variant="secondary">{comments.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowResolved(!showResolved)}
|
||||||
|
>
|
||||||
|
{showResolved ? 'Hide' : 'Show'} Resolved
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredComments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No comments yet</p>
|
||||||
|
<p className="text-sm">Be the first to leave feedback!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredComments
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-lg border p-3 transition-colors hover:bg-accent/50",
|
||||||
|
comment.isResolved && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Comment Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={comment.author.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{comment.author.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium">{comment.author.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10"
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatTime(comment.timestamp)}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleResolveComment(comment.id)}
|
||||||
|
>
|
||||||
|
{comment.isResolved ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Reply</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Content */}
|
||||||
|
{comment.content && (
|
||||||
|
<p className="text-sm mb-2">{comment.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Comment */}
|
||||||
|
{comment.voiceUrl && (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 h-1 bg-primary/30 rounded">
|
||||||
|
<div className="w-0 h-full bg-primary rounded" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(comment.voiceDuration || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamp & Meta */}
|
||||||
|
<p className="text-xs text-muted-foreground">{comment.createdAt}</p>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{comment.replies.length > 0 && (
|
||||||
|
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{reply.author.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium text-xs">{reply.author.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{reply.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{reply.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Input - Fixed at Bottom */}
|
||||||
|
<div className="shrink-0 p-4 border-t bg-background">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTimestamp(currentTime)}
|
||||||
|
className={cn(selectedTimestamp !== null && "border-primary")}
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4 mr-1" />
|
||||||
|
{selectedTimestamp !== null
|
||||||
|
? formatTime(selectedTimestamp)
|
||||||
|
: formatTime(currentTime)
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Pin to this time
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleAddComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant={isRecording ? "destructive" : "outline"}
|
||||||
|
onClick={() => setIsRecording(!isRecording)}
|
||||||
|
>
|
||||||
|
<Mic className={cn("h-4 w-4", isRecording && "animate-pulse")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
⌘+Enter to submit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { parseVideoUrl, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||||
|
|
||||||
|
export default function NewVideoPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUrlChange = (url: string) => {
|
||||||
|
setVideoUrl(url);
|
||||||
|
setUrlError('');
|
||||||
|
|
||||||
|
if (!url.trim()) {
|
||||||
|
setVideoSource(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = parseVideoUrl(url);
|
||||||
|
if (source) {
|
||||||
|
setVideoSource(source);
|
||||||
|
} else {
|
||||||
|
setVideoSource(null);
|
||||||
|
if (url.length > 10) {
|
||||||
|
setUrlError('Could not recognize this video URL. Currently supported: YouTube, Vimeo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!videoSource) {
|
||||||
|
setUrlError('Please enter a valid video URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual video creation
|
||||||
|
// const response = await fetch(`/api/projects/${projectId}/videos`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify({
|
||||||
|
// ...formData,
|
||||||
|
// ...videoSource,
|
||||||
|
// }),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
router.push(`/projects/${projectId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add video:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbnailUrl = videoSource ? getThumbnailUrl(videoSource, 'large') : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Video</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Paste a video link to add it to your project. Currently supports YouTube and Vimeo.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Video URL Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="url">Video URL</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL validation feedback */}
|
||||||
|
{urlError && (
|
||||||
|
<p className="text-sm text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{urlError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videoSource && (
|
||||||
|
<p className="text-sm text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Preview */}
|
||||||
|
{thumbnailUrl && videoSource && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Preview</Label>
|
||||||
|
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt="Video thumbnail"
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Video title (will auto-fill from video if empty)"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the original video title
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Add context about this video..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={isLoading || !videoSource}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Add Video
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export default function NewProjectPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual project creation
|
||||||
|
// const response = await fetch('/api/projects', {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify(formData),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Simulate API call for now
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Redirect to dashboard on success
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create project:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Projects
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Project</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Set up a new project to organize your videos and collect feedback
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Project Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="My Awesome Project"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Brief description of what this project is about..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={isLoading || !formData.name.trim()}>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+126
@@ -0,0 +1,126 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--primary: oklch(0.61 0.11 222);
|
||||||
|
--primary-foreground: oklch(0.98 0.02 201);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.97 0.001 106.424);
|
||||||
|
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||||
|
--accent: oklch(0.61 0.11 222);
|
||||||
|
--accent-foreground: oklch(0.98 0.02 201);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.923 0.003 48.717);
|
||||||
|
--input: oklch(0.923 0.003 48.717);
|
||||||
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
|
--chart-1: oklch(0.87 0.12 207);
|
||||||
|
--chart-2: oklch(0.80 0.13 212);
|
||||||
|
--chart-3: oklch(0.71 0.13 215);
|
||||||
|
--chart-4: oklch(0.61 0.11 222);
|
||||||
|
--chart-5: oklch(0.52 0.09 223);
|
||||||
|
--radius: 0;
|
||||||
|
--sidebar: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--sidebar-primary: oklch(0.61 0.11 222);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.02 201);
|
||||||
|
--sidebar-accent: oklch(0.61 0.11 222);
|
||||||
|
--sidebar-accent-foreground: oklch(0.98 0.02 201);
|
||||||
|
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||||
|
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.147 0.004 49.25);
|
||||||
|
--foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--card: oklch(0.216 0.006 56.043);
|
||||||
|
--card-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--primary: oklch(0.71 0.13 215);
|
||||||
|
--primary-foreground: oklch(0.30 0.05 230);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
|
--accent: oklch(0.71 0.13 215);
|
||||||
|
--accent-foreground: oklch(0.30 0.05 230);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
|
--chart-1: oklch(0.87 0.12 207);
|
||||||
|
--chart-2: oklch(0.80 0.13 212);
|
||||||
|
--chart-3: oklch(0.71 0.13 215);
|
||||||
|
--chart-4: oklch(0.61 0.11 222);
|
||||||
|
--chart-5: oklch(0.52 0.09 223);
|
||||||
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary: oklch(0.80 0.13 212);
|
||||||
|
--sidebar-primary-foreground: oklch(0.30 0.05 230);
|
||||||
|
--sidebar-accent: oklch(0.71 0.13 215);
|
||||||
|
--sidebar-accent-foreground: oklch(0.30 0.05 230);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { JetBrains_Mono } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-sans',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OpenFrame - Video Feedback Platform",
|
||||||
|
description: "Collect timestamped video feedback with text and voice comments",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={jetbrainsMono.variable} suppressHydrationWarning>
|
||||||
|
<body className="antialiased min-h-screen bg-background">
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="px-6 lg:px-8 flex h-16 items-center justify-between w-full">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<Video className="h-6 w-6 text-primary" />
|
||||||
|
<span className="font-bold text-xl">OpenFrame</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link href="/login">Sign in</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard">Get Started</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="px-6 lg:px-8 py-24 md:py-32">
|
||||||
|
<div className="flex flex-col items-center text-center gap-8 max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
|
||||||
|
Video feedback,{' '}
|
||||||
|
<span className="text-primary">reimagined</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl">
|
||||||
|
Collect timestamped feedback on your videos with text and voice comments.
|
||||||
|
Share with your team and clients, iterate faster.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/dashboard">
|
||||||
|
Start for free
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="lg">
|
||||||
|
<Link href="#features">
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
See how it works
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section id="features" className="px-6 lg:px-8 py-24 border-t">
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-16">
|
||||||
|
Everything you need for video feedback
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
<FeatureCard
|
||||||
|
icon={Video}
|
||||||
|
title="Multiple Sources"
|
||||||
|
description="Add videos from YouTube, Vimeo, or upload directly. One place for all your video content."
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={MessageSquare}
|
||||||
|
title="Timestamped Comments"
|
||||||
|
description="Leave feedback at specific moments. Click any comment to jump to that exact frame."
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Mic}
|
||||||
|
title="Voice Comments"
|
||||||
|
description="Record voice notes for detailed feedback. Sometimes speaking is faster than typing."
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Share2}
|
||||||
|
title="Easy Sharing"
|
||||||
|
description="Generate shareable links for clients and collaborators. No account required to comment."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="px-6 lg:px-8 py-24 border-t">
|
||||||
|
<div className="bg-accent rounded-2xl p-12 text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Ready to streamline your video workflow?</h2>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-xl mx-auto">
|
||||||
|
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
|
||||||
|
</p>
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/dashboard">
|
||||||
|
Get started for free
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t py-8">
|
||||||
|
<div className="px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Video className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-semibold">OpenFrame</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Built with Next.js, shadcn/ui, and ❤️
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center text-center p-6 rounded-lg border bg-card">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
ChevronDown,
|
||||||
|
MoreVertical,
|
||||||
|
User,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const mockVideo = {
|
||||||
|
id: 'v1',
|
||||||
|
title: 'Main Product Walkthrough',
|
||||||
|
description: 'Complete walkthrough of the new product features',
|
||||||
|
projectId: '1',
|
||||||
|
projectName: 'Product Demo v2',
|
||||||
|
versions: [
|
||||||
|
{ id: 'ver3', number: 3, label: 'Final Cut', isActive: true },
|
||||||
|
{ id: 'ver2', number: 2, label: 'Review Round 2', isActive: false },
|
||||||
|
{ id: 'ver1', number: 1, label: 'First Draft', isActive: false },
|
||||||
|
],
|
||||||
|
currentVersion: {
|
||||||
|
id: 'ver3',
|
||||||
|
number: 3,
|
||||||
|
label: 'Final Cut',
|
||||||
|
providerId: 'youtube',
|
||||||
|
videoId: 'dQw4w9WgXcQ', // Sample video
|
||||||
|
duration: 342, // 5:42
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockComments = [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
content: 'The transition here feels a bit abrupt. Can we add a fade?',
|
||||||
|
timestamp: 45.5,
|
||||||
|
author: { name: 'Sarah Chen', image: null },
|
||||||
|
createdAt: '2 hours ago',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'c1r1',
|
||||||
|
content: 'Good catch! I\'ll smooth that out in the next version.',
|
||||||
|
author: { name: 'Mike Johnson', image: null },
|
||||||
|
createdAt: '1 hour ago',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
content: 'Love this section! The pacing is perfect.',
|
||||||
|
timestamp: 120,
|
||||||
|
author: { name: 'Alex Rivera', image: null },
|
||||||
|
createdAt: '5 hours ago',
|
||||||
|
isResolved: true,
|
||||||
|
replies: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c3',
|
||||||
|
content: 'Can we add some background music here?',
|
||||||
|
timestamp: 200,
|
||||||
|
voiceUrl: '/mock-voice.mp3', // Mock voice comment
|
||||||
|
voiceDuration: 8.5,
|
||||||
|
author: { name: 'Jordan Lee', image: null },
|
||||||
|
createdAt: '1 day ago',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const videoId = params.videoId as string;
|
||||||
|
|
||||||
|
// In real app, fetch projectId from video data
|
||||||
|
const projectId = mockVideo.projectId;
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const playerRef = useRef<any>(null);
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(mockVideo.currentVersion.duration);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
|
const [comments, setComments] = useState(mockComments);
|
||||||
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
|
||||||
|
// Load YouTube iframe API
|
||||||
|
useEffect(() => {
|
||||||
|
// Load YouTube iframe API script
|
||||||
|
if (!window.YT) {
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||||
|
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize player when API is ready
|
||||||
|
const onYouTubeIframeAPIReady = () => {
|
||||||
|
playerRef.current = new window.YT.Player(iframeRef.current, {
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
setIsReady(true);
|
||||||
|
setDuration(playerRef.current.getDuration() || mockVideo.currentVersion.duration);
|
||||||
|
},
|
||||||
|
onStateChange: (event: any) => {
|
||||||
|
setIsPlaying(event.data === window.YT.PlayerState.PLAYING);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
onYouTubeIframeAPIReady();
|
||||||
|
} else {
|
||||||
|
window.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.onYouTubeIframeAPIReady = undefined;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update current time periodically
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReady || !playerRef.current) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (playerRef.current?.getCurrentTime && !isDragging) {
|
||||||
|
setCurrentTime(playerRef.current.getCurrentTime());
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isReady, isDragging]);
|
||||||
|
|
||||||
|
const filteredComments = comments.filter(c => showResolved || !c.isResolved);
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(() => {
|
||||||
|
if (!playerRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
playerRef.current.pauseVideo();
|
||||||
|
} else {
|
||||||
|
playerRef.current.playVideo();
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleSeekToTimestamp = useCallback((timestamp: number) => {
|
||||||
|
setCurrentTime(timestamp);
|
||||||
|
if (playerRef.current?.seekTo) {
|
||||||
|
playerRef.current.seekTo(timestamp, true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!timelineRef.current) return;
|
||||||
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const newTime = percentage * duration;
|
||||||
|
handleSeekToTimestamp(newTime);
|
||||||
|
}, [duration, handleSeekToTimestamp]);
|
||||||
|
|
||||||
|
const handleTimelineMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
handleTimelineClick(e);
|
||||||
|
}, [handleTimelineClick]);
|
||||||
|
|
||||||
|
const handleTimelineMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging || !timelineRef.current) return;
|
||||||
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const newTime = percentage * duration;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}, [isDragging, duration]);
|
||||||
|
|
||||||
|
const handleTimelineMouseUp = useCallback(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
handleSeekToTimestamp(currentTime);
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||||
|
|
||||||
|
const handleMuteToggle = useCallback(() => {
|
||||||
|
if (!playerRef.current) return;
|
||||||
|
if (isMuted) {
|
||||||
|
playerRef.current.unMute();
|
||||||
|
} else {
|
||||||
|
playerRef.current.mute();
|
||||||
|
}
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
}, [isMuted]);
|
||||||
|
|
||||||
|
const handleSkip = useCallback((seconds: number) => {
|
||||||
|
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||||
|
handleSeekToTimestamp(newTime);
|
||||||
|
}, [currentTime, duration, handleSeekToTimestamp]);
|
||||||
|
|
||||||
|
const handleAddComment = useCallback(() => {
|
||||||
|
if (!commentText.trim() && !isRecording) return;
|
||||||
|
|
||||||
|
const newComment = {
|
||||||
|
id: `c${Date.now()}`,
|
||||||
|
content: commentText,
|
||||||
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
|
author: { name: 'You', image: null },
|
||||||
|
createdAt: 'Just now',
|
||||||
|
isResolved: false,
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setComments(prev => [...prev, newComment]);
|
||||||
|
setCommentText('');
|
||||||
|
setSelectedTimestamp(null);
|
||||||
|
}, [commentText, currentTime, selectedTimestamp, isRecording]);
|
||||||
|
|
||||||
|
const handleResolveComment = useCallback((commentId: string) => {
|
||||||
|
setComments(prev => prev.map(c =>
|
||||||
|
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hide YouTube controls, enable JS API
|
||||||
|
const embedUrl = `https://www.youtube.com/embed/${mockVideo.currentVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-screen flex flex-col bg-background overflow-hidden"
|
||||||
|
onMouseUp={handleTimelineMouseUp}
|
||||||
|
onMouseLeave={() => isDragging && handleTimelineMouseUp()}
|
||||||
|
>
|
||||||
|
{/* Main Content - Full Width Layout */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Video Area */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Compact Header Bar */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
<Separator orientation="vertical" className="h-5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium">{mockVideo.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">• {mockVideo.projectName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Badge variant="secondary" className="mr-2">v{mockVideo.currentVersion.number}</Badge>
|
||||||
|
{mockVideo.currentVersion.label}
|
||||||
|
<ChevronDown className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{mockVideo.versions.map((version) => (
|
||||||
|
<DropdownMenuItem key={version.id}>
|
||||||
|
<Badge variant={version.isActive ? 'default' : 'secondary'} className="mr-2">
|
||||||
|
v{version.number}
|
||||||
|
</Badge>
|
||||||
|
{version.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player - Maximized */}
|
||||||
|
<div
|
||||||
|
className="flex-1 bg-black flex items-center justify-center relative cursor-pointer group"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={embedUrl}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Play/Pause overlay indicator */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity",
|
||||||
|
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-100"
|
||||||
|
)}>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center">
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-8 w-8 text-white" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-8 w-8 text-white ml-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Controls Bar */}
|
||||||
|
<div className="shrink-0 px-4 py-3 bg-background border-t">
|
||||||
|
{/* Control buttons */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4 ml-0.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleSkip(-10)}
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleSkip(10)}
|
||||||
|
>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleMuteToggle}
|
||||||
|
>
|
||||||
|
{isMuted ? (
|
||||||
|
<VolumeX className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 tabular-nums">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline with comment markers */}
|
||||||
|
<div
|
||||||
|
ref={timelineRef}
|
||||||
|
className="relative h-8 bg-muted rounded cursor-pointer select-none"
|
||||||
|
onMouseDown={handleTimelineMouseDown}
|
||||||
|
onMouseMove={handleTimelineMouseMove}
|
||||||
|
>
|
||||||
|
{/* Buffered/loaded indicator could go here */}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playhead */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none"
|
||||||
|
style={{ left: `calc(${(currentTime / duration) * 100}% - 2px)` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Comment markers */}
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<button
|
||||||
|
key={comment.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSeekToTimestamp(comment.timestamp);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10",
|
||||||
|
comment.isResolved ? "bg-green-500" : "bg-cyan-400"
|
||||||
|
)}
|
||||||
|
style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }}
|
||||||
|
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Sidebar - Fixed Right */}
|
||||||
|
<div className="w-80 shrink-0 border-l bg-card flex flex-col overflow-hidden">
|
||||||
|
{/* Comments Header */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between p-4 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span className="font-medium">Comments</span>
|
||||||
|
<Badge variant="secondary">{comments.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowResolved(!showResolved)}
|
||||||
|
>
|
||||||
|
{showResolved ? 'Hide' : 'Show'} Resolved
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredComments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No comments yet</p>
|
||||||
|
<p className="text-sm">Be the first to leave feedback!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredComments
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-lg border p-3 transition-colors hover:bg-accent/50",
|
||||||
|
comment.isResolved && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Comment Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={comment.author.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{comment.author.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium">{comment.author.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10"
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatTime(comment.timestamp)}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleResolveComment(comment.id)}
|
||||||
|
>
|
||||||
|
{comment.isResolved ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Reply</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Content */}
|
||||||
|
{comment.content && (
|
||||||
|
<p className="text-sm mb-2">{comment.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Comment */}
|
||||||
|
{comment.voiceUrl && (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 h-1 bg-primary/30 rounded">
|
||||||
|
<div className="w-0 h-full bg-primary rounded" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(comment.voiceDuration || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamp & Meta */}
|
||||||
|
<p className="text-xs text-muted-foreground">{comment.createdAt}</p>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{comment.replies.length > 0 && (
|
||||||
|
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{reply.author.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium text-xs">{reply.author.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{reply.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{reply.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Input - Fixed at Bottom */}
|
||||||
|
<div className="shrink-0 p-4 border-t bg-background">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTimestamp(currentTime)}
|
||||||
|
className={cn(selectedTimestamp !== null && "border-primary")}
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4 mr-1" />
|
||||||
|
{selectedTimestamp !== null
|
||||||
|
? formatTime(selectedTimestamp)
|
||||||
|
: formatTime(currentTime)
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Pin to this time
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleAddComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant={isRecording ? "destructive" : "outline"}
|
||||||
|
onClick={() => setIsRecording(!isRecording)}
|
||||||
|
>
|
||||||
|
<Mic className={cn("h-4 w-4", isRecording && "animate-pulse")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
⌘+Enter to submit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Header } from './header';
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+81
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user