refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
+314 -87
View File
@@ -26,10 +26,9 @@ import {
CheckCircle,
Upload,
Share2,
Check
Check,
} from 'lucide-react';
interface LandingPageProps {
isLoggedIn: boolean;
}
@@ -55,7 +54,8 @@ const coreWorkflowFeatures = [
},
{
title: 'Approval Workflow',
description: 'Assign specific team members or clients to review and sign off on a cut. Get an exact \"Approved\" status.',
description:
'Assign specific team members or clients to review and sign off on a cut. Get an exact \"Approved\" status.',
icon: CheckCircle,
},
];
@@ -73,7 +73,8 @@ const workflowAcceleratorFeatures = [
},
{
title: 'Real-time Webhooks',
description: 'Get instant Telegram alerts the second a comment is dropped. More integrations coming soon.',
description:
'Get instant Telegram alerts the second a comment is dropped. More integrations coming soon.',
icon: BellRing,
},
{
@@ -121,7 +122,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
const updateNavbar = () => {
const hasScrolled = window.scrollY > 20;
gsap.to(nav, {
backgroundColor: hasScrolled ? 'color-mix(in oklab, var(--background) 85%, transparent)' : 'transparent',
backgroundColor: hasScrolled
? 'color-mix(in oklab, var(--background) 85%, transparent)'
: 'transparent',
backdropFilter: hasScrolled ? 'blur(16px)' : 'blur(0px)',
borderBottomColor: hasScrolled ? 'var(--border)' : 'transparent',
duration: 0.3,
@@ -141,27 +144,50 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
}, []);
return (
<div ref={rootRef} className="min-h-screen overflow-x-hidden bg-background text-foreground font-sans selection:bg-primary/20">
<div
ref={rootRef}
className="min-h-screen overflow-x-hidden bg-background text-foreground font-sans selection:bg-primary/20"
>
{/* Header */}
<header ref={navbarRef} className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-transparent transition-colors duration-300">
<header
ref={navbarRef}
className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-transparent transition-colors duration-300"
>
<div className="mx-auto flex h-14 w-full max-w-[1200px] items-center justify-between px-4 sm:h-16 sm:px-6 lg:px-10">
<Link href="/" className="group relative isolate inline-flex items-center gap-2 overflow-hidden border border-border bg-background px-3 py-2">
<Link
href="/"
className="group relative isolate inline-flex items-center gap-2 overflow-hidden border border-border bg-background px-3 py-2"
>
<span className="pointer-events-none absolute inset-0 -translate-x-[101%] bg-primary/10 transition-transform duration-300 group-hover:translate-x-0" />
<Video className="relative z-10 h-4 w-4 text-primary" />
<span className="relative z-10 text-xs font-semibold tracking-[0.12em]">OPENFRAME</span>
</Link>
<nav className="hidden items-center gap-6 text-[11px] font-medium uppercase tracking-[0.14em] md:flex">
<Link className="text-muted-foreground transition-colors hover:text-foreground" href="#features">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="#features"
>
Features
</Link>
<Link className="text-muted-foreground transition-colors hover:text-foreground" href="#pricing">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="#pricing"
>
Pricing
</Link>
<a className="text-muted-foreground transition-colors hover:text-foreground" href="https://github.com/yusufipk/OpenFrame" target="_blank" rel="noreferrer">
<a
className="text-muted-foreground transition-colors hover:text-foreground"
href="https://github.com/yusufipk/OpenFrame"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
<a className="text-muted-foreground transition-colors hover:text-foreground" href="mailto:[email protected]">
<a
className="text-muted-foreground transition-colors hover:text-foreground"
href="mailto:[email protected]"
>
Contact
</a>
</nav>
@@ -177,7 +203,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</Link>
) : (
<>
<Link href="/login" className="text-xs font-medium text-muted-foreground hover:text-foreground hidden sm:block mr-4">
<Link
href="/login"
className="text-xs font-medium text-muted-foreground hover:text-foreground hidden sm:block mr-4"
>
Log in
</Link>
<Link href="/register" className={controlButtonClass}>
@@ -196,7 +225,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<div className="relative z-10 mx-auto max-w-[1000px] space-y-8">
<div data-hero-copy className="inline-flex items-center gap-2 border border-border/50 bg-secondary/30 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-md">
<div
data-hero-copy
className="inline-flex items-center gap-2 border border-border/50 bg-secondary/30 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-md"
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
@@ -204,16 +236,26 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<span className="font-mono tracking-wide uppercase">Open Source Video Review</span>
</div>
<h1 data-hero-copy className="text-4xl font-semibold leading-[0.95] tracking-[-0.03em] sm:text-5xl md:text-6xl lg:text-7xl">
<h1
data-hero-copy
className="text-4xl font-semibold leading-[0.95] tracking-[-0.03em] sm:text-5xl md:text-6xl lg:text-7xl"
>
Cut Video Approval Time in Half. <br className="hidden md:block" />
<span className="text-muted-foreground">Stop Chasing Timecodes.</span>
</h1>
<p data-hero-copy className="mx-auto max-w-2xl text-base text-muted-foreground md:text-xl">
OpenFrame puts comments, voice notes, and annotations on a single timeline so clients say &quot;yes&quot; faster and your team stops guessing.
<p
data-hero-copy
className="mx-auto max-w-2xl text-base text-muted-foreground md:text-xl"
>
OpenFrame puts comments, voice notes, and annotations on a single timeline so clients
say &quot;yes&quot; faster and your team stops guessing.
</p>
<div data-hero-copy className="mx-auto flex max-w-md flex-col items-center justify-center gap-2">
<div
data-hero-copy
className="mx-auto flex max-w-md flex-col items-center justify-center gap-2"
>
<Link
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
@@ -222,7 +264,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
<a href="#pricing" className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
<a
href="#pricing"
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Prefer self-hosting? <ArrowRight className="ml-1 inline h-3.5 w-3.5" />
</a>
</div>
@@ -252,7 +297,6 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<Mic className="h-4 w-4" />
</button>
</div>
</div>
</div>
</section>
@@ -261,7 +305,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-y border-border bg-card/10 px-4 py-16 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[1200px]">
<div className="mx-auto max-w-4xl">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Feedback chaos looks like this:</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Feedback chaos looks like this:
</h2>
<ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg">
<li className="flex items-start gap-3">
<XCircle className="mt-1 h-5 w-5 shrink-0 text-red-500/80" />
@@ -293,7 +339,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[1200px]">
<div className="mb-16 text-center">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">From upload to approval in one flow.</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
From upload to approval in one flow.
</h2>
</div>
<div className="grid gap-4 md:grid-cols-4 relative">
@@ -301,7 +349,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{ label: 'Upload a cut', icon: Upload },
{ label: 'Share a review link', icon: Share2 },
{ label: 'Get timestamped feedback', icon: MessageSquare },
{ label: 'Mark approved and move on', icon: Check }
{ label: 'Mark approved and move on', icon: Check },
].map((step, idx, arr) => (
<div key={step.label} className="group relative flex flex-col items-center">
<div className="flex flex-col items-center justify-center w-full min-h-[100px] md:aspect-[4/3] border border-border bg-card/20 px-4 py-6 md:p-6 text-center relative z-10 transition-all duration-300 group-hover:border-primary/40 group-hover:bg-card/40">
@@ -334,22 +382,31 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* 4) FEATURES */}
<section id="features" className="scroll-mt-20 border-t border-border bg-card/10">
{/* Feature 1 */}
<div className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 relative">
<div className="relative aspect-[16/10] w-full border border-border/50 bg-background overflow-hidden">
<Image src="/landing/compare-v2.webp" alt="Comparison Mode" fill className="object-cover object-left-top" sizes="(min-width: 1024px) 50vw, 100vw" />
<Image
src="/landing/compare-v2.webp"
alt="Comparison Mode"
fill
className="object-cover object-left-top"
sizes="(min-width: 1024px) 50vw, 100vw"
/>
<div className="absolute inset-0 bg-background/5" />
</div>
</div>
<div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Compare versions side-by-side. End &quot;which cut is this?&quot;</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Compare versions side-by-side. End &quot;which cut is this?&quot;
</h2>
<p className="text-base text-muted-foreground md:text-lg">
See what actually changed between versions then approve with confidence.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Cuts revision cycles.</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">
Cuts revision cycles.
</p>
</div>
</div>
</div>
@@ -358,20 +415,29 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="border-b border-border bg-background">
<div className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Explain it better with Voice.</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Explain it better with Voice.
</h2>
<p className="text-base text-muted-foreground md:text-lg">
No more &quot;What did you mean by this?&quot; emails. Every note lands at the exact moment in the video.
No more &quot;What did you mean by this?&quot; emails. Every note lands at the
exact moment in the video.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">
Faster feedback. Fewer misunderstandings.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Faster feedback. Fewer misunderstandings.</p>
</div>
<div data-reveal className="w-full lg:w-1/2">
<div className="border border-border bg-card p-6">
<div className="border border-border/50 bg-background p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center bg-secondary font-mono text-xs">Y</div>
<div className="flex h-8 w-8 items-center justify-center bg-secondary font-mono text-xs">
Y
</div>
<div className="space-y-1">
<p className="font-mono text-[11px] font-medium leading-none">Yusuf İpek</p>
<p className="font-mono text-[11px] font-medium leading-none">
Yusuf İpek
</p>
<p className="font-mono text-[10px] text-muted-foreground">00:03:45</p>
</div>
</div>
@@ -382,7 +448,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<Play className="h-3 w-3" />
</button>
{Array.from({ length: 40 }).map((_, i) => (
<span key={i} className="voice-bar w-full flex-1 bg-primary/60" style={{ height: `${[30, 80, 50, 90, 40, 70, 60, 45, 85, 55, 65, 35, 95, 75, 25, 40, 80, 50, 90, 30][i % 20]}%` }} />
<span
key={i}
className="voice-bar w-full flex-1 bg-primary/60"
style={{
height: `${[30, 80, 50, 90, 40, 70, 60, 45, 85, 55, 65, 35, 95, 75, 25, 40, 80, 50, 90, 30][i % 20]}%`,
}}
/>
))}
</div>
</div>
@@ -394,12 +466,37 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* Feature 3 */}
<div className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 relative aspect-video bg-card border border-border p-4">
<div
data-reveal
className="w-full lg:w-1/2 relative aspect-video bg-card border border-border p-4"
>
<div className="relative h-full w-full border border-border/50 overflow-hidden bg-background">
<Image src="https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=800&q=80" alt="Annotate" fill className="object-cover opacity-70" />
<svg className="absolute inset-0 h-full w-full pointer-events-none" viewBox="0 0 800 450" fill="none">
<circle cx="500" cy="225" r="80" stroke="#06b6d4" strokeWidth="4" className="opacity-90 drop-shadow-[0_0_8px_rgba(6,182,212,0.8)]" />
<path d="M500 145 Q550 80 620 120" stroke="#06b6d4" strokeWidth="4" className="opacity-90" strokeLinecap="round" />
<Image
src="https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=800&q=80"
alt="Annotate"
fill
className="object-cover opacity-70"
/>
<svg
className="absolute inset-0 h-full w-full pointer-events-none"
viewBox="0 0 800 450"
fill="none"
>
<circle
cx="500"
cy="225"
r="80"
stroke="#06b6d4"
strokeWidth="4"
className="opacity-90 drop-shadow-[0_0_8px_rgba(6,182,212,0.8)]"
/>
<path
d="M500 145 Q550 80 620 120"
stroke="#06b6d4"
strokeWidth="4"
className="opacity-90"
strokeLinecap="round"
/>
</svg>
{/* Circle Editor UI mock */}
@@ -423,11 +520,16 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div>
</div>
<div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Point. Draw. Done.</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Point. Draw. Done.
</h2>
<p className="text-base text-muted-foreground md:text-lg">
Precise feedback that leaves zero room for error. Circle, sketch, and point directly on the video frame.
Precise feedback that leaves zero room for error. Circle, sketch, and point
directly on the video frame.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">
Stops &quot;I thought you meant...&quot;
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Stops &quot;I thought you meant...&quot;</p>
</div>
</div>
</div>
@@ -437,16 +539,29 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border px-4 py-20 sm:px-6 lg:px-8 lg:py-32 bg-background">
<div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mb-12 flex flex-col items-center text-center">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Everything a real production workflow needs.</h2>
<p className="mt-4 max-w-2xl text-base text-muted-foreground">The core tools teams expect without the complexity that slows clients down.</p>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Everything a real production workflow needs.
</h2>
<p className="mt-4 max-w-2xl text-base text-muted-foreground">
The core tools teams expect without the complexity that slows clients down.
</p>
</div>
<div className="space-y-10">
<div>
<p data-reveal className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground">Core workflow</p>
<p
data-reveal
className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground"
>
Core workflow
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{coreWorkflowFeatures.map((feat) => (
<div key={feat.title} data-reveal className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80">
<div
key={feat.title}
data-reveal
className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"
>
<feat.icon className="mb-4 h-6 w-6 text-primary" />
<h3 className="mb-2 text-lg font-medium">{feat.title}</h3>
<p className="text-sm text-muted-foreground">{feat.description}</p>
@@ -456,10 +571,19 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div>
<div>
<p data-reveal className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground">Workflow Accelerators</p>
<p
data-reveal
className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground"
>
Workflow Accelerators
</p>
<div className="grid gap-4 sm:grid-cols-2">
{workflowAcceleratorFeatures.map((feat) => (
<div key={feat.title} data-reveal className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80">
<div
key={feat.title}
data-reveal
className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"
>
<feat.icon className="mb-4 h-6 w-6 text-primary" />
<h3 className="mb-2 text-lg font-medium">{feat.title}</h3>
<p className="text-sm text-muted-foreground">{feat.description}</p>
@@ -475,8 +599,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-card/20 px-4 py-20 sm:px-6 lg:px-8 lg:py-32">
<div className="mx-auto flex w-full max-w-[1200px] flex-col lg:flex-row gap-12 lg:items-center">
<div data-reveal className="lg:w-1/2 space-y-6">
<h2 className="text-4xl font-semibold tracking-[-0.02em] md:text-5xl">Clients don&apos;t need an account. They just review.</h2>
<p className="text-lg text-muted-foreground">If clients can&apos;t adopt the tool, approvals don&apos;t happen. OpenFrame keeps it frictionless.</p>
<h2 className="text-4xl font-semibold tracking-[-0.02em] md:text-5xl">
Clients don&apos;t need an account. They just review.
</h2>
<p className="text-lg text-muted-foreground">
If clients can&apos;t adopt the tool, approvals don&apos;t happen. OpenFrame keeps
it frictionless.
</p>
</div>
<div data-reveal className="lg:w-1/2 space-y-4">
<div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1">
@@ -494,7 +623,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1">
<MessageSquare className="mt-1 h-6 w-6 shrink-0 text-primary" />
<div>
<h3 className="text-lg font-semibold">Timestamped notes that are impossible to miss.</h3>
<h3 className="text-lg font-semibold">
Timestamped notes that are impossible to miss.
</h3>
</div>
</div>
</div>
@@ -502,41 +633,73 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</section>
{/* 7) PRICING */}
<section id="pricing" className="border-b border-border bg-[#0a0a0a] px-4 py-20 sm:px-6 lg:px-8 lg:py-32">
<section
id="pricing"
className="border-b border-border bg-[#0a0a0a] px-4 py-20 sm:px-6 lg:px-8 lg:py-32"
>
<div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mb-16">
<h2 className="text-3xl font-semibold md:text-5xl text-foreground" style={{ fontFamily: 'monospace', letterSpacing: '-0.02em' }}>
<h2
className="text-3xl font-semibold md:text-5xl text-foreground"
style={{ fontFamily: 'monospace', letterSpacing: '-0.02em' }}
>
Deploy it yourself - or let us run it for you.
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{/* Card 1: Open Source */}
<div data-reveal className="relative flex flex-col border border-border/40 bg-[#141414] p-8">
<div
data-reveal
className="relative flex flex-col border border-border/40 bg-[#141414] p-8"
>
<div className="mb-6">
<h3 className="text-xl font-semibold text-foreground">Open Source (Self-hosted)</h3>
<h3 className="text-xl font-semibold text-foreground">
Open Source (Self-hosted)
</h3>
</div>
<div className="mb-8 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#06b6d4]">Free</span>
</div>
<p className="mb-8 text-sm text-muted-foreground">For teams who want full control and can run their own infrastructure.</p>
<p className="mb-8 text-sm text-muted-foreground">
For teams who want full control and can run their own infrastructure.
</p>
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Full codebase access</li>
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Self-hosted infrastructure control</li>
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Manual update cadence</li>
<li className="flex items-center gap-3">
<CheckSquare className="h-4 w-4 text-[#06b6d4]" />
Full codebase access
</li>
<li className="flex items-center gap-3">
<CheckSquare className="h-4 w-4 text-[#06b6d4]" />
Self-hosted infrastructure control
</li>
<li className="flex items-center gap-3">
<CheckSquare className="h-4 w-4 text-[#06b6d4]" />
Manual update cadence
</li>
</ul>
<a href="https://github.com/yusufipk/OpenFrame" target="_blank" rel="noreferrer" className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden border border-border/50 bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 text-sm">
<a
href="https://github.com/yusufipk/OpenFrame"
target="_blank"
rel="noreferrer"
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden border border-border/50 bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 text-sm"
>
<Github className="mr-2 h-4 w-4" /> View on GitHub
</a>
</div>
{/* Card 2: Hosted Cloud */}
<div data-reveal className="relative flex flex-col border border-[#06b6d4]/40 bg-[#141414] p-8">
<div
data-reveal
className="relative flex flex-col border border-[#06b6d4]/40 bg-[#141414] p-8"
>
<div className="mb-6">
<p className="font-mono text-[10px] uppercase font-semibold text-muted-foreground tracking-widest mb-4">Best for teams who want zero setup.</p>
<p className="font-mono text-[10px] uppercase font-semibold text-muted-foreground tracking-widest mb-4">
Best for teams who want zero setup.
</p>
<h3 className="text-xl font-semibold text-foreground">Hosted Cloud</h3>
</div>
@@ -546,40 +709,77 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div>
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited collaborators</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Timestamped comments + voice notes</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Annotations + version compare</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Share links with permissions</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Exports (PDF/CSV)</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited YouTube Video Imports</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Download original uploaded video</span></li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Includes: 200 GB Storage</span></li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Unlimited collaborators</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Timestamped comments + voice notes</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Annotations + version compare</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Share links with permissions</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Exports (PDF/CSV)</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Unlimited YouTube Video Imports</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Download original uploaded video</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Includes: 200 GB Storage</span>
</li>
</ul>
<p className="mb-8 text-sm text-muted-foreground">Need more storage? Add 100 GB for $5/mo.</p>
<p className="mb-8 text-sm text-muted-foreground">
Need more storage? Add 100 GB for $5/mo.
</p>
<Link href={hostedCtaHref} className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm">
<Link
href={hostedCtaHref}
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
>
Start free
</Link>
</div>
{/* Card 3: Studio & Agency */}
<div data-reveal className="relative flex flex-col border border-border/40 bg-[#141414] p-8">
<div
data-reveal
className="relative flex flex-col border border-border/40 bg-[#141414] p-8"
>
<div className="mb-6">
<h3 className="text-xl font-semibold text-foreground">Need more than the standard limits?</h3>
<h3 className="text-xl font-semibold text-foreground">
Need more than the standard limits?
</h3>
</div>
<div className="mb-6 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#06b6d4]">Let&apos;s talk</span>
</div>
<p className="mb-8 text-sm text-foreground/80 leading-relaxed">
Custom capacity and setup help for high-volume production teams. Tell us your storage, usage, and workflow - we&apos;ll recommend the right approach.
Custom capacity and setup help for high-volume production teams. Tell us your
storage, usage, and workflow - we&apos;ll recommend the right approach.
</p>
<a href="mailto:[email protected]" className="group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 border border-border/50 text-sm">
<a
href="mailto:[email protected]"
className="group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 border border-border/50 text-sm"
>
Contact us
</a>
</div>
</div>
</div>
</section>
@@ -588,7 +788,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8 lg:py-24">
<div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mx-auto max-w-4xl">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Security & privacy, by design.</h2>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Security & privacy, by design.
</h2>
<ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg">
<li>- Permissioned share links (control who can view/comment)</li>
<li>- Private-by-default projects</li>
@@ -612,7 +814,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
},
{
q: 'What happens if I exceed my storage?',
a: 'You can add 100 GB for $5/mo. If you need much more, contact us at [email protected] and we\'ll help you choose the best setup.',
a: "You can add 100 GB for $5/mo. If you need much more, contact us at [email protected] and we'll help you choose the best setup.",
},
{
q: 'How is YouTube unlimited?',
@@ -655,10 +857,17 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* 10) FINAL CTA STRIP */}
<section className="border-b border-border bg-background px-4 py-16 sm:px-6 lg:px-8">
<div data-reveal className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left">
<div
data-reveal
className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left"
>
<div>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-4xl">Stop chasing feedback. Start getting approvals.</h2>
<p className="mt-2 text-sm text-muted-foreground">Your first review link takes minutes.</p>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-4xl">
Stop chasing feedback. Start getting approvals.
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your first review link takes minutes.
</p>
</div>
<Link
href={hostedCtaHref}
@@ -674,14 +883,32 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="mx-auto flex max-w-[1200px] flex-col items-center justify-between gap-4 sm:flex-row">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-primary" />
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span>
<span className="font-mono text-xs text-muted-foreground">
© 2026 IPEK TECH LLC. All rights reserved.
</span>
</div>
<div className="flex flex-wrap justify-center gap-4">
<a href="mailto:[email protected]" className="text-xs text-muted-foreground hover:text-foreground">info@open-frame.net</a>
<a href="https://github.com/yusufipk/OpenFrame" className="text-xs text-muted-foreground hover:text-foreground">GitHub</a>
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground">Terms</Link>
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground">Privacy</Link>
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground">Refund Policy</Link>
<a
href="mailto:info@open-frame.net"
className="text-xs text-muted-foreground hover:text-foreground"
>
info@open-frame.net
</a>
<a
href="https://github.com/yusufipk/OpenFrame"
className="text-xs text-muted-foreground hover:text-foreground"
>
GitHub
</a>
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground">
Terms
</Link>
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground">
Privacy
</Link>
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground">
Refund Policy
</Link>
</div>
</div>
</footer>
+5 -1
View File
@@ -64,7 +64,11 @@ export function DeleteFeedbackButton({
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant={variant} size={size} disabled={isDeleting}>
{isDeleting ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1.5 h-4 w-4" />}
{isDeleting ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-4 w-4" />
)}
Delete
</Button>
</AlertDialogTrigger>
+7 -1
View File
@@ -36,7 +36,13 @@ export function RefreshR2StatsButton() {
return (
<div className="flex flex-col items-end gap-1">
<Button type="button" variant="outline" size="sm" onClick={handleRefresh} disabled={isRefreshing}>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
+214 -154
View File
@@ -5,21 +5,21 @@ import { Button } from '@/components/ui/button';
import { Undo2, Trash2, Minus, Plus, X } from 'lucide-react';
export interface AnnotationStroke {
points: { x: number; y: number }[];
color: string;
width: number;
points: { x: number; y: number }[];
color: string;
width: number;
}
export interface AnnotationCanvasHandle {
getStrokes: () => AnnotationStroke[];
getStrokes: () => AnnotationStroke[];
}
interface AnnotationCanvasProps {
mode: 'draw' | 'view';
strokes?: AnnotationStroke[];
onConfirm?: (strokes: AnnotationStroke[]) => void;
onCancel?: () => void;
onDismiss?: () => void; // For view mode, close overlay
mode: 'draw' | 'view';
strokes?: AnnotationStroke[];
onConfirm?: (strokes: AnnotationStroke[]) => void;
onCancel?: () => void;
onDismiss?: () => void; // For view mode, close overlay
}
const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#AF52DE', '#FFFFFF'];
@@ -31,7 +31,11 @@ const MAX_WIDTH = 10;
// Reference canvas width for stroke scaling
const REF_WIDTH = 1000;
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) {
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(
function AnnotationCanvas(
{ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []);
@@ -42,90 +46,107 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
void onConfirm;
// Expose getStrokes so parent can grab current drawing without confirm
useImperativeHandle(ref, () => ({
useImperativeHandle(
ref,
() => ({
getStrokes: () => strokes,
}), [strokes]);
}),
[strokes]
);
// Render all strokes
const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => {
const renderStrokes = useCallback(
(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
strokeList: AnnotationStroke[],
active?: AnnotationStroke | null
) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = canvas.width / REF_WIDTH;
const draw = (s: AnnotationStroke) => {
if (s.points.length < 2) return;
ctx.strokeStyle = s.color;
ctx.lineWidth = s.width * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height);
for (let i = 1; i < s.points.length; i++) {
ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height);
}
ctx.stroke();
if (s.points.length < 2) return;
ctx.strokeStyle = s.color;
ctx.lineWidth = s.width * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height);
for (let i = 1; i < s.points.length; i++) {
ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height);
}
ctx.stroke();
};
strokeList.forEach(draw);
if (active) draw(active);
}, []);
},
[]
);
// Keep a stable ref to the latest strokes/currentStroke so the resize
// handler can redraw without being listed as a dependency (which would
// re-register the listener on every stroke change).
const strokesRef = useRef(strokes);
const currentStrokeRef = useRef(currentStroke);
useEffect(() => { strokesRef.current = strokes; }, [strokes]);
useEffect(() => { currentStrokeRef.current = currentStroke; }, [currentStroke]);
useEffect(() => {
strokesRef.current = strokes;
}, [strokes]);
useEffect(() => {
currentStrokeRef.current = currentStroke;
}, [currentStroke]);
// Resize canvas to match container — listener registered once, never re-added.
useEffect(() => {
const resizeCanvas = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const resizeCanvas = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const ctx = canvas.getContext('2d');
if (ctx) renderStrokes(ctx, canvas, strokesRef.current, currentStrokeRef.current);
};
const ctx = canvas.getContext('2d');
if (ctx) renderStrokes(ctx, canvas, strokesRef.current, currentStrokeRef.current);
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => window.removeEventListener('resize', resizeCanvas);
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => window.removeEventListener('resize', resizeCanvas);
}, [renderStrokes]); // renderStrokes is stable (useCallback with no deps that change)
// Re-render on stroke changes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
renderStrokes(ctx, canvas, strokes, currentStroke);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
renderStrokes(ctx, canvas, strokes, currentStroke);
}, [strokes, currentStroke, renderStrokes]);
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
let clientX: number, clientY: number;
if ('touches' in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height,
};
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
let clientX: number, clientY: number;
if ('touches' in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height,
};
}, []);
const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const handlePointerDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw') return;
e.preventDefault();
e.stopPropagation();
@@ -133,133 +154,172 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
if (!pt) return;
isDrawingRef.current = true;
setCurrentStroke({ points: [pt], color, width });
}, [mode, color, width, getPoint]);
},
[mode, color, width, getPoint]
);
const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const handlePointerMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw' || !isDrawingRef.current) return;
e.preventDefault();
e.stopPropagation();
const pt = getPoint(e);
if (!pt) return;
setCurrentStroke(prev => {
if (!prev) return prev;
return { ...prev, points: [...prev.points, pt] };
setCurrentStroke((prev) => {
if (!prev) return prev;
return { ...prev, points: [...prev.points, pt] };
});
}, [mode, getPoint]);
},
[mode, getPoint]
);
const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const handlePointerUp = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw' || !isDrawingRef.current) return;
e.preventDefault();
e.stopPropagation();
isDrawingRef.current = false;
setCurrentStroke(prev => {
if (prev && prev.points.length >= 2) {
setStrokes(s => [...s, prev]);
}
return null;
setCurrentStroke((prev) => {
if (prev && prev.points.length >= 2) {
setStrokes((s) => [...s, prev]);
}
return null;
});
}, [mode]);
},
[mode]
);
const handleUndo = useCallback(() => {
setStrokes(prev => prev.slice(0, -1));
setStrokes((prev) => prev.slice(0, -1));
}, []);
const handleClear = useCallback(() => {
setStrokes([]);
setStrokes([]);
}, []);
// View mode: click to dismiss
const handleViewClick = useCallback((e: React.MouseEvent) => {
const handleViewClick = useCallback(
(e: React.MouseEvent) => {
if (mode === 'view') {
e.stopPropagation();
onDismiss?.();
e.stopPropagation();
onDismiss?.();
}
}, [mode, onDismiss]);
},
[mode, onDismiss]
);
if (mode === 'view') {
return (
<div
ref={containerRef}
className="absolute inset-0 z-[60] cursor-pointer"
onClick={handleViewClick}
title="Click to dismiss annotation"
>
<canvas
ref={canvasRef}
className="w-full h-full pointer-events-none"
/>
</div>
);
return (
<div
ref={containerRef}
className="absolute inset-0 z-[60] cursor-pointer"
onClick={handleViewClick}
title="Click to dismiss annotation"
>
<canvas ref={canvasRef} className="w-full h-full pointer-events-none" />
</div>
);
}
return (
<div
ref={containerRef}
className="absolute inset-0 z-[60]"
onClick={(e) => e.stopPropagation()}
>
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
<div
ref={containerRef}
className="absolute inset-0 z-[60]"
onClick={(e) => e.stopPropagation()}
>
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
{/* Toolbar */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]">
{/* Colors */}
<div className="flex items-center justify-center flex-wrap gap-1.5">
{COLORS.map(c => (
<button
key={c}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
style={{
backgroundColor: c,
borderColor: color === c ? 'white' : 'transparent',
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
}}
onClick={() => setColor(c)}
/>
))}
</div>
{/* Toolbar */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]">
{/* Colors */}
<div className="flex items-center justify-center flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
style={{
backgroundColor: c,
borderColor: color === c ? 'white' : 'transparent',
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
}}
onClick={() => setColor(c)}
/>
))}
</div>
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
{/* Brush size */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.max(MIN_WIDTH, w - 1))}>
<Minus className="h-3 w-3" />
</Button>
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.min(MAX_WIDTH, w + 1))}>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Brush size */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setWidth((w) => Math.max(MIN_WIDTH, w - 1))}
>
<Minus className="h-3 w-3" />
</Button>
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setWidth((w) => Math.min(MAX_WIDTH, w + 1))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
{/* Actions */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleUndo} disabled={strokes.length === 0} title="Undo">
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:bg-destructive/10" onClick={handleClear} disabled={strokes.length === 0} title="Clear all">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleUndo}
disabled={strokes.length === 0}
title="Undo"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={handleClear}
disabled={strokes.length === 0}
title="Clear all"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
{/* Close */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel} title="Close annotation tool">
<X className="h-4 w-4" />
</Button>
</div>
{/* Close */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onCancel}
title="Close annotation tool"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
});
}
);
+15 -11
View File
@@ -1,8 +1,8 @@
"use client";
'use client';
import React, { Component, ErrorInfo, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Film } from "lucide-react";
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Film } from 'lucide-react';
interface Props {
children: ReactNode;
@@ -38,7 +38,11 @@ export class ErrorBoundary extends Component<Props, State> {
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(`ErrorBoundary${this.props.context ? ` [${this.props.context}]` : ""} caught an error:`, error, errorInfo);
console.error(
`ErrorBoundary${this.props.context ? ` [${this.props.context}]` : ''} caught an error:`,
error,
errorInfo
);
this.props.onError?.(error, errorInfo);
}
@@ -78,7 +82,7 @@ interface ErrorFallbackProps {
}
function ErrorFallback({ error, context, onReset, onReload }: ErrorFallbackProps) {
const isVideoContext = context?.toLowerCase().includes("video");
const isVideoContext = context?.toLowerCase().includes('video');
return (
<div className="flex min-h-[300px] flex-col items-center justify-center gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center">
@@ -95,16 +99,16 @@ function ErrorFallback({ error, context, onReset, onReload }: ErrorFallbackProps
<div className="space-y-1">
<h3 className="text-lg font-semibold">
{context ? `${context} crashed` : "Something went wrong"}
{context ? `${context} crashed` : 'Something went wrong'}
</h3>
<p className="text-muted-foreground text-sm max-w-sm">
{isVideoContext
? "The video player encountered an error. Try reloading or go back to the project."
: "An unexpected error occurred. Try resetting the component or reload the page."}
? 'The video player encountered an error. Try reloading or go back to the project.'
: 'An unexpected error occurred. Try resetting the component or reload the page.'}
</p>
</div>
{process.env.NODE_ENV === "development" && error?.message && (
{process.env.NODE_ENV === 'development' && error?.message && (
<div className="rounded bg-destructive/10 px-3 py-2 text-xs text-destructive font-mono max-w-sm overflow-auto">
{error.message}
</div>
@@ -127,7 +131,7 @@ function ErrorFallback({ error, context, onReset, onReload }: ErrorFallbackProps
*/
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
errorBoundaryProps?: Omit<Props, "children">
errorBoundaryProps?: Omit<Props, 'children'>
) {
return function WithErrorBoundaryWrapper(props: P) {
return (
+11 -13
View File
@@ -1,18 +1,18 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function ExampleWrapper({ className, ...props }: React.ComponentProps<"div">) {
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",
'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({
@@ -21,23 +21,21 @@ function Example({
className,
containerClassName,
...props
}: React.ComponentProps<"div"> & {
title?: string
containerClassName?: string
}: 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",
'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 className="text-muted-foreground px-1.5 py-2 text-xs font-medium">{title}</div>
)}
<div
data-slot="example-content"
@@ -49,7 +47,7 @@ function Example({
{children}
</div>
</div>
)
);
}
export { ExampleWrapper, Example }
export { ExampleWrapper, Example };
+5 -1
View File
@@ -55,7 +55,11 @@ export function GuestGate({ children }: { children: ReactNode }) {
}}
autoFocus
/>
<Button className="w-full" disabled={!guestName.trim() || guestName.trim().length > 100} onClick={confirm}>
<Button
className="w-full"
disabled={!guestName.trim() || guestName.trim().length > 100}
onClick={confirm}
>
Continue
</Button>
</div>
+2 -9
View File
@@ -1,11 +1,6 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
interface ShortcutItem {
keys: string[];
@@ -20,9 +15,7 @@ interface ShortcutGroup {
const shortcutGroups: ShortcutGroup[] = [
{
title: 'Navigation',
shortcuts: [
{ keys: ['Ctrl', 'K'], description: 'Open search' },
],
shortcuts: [{ keys: ['Ctrl', 'K'], description: 'Open search' }],
},
{
title: 'Playback',
+53 -42
View File
@@ -26,18 +26,24 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ThemeToggle } from '@/components/theme-toggle';
import { cn } from '@/lib/utils';
const KeyboardShortcutsModal = dynamic(
() => import('@/components/keyboard-shortcuts-modal').then(mod => mod.KeyboardShortcutsModal),
() => import('@/components/keyboard-shortcuts-modal').then((mod) => mod.KeyboardShortcutsModal),
{ ssr: false }
);
const SearchModal = dynamic(
() => import('@/components/search-modal').then(mod => mod.SearchModal),
() => import('@/components/search-modal').then((mod) => mod.SearchModal),
{ ssr: false }
);
@@ -84,7 +90,8 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
}, [showAppNavigation, user]);
// Hide header on video player pages — they use full viewport with their own back button
const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/');
const isVideoPage =
/\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/');
if (isVideoPage) return null;
return (
@@ -100,23 +107,26 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<SheetDescription className="sr-only">Access your projects and workspaces</SheetDescription>
<SheetDescription className="sr-only">
Access your projects and workspaces
</SheetDescription>
<nav className="flex flex-col gap-2 mt-10">
{showAppNavigation && 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>
))}
{showAppNavigation &&
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>
))}
{user?.isAdmin && (
<Link
href="/admin"
@@ -143,21 +153,20 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{showAppNavigation && 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>
))}
{showAppNavigation &&
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>
))}
{user?.isAdmin && (
<Link
href="/admin"
@@ -207,7 +216,13 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
</Button>
)}
{user && showAppNavigation && (
<Button asChild variant="ghost" size="icon" className="sm:hidden" aria-label="Feedback and reviews">
<Button
asChild
variant="ghost"
size="icon"
className="sm:hidden"
aria-label="Feedback and reviews"
>
<Link href="/feedback">
<MessageSquareQuote className="h-4 w-4" />
</Link>
@@ -222,9 +237,7 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
<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>
<AvatarFallback>{user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
@@ -237,9 +250,7 @@ export function Header({ user, showAppNavigation = false }: HeaderProps) {
<div className="flex flex-col space-y-1 leading-none">
{user.name && <p className="font-medium">{user.name}</p>}
{user.email && (
<p className="truncate text-sm text-muted-foreground">
{user.email}
</p>
<p className="truncate text-sm text-muted-foreground">{user.email}</p>
)}
</div>
</div>
+25 -25
View File
@@ -3,31 +3,31 @@ import React from 'react';
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
export function Linkify({ children }: { children: React.ReactNode }) {
if (typeof children !== 'string') {
return <>{children}</>;
}
if (typeof children !== 'string') {
return <>{children}</>;
}
const parts = children.split(URL_REGEX);
const parts = children.split(URL_REGEX);
return (
<>
{parts.map((part, i) => {
if (part.match(URL_REGEX)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
return <React.Fragment key={i}>{part}</React.Fragment>;
})}
</>
);
return (
<>
{parts.map((part, i) => {
if (part.match(URL_REGEX)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
return <React.Fragment key={i}>{part}</React.Fragment>;
})}
</>
);
}
+25 -14
View File
@@ -151,7 +151,9 @@ export function MembersManagementPage({
}
if (data.user) {
setSuccess(`Invited ${data.user.name || data.user.email || inviteEmail} as ${inviteRole.toLowerCase()}`);
setSuccess(
`Invited ${data.user.name || data.user.email || inviteEmail} as ${inviteRole.toLowerCase()}`
);
} else {
setSuccess(data.message || `Invitation sent to ${inviteEmail}`);
}
@@ -267,7 +269,9 @@ export function MembersManagementPage({
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
<Label htmlFor="email" className="mb-2 block">
Email Address
</Label>
<Input
id="email"
type="email"
@@ -280,7 +284,10 @@ export function MembersManagementPage({
</div>
<div className="w-full sm:w-40">
<Label className="mb-2 block">Role</Label>
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
<Select
value={inviteRole}
onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
@@ -341,11 +348,16 @@ export function MembersManagementPage({
)}
{members.map((member) => (
<div key={member.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border">
<div
key={member.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={member.user.image ?? undefined} />
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
<AvatarFallback>
{member.user.name?.charAt(0).toUpperCase() ?? 'U'}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
@@ -353,10 +365,7 @@ export function MembersManagementPage({
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<Select
value={member.role}
onValueChange={(v) => handleRoleChange(member.id, v)}
>
<Select value={member.role} onValueChange={(v) => handleRoleChange(member.id, v)}>
<SelectTrigger className="w-full sm:w-36 h-8">
<SelectValue />
</SelectTrigger>
@@ -401,17 +410,19 @@ export function MembersManagementPage({
<Clock3 className="h-5 w-5" />
Pending Invitations
</CardTitle>
<CardDescription>
Invitations that were sent but not accepted yet.
</CardDescription>
<CardDescription>Invitations that were sent but not accepted yet.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingInvitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 rounded-lg border">
<div
key={invitation.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 rounded-lg border"
>
<div>
<p className="text-sm font-medium">{invitation.email}</p>
<p className="text-xs text-muted-foreground">
{invitation.role === 'ADMIN' ? 'Admin' : 'Commentator'} · Sent by {invitation.invitedBy.name || invitation.invitedBy.email || 'Unknown'}
{invitation.role === 'ADMIN' ? 'Admin' : 'Commentator'} · Sent by{' '}
{invitation.invitedBy.name || invitation.invitedBy.email || 'Unknown'}
</p>
<p className="text-xs text-muted-foreground">
Expires {new Date(invitation.expiresAt).toLocaleString()}
+102 -83
View File
@@ -230,96 +230,115 @@ export function SearchModal({ open, onOpenChange }: SearchModalProps) {
</VisuallyHidden>
{/* Single flex-col wrapper keeps the grid from producing a stray gap row */}
<div className="flex flex-col">
{/* Search input */}
<div className="flex items-center border-b px-4">
<Search className="h-5 w-5 shrink-0 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={handleChange}
placeholder="Search projects, workspaces, videos…"
className="border-0 bg-transparent dark:bg-transparent shadow-none focus-visible:ring-0 h-14 text-base px-0"
/>
{loading && (
<Loader2 className="h-4 w-4 shrink-0 text-muted-foreground animate-spin ml-3" />
)}
</div>
{/* Search input */}
<div className="flex items-center border-b px-4">
<Search className="h-5 w-5 shrink-0 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={handleChange}
placeholder="Search projects, workspaces, videos…"
className="border-0 bg-transparent dark:bg-transparent shadow-none focus-visible:ring-0 h-14 text-base px-0"
/>
{loading && <Loader2 className="h-4 w-4 shrink-0 text-muted-foreground animate-spin ml-3" />}
</div>
{/* Results */}
<div className="h-[520px] overflow-y-auto">
{showInitial && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
Type at least 2 characters to search.
</p>
)}
{/* Results */}
<div className="h-[520px] overflow-y-auto">
{showInitial && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
Type at least 2 characters to search.
</p>
)}
{isEmpty && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
No results for &ldquo;{query}&rdquo;
</p>
)}
{isEmpty && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
No results for &ldquo;{query}&rdquo;
</p>
)}
{sections.map(({ kind, items, startIdx }) => {
const Icon = CategoryIcon[kind];
return (
<div key={kind}>
<div className="px-3 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{CATEGORY_LABELS[kind]}
</span>
</div>
{items.map((item, localIdx) => {
const globalIdx = startIdx + localIdx;
const label = getItemLabel(item);
const sub = getItemSub(item);
const isActive = globalIdx === activeIdx;
{sections.map(({ kind, items, startIdx }) => {
const Icon = CategoryIcon[kind];
return (
<div key={kind}>
<div className="px-3 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{CATEGORY_LABELS[kind]}
</span>
</div>
{items.map((item, localIdx) => {
const globalIdx = startIdx + localIdx;
const label = getItemLabel(item);
const sub = getItemSub(item);
const isActive = globalIdx === activeIdx;
return (
<button
key={`${kind}-${item.kind === 'project' ? item.data.id : item.kind === 'workspace' ? item.data.id : item.data.id}`}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
)}
onMouseEnter={() => setActiveIdx(globalIdx)}
onClick={() => handleSelect(item)}
>
<Icon className={cn('h-4 w-4 shrink-0', isActive ? 'text-accent-foreground/70' : 'text-muted-foreground')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{label}</p>
{sub && (
<p className={cn('text-xs truncate', isActive ? 'text-accent-foreground/60' : 'text-muted-foreground')}>{sub}</p>
return (
<button
key={`${kind}-${item.kind === 'project' ? item.data.id : item.kind === 'workspace' ? item.data.id : item.data.id}`}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
)}
</div>
</button>
);
})}
</div>
);
})}
onMouseEnter={() => setActiveIdx(globalIdx)}
onClick={() => handleSelect(item)}
>
<Icon
className={cn(
'h-4 w-4 shrink-0',
isActive ? 'text-accent-foreground/70' : 'text-muted-foreground'
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{label}</p>
{sub && (
<p
className={cn(
'text-xs truncate',
isActive ? 'text-accent-foreground/60' : 'text-muted-foreground'
)}
>
{sub}
</p>
)}
</div>
</button>
);
})}
</div>
);
})}
{sections.length > 0 && (
<div className="h-2" />
)}
{sections.length > 0 && <div className="h-2" />}
</div>
{/* Footer hint */}
<div className="border-t px-4 py-2.5 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">
</kbd>
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">
</kbd>
navigate
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">
</kbd>
open
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">
Esc
</kbd>
close
</span>
</div>
</div>
{/* Footer hint */}
<div className="border-t px-4 py-2.5 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
navigate
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
open
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">Esc</kbd>
close
</span>
</div>
</div>{/* end flex-col wrapper */}
{/* end flex-col wrapper */}
</DialogContent>
</Dialog>
);
+4 -1
View File
@@ -32,7 +32,10 @@ export function ShareLinkBootstrap({ videoId, shareToken }: ShareLinkBootstrapPr
return;
}
const payload = (await response.json().catch(() => null)) as { requiresPassword?: boolean; error?: string } | null;
const payload = (await response.json().catch(() => null)) as {
requiresPassword?: boolean;
error?: string;
} | null;
if (payload?.requiresPassword) {
router.replace(`/watch/${videoId}?unlock=1`);
return;
+8 -2
View File
@@ -53,7 +53,9 @@ export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) {
<Lock className="h-6 w-6 text-primary" />
</div>
<h1 className="text-xl font-semibold">Password Required</h1>
<p className="text-sm text-muted-foreground mt-1">Enter the password to continue to the shared video.</p>
<p className="text-sm text-muted-foreground mt-1">
Enter the password to continue to the shared video.
</p>
</div>
<div className="space-y-3">
@@ -79,7 +81,11 @@ export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) {
</div>
<p className="text-xs text-muted-foreground text-center mt-4">
Or <Link href="/login" className="underline hover:text-foreground">sign in</Link> with your account
Or{' '}
<Link href="/login" className="underline hover:text-foreground">
sign in
</Link>{' '}
with your account
</p>
</div>
</div>
+60 -62
View File
@@ -1,31 +1,23 @@
"use client"
'use client';
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import * as React from 'react';
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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 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} />
)
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 AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
@@ -35,18 +27,21 @@ function AlertDialogOverlay({
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)}
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",
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
size?: 'default' | 'sm';
}) {
return (
<AlertDialogPortal>
@@ -55,55 +50,52 @@ function AlertDialogContent({
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",
'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">) {
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",
'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 AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
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)}
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({
@@ -113,10 +105,13 @@ function AlertDialogTitle({
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)}
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({
@@ -126,19 +121,22 @@ function AlertDialogDescription({
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)}
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",
variant = 'default',
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
@@ -147,16 +145,16 @@ function AlertDialogAction({
{...props}
/>
</Button>
)
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
variant = 'outline',
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
@@ -165,7 +163,7 @@ function AlertDialogCancel({
{...props}
/>
</Button>
)
);
}
export {
@@ -181,4 +179,4 @@ export {
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
};
+29 -42
View File
@@ -1,44 +1,38 @@
"use client"
'use client';
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import * as React from 'react';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Avatar({
className,
size = "default",
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
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",
'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>) {
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
)}
className={cn('rounded-full aspect-square size-full object-cover', className)}
{...props}
/>
)
);
}
function AvatarFallback({
@@ -49,61 +43,54 @@ function AvatarFallback({
<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",
'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">) {
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",
'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">) {
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",
'*: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">) {
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)}
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,
}
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
+19 -19
View File
@@ -1,36 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils"
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",
'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",
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",
variant: 'default',
},
}
)
);
function Badge({
className,
variant = "default",
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (
<Comp
@@ -39,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+31 -26
View File
@@ -1,50 +1,55 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils"
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",
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",
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",
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",
variant: 'default',
size: 'default',
},
}
)
);
function Button({
className,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
@@ -54,7 +59,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+42 -47
View File
@@ -1,94 +1,89 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Card({
className,
size = "default",
size = 'default',
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
}: 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)}
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">) {
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]",
'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">) {
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)}
className={cn('text-sm font-medium group-data-[size=sm]/card:text-sm', className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
className={cn('text-muted-foreground text-xs/relaxed', className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
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(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
'rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
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,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+39 -48
View File
@@ -1,34 +1,26 @@
"use client"
'use client';
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
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"
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 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 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 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 DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -38,10 +30,13 @@ function DialogOverlay({
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)}
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({
@@ -50,7 +45,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -58,7 +53,7 @@ function DialogContent({
<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",
'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}
@@ -67,25 +62,24 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn("gap-1 text-left flex flex-col", className)}
className={cn('gap-1 text-left flex flex-col', className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -93,16 +87,13 @@ function DialogFooter({
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}: 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
)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
@@ -112,20 +103,17 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-sm font-medium", className)}
className={cn('text-sm font-medium', className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -135,10 +123,13 @@ function DialogDescription({
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)}
className={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...props}
/>
)
);
}
export {
@@ -152,4 +143,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+49 -65
View File
@@ -1,39 +1,30 @@
"use client"
'use client';
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
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 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} />
)
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}
/>
)
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
align = "start",
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
@@ -43,29 +34,28 @@ function DropdownMenuContent({
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 )}
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 DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
@@ -78,7 +68,7 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -88,7 +78,7 @@ function DropdownMenuCheckboxItem({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
@@ -106,24 +96,18 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
<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}
/>
)
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
@@ -132,7 +116,7 @@ function DropdownMenuRadioItem({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.RadioItem
@@ -149,13 +133,12 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -163,16 +146,16 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
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)}
className={cn('text-muted-foreground px-2 py-2 text-xs data-inset:pl-7', className)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -182,29 +165,27 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 h-px", className)}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
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)}
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 DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -213,7 +194,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -228,7 +209,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -238,10 +219,13 @@ function DropdownMenuSubContent({
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 )}
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 {
@@ -260,4 +244,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};
+84 -87
View File
@@ -1,71 +1,76 @@
"use client"
'use client';
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
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"
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
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",
'gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col',
className
)}
{...props}
/>
)
);
}
const fieldVariants = cva("data-[invalid=true]:text-destructive gap-2 group/field flex w-full", {
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",
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",
'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",
'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",
orientation: 'vertical',
},
})
});
function Field({
className,
orientation = "vertical",
orientation = 'vertical',
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
@@ -74,79 +79,76 @@ function Field({
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
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
)}
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>) {
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",
'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">) {
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",
'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">) {
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",
'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
}: 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)}
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" />
@@ -159,7 +161,7 @@ function FieldSeparator({
</span>
)}
</div>
)
);
}
function FieldError({
@@ -167,50 +169,45 @@ function FieldError({
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children
return children;
}
if (!errors?.length) {
return null
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
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>
)}
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
)
}, [children, errors])
);
}, [children, errors]);
if (!content) {
return null
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-xs font-normal", className)}
className={cn('text-destructive text-xs font-normal', className)}
{...props}
>
{content}
</div>
)
);
}
export {
@@ -224,4 +221,4 @@ export {
FieldSet,
FieldContent,
FieldTitle,
}
};
+56 -59
View File
@@ -1,25 +1,25 @@
"use client"
'use client';
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
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"
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">) {
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",
'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(
@@ -27,25 +27,25 @@ const inputGroupAddonVariants = cva(
{
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",
'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",
align: 'inline-start',
},
}
)
);
function InputGroupAddon({
className,
align = "inline-start",
align = 'inline-start',
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
@@ -53,40 +53,37 @@ function InputGroupAddon({
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
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",
},
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",
},
}
)
},
defaultVariants: {
size: 'xs',
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
@@ -96,10 +93,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
@@ -108,33 +105,33 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
)}
{...props}
/>
)
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
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)}
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">) {
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)}
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 {
@@ -144,4 +141,4 @@ export {
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
};
+6 -6
View File
@@ -1,19 +1,19 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
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",
'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 }
export { Input };
+8 -11
View File
@@ -1,24 +1,21 @@
"use client"
'use client';
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import * as React from 'react';
import { Label as LabelPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
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",
'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 }
export { Label };
+1 -4
View File
@@ -12,10 +12,7 @@ function Progress({
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${clamped}%` }}
/>
<div className="h-full bg-primary transition-all" style={{ width: `${clamped}%` }} />
</div>
);
}
+10 -10
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import * as React from 'react';
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { CircleIcon } from "lucide-react"
import { cn } from '@/lib/utils';
import { CircleIcon } from 'lucide-react';
function RadioGroup({
className,
@@ -13,10 +13,10 @@ function RadioGroup({
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-2 w-full", className)}
className={cn('grid gap-2 w-full', className)}
{...props}
/>
)
);
}
function RadioGroupItem({
@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary 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 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
'border-input text-primary 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 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@@ -39,7 +39,7 @@ function RadioGroupItem({
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
);
}
export { RadioGroup, RadioGroupItem }
export { RadioGroup, RadioGroupItem };
+46 -47
View File
@@ -1,43 +1,36 @@
"use client"
'use client';
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import * as React from 'react';
import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
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 Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
className={cn('scroll-my-1', className)}
{...props}
/>
)
);
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
@@ -54,22 +47,27 @@ function SelectTrigger({
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
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 )}
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}
@@ -78,8 +76,8 @@ function SelectContent({
<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" && ""
'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}
@@ -87,20 +85,17 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
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)}
className={cn('text-muted-foreground px-2 py-2 text-xs', className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -124,7 +119,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -134,10 +129,10 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 h-px pointer-events-none", className)}
className={cn('bg-border -mx-1 h-px pointer-events-none', className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -147,13 +142,15 @@ function SelectScrollUpButton({
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)}
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -163,13 +160,15 @@ function SelectScrollDownButton({
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)}
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -183,4 +182,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+8 -8
View File
@@ -1,13 +1,13 @@
"use client"
'use client';
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from 'react';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = "horizontal",
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function 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",
'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+39 -43
View File
@@ -1,32 +1,26 @@
"use client"
'use client';
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
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"
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} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...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 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 SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -36,21 +30,24 @@ function SheetOverlay({
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)}
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",
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
@@ -58,55 +55,54 @@ function SheetContent({
<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)}
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
/>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn("gap-0.5 p-4 flex flex-col", className)}
className={cn('gap-0.5 p-4 flex flex-col', className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
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)}
className={cn('gap-2 p-4 mt-auto flex flex-col', className)}
{...props}
/>
)
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
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)}
className={cn('text-foreground text-sm font-medium', className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -116,10 +112,10 @@ function SheetDescription({
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
className={cn('text-muted-foreground text-xs/relaxed', className)}
{...props}
/>
)
);
}
export {
@@ -131,4 +127,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
+5 -5
View File
@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
className={cn('bg-muted animate-pulse rounded-md', className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
+33 -45
View File
@@ -1,101 +1,89 @@
"use client"
'use client';
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-xs", className)}
className={cn('w-full caption-bottom text-xs', className)}
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
className={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-xs", className)}
className={cn('text-muted-foreground mt-4 text-xs', className)}
{...props}
/>
)
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+25 -35
View File
@@ -1,50 +1,46 @@
"use client"
'use client';
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Tabs as TabsPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Tabs({
className,
orientation = "horizontal",
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"gap-2 group/tabs flex data-horizontal:flex-col",
className
)}
className={cn('gap-2 group/tabs flex data-horizontal:flex-col', className)}
{...props}
/>
)
);
}
const tabsListVariants = cva(
"rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
'rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
function TabsList({
className,
variant = "default",
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
@@ -52,39 +48,33 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className
)}
{...props}
/>
)
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("text-xs/relaxed flex-1 outline-none", className)}
className={cn('text-xs/relaxed flex-1 outline-none', className)}
{...props}
/>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+6 -6
View File
@@ -1,18 +1,18 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
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",
'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 }
export { Textarea };
+12 -16
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
@@ -15,19 +15,15 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...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 TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -42,7 +38,7 @@ function TooltipContent({
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)",
'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}
@@ -51,7 +47,7 @@ function TooltipContent({
<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 }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+37 -25
View File
@@ -46,7 +46,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import {
parseVideoUrl,
fetchVideoMetadata,
getThumbnailUrl,
type VideoSource,
} from '@/lib/video-providers';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
interface VideoCardProps {
@@ -210,30 +215,32 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
{imgError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<span className="text-xs text-muted-foreground font-medium">Processing thumbnail...</span>
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
<span className="text-xs text-muted-foreground font-medium">
Processing thumbnail...
</span>
<span className="text-[11px] text-muted-foreground/90">
Video may already be playable
</span>
</div>
) : resolvedThumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={() => {
setImgError(true);
// Check again after 10 seconds in case Bunny is still processing
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
) : (
resolvedThumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
onError={() => {
setImgError(true);
// Check again after 10 seconds in case Bunny is still processing
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 text-xs text-muted-foreground font-medium">
Thumbnail unavailable
</div>
)
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 text-xs text-muted-foreground font-medium">
Thumbnail unavailable
</div>
)}
{!imgError && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
@@ -348,7 +355,11 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
{editError}
</p>
)}
<Button onClick={handleEdit} disabled={!editTitle.trim() || isSaving} className="w-full">
<Button
onClick={handleEdit}
disabled={!editTitle.trim() || isSaving}
className="w-full"
>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Changes
</Button>
@@ -420,7 +431,8 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{video.title}&quot;?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this video, all its versions, and all comments. This action cannot be undone.
This will permanently delete this video, all its versions, and all comments. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+170 -141
View File
@@ -81,10 +81,16 @@ export function VideoDragDropUploader({
const [uploadProgress, setUploadProgress] = useState(0);
const [showCancelUploadDialog, setShowCancelUploadDialog] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(fixedProjectId ?? null);
const [selectedProjectName, setSelectedProjectName] = useState<string | null>(fixedProjectName ?? null);
const [selectedProjectName, setSelectedProjectName] = useState<string | null>(
fixedProjectName ?? null
);
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
const pendingUploadRef = useRef<{ projectId: string; videoId: string; uploadToken: string } | null>(null);
const pendingUploadRef = useRef<{
projectId: string;
videoId: string;
uploadToken: string;
} | null>(null);
const cancelRequestedRef = useRef(false);
const dragDepthRef = useRef(0);
const hasLoadedProjectsRef = useRef(false);
@@ -216,160 +222,170 @@ export function VideoDragDropUploader({
toast.info('Upload cancelled');
}, [cleanupUploadState, isUploading]);
const uploadFileToProject = useCallback(async (file: File, projectId: string, projectName?: string) => {
setDialogOpen(true);
cancelRequestedRef.current = false;
setIsUploading(true);
setUploadStatus('Initializing upload...');
setUploadProgress(0);
setSelectedProjectId(projectId);
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
const uploadFileToProject = useCallback(
async (file: File, projectId: string, projectName?: string) => {
setDialogOpen(true);
cancelRequestedRef.current = false;
setIsUploading(true);
setUploadStatus('Initializing upload...');
setUploadProgress(0);
setSelectedProjectId(projectId);
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
let createdVideoId: string | null = null;
let uploadToken: string | null = null;
let createdVideoId: string | null = null;
let uploadToken: string | null = null;
try {
const title = getDefaultTitleFromFile(file);
try {
const title = getDefaultTitleFromFile(file);
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const initPayload = (await initResponse.json().catch(() => null)) as {
data?: {
videoId: string;
libraryId: string;
signature: string;
expirationTime: number;
uploadToken: string;
};
error?: string;
} | null;
if (!initResponse.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize upload');
}
createdVideoId = initPayload.data.videoId;
uploadToken = initPayload.data.uploadToken;
pendingUploadRef.current = {
projectId,
videoId: createdVideoId,
uploadToken,
};
const { Upload } = await import('tus-js-client');
await new Promise<void>((resolve, reject) => {
const upload = new Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: initPayload.data!.signature,
AuthorizationExpire: initPayload.data!.expirationTime.toString(),
VideoId: initPayload.data!.videoId,
LibraryId: initPayload.data!.libraryId,
},
metadata: {
filetype: file.type,
title,
},
onError: (error) => {
activeTusUploadRef.current = null;
reject(new Error(error.message));
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1));
setUploadProgress(percentage);
setUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
activeTusUploadRef.current = null;
resolve();
},
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
activeTusUploadRef.current = upload;
upload.start();
});
const initPayload = (await initResponse.json().catch(() => null)) as {
data?: {
videoId: string;
libraryId: string;
signature: string;
expirationTime: number;
uploadToken: string;
};
error?: string;
} | null;
setUploadStatus('Saving video...');
if (!initResponse.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize upload');
}
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: null,
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
providerId: 'bunny',
videoId: initPayload.data.videoId,
thumbnailUrl: bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initPayload.data.videoId}/thumbnail.jpg`
: null,
duration: null,
createdVideoId = initPayload.data.videoId;
uploadToken = initPayload.data.uploadToken;
pendingUploadRef.current = {
projectId,
videoId: createdVideoId,
uploadToken,
}),
});
};
const createPayload = (await createResponse.json().catch(() => null)) as { error?: string } | null;
const { Upload } = await import('tus-js-client');
await new Promise<void>((resolve, reject) => {
const upload = new Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: initPayload.data!.signature,
AuthorizationExpire: initPayload.data!.expirationTime.toString(),
VideoId: initPayload.data!.videoId,
LibraryId: initPayload.data!.libraryId,
},
metadata: {
filetype: file.type,
title,
},
onError: (error) => {
activeTusUploadRef.current = null;
reject(new Error(error.message));
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1));
setUploadProgress(percentage);
setUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
activeTusUploadRef.current = null;
resolve();
},
});
if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video');
}
activeTusUploadRef.current = upload;
upload.start();
});
toast.success(`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`);
setDialogOpen(false);
setDroppedFile(null);
cleanupUploadState();
router.push(`/projects/${projectId}`);
router.refresh();
} catch (error) {
console.error('Drag-drop upload failed:', error);
setUploadStatus('Saving video...');
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: null,
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
providerId: 'bunny',
videoId: initPayload.data.videoId,
thumbnailUrl: bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initPayload.data.videoId}/thumbnail.jpg`
: null,
duration: null,
uploadToken,
}),
});
const createPayload = (await createResponse.json().catch(() => null)) as {
error?: string;
} | null;
if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video');
}
toast.success(
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
);
setDialogOpen(false);
setDroppedFile(null);
cleanupUploadState();
router.push(`/projects/${projectId}`);
router.refresh();
} catch (error) {
console.error('Drag-drop upload failed:', error);
if (cancelRequestedRef.current) {
setUploadStatus('');
setUploadProgress(0);
setIsUploading(false);
return;
}
if (createdVideoId && uploadToken) {
try {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
});
} catch (cleanupError) {
console.error('Failed to cleanup pending upload:', cleanupError);
}
}
if (cancelRequestedRef.current) {
setUploadStatus('');
setUploadProgress(0);
setIsUploading(false);
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
}
},
[bunnyCdnHostname, cleanupUploadState, projectsById, router]
);
const handleDropFile = useCallback(
(file: File) => {
if (!canUpload) {
toast.error('You do not have permission to upload videos here');
return;
}
if (createdVideoId && uploadToken) {
try {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
});
} catch (cleanupError) {
console.error('Failed to cleanup pending upload:', cleanupError);
}
setDroppedFile(file);
if (fixedProjectId) {
void uploadFileToProject(file, fixedProjectId, fixedProjectName);
return;
}
setUploadStatus('');
setUploadProgress(0);
setIsUploading(false);
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
}
}, [bunnyCdnHostname, cleanupUploadState, projectsById, router]);
const handleDropFile = useCallback((file: File) => {
if (!canUpload) {
toast.error('You do not have permission to upload videos here');
return;
}
setDroppedFile(file);
if (fixedProjectId) {
void uploadFileToProject(file, fixedProjectId, fixedProjectName);
return;
}
setDialogOpen(true);
void ensureProjectsLoaded();
}, [canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadFileToProject]);
setDialogOpen(true);
void ensureProjectsLoaded();
},
[canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadFileToProject]
);
useEffect(() => {
const handleDragEnter = (event: DragEvent) => {
@@ -498,8 +514,12 @@ export function VideoDragDropUploader({
void uploadFileToProject(droppedFile, project.id, project.name);
}}
>
<p className="text-2xl font-semibold leading-tight text-foreground">{project.name}</p>
<p className="mt-1 text-sm text-muted-foreground">{project.description || 'Project'}</p>
<p className="text-2xl font-semibold leading-tight text-foreground">
{project.name}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{project.description || 'Project'}
</p>
</button>
))}
</div>
@@ -511,7 +531,12 @@ export function VideoDragDropUploader({
{(selectedProjectId || fixedProjectId) && (
<p className="text-sm text-muted-foreground">
Target: <span className="font-medium text-foreground">{selectedProjectName ?? fixedProjectName ?? projectsById.get(selectedProjectId ?? '')}</span>
Target:{' '}
<span className="font-medium text-foreground">
{selectedProjectName ??
fixedProjectName ??
projectsById.get(selectedProjectId ?? '')}
</span>
</p>
)}
@@ -523,7 +548,10 @@ export function VideoDragDropUploader({
</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="h-2 w-full rounded-full bg-secondary">
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: `${uploadProgress}%` }} />
<div
className="h-2 rounded-full bg-primary transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
@@ -543,7 +571,8 @@ export function VideoDragDropUploader({
<AlertDialogHeader>
<AlertDialogTitle>Cancel upload?</AlertDialogTitle>
<AlertDialogDescription>
A video upload is in progress. If you cancel now, the current upload will be discarded.
A video upload is in progress. If you cancel now, the current upload will be
discarded.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+84 -53
View File
@@ -50,7 +50,10 @@ function formatTime(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, index: number): string {
function formatBunnyQualityLabel(
level: { height?: number; bitrate?: number },
index: number
): string {
if (typeof level.height === 'number' && level.height > 0) {
return `${level.height}p`;
}
@@ -85,12 +88,9 @@ export function VideoPageContent({
const timelineRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const scheduleWatchProgressSaveRef = useRef<(input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => void>(() => {});
const scheduleWatchProgressSaveRef = useRef<
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void
>(() => {});
const {
playingVoiceId,
@@ -208,13 +208,16 @@ export function VideoPageContent({
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
// Memoize version selection handler to prevent recreating on each render
const handleVersionSelect = useCallback((versionId: string) => {
setActiveVersionId(versionId);
}, [setActiveVersionId]);
const handleVersionSelect = useCallback(
(versionId: string) => {
setActiveVersionId(versionId);
},
[setActiveVersionId]
);
// Memoize toggle show resolved handler
const handleToggleShowResolved = useCallback(() => {
setShowResolved(prev => !prev);
setShowResolved((prev) => !prev);
}, []);
const handleAssetMentionClick = useCallback((assetId: string) => {
@@ -258,9 +261,11 @@ export function VideoPageContent({
// Memoize active version lookup to avoid recalculating on every render
const activeVersion = useMemo(() => {
return video?.versions?.find((v) => v.id === activeVersionId) ||
return (
video?.versions?.find((v) => v.id === activeVersionId) ||
video?.versions?.find((v) => v.isActive) ||
video?.versions?.[0];
video?.versions?.[0]
);
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const activeVersionDuration = activeVersion?.duration;
@@ -526,11 +531,15 @@ export function VideoPageContent({
});
const containerHeight = 'h-screen';
const backHref = mode === 'dashboard'
? `/projects/${propProjectId}`
: (video?.projectId ? `/projects/${video.projectId}` : '/');
const backHref =
mode === 'dashboard'
? `/projects/${propProjectId}`
: video?.projectId
? `/projects/${video.projectId}`
: '/';
const isBunnyVersion = activeVersion?.providerId === 'bunny';
const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
const showBunnyProcessingOverlay =
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
const confirmGuestName = useCallback(() => {
@@ -592,34 +601,60 @@ export function VideoPageContent({
}
}, []);
const headerActions: VideoPageHeaderActions = useMemo(() => ({
onVersionSelect: handleVersionSelect,
onDeleteCurrentVersionClick: handleDeleteCurrentVersionClick,
onDownload: startDownload,
onOpenCompare: handleOpenCompare,
onCreateVersion: handleCreateVersion,
}), [handleVersionSelect, handleDeleteCurrentVersionClick, startDownload, handleOpenCompare, handleCreateVersion]);
const headerActions: VideoPageHeaderActions = useMemo(
() => ({
onVersionSelect: handleVersionSelect,
onDeleteCurrentVersionClick: handleDeleteCurrentVersionClick,
onDownload: startDownload,
onOpenCompare: handleOpenCompare,
onCreateVersion: handleCreateVersion,
}),
[
handleVersionSelect,
handleDeleteCurrentVersionClick,
startDownload,
handleOpenCompare,
handleCreateVersion,
]
);
const commentsActions: VideoPageCommentsActions = useMemo(() => ({
onExportComments: exportComments,
onResolveComment: handleResolveComment,
onEditComment: handleEditComment,
onDeleteComment: handleDeleteComment,
onReplyComment: handleReplyComment,
onSubmitReplyWithMedia: submitReplyWithMedia,
onStartEditAnnotation: handleStartEditAnnotation,
}), [exportComments, handleResolveComment, handleEditComment, handleDeleteComment, handleReplyComment, submitReplyWithMedia, handleStartEditAnnotation]);
const commentsActions: VideoPageCommentsActions = useMemo(
() => ({
onExportComments: exportComments,
onResolveComment: handleResolveComment,
onEditComment: handleEditComment,
onDeleteComment: handleDeleteComment,
onReplyComment: handleReplyComment,
onSubmitReplyWithMedia: submitReplyWithMedia,
onStartEditAnnotation: handleStartEditAnnotation,
}),
[
exportComments,
handleResolveComment,
handleEditComment,
handleDeleteComment,
handleReplyComment,
submitReplyWithMedia,
handleStartEditAnnotation,
]
);
const composerActions: VideoPageComposerActions = useMemo(() => ({
onSubmitCommentWithMedia: submitCommentWithMedia,
onAddComment: handleAddComment,
onPauseVideoForAnnotation: pauseVideoForAnnotation,
}), [submitCommentWithMedia, handleAddComment, pauseVideoForAnnotation]);
const composerActions: VideoPageComposerActions = useMemo(
() => ({
onSubmitCommentWithMedia: submitCommentWithMedia,
onAddComment: handleAddComment,
onPauseVideoForAnnotation: pauseVideoForAnnotation,
}),
[submitCommentWithMedia, handleAddComment, pauseVideoForAnnotation]
);
const compareActions: VideoPageCompareActions = useMemo(() => ({
onToggleVersion: toggleCompareVersion,
onCompare: handleCompareConfirm,
}), [toggleCompareVersion, handleCompareConfirm]);
const compareActions: VideoPageCompareActions = useMemo(
() => ({
onToggleVersion: toggleCompareVersion,
onCompare: handleCompareConfirm,
}),
[toggleCompareVersion, handleCompareConfirm]
);
if (loading) {
return (
@@ -662,7 +697,7 @@ export function VideoPageContent({
onMouseLeave={() => isDragging && handleTimelineMouseUp()}
>
<div className="flex-1 flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden min-h-0">
<div className={cn("flex-1 w-full flex flex-col min-h-0", isFullscreenMode && "relative")}>
<div className={cn('flex-1 w-full flex flex-col min-h-0', isFullscreenMode && 'relative')}>
<VideoPageHeader
mode={mode}
backHref={backHref}
@@ -835,7 +870,7 @@ export function VideoPageContent({
onAssetMentionClick={handleAssetMentionClick}
activePane={activeSidePane}
setActivePane={setActiveSidePane}
assetsPane={(
assetsPane={
<AssetsPane
videoId={videoId}
assets={assets}
@@ -855,8 +890,8 @@ export function VideoPageContent({
highlightedAssetId={highlightedAssetId}
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
/>
)}
composer={(
}
composer={
<CommentComposer
isRecording={isRecording}
recordingTime={recordingTime}
@@ -895,15 +930,11 @@ export function VideoPageContent({
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
assets={assets}
/>
)}
}
/>
</div>
<ImagePreviewDialog
previewImage={previewImage}
onClose={() => setPreviewImage(null)}
/>
<ImagePreviewDialog previewImage={previewImage} onClose={() => setPreviewImage(null)} />
<CompareVersionsDialog
open={showCompareDialog}
@@ -945,6 +976,6 @@ export function VideoPageContent({
/>
</>
) : null}
</div >
</div>
);
}
@@ -51,11 +51,9 @@ export function ApprovalRequestDialog({
);
const toggleApprover = (userId: string) => {
setSelectedApproverIds((current) => (
current.includes(userId)
? current.filter((id) => id !== userId)
: [...current, userId]
));
setSelectedApproverIds((current) =>
current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId]
);
};
const handleCreate = async () => {
@@ -74,9 +72,7 @@ export function ApprovalRequestDialog({
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request Approval</DialogTitle>
<DialogDescription>
Select one or more approvers for this version.
</DialogDescription>
<DialogDescription>Select one or more approvers for this version.</DialogDescription>
</DialogHeader>
{isBlockedByPendingRequest ? (
@@ -93,14 +89,23 @@ export function ApprovalRequestDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Approvers ({selectedApproverIds.length} selected)</p>
<Button size="sm" variant="ghost" onClick={onRefreshCandidates} disabled={isLoadingCandidates}>
<p className="text-xs text-muted-foreground">
Approvers ({selectedApproverIds.length} selected)
</p>
<Button
size="sm"
variant="ghost"
onClick={onRefreshCandidates}
disabled={isLoadingCandidates}
>
{isLoadingCandidates ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Refresh'}
</Button>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border p-2 space-y-1">
{selectableCandidates.length === 0 ? (
<p className="text-sm text-muted-foreground px-2 py-3">No eligible approvers found.</p>
<p className="text-sm text-muted-foreground px-2 py-3">
No eligible approvers found.
</p>
) : (
selectableCandidates.map((candidate) => {
const selected = selectedApproverIds.includes(candidate.id);
@@ -116,11 +121,17 @@ export function ApprovalRequestDialog({
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-7 w-7">
<AvatarImage src={candidate.image ?? undefined} />
<AvatarFallback>{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}</AvatarFallback>
<AvatarFallback>
{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{candidate.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground truncate">{candidate.email || 'No email'}</p>
<p className="text-sm font-medium truncate">
{candidate.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{candidate.email || 'No email'}
</p>
</div>
</div>
{selected ? (
@@ -152,7 +163,9 @@ export function ApprovalRequestDialog({
<DialogFooter>
<Button
onClick={handleCreate}
disabled={isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0}
disabled={
isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0
}
>
{isSubmittingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Request
@@ -26,21 +26,45 @@ interface ApprovalRequestsPanelProps {
isCancelingRequest: boolean;
error: string;
onRefresh: () => void;
onSubmitDecision: (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => Promise<boolean>;
onSubmitDecision: (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => Promise<boolean>;
onCancelRequest: (requestId: string) => Promise<boolean>;
}
function statusBadge(status: ApprovalRequest['status']) {
if (status === 'PENDING') {
return <Badge variant="secondary" className="gap-1"><Clock3 className="h-3 w-3" />Pending</Badge>;
return (
<Badge variant="secondary" className="gap-1">
<Clock3 className="h-3 w-3" />
Pending
</Badge>
);
}
if (status === 'APPROVED') {
return <Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600"><CheckCircle2 className="h-3 w-3" />Approved</Badge>;
return (
<Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600">
<CheckCircle2 className="h-3 w-3" />
Approved
</Badge>
);
}
if (status === 'REJECTED') {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Rejected</Badge>;
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Rejected
</Badge>
);
}
return <Badge variant="outline" className="gap-1"><ShieldX className="h-3 w-3" />Canceled</Badge>;
return (
<Badge variant="outline" className="gap-1">
<ShieldX className="h-3 w-3" />
Canceled
</Badge>
);
}
function decisionLabel(
@@ -76,18 +100,25 @@ export function ApprovalRequestsPanel({
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !pendingRequest) return null;
return pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
return (
pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null
);
}, [currentUserId, pendingRequest]);
const canCancelPendingRequest = !!pendingRequest
&& !!currentUserId
&& (pendingRequest.requestedById === currentUserId || canRequestApproval);
const canCancelPendingRequest =
!!pendingRequest &&
!!currentUserId &&
(pendingRequest.requestedById === currentUserId || canRequestApproval);
const handleDecision = async (decision: 'APPROVED' | 'REJECTED') => {
if (!pendingRequest) return;
const success = await onSubmitDecision(pendingRequest.id, decision, decisionNote.trim() || undefined);
const success = await onSubmitDecision(
pendingRequest.id,
decision,
decisionNote.trim() || undefined
);
if (success) {
setDecisionNote('');
}
@@ -98,7 +129,9 @@ export function ApprovalRequestsPanel({
<SheetContent side="right" className="w-full sm:max-w-xl p-0">
<SheetHeader>
<SheetTitle>Approvals</SheetTitle>
<SheetDescription>Review request history and respond to pending approvals.</SheetDescription>
<SheetDescription>
Review request history and respond to pending approvals.
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
@@ -114,7 +147,11 @@ export function ApprovalRequestsPanel({
Request Approval
</Button>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
{isLoadingRequests ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</Button>
</div>
</div>
@@ -129,7 +166,8 @@ export function ApprovalRequestsPanel({
<div className="rounded-md border p-3 space-y-2">
<p className="text-sm font-medium">Your response is required</p>
<p className="text-xs text-muted-foreground">
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'} requested approval.
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'}{' '}
requested approval.
</p>
<Textarea
value={decisionNote}
@@ -176,29 +214,39 @@ export function ApprovalRequestsPanel({
<div className="space-y-2">
{requests.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No approval requests yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">
No approval requests yet.
</p>
) : (
requests.map((request) => (
<div key={request.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
Requested by {request.requestedBy.name || request.requestedBy.email || 'Unknown'}
Requested by{' '}
{request.requestedBy.name || request.requestedBy.email || 'Unknown'}
</p>
{statusBadge(request.status)}
</div>
{request.message ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{request.message}</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{request.message}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{new Date(request.createdAt).toLocaleString()}
</p>
<div className="space-y-1">
{request.decisions.map((decision) => (
<div key={decision.id} className="flex items-center justify-between gap-2 text-xs">
<div
key={decision.id}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="truncate">
{decision.approver.name || decision.approver.email || 'Unknown'}
</span>
<span className="text-muted-foreground">{decisionLabel(request.status, decision.status)}</span>
<span className="text-muted-foreground">
{decisionLabel(request.status, decision.status)}
</span>
</div>
))}
</div>
+46 -14
View File
@@ -68,9 +68,10 @@ export const AssetListSection = memo(function AssetListSection({
return (
<div className="space-y-2">
{assets.map((asset) => {
const isBunnyProcessing = asset.provider === 'BUNNY'
&& !!bunnyProcessingByAssetId[asset.id]
&& !bunnyReadyByAssetId[asset.id];
const isBunnyProcessing =
asset.provider === 'BUNNY' &&
!!bunnyProcessingByAssetId[asset.id] &&
!bunnyReadyByAssetId[asset.id];
return (
<div
key={asset.id}
@@ -96,22 +97,42 @@ export const AssetListSection = memo(function AssetListSection({
</div>
</div>
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {' '}
{new Date(asset.createdAt).toLocaleDateString()}
</p>
<div className="pt-1 flex items-center gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
title={
asset.kind === 'VIDEO'
? 'Play video'
: asset.kind === 'AUDIO'
? 'Play recording'
: 'View image'
}
aria-label={
asset.kind === 'VIDEO'
? 'Play video'
: asset.kind === 'AUDIO'
? 'Play recording'
: 'View image'
}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : asset.kind === 'AUDIO' ? <Volume2 className="h-3 w-3" /> : <Play className="h-3 w-3" />}
{asset.kind === 'IMAGE' ? (
<ImageIcon className="h-3 w-3" />
) : asset.kind === 'AUDIO' ? (
<Volume2 className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
{canDownloadAssets &&
asset.provider !== 'YOUTUBE' &&
(asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -122,7 +143,11 @@ export const AssetListSection = memo(function AssetListSection({
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
@@ -146,10 +171,13 @@ export const AssetListSection = memo(function AssetListSection({
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
)
)}
))}
{asset.canDelete && (
<Button
@@ -161,7 +189,11 @@ export const AssetListSection = memo(function AssetListSection({
disabled={activeDeleteAssetId === asset.id}
onClick={() => onDeleteAsset(asset.id)}
>
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
{activeDeleteAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
)}
</div>
+441 -256
View File
@@ -3,7 +3,20 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react';
import {
Download,
FileVideo,
Image as ImageIcon,
Loader2,
Mic,
Pause,
Play,
Square,
UploadCloud,
Volume2,
X,
Youtube,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -16,10 +29,16 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog';
import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/video-page/bunny-preview-player';
import {
BunnyPreviewPlayer,
type BunnyPreviewPlayerHandle,
} from '@/components/video-page/bunny-preview-player';
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { VideoAsset } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
import {
extractPastedImageFile,
validateImageFile,
} from '@/components/video-page/image-upload-utils';
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
@@ -113,10 +132,16 @@ export const AssetsPane = memo(function AssetsPane({
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
const [bunnyProgress, setBunnyProgress] = useState(0);
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>(
{}
);
const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<
Record<string, number>
>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<
Record<string, boolean>
>({});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
@@ -145,7 +170,15 @@ export const AssetsPane = memo(function AssetsPane({
const dragCounterRef = useRef(0);
// Audio playback for asset preview dialog and recording preview
const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia();
const {
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
playVoice,
stopVoice,
toggleVoiceSpeed,
} = useCommentMedia();
const sortedAssets = useMemo(() => {
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
@@ -158,7 +191,10 @@ export const AssetsPane = memo(function AssetsPane({
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
setFocusedAssetId(highlightedAssetId);
window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500);
window.setTimeout(
() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)),
2500
);
}
onHighlightedAssetHandled();
@@ -170,11 +206,14 @@ export const AssetsPane = memo(function AssetsPane({
const sendYouTubeCommand = (func: string, args: unknown[] = []) => {
const iframe = youtubeIframeRef.current;
if (!iframe?.contentWindow) return;
iframe.contentWindow.postMessage(JSON.stringify({
event: 'command',
func,
args,
}), '*');
iframe.contentWindow.postMessage(
JSON.stringify({
event: 'command',
func,
args,
}),
'*'
);
};
const onMessage = (event: MessageEvent) => {
@@ -186,7 +225,9 @@ export const AssetsPane = memo(function AssetsPane({
} catch {
return;
}
const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info;
const info = (
parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } }
)?.info;
if (!info) return;
if (typeof info.currentTime === 'number') {
youtubePreviewStateRef.current.currentTime = info.currentTime;
@@ -202,9 +243,22 @@ export const AssetsPane = memo(function AssetsPane({
const onKeyDown = (event: KeyboardEvent) => {
if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
)
return;
const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']);
const handledKeys = new Set([
'Space',
'KeyK',
'ArrowLeft',
'ArrowRight',
'KeyJ',
'KeyL',
'KeyM',
'Escape',
]);
if (!handledKeys.has(event.code)) return;
event.preventDefault();
@@ -280,53 +334,61 @@ export const AssetsPane = memo(function AssetsPane({
useEffect(() => {
if (!selectedAsset || selectedAsset.provider !== 'BUNNY') return;
if (bunnyReadyByAssetId[selectedAsset.id]) return;
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
setBunnyProcessingByAssetId((prev) =>
prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }
);
}, [bunnyReadyByAssetId, selectedAsset]);
const handleImageUpload = useCallback(async (file: File) => {
if (!file) return;
const handleImageUpload = useCallback(
async (file: File) => {
if (!file) return;
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('image', file);
formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('image');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
const uploadRes = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string; reservationId?: string | null }; error?: string } | null;
const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image');
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
await createAsset({
provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
if (imageInputRef.current) imageInputRef.current.value = '';
setImageTitle('');
setPendingImageFile(null);
} catch (error) {
console.error('Failed to upload image asset:', error);
toast.error('Failed to upload image');
} finally {
setIsUploadingImage(false);
}
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('image', file);
formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('image');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
const uploadRes = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as {
data?: { url?: string; reservationId?: string | null };
error?: string;
} | null;
const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image');
return;
}
await createAsset({
provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
if (imageInputRef.current) imageInputRef.current.value = '';
setImageTitle('');
setPendingImageFile(null);
} catch (error) {
console.error('Failed to upload image asset:', error);
toast.error('Failed to upload image');
} finally {
setIsUploadingImage(false);
}
},
[videoId, getGuestUploadToken, createAsset, imageTitle]
);
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -367,101 +429,104 @@ export const AssetsPane = memo(function AssetsPane({
}
};
const handleBunnyFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith('video/')) {
toast.error('Please select a video file');
return;
}
let uploadedVideoId: string | null = null;
let uploadToken: string | null = null;
try {
setIsUploadingBunny(true);
setBunnyProgress(0);
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }),
});
const initPayload = (await initRes.json().catch(() => null)) as {
data?: {
videoId: string;
libraryId: string;
signature: string;
expirationTime: number;
uploadToken: string;
};
error?: string;
} | null;
if (!initRes.ok || !initPayload?.data) {
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
const handleBunnyFileUpload = useCallback(
async (file: File) => {
if (!file.type.startsWith('video/')) {
toast.error('Please select a video file');
return;
}
const initData = initPayload.data;
uploadedVideoId = initData.videoId;
uploadToken = initData.uploadToken;
let uploadedVideoId: string | null = null;
let uploadToken: string | null = null;
try {
setIsUploadingBunny(true);
setBunnyProgress(0);
await new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: initData.signature,
AuthorizationExpire: initData.expirationTime.toString(),
VideoId: initData.videoId,
LibraryId: initData.libraryId,
},
metadata: {
filetype: file.type,
title: file.name,
},
onError: (error) => reject(error),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0;
setBunnyProgress(Math.min(100, Math.max(0, percentage)));
},
onSuccess: () => resolve(),
});
upload.start();
});
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
const thumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
: undefined;
const createdAsset = await createAsset({
provider: 'BUNNY',
sourceUrl,
providerVideoId: initData.videoId,
uploadToken: initData.uploadToken,
thumbnailUrl,
displayName: bunnyTitle.trim() || file.name,
});
if (!createdAsset) {
throw new Error('Failed to finalize Bunny asset');
}
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
setBunnyTitle('');
} catch (error) {
console.error('Failed to upload Bunny asset:', error);
toast.error('Failed to upload Bunny video');
if (uploadedVideoId && uploadToken) {
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'DELETE',
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
}).catch(() => undefined);
body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }),
});
const initPayload = (await initRes.json().catch(() => null)) as {
data?: {
videoId: string;
libraryId: string;
signature: string;
expirationTime: number;
uploadToken: string;
};
error?: string;
} | null;
if (!initRes.ok || !initPayload?.data) {
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
return;
}
const initData = initPayload.data;
uploadedVideoId = initData.videoId;
uploadToken = initData.uploadToken;
await new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: initData.signature,
AuthorizationExpire: initData.expirationTime.toString(),
VideoId: initData.videoId,
LibraryId: initData.libraryId,
},
metadata: {
filetype: file.type,
title: file.name,
},
onError: (error) => reject(error),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0;
setBunnyProgress(Math.min(100, Math.max(0, percentage)));
},
onSuccess: () => resolve(),
});
upload.start();
});
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
const thumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
: undefined;
const createdAsset = await createAsset({
provider: 'BUNNY',
sourceUrl,
providerVideoId: initData.videoId,
uploadToken: initData.uploadToken,
thumbnailUrl,
displayName: bunnyTitle.trim() || file.name,
});
if (!createdAsset) {
throw new Error('Failed to finalize Bunny asset');
}
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
setBunnyTitle('');
} catch (error) {
console.error('Failed to upload Bunny asset:', error);
toast.error('Failed to upload Bunny video');
if (uploadedVideoId && uploadToken) {
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
}).catch(() => undefined);
}
} finally {
setIsUploadingBunny(false);
setBunnyProgress(0);
}
} finally {
setIsUploadingBunny(false);
setBunnyProgress(0);
}
}, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]);
},
[videoId, bunnyTitle, bunnyCdnHostname, createAsset]
);
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -536,7 +601,10 @@ export const AssetsPane = memo(function AssetsPane({
setIsRecording(false);
setRecordingTime(0);
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setPendingAudioFile(null);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
@@ -574,9 +642,9 @@ export const AssetsPane = memo(function AssetsPane({
const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) {
toast.error(
uploadPayload?.error
|| (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null)
|| 'Failed to upload voice recording'
uploadPayload?.error ||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
'Failed to upload voice recording'
);
return;
}
@@ -592,7 +660,10 @@ export const AssetsPane = memo(function AssetsPane({
});
setVoiceTitle('');
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setPendingAudioFile(null);
} catch (error) {
console.error('Failed to upload voice asset:', error);
@@ -602,12 +673,15 @@ export const AssetsPane = memo(function AssetsPane({
}
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, [canUploadAssets]);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
},
[canUploadAssets]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -619,38 +693,44 @@ export const AssetsPane = memo(function AssetsPane({
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
if (file.type.startsWith('image/')) {
const imageError = await validateImageFile(file);
if (imageError) { toast.error(imageError); return; }
// Stage the file so the user can optionally set a name before uploading
setUploadTab('image');
setPendingImageFile(file);
} else if (file.type.startsWith('video/')) {
// Videos upload immediately (large files, no staging)
setUploadTab('bunny');
await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) {
const audioError = getAudioUploadValidationError(file);
if (audioError) {
toast.error(audioError);
return;
if (file.type.startsWith('image/')) {
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('image');
setPendingImageFile(file);
} else if (file.type.startsWith('video/')) {
// Videos upload immediately (large files, no staging)
setUploadTab('bunny');
await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) {
const audioError = getAudioUploadValidationError(file);
if (audioError) {
toast.error(audioError);
return;
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
}, [canUploadAssets, handleBunnyFileUpload]);
},
[canUploadAssets, handleBunnyFileUpload]
);
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'AUDIO') {
@@ -681,7 +761,10 @@ export const AssetsPane = memo(function AssetsPane({
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.thumbnailUrl || `https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`}
src={
asset.thumbnailUrl ||
`https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`
}
alt={asset.displayName}
className="h-full w-full object-contain"
/>
@@ -693,7 +776,9 @@ export const AssetsPane = memo(function AssetsPane({
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
const hasThumbnailLoadError = !!bunnyThumbnailLoadErrorByAssetId[asset.id];
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
const thumbnailSrc = asset.thumbnailUrl
? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`
: null;
const showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError;
return (
@@ -718,9 +803,7 @@ export const AssetsPane = memo(function AssetsPane({
{isProcessing && !isReadyToPlay && (
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-white" />
<span className="text-[10px] text-white/90 font-medium">
Processing...
</span>
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
</div>
)}
</div>
@@ -742,7 +825,9 @@ export const AssetsPane = memo(function AssetsPane({
return;
}
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
setBunnyProcessingByAssetId((prev) =>
prev[asset.id] ? prev : { ...prev, [asset.id]: true }
);
}
setSelectedAsset(asset);
};
@@ -769,14 +854,24 @@ export const AssetsPane = memo(function AssetsPane({
</div>
{canUploadAssets ? (
<div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
<div
className={cn(
'rounded-lg border p-3 space-y-3 relative transition-colors',
isDragOver && 'border-primary bg-primary/5'
)}
>
{isDragOver && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
<UploadCloud className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop to upload</span>
</div>
)}
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}>
<Tabs
value={uploadTab}
onValueChange={(value) =>
setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')
}
>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger>
@@ -792,8 +887,12 @@ export const AssetsPane = memo(function AssetsPane({
value={imageTitle}
onChange={(event) => setImageTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<p className="text-xs text-muted-foreground">Tip: you can paste an image here with Ctrl/Cmd+V.</p>
<p className="text-xs text-muted-foreground">
If set, this name will be used in @asset mentions.
</p>
<p className="text-xs text-muted-foreground">
Tip: you can paste an image here with Ctrl/Cmd+V.
</p>
{pendingImageFile ? (
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
<span className="truncate">Attached: {pendingImageFile.name}</span>
@@ -823,10 +922,18 @@ export const AssetsPane = memo(function AssetsPane({
imageInputRef.current?.click();
}}
>
{isUploadingImage || isCreatingAsset
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
: <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingImage ? 'Uploading...' : isCreatingAsset ? 'Saving...' : pendingImageFile ? 'Upload Image' : 'Select Image'}
{isUploadingImage || isCreatingAsset ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingImage
? 'Uploading...'
: isCreatingAsset
? 'Saving...'
: pendingImageFile
? 'Upload Image'
: 'Select Image'}
</Button>
<input
ref={imageInputRef}
@@ -868,14 +975,20 @@ export const AssetsPane = memo(function AssetsPane({
value={bunnyTitle}
onChange={(event) => setBunnyTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<p className="text-xs text-muted-foreground">
If set, this name will be used in @asset mentions.
</p>
<Button
variant="outline"
className="w-full"
disabled={isUploadingBunny || isCreatingAsset}
onClick={() => bunnyInputRef.current?.click()}
>
{isUploadingBunny ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingBunny ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
</Button>
<input
@@ -887,7 +1000,10 @@ export const AssetsPane = memo(function AssetsPane({
/>
{isUploadingBunny && (
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
<div className="bg-primary h-2 rounded-full" style={{ width: `${bunnyProgress}%` }} />
<div
className="bg-primary h-2 rounded-full"
style={{ width: `${bunnyProgress}%` }}
/>
</div>
)}
</div>
@@ -920,7 +1036,11 @@ export const AssetsPane = memo(function AssetsPane({
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
</Button>
</div>
@@ -933,13 +1053,26 @@ export const AssetsPane = memo(function AssetsPane({
</span>
<span className="text-red-500 font-medium">Recording</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:
{String(recordingTime % 60).padStart(2, '0')}
</span>
</div>
<Button size="icon" variant="outline" className="h-9 w-9 shrink-0" title="Stop recording" onClick={stopRecording}>
<Button
size="icon"
variant="outline"
className="h-9 w-9 shrink-0"
title="Stop recording"
onClick={stopRecording}
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9 shrink-0" title="Cancel recording" onClick={cancelRecording}>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0"
title="Cancel recording"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
@@ -950,14 +1083,23 @@ export const AssetsPane = memo(function AssetsPane({
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)}
onClick={() =>
audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)
}
>
{playingVoiceId === 'recording-preview' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{playingVoiceId === 'recording-preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%' }}
style={{
width:
playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
@@ -980,21 +1122,38 @@ export const AssetsPane = memo(function AssetsPane({
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingVoice ? 'Uploading...' : 'Upload Recording'}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" title="Discard and re-record" onClick={cancelRecording}>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
title="Discard and re-record"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button variant="outline" className="w-full" onClick={startRecording} disabled={isUploadingVoice || isCreatingAsset}>
<Button
variant="outline"
className="w-full"
onClick={startRecording}
disabled={isUploadingVoice || isCreatingAsset}
>
<Mic className="h-4 w-4 mr-2" />
Start Recording
</Button>
)}
<p className="text-xs text-muted-foreground">Or drag an audio file anywhere onto this panel.</p>
<p className="text-xs text-muted-foreground">
Or drag an audio file anywhere onto this panel.
</p>
</div>
)}
</div>
@@ -1033,7 +1192,15 @@ export const AssetsPane = memo(function AssetsPane({
}}
/>
<Dialog open={selectedAsset?.kind === 'AUDIO'} onOpenChange={(open) => { if (!open) { stopVoice(); setSelectedAsset(null); } }}>
<Dialog
open={selectedAsset?.kind === 'AUDIO'}
onOpenChange={(open) => {
if (!open) {
stopVoice();
setSelectedAsset(null);
}
}}
>
<DialogContent className="max-w-sm">
<DialogTitle>{selectedAsset?.displayName || 'Voice Recording'}</DialogTitle>
{selectedAsset?.sourceUrl ? (
@@ -1042,14 +1209,22 @@ export const AssetsPane = memo(function AssetsPane({
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)}
onClick={() =>
selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)
}
>
{playingVoiceId === selectedAsset?.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{playingVoiceId === selectedAsset?.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%' }}
style={{
width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
@@ -1070,7 +1245,10 @@ export const AssetsPane = memo(function AssetsPane({
</DialogContent>
</Dialog>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
<Dialog
open={selectedAsset?.kind === 'VIDEO'}
onOpenChange={(open) => !open && setSelectedAsset(null)}
>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none rounded-none flex items-center justify-center"
@@ -1083,20 +1261,23 @@ export const AssetsPane = memo(function AssetsPane({
}
}}
>
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
<DialogTitle className="sr-only">
{selectedAsset?.displayName || 'Video Preview'}
</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div
className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={selectedAsset?.displayName || undefined}>
<p
className="flex-1 min-w-0 text-sm text-foreground truncate"
title={selectedAsset?.displayName || undefined}
>
{selectedAsset?.displayName || 'Video Preview'}
</p>
{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0"
>
<Button asChild variant="outline" size="sm" className="h-8 shrink-0">
<a
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
target="_blank"
@@ -1116,8 +1297,7 @@ export const AssetsPane = memo(function AssetsPane({
title="Download Bunny video"
aria-label="Download Bunny video"
disabled={
activeDownloadAssetId === selectedAsset.id
|| isSelectedBunnyProcessing
activeDownloadAssetId === selectedAsset.id || isSelectedBunnyProcessing
}
>
{activeDownloadAssetId === selectedAsset.id ? (
@@ -1132,7 +1312,9 @@ export const AssetsPane = memo(function AssetsPane({
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
<DropdownMenuItem
onClick={() => void downloadAsset(selectedAsset, 'compressed')}
>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
@@ -1151,32 +1333,35 @@ export const AssetsPane = memo(function AssetsPane({
</div>
<div className="flex-1 min-h-0 w-full p-2 sm:p-4">
{selectedAsset ? (
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
<iframe
ref={youtubeIframeRef}
className="w-full h-full"
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
title={selectedAsset.displayName}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
{selectedAsset ? (
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
<iframe
ref={youtubeIframeRef}
className="w-full h-full"
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
title={selectedAsset.displayName}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
) : (
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({
...prev,
[selectedBunnyAssetId]: false,
}));
}}
/>
</div>
) : (
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: false }));
}}
/>
)
) : null}
)
) : null}
</div>
</div>
</DialogContent>
File diff suppressed because it is too large Load Diff
+65 -24
View File
@@ -2,7 +2,18 @@
import { memo, type RefObject } from 'react';
import Link from 'next/link';
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
import {
Image as ImageIcon,
Loader2,
Mic,
Pause,
Pencil,
Play,
Send,
Tag,
Trash2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -145,12 +156,7 @@ export const CommentComposer = memo(function CommentComposer({
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
@@ -158,12 +164,20 @@ export const CommentComposer = memo(function CommentComposer({
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<img
src={URL.createObjectURL(imageBlob)}
alt="Preview"
className="max-h-40 w-auto object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Button
size="icon"
variant="destructive"
onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -205,7 +219,10 @@ export const CommentComposer = memo(function CommentComposer({
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
<button
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => { setAnnotationStrokes(null); setIsAnnotating(false); }}
onClick={() => {
setAnnotationStrokes(null);
setIsAnnotating(false);
}}
>
Remove
</button>
@@ -214,12 +231,20 @@ export const CommentComposer = memo(function CommentComposer({
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<img
src={URL.createObjectURL(imageBlob)}
alt="Preview"
className="max-h-40 w-auto object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Button
size="icon"
variant="destructive"
onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -246,7 +271,11 @@ export const CommentComposer = memo(function CommentComposer({
<Button
size="icon"
onClick={handleAddComment}
disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
disabled={
(!commentText.trim() && !imageBlob && !annotationStrokes) ||
isSubmittingComment ||
isUploadingImage
}
>
{isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -279,7 +308,11 @@ export const CommentComposer = memo(function CommentComposer({
pauseVideoForAnnotation();
setIsAnnotating(true);
}}
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
title={
annotationStrokes
? 'Annotation added ✓ (click to redraw)'
: 'Draw annotation on video'
}
>
<Pencil className="h-4 w-4" />
</Button>
@@ -297,9 +330,14 @@ export const CommentComposer = memo(function CommentComposer({
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={selectedTagId ? {
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
} : undefined}
style={
selectedTagId
? {
backgroundColor: availableTags.find((t) => t.id === selectedTagId)
?.color,
}
: undefined
}
>
<Tag className="h-4 w-4" />
</Button>
@@ -323,7 +361,10 @@ export const CommentComposer = memo(function CommentComposer({
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
<Link
href={`/projects/${projectId}/settings#comment-tags`}
className="gap-2 text-muted-foreground"
>
<Tag className="h-3 w-3" />
Manage Tags
</Link>
+10 -6
View File
@@ -68,15 +68,19 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
assetKind === 'VIDEO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
: assetKind === 'AUDIO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
}
>
{assetKind === 'VIDEO' ? <Video className="h-2.5 w-2.5" /> : assetKind === 'AUDIO' ? <Volume2 className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label}
{assetKind === 'VIDEO' ? (
<Video className="h-2.5 w-2.5" />
) : assetKind === 'AUDIO' ? (
<Volume2 className="h-2.5 w-2.5" />
) : (
<ImageIcon className="h-2.5 w-2.5" />
)}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">@{label}</span>
</button>
);
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,13 @@ import { memo } from 'react';
import { CheckCircle2, GitCompareArrows } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { Version } from '@/components/video-page/types';
@@ -30,9 +36,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select Versions to Compare</DialogTitle>
<DialogDescription>
Choose 2 or more versions to compare side by side.
</DialogDescription>
<DialogDescription>Choose 2 or more versions to compare side by side.</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-2 max-h-64 overflow-y-auto">
{versions
@@ -46,9 +50,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
type="button"
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-accent/50'
)}
onClick={() => onToggleVersion(v.id)}
>
@@ -60,9 +62,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<CheckCircle2 className="h-3 w-3" />
)}
{isSelected && <CheckCircle2 className="h-3 w-3" />}
</div>
<Badge variant="secondary">v{v.versionNumber}</Badge>
<span className="text-sm font-medium truncate">
+11 -5
View File
@@ -10,7 +10,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
DownloadTarget,
Version,
} from '@/components/video-page/types';
interface DownloadControlsProps {
activeVersion: Version | null | undefined;
@@ -39,8 +43,9 @@ export const DownloadControls = memo(function DownloadControls({
}: DownloadControlsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
@@ -173,8 +178,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
}: DownloadMenuItemsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
+1 -5
View File
@@ -41,11 +41,7 @@ export const GuestNameGate = memo(function GuestNameGate({
}}
autoFocus
/>
<Button
className="w-full"
disabled={!guestName.trim()}
onClick={onConfirm}
>
<Button className="w-full" disabled={!guestName.trim()} onClick={onConfirm}>
Continue
</Button>
</div>
+79 -70
View File
@@ -50,7 +50,9 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
setIsLoadingCandidates(true);
setError('');
try {
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, { cache: 'no-store' });
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, {
cache: 'no-store',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approvers');
@@ -64,80 +66,85 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
}
}, [projectId]);
const createRequest = useCallback(async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
const createRequest = useCallback(
async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
}, [activeVersionId, fetchRequests]);
},
[activeVersionId, fetchRequests]
);
const submitDecision = useCallback(async (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
const submitDecision = useCallback(
async (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
}, [fetchRequests]);
},
[fetchRequests]
);
const cancelRequest = useCallback(async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
const cancelRequest = useCallback(
async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
}, [fetchRequests]);
},
[fetchRequests]
);
const activePendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
@@ -146,9 +153,11 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
const myPendingDecision = useMemo(() => {
if (!currentUserId || !activePendingRequest) return null;
return activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
return (
activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null
);
}, [activePendingRequest, currentUserId]);
return {
File diff suppressed because it is too large Load Diff
@@ -24,9 +24,10 @@ export function useCommentMedia() {
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
const dur =
isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
@@ -37,54 +38,57 @@ export function useCommentMedia() {
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
const playVoice = useCallback(
(commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
audio.onplay = () => {
startVoiceTracking();
};
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onplay = () => {
startVoiceTracking();
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
void audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
void audio.play();
},
[playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]
);
const stopVoice = useCallback(() => {
if (audioPlayerRef.current) {
@@ -2,7 +2,13 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
Comment,
DownloadTarget,
Version,
VideoData,
} from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
function sanitizeDownloadFileName(value: string): string {
@@ -54,73 +60,80 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
const isDownloadingVideo = activeDownloadTarget !== null;
const startDownload = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return;
if (!video.canDownload) {
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download');
return;
}
const startDownload = useCallback(
async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return;
if (!video.canDownload) {
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download');
return;
}
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try {
let downloadUrl: string | null = null;
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try {
let downloadUrl: string | null = null;
if (activeVersion.providerId === 'bunny') {
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, {
cache: 'no-store',
});
if (activeVersion.providerId === 'bunny') {
const prepareRes = await fetch(
`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`,
{
cache: 'no-store',
}
);
if (!prepareRes.ok) {
const prepareBody = await prepareRes.json().catch(() => null);
const fallbackError = preference === 'original'
? 'Original file is not available for this video'
: 'Compressed file is not available for this video';
const errorMessage = typeof prepareBody?.error === 'string'
? prepareBody.error
: fallbackError;
throw new Error(errorMessage);
if (!prepareRes.ok) {
const prepareBody = await prepareRes.json().catch(() => null);
const fallbackError =
preference === 'original'
? 'Original file is not available for this video'
: 'Compressed file is not available for this video';
const errorMessage =
typeof prepareBody?.error === 'string' ? prepareBody.error : fallbackError;
throw new Error(errorMessage);
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
throw new Error('Direct download URL is not allowed');
}
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
throw new Error('Direct download URL is not allowed');
throw new Error('Missing download URL');
}
}
if (!downloadUrl) {
throw new Error('Missing download URL');
const versionLabel =
activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
}
document.body.appendChild(a);
a.click();
a.remove();
} catch (error) {
console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else {
toast.error('Failed to start download');
}
} finally {
setActiveDownloadTarget(null);
}
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
}
document.body.appendChild(a);
a.click();
a.remove();
} catch (error) {
console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else {
toast.error('Failed to start download');
}
} finally {
setActiveDownloadTarget(null);
}
}, [activeVersion, video, isDownloadingVideo]);
},
[activeVersion, video, isDownloadingVideo]
);
return {
activeDownloadTarget,
@@ -3,7 +3,12 @@
import { useState, type Dispatch, type SetStateAction } from 'react';
import { toast } from 'sonner';
import * as tus from 'tus-js-client';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import {
parseVideoUrl,
getThumbnailUrl,
fetchVideoMetadata,
type VideoSource,
} from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
@@ -94,7 +99,9 @@ export function useVersionActions({
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
uploadedBunnyVideoId = bunnyVideoId;
uploadedBunnyUploadToken = uploadToken;
@@ -179,7 +186,10 @@ export function useVersionActions({
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
body: JSON.stringify({
videoId: uploadedBunnyVideoId,
uploadToken: uploadedBunnyUploadToken,
}),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
+167 -138
View File
@@ -64,45 +64,54 @@ export function useVideoAssets({
const assetsEtagRef = useRef<string | null>(null);
const isMutatingRef = useRef(false);
const fetchAssets = useCallback(async (options?: { useEtag?: boolean; silent?: boolean }) => {
const useEtag = options?.useEtag ?? false;
const silent = options?.silent ?? false;
if (!silent) setIsLoadingAssets(true);
try {
const headers: HeadersInit = {};
if (useEtag && assetsEtagRef.current) {
headers['If-None-Match'] = assetsEtagRef.current;
}
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store', headers });
if (res.status === 304) return;
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
if (!silent) {
toast.error(payload?.error || 'Failed to fetch assets');
const fetchAssets = useCallback(
async (options?: { useEtag?: boolean; silent?: boolean }) => {
const useEtag = options?.useEtag ?? false;
const silent = options?.silent ?? false;
if (!silent) setIsLoadingAssets(true);
try {
const headers: HeadersInit = {};
if (useEtag && assetsEtagRef.current) {
headers['If-None-Match'] = assetsEtagRef.current;
}
return;
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, {
cache: 'no-store',
headers,
});
if (res.status === 304) return;
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
if (!silent) {
toast.error(payload?.error || 'Failed to fetch assets');
}
return;
}
const etag = res.headers.get('etag');
if (etag) assetsEtagRef.current = etag;
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets(list);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
if (!silent) {
toast.error('Failed to fetch assets');
}
} finally {
if (!silent) setIsLoadingAssets(false);
}
const etag = res.headers.get('etag');
if (etag) assetsEtagRef.current = etag;
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets(list);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
if (!silent) {
toast.error('Failed to fetch assets');
}
} finally {
if (!silent) setIsLoadingAssets(false);
}
}, [videoId]);
},
[videoId]
);
const loadMoreAssets = useCallback(async () => {
if (isLoadingMoreAssets || !hasMoreAssets) return;
setIsLoadingMoreAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
const res = await fetch(
`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`,
{ cache: 'no-store' }
);
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to load more assets');
@@ -110,7 +119,10 @@ export function useVideoAssets({
}
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
setAssets((prev) => [
...prev,
...list.filter((asset) => !prev.some((existing) => existing.id === asset.id)),
]);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
@@ -145,118 +157,135 @@ export function useVideoAssets({
};
}, [fetchAssets, isLoadingMoreAssets]);
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
return null;
}
setIsCreatingAsset(true);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
}),
});
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
if (!res.ok || !body?.data) {
toast.error(body?.error || 'Failed to create asset');
const createAsset = useCallback(
async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
return null;
}
setAssets((prev) => [body.data!, ...prev]);
setNextAssetsOffset((prev) => prev + 1);
return body.data;
} catch {
toast.error('Failed to create asset');
return null;
} finally {
setIsCreatingAsset(false);
isMutatingRef.current = false;
}
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
const deleteAsset = useCallback(async (assetId: string) => {
setActiveDeleteAssetId(assetId);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
method: 'DELETE',
});
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to delete asset');
return false;
}
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
return true;
} catch {
toast.error('Failed to delete asset');
return false;
} finally {
setActiveDeleteAssetId(null);
isMutatingRef.current = false;
}
}, [videoId]);
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
}
if (asset.provider === 'YOUTUBE') {
toast.error('YouTube assets cannot be downloaded');
return;
}
setActiveDownloadAssetId(asset.id);
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
if (!prepareRes.ok) {
toast.error(prepareBody?.error || 'Download is not available');
return;
setIsCreatingAsset(true);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
}),
});
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
if (!res.ok || !body?.data) {
toast.error(body?.error || 'Failed to create asset');
return null;
}
downloadUrl = `${downloadUrl}?source=${preference}`;
setAssets((prev) => [body.data!, ...prev]);
setNextAssetsOffset((prev) => prev + 1);
return body.data;
} catch {
toast.error('Failed to create asset');
return null;
} finally {
setIsCreatingAsset(false);
isMutatingRef.current = false;
}
},
[canUploadAssets, videoId, isAuthenticated, guestName]
);
const deleteAsset = useCallback(
async (assetId: string) => {
setActiveDeleteAssetId(assetId);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
method: 'DELETE',
});
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to delete asset');
return false;
}
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
return true;
} catch {
toast.error('Failed to delete asset');
return false;
} finally {
setActiveDeleteAssetId(null);
isMutatingRef.current = false;
}
},
[videoId]
);
const downloadAsset = useCallback(
async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
}
if (asset.provider === 'YOUTUBE') {
toast.error('YouTube assets cannot be downloaded');
return;
}
const a = document.createElement('a');
a.href = downloadUrl;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error('Failed to start download');
} finally {
setActiveDownloadAssetId(null);
}
}, [canDownloadAssets, videoId]);
setActiveDownloadAssetId(asset.id);
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
const payload = (await response.json().catch(() => null)) as
| { data?: { token?: string }; error?: string }
| null;
const token = payload?.data?.token;
if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload');
}
return token;
}, [isAuthenticated, videoId]);
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
cache: 'no-store',
});
const prepareBody = (await prepareRes.json().catch(() => null)) as {
error?: string;
} | null;
if (!prepareRes.ok) {
toast.error(prepareBody?.error || 'Download is not available');
return;
}
downloadUrl = `${downloadUrl}?source=${preference}`;
}
const a = document.createElement('a');
a.href = downloadUrl;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error('Failed to start download');
} finally {
setActiveDownloadAssetId(null);
}
},
[canDownloadAssets, videoId]
);
const getGuestUploadToken = useCallback(
async (intent: 'image' | 'audio') => {
if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
const payload = (await response.json().catch(() => null)) as {
data?: { token?: string };
error?: string;
} | null;
const token = payload?.data?.token;
if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload');
}
return token;
},
[isAuthenticated, videoId]
);
return {
assets,
@@ -9,11 +9,7 @@ interface UseVideoPageDataParams {
propProjectId?: string;
}
export function useVideoPageData({
mode,
videoId,
propProjectId,
}: UseVideoPageDataParams) {
export function useVideoPageData({ mode, videoId, propProjectId }: UseVideoPageDataParams) {
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -79,11 +75,11 @@ export function useVideoPageData({
return {
...prev,
versions: prev.versions.map((version) => (
versions: prev.versions.map((version) =>
version.id === versionId
? { ...version, comments: commentsList, _count: { comments: totalComments } }
: version
)),
),
};
});
}, []);
@@ -94,9 +90,10 @@ export function useVideoPageData({
const res = await fetch(apiBasePath, { cache: 'no-store' });
if (!res.ok) {
const errorText = mode === 'dashboard' ? await res.text() : '';
setError(mode === 'dashboard'
? `Failed to load video: ${res.status} ${errorText}`
: 'Video not found or access denied'
setError(
mode === 'dashboard'
? `Failed to load video: ${res.status} ${errorText}`
: 'Video not found or access denied'
);
setLoading(false);
return;
@@ -114,7 +111,8 @@ export function useVideoPageData({
};
setVideo(normalizedData);
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
const active =
normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
if (active) setActiveVersionId(active.id);
} catch (err) {
console.error('Error fetching video:', err);
+202 -131
View File
@@ -27,12 +27,9 @@ interface UseVideoPlayerParams {
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
speedOptions: number[];
scheduleWatchProgressSaveRef: RefObject<(input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => void>;
scheduleWatchProgressSaveRef: RefObject<
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void
>;
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
}
@@ -166,11 +163,19 @@ export function useVideoPlayer({
setIsBunnyPortraitSource(false);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
try {
playerRef.current.destroy();
} catch {
/* ignore */
}
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
@@ -222,7 +227,8 @@ export function useVideoPlayer({
let retryAttempt = 0;
let usingHlsJs = false;
let hlsInstance: Hls | null = null;
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
let sourceMode: 'hls' | 'original' =
bunnySourcePreference === 'original' ? 'original' : 'hls';
const clearRetryTimer = () => {
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
@@ -268,7 +274,11 @@ export function useVideoPlayer({
usingHlsJs = false;
clearRetryTimer();
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
hlsInstance = null;
@@ -288,7 +298,10 @@ export function useVideoPlayer({
const saveProgress = () => {
const current = videoEl.currentTime || 0;
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
const duration =
Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
scheduleWatchProgressSaveRef.current({
progress: current,
duration,
@@ -310,17 +323,23 @@ export function useVideoPlayer({
setIsReady(true);
const resumeState = bunnySourceSwitchResumeRef.current;
if (resumeState) {
const knownDuration = Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
const targetTime = knownDuration > 0
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
: Math.max(0, resumeState.time);
const knownDuration =
Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
const targetTime =
knownDuration > 0
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
: Math.max(0, resumeState.time);
videoEl.currentTime = targetTime;
setCurrentTime(targetTime);
bunnySourceSwitchResumeRef.current = null;
if (resumeState.wasPlaying) {
videoEl.play().catch((err) => console.error('Error resuming Bunny video after source switch:', err));
videoEl
.play()
.catch((err) =>
console.error('Error resuming Bunny video after source switch:', err)
);
}
}
syncDuration();
@@ -348,7 +367,11 @@ export function useVideoPlayer({
if (!isDraggingRef.current) {
setCurrentTime(videoEl.currentTime || 0);
}
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
if (
Number.isFinite(videoEl.duration) &&
videoEl.duration > 0 &&
videoEl.duration !== cachedDuration
) {
cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
@@ -380,10 +403,12 @@ export function useVideoPlayer({
videoEl.addEventListener('error', onVideoError);
const configureHlsLevels = (levels: Level[]) => {
setQualityOptions(levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
})));
setQualityOptions(
levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
}))
);
const pendingQuality = pendingHlsQualityRef.current;
pendingHlsQualityRef.current = null;
@@ -438,24 +463,29 @@ export function useVideoPlayer({
hls.on(Hls.Events.ERROR, (_, data) => {
if (destroyed) return;
const responseCode = (data as { response?: { code?: number } }).response?.code;
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus = responseCode === undefined
|| responseCode === 0
|| responseCode === 403
|| responseCode === 404
|| responseCode === 423
|| responseCode === 429
|| responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure
&& hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
&& hasProcessingLikeStatus
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing = !data.details
&& !data.type
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
const isManifestLoadFailure =
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus =
responseCode === undefined ||
responseCode === 0 ||
responseCode === 403 ||
responseCode === 404 ||
responseCode === 423 ||
responseCode === 429 ||
responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing =
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
hasProcessingLikeStatus &&
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing =
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (
isLikelyProcessing ||
isNetworkPreMetadataProcessing ||
isUnknownPreMetadataProcessing
) {
if (activateOriginalFallback()) {
return;
}
@@ -495,11 +525,10 @@ export function useVideoPlayer({
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
return cachedDuration;
},
getPlayerState: () => (
getPlayerState: () =>
videoEl.paused
? (window.YT?.PlayerState?.PAUSED ?? 2)
: (window.YT?.PlayerState?.PLAYING ?? 1)
),
: (window.YT?.PlayerState?.PLAYING ?? 1),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
@@ -513,7 +542,11 @@ export function useVideoPlayer({
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
videoEl.removeAttribute('src');
@@ -541,11 +574,19 @@ export function useVideoPlayer({
window.onYouTubeIframeAPIReady = undefined;
}
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
try {
playerRef.current.destroy();
} catch {
/* ignore */
}
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
@@ -553,25 +594,44 @@ export function useVideoPlayer({
bunnyRetryTimerRef.current = null;
}
};
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, bunnySourcePreference, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
}, [
activeProviderId,
activeVersionId,
embedUrl,
isApiLoaded,
canInitializePlayer,
formatBunnyQualityLabel,
bunnySourcePreference,
hlsRef,
iframeRef,
playerRef,
scheduleWatchProgressSaveRef,
videoRef,
]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreenMode(true);
setShowComments(false);
}).catch((err) => {
console.error('Fullscreen failed:', err);
toast.error('Unable to enter fullscreen mode');
});
document.documentElement
.requestFullscreen()
.then(() => {
setIsFullscreenMode(true);
setShowComments(false);
})
.catch((err) => {
console.error('Fullscreen failed:', err);
toast.error('Unable to enter fullscreen mode');
});
} else {
document.exitFullscreen().then(() => {
setIsFullscreenMode(false);
setShowComments(true);
}).catch((err) => {
console.error('Exit fullscreen failed:', err);
toast.error('Unable to exit fullscreen mode');
});
document
.exitFullscreen()
.then(() => {
setIsFullscreenMode(false);
setShowComments(true);
})
.catch((err) => {
console.error('Exit fullscreen failed:', err);
toast.error('Unable to exit fullscreen mode');
});
}
}, []);
@@ -730,7 +790,16 @@ export function useVideoPlayer({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
}, [
isPlaying,
currentTime,
duration,
isMuted,
playbackSpeed,
speedOptions,
toggleFullscreen,
playerRef,
]);
const handlePlayPause = useCallback(() => {
if (!playerRef.current) return;
@@ -741,41 +810,41 @@ export function useVideoPlayer({
}
}, [isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback((
timestamp: number,
annotation?: string | null,
options?: { pauseAfterSeek?: boolean }
) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
const playerState = playerRef.current.getPlayerState?.();
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
const wasPlayingBeforeSeek = typeof playerState === 'number'
? playerState === ytPlayingState || playerState === ytBufferingState
: isPlaying;
const handleSeekToTimestamp = useCallback(
(timestamp: number, annotation?: string | null, options?: { pauseAfterSeek?: boolean }) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
const playerState = playerRef.current.getPlayerState?.();
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
const wasPlayingBeforeSeek =
typeof playerState === 'number'
? playerState === ytPlayingState || playerState === ytBufferingState
: isPlaying;
playerRef.current.seekTo(timestamp, true);
if (options?.pauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
playerRef.current.seekTo(timestamp, true);
if (options?.pauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
}
}
}
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} catch {
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} catch {
setViewingAnnotation(null);
}
} else {
setViewingAnnotation(null);
}
} else {
setViewingAnnotation(null);
}
}, [isPlaying, playerRef, setViewingAnnotation]);
},
[isPlaying, playerRef, setViewingAnnotation]
);
const handleMuteToggle = useCallback(() => {
if (!playerRef.current) return;
@@ -803,49 +872,51 @@ export function useVideoPlayer({
[playerRef]
);
const handleQualityChange = useCallback((level: number) => {
const shouldCaptureSourceSwitch = (
activeProviderId === 'bunny'
&& ((level === -2 && bunnySourcePreference !== 'original')
|| (level !== -2 && bunnySourcePreference === 'original'))
);
const handleQualityChange = useCallback(
(level: number) => {
const shouldCaptureSourceSwitch =
activeProviderId === 'bunny' &&
((level === -2 && bunnySourcePreference !== 'original') ||
(level !== -2 && bunnySourcePreference === 'original'));
if (shouldCaptureSourceSwitch) {
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
bunnySourceSwitchResumeRef.current = {
time: Number.isFinite(current) ? Math.max(0, current) : 0,
wasPlaying: isPlaying,
};
}
if (shouldCaptureSourceSwitch) {
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
bunnySourceSwitchResumeRef.current = {
time: Number.isFinite(current) ? Math.max(0, current) : 0,
wasPlaying: isPlaying,
};
}
if (level === -2) {
pendingHlsQualityRef.current = null;
setBunnySourcePreference('original');
setSelectedQualityLevel(-2);
return;
}
if (level === -2) {
pendingHlsQualityRef.current = null;
setBunnySourcePreference('original');
setSelectedQualityLevel(-2);
return;
}
pendingHlsQualityRef.current = level;
setBunnySourcePreference('auto');
pendingHlsQualityRef.current = level;
setBunnySourcePreference('auto');
const hls = hlsRef.current;
if (!hls) {
setSelectedQualityLevel(level === -1 ? -1 : level);
return;
}
const hls = hlsRef.current;
if (!hls) {
setSelectedQualityLevel(level === -1 ? -1 : level);
return;
}
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
}, [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]);
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
},
[activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]
);
const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -28,7 +28,11 @@ export function useWatchProgress({
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressWriteInFlightRef = useRef(false);
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
const pendingProgressPayloadRef = useRef<{
progress: number;
duration: number;
force: boolean;
} | null>(null);
const lastSavedProgressRef = useRef<number>(0);
const lastPathnameRef = useRef<string>(pathname);
@@ -69,51 +73,49 @@ export function useWatchProgress({
}
}, [isAuthenticated, activeVersionId, videoId]);
const scheduleWatchProgressSave = useCallback((input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => {
if (!isAuthenticated || !activeVersionId) return;
const scheduleWatchProgressSave = useCallback(
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => {
if (!isAuthenticated || !activeVersionId) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
const force = input.force ?? false;
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
const force = input.force ?? false;
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
}
: { progress, duration, force };
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
: { progress, duration, force };
void flushScheduledWatchProgress();
return;
}
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
void flushScheduledWatchProgress();
return;
}
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
}
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
}, [isAuthenticated, activeVersionId, flushScheduledWatchProgress]);
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
},
[isAuthenticated, activeVersionId, flushScheduledWatchProgress]
);
useEffect(() => {
return () => {
@@ -138,28 +140,31 @@ export function useWatchProgress({
}
}, [videoId, activeVersionId]);
const loadWatchProgress = useCallback(async (showPrompt = true) => {
if (!isAuthenticated || !activeVersionId) return;
const loadWatchProgress = useCallback(
async (showPrompt = true) => {
if (!isAuthenticated || !activeVersionId) return;
setSavedProgress(null);
setShowResumePrompt(false);
setSavedProgress(null);
setShowResumePrompt(false);
try {
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
if (res.ok) {
const response = await res.json();
const progress = response.data?.progress || 0;
const percentage = response.data?.percentage || 0;
try {
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
if (res.ok) {
const response = await res.json();
const progress = response.data?.progress || 0;
const percentage = response.data?.percentage || 0;
if (showPrompt && percentage > 5 && percentage < 95) {
setSavedProgress(progress);
setShowResumePrompt(true);
if (showPrompt && percentage > 5 && percentage < 95) {
setSavedProgress(progress);
setShowResumePrompt(true);
}
}
} catch (err) {
console.error('Error loading watch progress:', err);
}
} catch (err) {
console.error('Error loading watch progress:', err);
}
}, [isAuthenticated, activeVersionId, videoId]);
},
[isAuthenticated, activeVersionId, videoId]
);
useEffect(() => {
loadWatchProgress();
@@ -194,7 +199,14 @@ export function useWatchProgress({
progressSaveTimerRef.current = null;
}
};
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
}, [
isAuthenticated,
isReady,
videoDuration,
activeVersionId,
scheduleWatchProgressSave,
playerRef,
]);
useEffect(() => {
if (!isAuthenticated) return;
@@ -211,11 +223,16 @@ export function useWatchProgress({
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
const data = new Blob([JSON.stringify({
progress: finalProgress,
duration: finalDuration,
versionId: activeVersionId,
})], { type: 'application/json' });
const data = new Blob(
[
JSON.stringify({
progress: finalProgress,
duration: finalDuration,
versionId: activeVersionId,
}),
],
{ type: 'application/json' }
);
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
}
};
@@ -240,7 +257,15 @@ export function useWatchProgress({
window.removeEventListener('beforeunload', saveProgressOnLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
}, [
isAuthenticated,
currentTime,
videoDuration,
activeVersionId,
videoId,
scheduleWatchProgressSave,
playerRef,
]);
const handleResumeFromSaved = useCallback(() => {
if (savedProgress !== null && playerRef.current) {
+11 -3
View File
@@ -21,7 +21,9 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
downloadFileName,
canDownload = true,
}: ImagePreviewDialogProps) {
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
const resolvedDownloadName =
downloadFileName ||
(previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
return (
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
@@ -38,9 +40,15 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
}}
>
<DialogTitle className="sr-only">{title || 'Image Preview'}</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div
className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={title || undefined}>
<p
className="flex-1 min-w-0 text-sm text-foreground truncate"
title={title || undefined}
>
{title || 'Image Preview'}
</p>
{canDownload ? (
+42 -19
View File
@@ -26,7 +26,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { AnnotationCanvas, type AnnotationCanvasHandle, type AnnotationStroke } from '@/components/annotation-canvas';
import {
AnnotationCanvas,
type AnnotationCanvasHandle,
type AnnotationStroke,
} from '@/components/annotation-canvas';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
interface PlayerCoreProps {
@@ -157,13 +161,20 @@ export const PlayerCore = memo(function PlayerCore({
>
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
{activeProviderId === 'bunny' ? (
<div ref={bunnyViewportRef} className="absolute inset-0 flex items-center justify-center bg-black">
<div
ref={bunnyViewportRef}
className="absolute inset-0 flex items-center justify-center bg-black"
>
<div
className={cn(
'relative flex items-center justify-center bg-black',
isBunnyPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={isBunnyPortraitSource && bunnyPortraitFrameWidth > 0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined}
style={
isBunnyPortraitSource && bunnyPortraitFrameWidth > 0
? { width: `${bunnyPortraitFrameWidth}px` }
: undefined
}
>
<video
key={activeVersionId}
@@ -198,9 +209,12 @@ export const PlayerCore = memo(function PlayerCore({
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) && 'opacity-0 pointer-events-none',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) &&
'opacity-0 pointer-events-none',
isPlaying
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
? cursorIdle
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100'
: 'opacity-100'
)}
>
@@ -318,11 +332,15 @@ export const PlayerCore = memo(function PlayerCore({
</div>
</div>
<div className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode ? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div
className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode
? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300'
: '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center gap-1 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" />}
@@ -348,12 +366,7 @@ export const PlayerCore = memo(function PlayerCore({
<SkipForward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleMuteToggle}
>
<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>
@@ -387,7 +400,9 @@ export const PlayerCore = memo(function PlayerCore({
<DropdownMenuItem
key={option.level}
onClick={() => handleQualityChange(option.level)}
className={cn(option.level === selectedQualityLevel && 'font-bold text-primary')}
className={cn(
option.level === selectedQualityLevel && 'font-bold text-primary'
)}
>
{option.label}
</DropdownMenuItem>
@@ -423,7 +438,11 @@ export const PlayerCore = memo(function PlayerCore({
onClick={toggleFullscreen}
title={isFullscreenMode ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
>
{isFullscreenMode ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
{isFullscreenMode ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</Button>
{isFullscreenMode ? (
@@ -434,7 +453,11 @@ export const PlayerCore = memo(function PlayerCore({
onClick={() => setShowComments(!showComments)}
title={showComments ? 'Hide comments' : 'Show comments'}
>
{showComments ? <MessageSquareOff className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
{showComments ? (
<MessageSquareOff className="h-4 w-4" />
) : (
<MessageSquare className="h-4 w-4" />
)}
</Button>
) : (
<Button
+5 -1
View File
@@ -200,7 +200,11 @@ export interface VideoPageCommentsActions {
onResolveComment: (commentId: string, currentlyResolved: boolean) => void;
onEditComment: (commentId: string) => void;
onDeleteComment: (commentId: string) => void;
onReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
onReplyComment: (
parentId: string,
voiceData?: { url: string; duration: number },
imageData?: { url: string }
) => void;
onSubmitReplyWithMedia: (parentId: string) => void;
onStartEditAnnotation: () => void;
}
@@ -1,10 +1,25 @@
'use client';
import { memo } from 'react';
import { AlertCircle, CheckCircle2, FileVideo, Link as LinkIcon, Loader2, Plus, UploadCloud } from 'lucide-react';
import {
AlertCircle,
CheckCircle2,
FileVideo,
Link as LinkIcon,
Loader2,
Plus,
UploadCloud,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -67,8 +82,14 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
<Tabs
value={newVersionMode}
onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')}
className="mb-2"
>
<TabsList
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
>
<TabsTrigger value="url">Link URL</TabsTrigger>
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
</TabsList>
@@ -106,12 +127,17 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<div className="space-y-2">
<Label htmlFor="versionFile">Video File</Label>
<div className="flex items-center justify-center w-full">
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
<label
htmlFor="versionFile"
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{newVersionFile ? (
<>
<FileVideo className="w-8 h-8 mb-2 text-primary" />
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">
{newVersionFile.name}
</p>
<p className="text-xs text-muted-foreground">
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
@@ -126,14 +152,21 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
</>
)}
</div>
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
onNewVersionFileChange(file);
} else {
toast.error('Please select a valid video file');
}
}} disabled={isCreatingVersion} />
<input
id="versionFile"
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
onNewVersionFileChange(file);
} else {
toast.error('Please select a valid video file');
}
}}
disabled={isCreatingVersion}
/>
</label>
</div>
</div>
@@ -154,7 +187,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${newVersionUploadProgress}%` }}
></div>
</div>
)}
</div>
@@ -162,7 +198,11 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<Button
onClick={onCreateVersion}
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
disabled={
(newVersionMode === 'url' && !newVersionSource) ||
(newVersionMode === 'file' && !newVersionFile) ||
isCreatingVersion
}
className="w-full"
>
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+47 -15
View File
@@ -2,7 +2,17 @@
import { memo } from 'react';
import Link from 'next/link';
import { ArrowLeft, ChevronDown, GitCompareArrows, ListChecks, MoreVertical, Plus, Share2, ShieldCheck, Trash2 } from 'lucide-react';
import {
ArrowLeft,
ChevronDown,
GitCompareArrows,
ListChecks,
MoreVertical,
Plus,
Share2,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -17,7 +27,11 @@ import { cn } from '@/lib/utils';
import { DownloadControls } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
DownloadTarget,
Version,
} from '@/components/video-page/types';
import type { VideoSource } from '@/lib/video-providers';
interface VideoPageHeaderProps {
@@ -118,11 +132,15 @@ export const VideoPageHeader = memo(function VideoPageHeader({
const canManageVideo = canShareVideo || canRequestApproval;
return (
<div className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div
className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode
? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300'
: '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Link
href={backHref}
@@ -152,10 +170,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{versions.map((version) => (
<DropdownMenuItem
key={version.id}
onClick={() => onVersionSelect(version.id)}
>
<DropdownMenuItem key={version.id} onClick={() => onVersionSelect(version.id)}>
<Badge
variant={version.id === activeVersionId ? 'default' : 'secondary'}
className="mr-2"
@@ -203,22 +218,39 @@ export const VideoPageHeader = memo(function VideoPageHeader({
{mode === 'dashboard' && (
<>
{canManageVideo ? (
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={() => setShowVersionDialog(true)}
className="hidden sm:inline-flex"
>
<Plus className="h-4 w-4 mr-1" />
New Version
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={onOpenApprovalsPanel}
className="hidden sm:inline-flex"
>
<ListChecks className="h-4 w-4 mr-1" />
Approvals
{hasPendingApprovalRequest ? (
<Badge variant="default" className="ml-2 hidden xl:inline-flex">Pending</Badge>
<Badge variant="default" className="ml-2 hidden xl:inline-flex">
Pending
</Badge>
) : null}
</Button>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={onOpenCompare}
className="hidden sm:inline-flex"
>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
+30 -4
View File
@@ -25,8 +25,21 @@ export const VideoPageLoading = memo(function VideoPageLoading({
return (
<div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
<div className="flex-1 flex overflow-hidden min-h-0">
<div className={cn('flex-1 flex flex-col overflow-hidden min-h-0', isFullscreenMode && 'relative')}>
<div className={cn('shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div
className={cn(
'flex-1 flex flex-col overflow-hidden min-h-0',
isFullscreenMode && 'relative'
)}
>
<div
className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
isFullscreenMode &&
cursorIdle &&
isPlaying &&
'opacity-0 pointer-events-none transition-opacity duration-300'
)}
>
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-12" />
<Separator orientation="vertical" className="h-5" />
@@ -41,7 +54,15 @@ export const VideoPageLoading = memo(function VideoPageLoading({
</div>
</div>
<div className="flex-1 bg-black min-h-0" />
<div className={cn('shrink-0 px-4 py-2 bg-background border-t', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div
className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode &&
cursorIdle &&
isPlaying &&
'opacity-0 pointer-events-none transition-opacity duration-300'
)}
>
<div className="flex items-center gap-1 mb-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
@@ -55,7 +76,12 @@ export const VideoPageLoading = memo(function VideoPageLoading({
<Skeleton className="h-8 w-full rounded" />
</div>
</div>
<div className={cn('hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden', isFullscreenMode && !showComments && 'hidden')}>
<div
className={cn(
'hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden',
isFullscreenMode && !showComments && 'hidden'
)}
>
<div className="shrink-0 flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5" />