mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
+314
-87
@@ -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 "yes" 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 "yes" 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 "which cut is this?"</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
|
||||
Compare versions side-by-side. End "which cut is this?"
|
||||
</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 "What did you mean by this?" emails. Every note lands at the exact moment in the video.
|
||||
No more "What did you mean by this?" 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 "I thought you meant..."
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-primary">Stops "I thought you meant..."</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't need an account. They just review.</h2>
|
||||
<p className="text-lg text-muted-foreground">If clients can't adopt the tool, approvals don't happen. OpenFrame keeps it frictionless.</p>
|
||||
<h2 className="text-4xl font-semibold tracking-[-0.02em] md:text-5xl">
|
||||
Clients don't need an account. They just review.
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
If clients can't adopt the tool, approvals don'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'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'll recommend the right approach.
|
||||
Custom capacity and setup help for high-volume production teams. Tell us your
|
||||
storage, usage, and workflow - we'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 “{query}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8 px-4">
|
||||
No results for “{query}”
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "{video.title}"?</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user