feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
@@ -0,0 +1,27 @@
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface AuthLoadingSpinnerProps {
label?: string;
className?: string;
}
export function AuthLoadingSpinner({
label = "Loading...",
className,
}: AuthLoadingSpinnerProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-3 text-neutral-600",
className
)}
role="status"
aria-live="polite"
>
<Loader2 className="h-8 w-8 animate-spin text-primary-600" aria-hidden />
<p className="text-sm font-medium">{label}</p>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useForm } from "react-hook-form";
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
import { SupabaseSetupNotice } from "@/components/auth/SupabaseSetupNotice";
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
import { Button } from "@/components/ui/button";
import { createClient } from "@/lib/supabase";
import { cn } from "@/lib/utils";
type AuthTab = "sign-in" | "sign-up";
export function AuthPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const supabase = useMemo(() => createClient(), []);
const initialTab =
searchParams.get("tab") === "sign-up" ? "sign-up" : "sign-in";
const [activeTab, setActiveTab] = useState<AuthTab>(initialTab);
const [authLoading, setAuthLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formMessage, setFormMessage] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<AuthFormValues>({
resolver: zodResolver(authFormSchema),
defaultValues: { email: "", password: "" },
});
const redirectIfAuthenticated = useCallback(async () => {
if (!supabase) return false;
const {
data: { session },
} = await supabase.auth.getSession();
if (session) {
router.replace("/dashboard");
return true;
}
return false;
}, [router, supabase]);
useEffect(() => {
if (!supabase) {
setAuthLoading(false);
return;
}
let mounted = true;
const init = async () => {
const redirected = await redirectIfAuthenticated();
if (mounted && !redirected) {
setAuthLoading(false);
}
};
init();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (session) {
router.replace("/dashboard");
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [redirectIfAuthenticated, router, supabase]);
useEffect(() => {
const error = searchParams.get("error");
if (error === "auth_callback_failed") {
setFormError("Authentication failed. Please try again.");
}
}, [searchParams]);
const handleTabChange = (tab: AuthTab) => {
setActiveTab(tab);
setFormError(null);
setFormMessage(null);
reset();
};
const onSubmit = async (values: AuthFormValues) => {
if (!supabase) return;
setSubmitting(true);
setFormError(null);
setFormMessage(null);
if (activeTab === "sign-in") {
const { error } = await supabase.auth.signInWithPassword({
email: values.email,
password: values.password,
});
if (error) {
setFormError(error.message);
setSubmitting(false);
return;
}
router.replace("/dashboard");
} else {
const { data, error } = await supabase.auth.signUp({
email: values.email,
password: values.password,
});
if (error) {
setFormError(error.message);
setSubmitting(false);
return;
}
if (data.session) {
router.replace("/dashboard");
} else {
setFormMessage(
"Check your email to confirm your account, then sign in."
);
setActiveTab("sign-in");
}
}
setSubmitting(false);
};
const handleGoogleSignIn = async () => {
if (!supabase) return;
setOauthLoading(true);
setFormError(null);
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
},
});
if (error) {
setFormError(error.message);
setOauthLoading(false);
}
};
if (authLoading) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
<AuthLoadingSpinner label="Checking authentication..." />
</div>
);
}
if (!supabase) {
return (
<SupabaseSetupNotice nextPath={searchParams.get("next")} />
);
}
const isBusy = submitting || oauthLoading;
return (
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
<div className="text-center">
<h1 className="font-heading text-3xl font-bold text-neutral-900">
Welcome to CreatorStudio
</h1>
<p className="mt-2 text-sm text-neutral-600">
{activeTab === "sign-in"
? "Sign in to continue to your dashboard"
: "Create a free account to get started"}
</p>
</div>
<div className="mt-8 flex rounded-lg border border-gray-100 bg-neutral-50 p-1">
{(["sign-in", "sign-up"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => handleTabChange(tab)}
className={cn(
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
activeTab === tab
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-600 hover:text-neutral-900"
)}
>
{tab === "sign-in" ? "Sign In" : "Sign Up"}
</button>
))}
</div>
<div className="mt-6 rounded-xl border border-gray-100 bg-white p-6 shadow-sm">
<Button
type="button"
variant="outline"
className="w-full"
disabled={isBusy}
onClick={handleGoogleSignIn}
>
{oauthLoading ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : null}
Continue with Google
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
</div>
<p className="relative mx-auto w-fit bg-white px-3 text-xs text-neutral-500">
or continue with email
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
disabled={isBusy}
className={cn(
"mt-1.5 w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:opacity-50",
errors.email ? "border-red-300" : "border-gray-100"
)}
{...register("email")}
/>
{errors.email && (
<p className="mt-1.5 text-xs text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Password
</label>
<input
id="password"
type="password"
autoComplete={
activeTab === "sign-in" ? "current-password" : "new-password"
}
disabled={isBusy}
className={cn(
"mt-1.5 w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:opacity-50",
errors.password ? "border-red-300" : "border-gray-100"
)}
{...register("password")}
/>
{errors.password && (
<p className="mt-1.5 text-xs text-red-600">
{errors.password.message}
</p>
)}
</div>
{formError && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
{formMessage && (
<p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">
{formMessage}
</p>
)}
<Button type="submit" className="w-full" disabled={isBusy}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : null}
{activeTab === "sign-in" ? "Sign In" : "Create Account"}
</Button>
</form>
</div>
<p className="mt-6 text-center text-xs text-neutral-500">
By continuing, you agree to our{" "}
<Link href="/terms" className="text-primary-600 hover:underline">
Terms
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-primary-600 hover:underline">
Privacy Policy
</Link>
.
</p>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
interface SupabaseSetupNoticeProps {
nextPath: string | null;
}
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
const router = useRouter();
const isDev = process.env.NODE_ENV === "development";
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
return (
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 text-center shadow-sm">
<h1 className="font-heading text-xl font-bold text-neutral-900">
Supabase not configured
</h1>
<p className="mt-3 text-sm text-neutral-600">
Copy <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>{" "}
to <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code> and set{" "}
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>{" "}
and{" "}
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
, then restart the dev server.
</p>
{isDev ? (
<Button
type="button"
className="mt-6 w-full"
onClick={() => router.push(continueHref)}
>
Continue without signing in (dev only)
</Button>
) : (
<Button type="button" className="mt-6 w-full" asChild>
<Link href="/">Back to home</Link>
</Button>
)}
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod";
export const authFormSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(72, "Password must be at most 72 characters"),
});
export type AuthFormValues = z.infer<typeof authFormSchema>;
@@ -0,0 +1,27 @@
"use client";
import { FolderOpen } from "lucide-react";
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
export function DashboardEmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-20 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary-50 text-primary-600">
<FolderOpen className="h-10 w-10" aria-hidden />
</div>
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
No projects yet
</h3>
<p className="mt-2 max-w-sm text-sm text-neutral-600">
Create a video, image, or trim project to see it here. Everything you
save appears in this workspace.
</p>
<NewProjectMenu
triggerLabel="Create your first project"
triggerClassName="mt-8 gap-2"
align="center"
/>
</div>
);
}
@@ -0,0 +1,47 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { getUserProfile } from "@/lib/profiles";
import { getPlanLabel, type PlanId } from "@/lib/plans";
import { cn } from "@/lib/utils";
const planBadgeStyles: Record<PlanId, string> = {
free: "bg-neutral-100 text-neutral-700",
pro: "bg-primary-100 text-primary-700",
business: "bg-violet-100 text-violet-700",
};
interface DashboardPlanBadgeProps {
userId: string;
}
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
const profile = await getUserProfile(userId);
return (
<>
<p
className={cn(
"mt-1 inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold",
planBadgeStyles[profile.plan]
)}
>
{getPlanLabel(profile.plan)}
</p>
{profile.plan !== "business" ? (
<Button size="sm" className="mt-3 w-full" asChild>
<Link href="/#pricing">Upgrade plan</Link>
</Button>
) : null}
</>
);
}
export function DashboardPlanBadgeSkeleton() {
return (
<div
className="mt-1 h-5 w-20 animate-pulse rounded-full bg-gray-200"
aria-hidden
/>
);
}
@@ -0,0 +1,27 @@
import { DashboardProjectsSection } from "@/components/dashboard/DashboardProjectsSection";
import { mapProjectRow, type ProjectRow } from "@/lib/projects";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
export async function DashboardProjectsContent() {
let projects: ReturnType<typeof mapProjectRow>[] = [];
if (isSupabaseConfigured()) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data } = user
? await supabase
.from("projects")
.select("*")
.eq("user_id", user.id)
.order("updated_at", { ascending: false })
: { data: [] };
projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow);
}
return <DashboardProjectsSection projects={projects} />;
}
@@ -0,0 +1,83 @@
"use client";
import { useMemo, useState } from "react";
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
import { ProjectCard } from "@/components/dashboard/ProjectCard";
import { SkeletonProjectCard } from "@/components/dashboard/SkeletonProjectCard";
import type { DashboardProject } from "@/lib/projects";
const SKELETON_CARD_COUNT = 6;
interface DashboardProjectsSectionProps {
projects?: DashboardProject[];
isLoading?: boolean;
}
export function DashboardProjectsSection({
projects = [],
isLoading = false,
}: DashboardProjectsSectionProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredProjects = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return projects;
return projects.filter((project) =>
project.name.toLowerCase().includes(query)
);
}, [projects, searchQuery]);
const showEmpty = !isLoading && projects.length === 0;
const showNoResults =
!isLoading && !showEmpty && filteredProjects.length === 0;
return (
<div className="flex min-h-0 flex-1 flex-col">
<DashboardTopBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="flex-1 overflow-auto p-6">
<h2 className="font-heading text-xl font-bold text-neutral-900">
Recent Projects
</h2>
{showEmpty && (
<div className="mt-8">
<DashboardEmptyState />
</div>
)}
{isLoading && (
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
<SkeletonProjectCard key={index} />
))}
</div>
)}
{showNoResults && (
<div className="mt-8 rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-12 text-center">
<p className="font-heading text-lg font-semibold text-neutral-900">
No projects match your search
</p>
<p className="mt-2 text-sm text-neutral-600">
Try a different keyword or clear the search bar.
</p>
</div>
)}
{!isLoading && !showEmpty && filteredProjects.length > 0 && (
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
interface DashboardShellProps {
userEmail: string;
userName?: string | null;
userId: string;
children: React.ReactNode;
}
export function DashboardShell({
userEmail,
userName,
userId,
children,
}: DashboardShellProps) {
return (
<div className="flex min-h-screen bg-neutral-50">
<DashboardSidebar
userEmail={userEmail}
userName={userName}
userId={userId}
/>
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div>
</div>
);
}
@@ -0,0 +1,89 @@
import Link from "next/link";
import { Suspense } from "react";
import { Sparkles } from "lucide-react";
import {
DashboardPlanBadge,
DashboardPlanBadgeSkeleton,
} from "@/components/dashboard/DashboardPlanBadge";
import { DashboardSidebarNav } from "@/components/dashboard/DashboardSidebarNav";
interface DashboardSidebarProps {
userEmail: string;
userName?: string | null;
userId: string;
}
function getInitials(email: string, name?: string | null): string {
if (name?.trim()) {
const parts = name.trim().split(/\s+/);
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
return email.slice(0, 2).toUpperCase();
}
export function DashboardSidebar({
userEmail,
userName,
userId,
}: DashboardSidebarProps) {
const initials = getInitials(userEmail, userName);
return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-100 bg-white">
<div className="border-b border-gray-100 px-4 py-5">
<Link
href="/"
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-white">
<Sparkles className="h-5 w-5" aria-hidden />
</span>
<span className="font-heading text-lg font-bold text-neutral-900">
FlatRender
</span>
</Link>
</div>
<DashboardSidebarNav />
<div className="border-t border-gray-100 p-4">
<div className="mb-3 rounded-lg border border-gray-100 bg-neutral-50 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
Current plan
</p>
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
<DashboardPlanBadge userId={userId} />
</Suspense>
</div>
<div className="flex items-center gap-3 rounded-lg px-2 py-2">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-sm font-semibold text-primary-700"
aria-hidden
>
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">
{userName ?? userEmail.split("@")[0]}
</p>
<p className="truncate text-xs text-neutral-500">{userEmail}</p>
</div>
</div>
<form action="/auth/sign-out" method="post" className="mt-3">
<button
type="submit"
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
>
Sign out
</button>
</form>
</div>
</aside>
);
}
@@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
FolderOpen,
LayoutTemplate,
Settings,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "My Projects", href: "/dashboard", icon: FolderOpen },
{ label: "Templates", href: "/templates", icon: LayoutTemplate },
{ label: "Upgrade", href: "/#pricing", icon: Zap },
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
] as const;
export function DashboardSidebarNav() {
const pathname = usePathname();
return (
<nav className="flex-1 space-y-1 px-3 py-4" aria-label="Dashboard">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(item.href);
return (
<Link
key={item.label}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
isActive
? "bg-primary-50 text-primary-700"
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{item.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Search } from "lucide-react";
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
interface DashboardTopBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
}
export function DashboardTopBar({
searchQuery,
onSearchChange,
}: DashboardTopBarProps) {
return (
<header className="flex flex-col gap-4 border-b border-gray-100 bg-white px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<label className="relative max-w-md flex-1">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400"
aria-hidden
/>
<input
type="search"
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search projects..."
className="w-full rounded-lg border border-gray-100 bg-neutral-50 py-2.5 pl-10 pr-4 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
/>
</label>
<NewProjectMenu triggerClassName="shrink-0 gap-2" />
</header>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { ProjectType } from "@/lib/projects";
interface NewProjectMenuProps {
triggerLabel?: string;
triggerClassName?: string;
align?: "start" | "center" | "end";
}
export function NewProjectMenu({
triggerLabel = "New Project",
triggerClassName,
align = "end",
}: NewProjectMenuProps) {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const createProject = async (type: ProjectType) => {
setIsCreating(true);
try {
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
const data = (await response.json()) as {
project?: { id: string; type: ProjectType };
error?: string;
};
if (!response.ok || !data.project) {
return;
}
if (data.project.type === "video") {
router.push(`/studio/video/${data.project.id}`);
return;
}
if (data.project.type === "image") {
router.push(`/studio/image/${data.project.id}`);
return;
}
router.push("/studio/trimmer");
} finally {
setIsCreating(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className={triggerClassName} disabled={isCreating}>
<Plus className="h-4 w-4" aria-hidden />
{isCreating ? "Creating…" : triggerLabel}
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-56">
<DropdownMenuItem
className="cursor-pointer gap-2"
onClick={() => router.push("/studio/video/new")}
>
<Clapperboard className="h-4 w-4 text-primary-600" />
Video Project
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onClick={() => createProject("image")}
>
<ImageIcon className="h-4 w-4 text-violet-600" />
Image Project
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onClick={() => createProject("trimmer")}
>
<Scissors className="h-4 w-4 text-amber-600" />
Trim/Crop Video
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { OptimizedImage } from "@/components/ui/optimized-image";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
import {
formatLastEdited,
getProjectStudioPath,
getProjectThumbnailSrc,
getProjectTypeLabel,
type DashboardProject,
} from "@/lib/projects";
import { cn } from "@/lib/utils";
interface ProjectCardProps {
project: DashboardProject;
}
function statusBadgeClass(status: DashboardProject["status"]): string {
switch (status) {
case "rendering":
return "bg-amber-100 text-amber-800";
case "ready":
return "bg-green-100 text-green-800";
default:
return "bg-neutral-100 text-neutral-600";
}
}
function statusLabel(status: DashboardProject["status"]): string {
switch (status) {
case "rendering":
return "Rendering";
case "ready":
return "Ready";
default:
return "Draft";
}
}
function typeBadgeClass(type: DashboardProject["type"]): string {
switch (type) {
case "video":
return "bg-primary-100 text-primary-700";
case "image":
return "bg-violet-100 text-violet-700";
case "trimmer":
return "bg-amber-100 text-amber-800";
default:
return "bg-neutral-100 text-neutral-600";
}
}
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
export function ProjectCard({ project }: ProjectCardProps) {
const studioPath = getProjectStudioPath(project);
const showRenderStatus = project.type === "video";
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
// For ready projects use their render; for others use a stock preview clip
const previewVideoSrc =
project.status === "ready" && project.renderUrl
? project.renderUrl
: project.type === "video"
? getTemplatePreviewVideoSrc(project.id)
: null;
return (
<article
className="group overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm transition-shadow hover:shadow-md"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="relative aspect-video overflow-hidden bg-neutral-100">
{/* Thumbnail */}
<OptimizedImage
src={getProjectThumbnailSrc(project.thumbnailSeed)}
alt={project.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
{/* Hover video preview (video projects only) */}
{previewVideoSrc ? (
<AnimatePresence>
{isHovered ? (
<motion.div
key="preview-video"
className="pointer-events-none absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
>
<video
src={previewVideoSrc}
autoPlay
muted
loop
playsInline
preload="metadata"
aria-hidden
className="h-full w-full object-cover"
/>
</motion.div>
) : null}
</AnimatePresence>
) : null}
{/* Action overlay */}
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
<Button
asChild
size="sm"
className="bg-white text-neutral-900 hover:bg-neutral-100"
>
<Link href={studioPath}>
<ExternalLink className="h-3.5 w-3.5" />
Open in Studio
</Link>
</Button>
{project.status === "ready" && project.renderUrl ? (
<Button
asChild
size="sm"
variant="outline"
className="border-white/80 bg-transparent text-white hover:bg-white/10"
>
<a href={project.renderUrl} download>
<Download className="h-3.5 w-3.5" />
Download
</a>
</Button>
) : null}
</div>
</div>
<div className="flex items-start justify-between gap-2 p-4">
<div className="min-w-0">
<h3 className="truncate font-heading text-sm font-semibold text-neutral-900">
{project.name}
</h3>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded px-2 py-0.5 text-[10px] font-bold tracking-wide",
typeBadgeClass(project.type)
)}
>
{getProjectTypeLabel(project.type)}
</span>
{showRenderStatus ? (
<span
className={cn(
"rounded-full px-2.5 py-0.5 text-xs font-medium",
statusBadgeClass(project.status)
)}
>
{statusLabel(project.status)}
</span>
) : null}
<span className="text-xs text-neutral-500">
{formatLastEdited(project.lastEditedAt)}
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
aria-label={`Actions for ${project.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={studioPath} className="gap-2">
<ExternalLink className="h-4 w-4" />
Open in Studio
</Link>
</DropdownMenuItem>
{project.renderUrl ? (
<DropdownMenuItem asChild>
<a href={project.renderUrl} download className="gap-2">
<Download className="h-4 w-4" />
Download
</a>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2">
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Copy className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</article>
);
}
@@ -0,0 +1,21 @@
export function SkeletonProjectCard() {
return (
<article
className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"
aria-hidden
>
<div className="aspect-video animate-pulse bg-gray-200" />
<div className="flex items-start justify-between gap-2 p-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-3/4 max-w-[180px] animate-pulse rounded bg-gray-200" />
<div className="flex flex-wrap items-center gap-2">
<div className="h-5 w-14 animate-pulse rounded bg-gray-200" />
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-200" />
<div className="h-4 w-20 animate-pulse rounded bg-gray-200" />
</div>
</div>
<div className="h-8 w-8 shrink-0 animate-pulse rounded-lg bg-gray-200" />
</div>
</article>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
import {
getBaseImageLayer,
useImageEditorStore,
} from "@/lib/image-editor-store";
export function AiRemoveBgModal() {
const isOpen = useImageEditorStore((s) => s.isAiModalOpen);
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
const replaceBaseImage = useImageEditorStore((s) => s.replaceBaseImage);
const layers = useImageEditorStore((s) => s.layers);
const [isLoading, setIsLoading] = useState(false);
const handleRemoveBg = async () => {
const stage = getImageEditorStage();
const base = getBaseImageLayer({ layers });
if (!stage || !base) {
toast({ title: "Open an image first." });
return;
}
const dataUrl = stage.toDataURL({ pixelRatio: 1 });
setIsLoading(true);
try {
const response = await fetch("/api/remove-bg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: dataUrl }),
});
const payload = (await response.json()) as {
image?: string;
error?: string;
};
if (!response.ok || !payload.image) {
toast({ title: payload.error ?? "Background removal failed." });
return;
}
replaceBaseImage(payload.image);
toast({ title: "Background removed!" });
setAiModalOpen(false);
} catch {
toast({ title: "Could not reach background removal service." });
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setAiModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary-400" />
AI Background Removal
</DialogTitle>
<DialogDescription>
Remove the background from your base image. The result replaces the
background layer with a transparent PNG.
</DialogDescription>
</DialogHeader>
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
disabled={isLoading}
onClick={handleRemoveBg}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
</>
) : (
"Remove Background"
)}
</Button>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { ImageCropAspectRatio } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
const ASPECT_OPTIONS: { id: ImageCropAspectRatio; label: string }[] = [
{ id: "free", label: "Free" },
{ id: "1:1", label: "1:1" },
{ id: "16:9", label: "16:9" },
{ id: "4:3", label: "4:3" },
{ id: "9:16", label: "9:16" },
];
export function ImageCropControls() {
const [applying, setApplying] = useState(false);
const activeTool = useImageEditorStore((s) => s.activeTool);
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
const setCropAspectRatio = useImageEditorStore((s) => s.setCropAspectRatio);
const applyCrop = useImageEditorStore((s) => s.applyCrop);
const cancelCrop = useImageEditorStore((s) => s.cancelCrop);
if (activeTool !== "crop") return null;
const handleApply = async () => {
setApplying(true);
try {
await applyCrop();
} finally {
setApplying(false);
}
};
return (
<div className="flex shrink-0 flex-wrap items-center justify-center gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3">
<div className="flex flex-wrap gap-2">
{ASPECT_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setCropAspectRatio(option.id)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500",
cropAspectRatio === option.id
? "border-violet-600 bg-violet-600 text-white"
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
)}
>
{option.label}
</button>
))}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-700 bg-gray-800 text-gray-200"
onClick={cancelCrop}
disabled={applying}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="bg-violet-600 hover:bg-violet-700"
onClick={() => void handleApply()}
disabled={applying}
>
{applying ? "Applying…" : "Apply Crop"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import dynamic from "next/dynamic";
import { AiRemoveBgModal } from "@/components/image-editor/AiRemoveBgModal";
import { ImageCropControls } from "@/components/image-editor/ImageCropControls";
import { ImageEditorRightPanel } from "@/components/image-editor/ImageEditorRightPanel";
import { ImageEditorToolbar } from "@/components/image-editor/ImageEditorToolbar";
import { ImageEditorTopBar } from "@/components/image-editor/ImageEditorTopBar";
import { StudioMobileGate } from "@/components/studio/StudioMobileGate";
import { Toaster } from "@/components/ui/toaster";
import { useImageProjectPersistence } from "@/hooks/useImageProjectPersistence";
import { useIsMobile } from "@/hooks/useIsMobile";
const ImageEditorCanvas = dynamic(
() =>
import("@/components/image-editor/canvas/ImageEditorCanvas").then(
(mod) => mod.ImageEditorCanvas
),
{ ssr: false, loading: () => <div className="h-full w-full bg-gray-950" /> }
);
export interface ImageEditorLayoutProps {
projectId?: string;
}
export function ImageEditorLayout({ projectId }: ImageEditorLayoutProps) {
const { isMobile, isReady } = useIsMobile();
const { projectName, saveStatus, retrySave } =
useImageProjectPersistence(projectId);
if (!isReady) {
return <div className="h-screen w-screen bg-gray-950" aria-hidden />;
}
if (isMobile) {
return <StudioMobileGate variant="image" />;
}
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-950 text-white">
<Toaster />
<ImageEditorTopBar
projectId={projectId}
projectName={projectName}
saveStatus={saveStatus}
onSaveRetry={retrySave}
/>
<div className="flex min-h-0 flex-1">
<ImageEditorToolbar />
<main className="flex min-w-0 flex-1 flex-col">
<ImageCropControls />
<div className="min-h-0 flex-1">
<ImageEditorCanvas />
</div>
</main>
<ImageEditorRightPanel />
</div>
<AiRemoveBgModal />
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { AdjustPanel } from "@/components/image-editor/panels/AdjustPanel";
import { FiltersPanel } from "@/components/image-editor/panels/FiltersPanel";
import { LayersPanel } from "@/components/image-editor/panels/LayersPanel";
import type { ImagePanelTab } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
const TABS: { id: ImagePanelTab; label: string }[] = [
{ id: "adjust", label: "Adjust" },
{ id: "filters", label: "Filters" },
{ id: "layers", label: "Layers" },
];
export function ImageEditorRightPanel() {
const activePanelTab = useImageEditorStore((s) => s.activePanelTab);
const setActivePanelTab = useImageEditorStore((s) => s.setActivePanelTab);
return (
<aside className="flex w-[280px] shrink-0 flex-col border-l border-gray-800 bg-gray-900">
<div className="flex border-b border-gray-800">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActivePanelTab(tab.id)}
className={cn(
"flex-1 px-2 py-3 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500",
activePanelTab === tab.id
? "border-b-2 border-primary-500 text-white"
: "text-gray-500 hover:text-gray-300"
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{activePanelTab === "adjust" ? <AdjustPanel /> : null}
{activePanelTab === "filters" ? <FiltersPanel /> : null}
{activePanelTab === "layers" ? <LayersPanel /> : null}
</div>
</aside>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import {
Crop,
MousePointer2,
Pencil,
Shapes,
Sparkles,
Type,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ImageShapeKind, ImageTool } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
const TOOLS: { id: ImageTool; label: string; icon: typeof MousePointer2 }[] = [
{ id: "select", label: "Select", icon: MousePointer2 },
{ id: "crop", label: "Crop", icon: Crop },
{ id: "text", label: "Text", icon: Type },
{ id: "shape", label: "Shape", icon: Shapes },
{ id: "draw", label: "Draw", icon: Pencil },
{ id: "ai", label: "AI", icon: Sparkles },
];
const SHAPES: { id: ImageShapeKind; label: string }[] = [
{ id: "rect", label: "Rectangle" },
{ id: "circle", label: "Circle" },
{ id: "line", label: "Line" },
{ id: "arrow", label: "Arrow" },
];
export function ImageEditorToolbar() {
const [shapeOpen, setShapeOpen] = useState(false);
const activeTool = useImageEditorStore((s) => s.activeTool);
const setActiveTool = useImageEditorStore((s) => s.setActiveTool);
const setPendingShape = useImageEditorStore((s) => s.setPendingShape);
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
return (
<aside className="flex w-14 shrink-0 flex-col items-center gap-1 border-r border-gray-800 bg-gray-900 py-3">
{TOOLS.map((tool) => {
const Icon = tool.icon;
if (tool.id === "shape") {
return (
<Popover key={tool.id} open={shapeOpen} onOpenChange={setShapeOpen}>
<PopoverTrigger asChild>
<button
type="button"
title={tool.label}
onClick={() => setActiveTool("shape")}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
activeTool === "shape"
? "bg-primary-600 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
)}
>
<Icon className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-36">
{SHAPES.map((shape) => (
<button
key={shape.id}
type="button"
onClick={() => {
setPendingShape(shape.id);
setActiveTool("shape");
setShapeOpen(false);
}}
className="flex w-full rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-gray-700"
>
{shape.label}
</button>
))}
</PopoverContent>
</Popover>
);
}
return (
<button
key={tool.id}
type="button"
title={tool.label}
onClick={() => {
if (tool.id === "ai") {
setAiModalOpen(true);
} else {
setActiveTool(tool.id);
}
}}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
activeTool === tool.id
? "bg-primary-600 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
)}
>
<Icon className="h-4 w-4" />
</button>
);
})}
</aside>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { Download, FolderOpen, Sparkles } from "lucide-react";
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { toast } from "@/components/ui/use-toast";
import { downloadStageImage } from "@/lib/image-editor-export";
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
import type { ExportImageFormat } from "@/lib/image-editor-types";
import type { ProjectSaveStatus } from "@/lib/project-save-status";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
export interface ImageEditorTopBarProps {
projectId?: string;
projectName?: string;
saveStatus?: ProjectSaveStatus;
onSaveRetry?: () => void;
}
export function ImageEditorTopBar({
projectId,
projectName,
saveStatus = "idle",
onSaveRetry,
}: ImageEditorTopBarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
const exportFormat = useImageEditorStore((s) => s.exportFormat);
const exportQuality = useImageEditorStore((s) => s.exportQuality);
const setExportFormat = useImageEditorStore((s) => s.setExportFormat);
const setExportQuality = useImageEditorStore((s) => s.setExportQuality);
const hasImage = useImageEditorStore((s) =>
s.layers.some((l) => l.type === "image")
);
const handleOpenFile = (file: File) => {
const url = URL.createObjectURL(file);
const img = new window.Image();
img.onload = () => {
loadBaseImage(url, img.naturalWidth, img.naturalHeight);
};
img.src = url;
};
const handleExport = () => {
const stage = getImageEditorStage();
if (!stage) {
toast({ title: "Canvas not ready." });
return;
}
downloadStageImage(stage, exportFormat, exportQuality);
toast({ title: "Export started" });
setExportOpen(false);
};
return (
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
<div className="flex items-center gap-3">
<Link
href="/image-maker"
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white"
>
<Sparkles className="h-4 w-4 text-violet-500" />
<span className="font-heading font-semibold text-white">
{projectName ?? "Image Editor"}
</span>
</Link>
{projectId ? (
<span className="text-xs text-gray-500">{projectId.slice(0, 8)}</span>
) : null}
<ProjectSaveIndicator status={saveStatus} onRetry={onSaveRetry} />
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleOpenFile(file);
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-700 bg-gray-800 text-gray-200"
onClick={() => fileRef.current?.click()}
>
<FolderOpen className="h-4 w-4" />
Open
</Button>
</div>
<div className="relative">
<Button
type="button"
size="sm"
className="bg-primary-600 hover:bg-primary-700"
disabled={!hasImage}
onClick={() => setExportOpen((v) => !v)}
>
<Download className="h-4 w-4" />
Export
</Button>
{exportOpen ? (
<div className="absolute right-0 top-full z-50 mt-2 w-64 rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-xl">
<p className="mb-2 text-xs font-semibold text-gray-400">Format</p>
<div className="mb-4 flex gap-2">
{(["png", "jpg", "webp"] as ExportImageFormat[]).map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => setExportFormat(fmt)}
className={cn(
"flex-1 rounded-lg border py-1.5 text-xs font-medium uppercase",
exportFormat === fmt
? "border-primary-500 bg-primary-600/20 text-white"
: "border-gray-700 text-gray-400"
)}
>
{fmt}
</button>
))}
</div>
{exportFormat !== "png" ? (
<div className="mb-4">
<div className="mb-2 flex justify-between text-xs text-gray-400">
<span>Quality</span>
<span>{exportQuality}%</span>
</div>
<Slider
min={60}
max={100}
step={1}
value={[exportQuality]}
onValueChange={([v]) => setExportQuality(v ?? 90)}
/>
</div>
) : null}
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={handleExport}
>
Download
</Button>
</div>
) : null}
</div>
</header>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Image } from "react-konva";
import type Konva from "konva";
import useImage from "use-image";
import {
applyAdjustmentsToNode,
buildKonvaFilterList,
} from "@/lib/image-editor-konva";
import type { ImageAdjustments, ImageLayer } from "@/lib/image-editor-types";
interface ImageBaseLayerProps {
layer: ImageLayer;
adjustments: ImageAdjustments;
interactive?: boolean;
onSelect: () => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function ImageBaseLayer({
layer,
adjustments,
interactive = true,
onSelect,
registerNode,
}: ImageBaseLayerProps) {
const [konvaNode, setKonvaNode] = useState<Konva.Image | null>(null);
const src =
typeof layer.props.src === "string" ? layer.props.src : undefined;
const [image] = useImage(src ?? "", "anonymous");
const filters = buildKonvaFilterList(adjustments);
useEffect(() => {
if (!konvaNode || !image) return;
applyAdjustmentsToNode(konvaNode, adjustments, filters);
}, [konvaNode, image, adjustments, filters]);
if (!image) return null;
return (
<Image
ref={(node) => {
registerNode(layer.id, node);
setKonvaNode(node);
}}
image={image}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
listening={interactive}
onMouseDown={
interactive
? (event) => {
event.cancelBubble = true;
onSelect();
}
: undefined
}
onTap={
interactive
? (event) => {
event.cancelBubble = true;
onSelect();
}
: undefined
}
/>
);
}
@@ -0,0 +1,53 @@
"use client";
import { Rnd } from "react-rnd";
import { getCropAspectRatioValue } from "@/lib/image-editor-crop";
import type { CropRect, ImageCropAspectRatio } from "@/lib/image-editor-types";
interface ImageCropOverlayProps {
cropRect: CropRect;
scale: number;
aspectRatio: ImageCropAspectRatio;
onCropChange: (rect: CropRect) => void;
}
export function ImageCropOverlay({
cropRect,
scale,
aspectRatio,
onCropChange,
}: ImageCropOverlayProps) {
const lockRatio = getCropAspectRatioValue(aspectRatio);
return (
<Rnd
size={{
width: cropRect.w * scale,
height: cropRect.h * scale,
}}
position={{
x: cropRect.x * scale,
y: cropRect.y * scale,
}}
bounds="parent"
lockAspectRatio={lockRatio}
onDragStop={(_e, data) =>
onCropChange({
...cropRect,
x: data.x / scale,
y: data.y / scale,
})
}
onResizeStop={(_e, _dir, ref, _delta, position) =>
onCropChange({
x: position.x / scale,
y: position.y / scale,
w: ref.offsetWidth / scale,
h: ref.offsetHeight / scale,
})
}
className="border-2 border-dashed border-violet-500 bg-violet-500/10"
/>
);
}
@@ -0,0 +1,246 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Layer, Rect, Stage, Transformer } from "react-konva";
import type Konva from "konva";
import { ImageCropOverlay } from "@/components/image-editor/canvas/ImageCropOverlay";
import { ImageEditorLayerNode } from "@/components/image-editor/canvas/ImageEditorLayerNode";
import { VignetteOverlay } from "@/components/image-editor/canvas/VignetteOverlay";
import { useContainerSize } from "@/hooks/useContainerSize";
import {
nodeToImageLayer,
resetNodeScale,
} from "@/lib/image-editor-transform";
import { registerImageEditorStage } from "@/lib/image-editor-stage-ref";
import {
getBaseImageLayer,
useImageEditorStore,
} from "@/lib/image-editor-store";
export function ImageEditorCanvas() {
const { ref: containerRef, width: cw, height: ch } = useContainerSize();
const transformerRef = useRef<Konva.Transformer>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const [drawPoints, setDrawPoints] = useState<number[]>([]);
const pendingShape = useImageEditorStore((s) => s.pendingShape);
const canvasWidth = useImageEditorStore((s) => s.canvasWidth);
const canvasHeight = useImageEditorStore((s) => s.canvasHeight);
const layers = useImageEditorStore((s) => s.layers);
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
const activeTool = useImageEditorStore((s) => s.activeTool);
const adjustments = useImageEditorStore((s) => s.adjustments);
const cropRect = useImageEditorStore((s) => s.cropRect);
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
const updateLayer = useImageEditorStore((s) => s.updateLayer);
const setCropRect = useImageEditorStore((s) => s.setCropRect);
const addLayer = useImageEditorStore((s) => s.addLayer);
const scale = cw > 0 ? Math.min(cw / canvasWidth, ch / canvasHeight) : 1;
const stageW = canvasWidth * scale;
const stageH = canvasHeight * scale;
const sorted = useMemo(
() => [...layers].sort((a, b) => a.zIndex - b.zIndex),
[layers]
);
const baseLayer = getBaseImageLayer({ layers });
useEffect(() => {
const tr = transformerRef.current;
if (!tr || activeTool !== "select") {
tr?.nodes([]);
return;
}
if (!selectedLayerId) {
tr.nodes([]);
return;
}
const node = nodeRefs.current.get(selectedLayerId);
if (node) {
tr.nodes([node]);
tr.getLayer()?.batchDraw();
}
}, [selectedLayerId, sorted, activeTool]);
const pointerToCanvas = useCallback(
(stage: Konva.Stage) => {
const pos = stage.getPointerPosition();
if (!pos) return null;
return { x: pos.x / scale, y: pos.y / scale };
},
[scale]
);
const handleStagePointerDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const pt = pointerToCanvas(stage);
if (!pt) return;
if (activeTool === "text") {
addLayer({
type: "text",
name: "Text",
x: pt.x,
y: pt.y,
width: 280,
height: 48,
props: { text: "New text", fontSize: 36, fill: "#ffffff" },
});
return;
}
if (activeTool === "shape") {
addLayer({
type: "shape",
name: pendingShape,
x: pt.x,
y: pt.y,
width: pendingShape === "line" ? 160 : 120,
height: pendingShape === "line" ? 8 : 120,
props: { shape: pendingShape, fill: "#2563EB" },
});
return;
}
if (activeTool === "draw") {
setDrawPoints([pt.x, pt.y]);
return;
}
if (e.target === stage) setSelectedLayer(null);
};
const handleStagePointerMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (activeTool !== "draw" || drawPoints.length === 0) return;
const stage = e.target.getStage();
if (!stage) return;
const pt = pointerToCanvas(stage);
if (!pt) return;
setDrawPoints((prev) => [...prev, pt.x, pt.y]);
};
const handleStagePointerUp = () => {
if (activeTool !== "draw" || drawPoints.length < 4) {
setDrawPoints([]);
return;
}
addLayer({
type: "draw",
name: "Drawing",
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
props: { points: drawPoints, stroke: "#ffffff", strokeWidth: 4 },
});
setDrawPoints([]);
};
const isCropping = activeTool === "crop";
if (cw <= 0) {
return <div ref={containerRef} className="h-full w-full bg-gray-950" />;
}
return (
<div
ref={containerRef}
className="relative flex h-full w-full items-center justify-center overflow-hidden bg-gray-950"
>
<div
className="relative shadow-2xl"
style={{ width: stageW, height: stageH }}
>
<Stage
ref={(node) => registerImageEditorStage(node)}
width={stageW}
height={stageH}
scaleX={scale}
scaleY={scale}
onMouseDown={isCropping ? undefined : handleStagePointerDown}
onMousemove={isCropping ? undefined : handleStagePointerMove}
onMouseup={isCropping ? undefined : handleStagePointerUp}
className="bg-checkerboard"
>
<Layer>
<Rect
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fill="#ffffff"
listening={false}
/>
{sorted.map((layer) => (
<ImageEditorLayerNode
key={layer.id}
layer={layer}
adjustments={adjustments}
isBaseImage={layer.id === baseLayer?.id}
interactive={!isCropping}
onSelect={() => setSelectedLayer(layer.id)}
onDragEnd={(x, y) => updateLayer(layer.id, { x, y })}
onTransformEnd={(node) => {
resetNodeScale(node);
updateLayer(layer.id, nodeToImageLayer(node));
}}
registerNode={(id, node) => {
if (node) nodeRefs.current.set(id, node);
else nodeRefs.current.delete(id);
}}
/>
))}
{drawPoints.length > 0 ? (
<ImageEditorLayerNode
layer={{
id: "preview-draw",
type: "draw",
name: "preview",
visible: true,
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
rotation: 0,
opacity: 1,
zIndex: 9999,
props: {
points: drawPoints,
stroke: "#ffffff",
strokeWidth: 4,
},
}}
adjustments={adjustments}
isBaseImage={false}
interactive={false}
onSelect={() => undefined}
onDragEnd={() => undefined}
onTransformEnd={() => undefined}
registerNode={() => undefined}
/>
) : null}
<VignetteOverlay
width={canvasWidth}
height={canvasHeight}
amount={adjustments.vignette}
/>
{activeTool === "select" ? (
<Transformer ref={transformerRef} rotateEnabled borderStroke="#7C3AED" />
) : null}
</Layer>
</Stage>
{isCropping && cropRect ? (
<ImageCropOverlay
cropRect={cropRect}
scale={scale}
aspectRatio={cropAspectRatio}
onCropChange={setCropRect}
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { Arrow, Circle, Line, Rect, Text } from "react-konva";
import type Konva from "konva";
import { ImageBaseLayer } from "@/components/image-editor/canvas/ImageBaseLayer";
import type {
ImageAdjustments,
ImageLayer,
ImageShapeKind,
} from "@/lib/image-editor-types";
interface ImageEditorLayerNodeProps {
layer: ImageLayer;
adjustments: ImageAdjustments;
isBaseImage: boolean;
interactive?: boolean;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function ImageEditorLayerNode({
layer,
adjustments,
isBaseImage,
interactive = true,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: ImageEditorLayerNodeProps) {
if (!layer.visible) return null;
if (layer.type === "image") {
return (
<ImageBaseLayer
layer={layer}
adjustments={adjustments}
interactive={interactive}
onSelect={onSelect}
registerNode={registerNode}
/>
);
}
const common = {
rotation: layer.rotation,
opacity: layer.opacity,
listening: interactive,
draggable: interactive && !isBaseImage,
onMouseDown: interactive
? (e: Konva.KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
onSelect();
}
: undefined,
onTap: interactive
? (e: Konva.KonvaEventObject<TouchEvent>) => {
e.cancelBubble = true;
onSelect();
}
: undefined,
onDragEnd: interactive
? (e: Konva.KonvaEventObject<DragEvent>) =>
onDragEnd(e.target.x(), e.target.y())
: undefined,
onTransformEnd: interactive
? (e: Konva.KonvaEventObject<Event>) => onTransformEnd(e.target)
: undefined,
};
if (layer.type === "text") {
return (
<Text
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
width={layer.width}
text={typeof layer.props.text === "string" ? layer.props.text : "Text"}
fontSize={
typeof layer.props.fontSize === "number" ? layer.props.fontSize : 36
}
fill={
typeof layer.props.fill === "string" ? layer.props.fill : "#ffffff"
}
{...common}
/>
);
}
if (layer.type === "draw") {
const points = Array.isArray(layer.props.points)
? (layer.props.points as number[])
: [];
return (
<Line
ref={(n) => registerNode(layer.id, n)}
points={points}
x={layer.x}
y={layer.y}
stroke={
typeof layer.props.stroke === "string"
? layer.props.stroke
: "#ffffff"
}
strokeWidth={
typeof layer.props.strokeWidth === "number"
? layer.props.strokeWidth
: 4
}
tension={0.5}
lineCap="round"
lineJoin="round"
{...common}
/>
);
}
if (layer.type === "shape") {
const shape = (layer.props.shape as ImageShapeKind) ?? "rect";
const fill =
typeof layer.props.fill === "string" ? layer.props.fill : "#2563EB";
if (shape === "circle") {
const r = Math.min(layer.width, layer.height) / 2;
return (
<Circle
ref={(n) => registerNode(layer.id, n)}
x={layer.x + layer.width / 2}
y={layer.y + layer.height / 2}
radius={r}
fill={fill}
{...common}
/>
);
}
if (shape === "line") {
return (
<Line
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
points={[0, 0, layer.width, layer.height]}
stroke={fill}
strokeWidth={4}
{...common}
/>
);
}
if (shape === "arrow") {
return (
<Arrow
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y + layer.height / 2}
points={[0, 0, layer.width, 0]}
fill={fill}
stroke={fill}
pointerLength={10}
pointerWidth={10}
{...common}
/>
);
}
return (
<Rect
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
fill={fill}
{...common}
/>
);
}
return null;
}
@@ -0,0 +1,39 @@
"use client";
import { Rect } from "react-konva";
interface VignetteOverlayProps {
width: number;
height: number;
amount: number;
}
export function VignetteOverlay({
width,
height,
amount,
}: VignetteOverlayProps) {
if (amount <= 0) return null;
const opacity = Math.min(0.85, amount / 100);
return (
<Rect
x={0}
y={0}
width={width}
height={height}
fillRadialGradientStartPoint={{ x: width / 2, y: height / 2 }}
fillRadialGradientStartRadius={0}
fillRadialGradientEndPoint={{ x: width / 2, y: height / 2 }}
fillRadialGradientEndRadius={Math.max(width, height) / 1.1}
fillRadialGradientColorStops={[
0,
"rgba(0,0,0,0)",
1,
`rgba(0,0,0,${opacity})`,
]}
listening={false}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { Slider } from "@/components/ui/slider";
import { useImageEditorStore } from "@/lib/image-editor-store";
const SLIDERS = [
{ key: "brightness" as const, label: "Brightness", min: -100, max: 100 },
{ key: "contrast" as const, label: "Contrast", min: -100, max: 100 },
{ key: "saturation" as const, label: "Saturation", min: -100, max: 100 },
{ key: "hue" as const, label: "Hue", min: -180, max: 180 },
{ key: "blur" as const, label: "Blur", min: 0, max: 20 },
{ key: "sharpen" as const, label: "Sharpen", min: 0, max: 10 },
{ key: "vignette" as const, label: "Vignette", min: 0, max: 100 },
];
export function AdjustPanel() {
const adjustments = useImageEditorStore((s) => s.adjustments);
const setAdjustments = useImageEditorStore((s) => s.setAdjustments);
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
if (!hasBase) {
return (
<p className="text-xs text-gray-500">Open an image to use adjustments.</p>
);
}
return (
<div className="space-y-5">
{SLIDERS.map(({ key, label, min, max }) => (
<div key={key}>
<div className="mb-2 flex justify-between text-xs text-gray-400">
<span>{label}</span>
<span className="tabular-nums text-gray-300">
{adjustments[key]}
</span>
</div>
<Slider
min={min}
max={max}
step={key === "hue" ? 1 : key === "blur" ? 0.5 : 1}
value={[adjustments[key]]}
onValueChange={([value]) =>
setAdjustments({ [key]: value ?? adjustments[key] })
}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { FILTER_PRESETS } from "@/lib/image-editor-filters";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
export function FiltersPanel() {
const activeFilterPreset = useImageEditorStore((s) => s.activeFilterPreset);
const applyFilterPreset = useImageEditorStore((s) => s.applyFilterPreset);
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
if (!hasBase) {
return <p className="text-xs text-gray-500">Open an image to apply filters.</p>;
}
return (
<div className="grid grid-cols-2 gap-2">
{FILTER_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => applyFilterPreset(preset.id)}
className={cn(
"rounded-lg border px-2 py-3 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
activeFilterPreset === preset.id
? "border-primary-500 bg-primary-600/20 text-white"
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
)}
>
<span
className="mb-2 block h-10 w-full rounded-md"
style={{
background:
preset.id === "bw"
? "linear-gradient(135deg,#6b7280,#111827)"
: preset.id === "vivid"
? "linear-gradient(135deg,#f59e0b,#ef4444)"
: preset.id === "cool"
? "linear-gradient(135deg,#38bdf8,#6366f1)"
: preset.id === "warm"
? "linear-gradient(135deg,#fb923c,#facc15)"
: "linear-gradient(135deg,#4b5563,#9ca3af)",
}}
/>
{preset.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,157 @@
"use client";
import {
DndContext,
closestCenter,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Eye, EyeOff, GripVertical, Trash2 } from "lucide-react";
import type { ImageLayer } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
function layerIcon(type: ImageLayer["type"]): string {
switch (type) {
case "image":
return "🖼";
case "text":
return "T";
case "shape":
return "□";
case "draw":
return "✎";
default:
return "•";
}
}
function SortableLayerRow({ layer }: { layer: ImageLayer }) {
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
const toggleLayerVisibility = useImageEditorStore((s) => s.toggleLayerVisibility);
const deleteLayer = useImageEditorStore((s) => s.deleteLayer);
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: layer.id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(
"flex items-center gap-1 rounded-lg border px-2 py-2",
selectedLayerId === layer.id
? "border-primary-500 bg-primary-600/15"
: "border-gray-700 bg-gray-800/80"
)}
>
<button
type="button"
className="cursor-grab text-gray-500 hover:text-gray-300"
aria-label={`Reorder ${layer.name}`}
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setSelectedLayer(layer.id)}
className="flex min-w-0 flex-1 items-center gap-2 text-left text-xs text-gray-200"
>
<span className="w-4 text-center">{layerIcon(layer.type)}</span>
<span className="truncate">{layer.name}</span>
</button>
<button
type="button"
onClick={() => toggleLayerVisibility(layer.id)}
className="text-gray-400 hover:text-white"
aria-label={layer.visible ? "Hide layer" : "Show layer"}
>
{layer.visible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button>
{layer.type !== "image" ? (
<button
type="button"
onClick={() => deleteLayer(layer.id)}
className="text-gray-400 hover:text-red-400"
aria-label={`Delete ${layer.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : (
<span className="w-3.5" />
)}
</div>
);
}
export function LayersPanel() {
const layers = useImageEditorStore((s) => s.layers);
const reorderLayers = useImageEditorStore((s) => s.reorderLayers);
const reversed = [...layers].sort((a, b) => b.zIndex - a.zIndex);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = reversed.findIndex((l) => l.id === active.id);
const newIndex = reversed.findIndex((l) => l.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const next = [...reversed];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
reorderLayers([...next].reverse().map((l) => l.id));
};
if (layers.length === 0) {
return <p className="text-xs text-gray-500">No layers yet.</p>;
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={reversed.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{reversed.map((layer) => (
<SortableLayerRow key={layer.id} layer={layer} />
))}
</div>
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,43 @@
import { OptimizedImage } from "@/components/ui/optimized-image";
export function ImageMakerBeforeAfter() {
return (
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-xl">
<div className="grid grid-cols-2 divide-x divide-gray-100">
<div className="relative">
<div className="relative aspect-[4/5] sm:aspect-square">
<OptimizedImage
src="https://picsum.photos/seed/im-before/400/500"
alt="Before editing"
fill
priority
sizes="(max-width: 1024px) 50vw, 320px"
className="object-cover grayscale"
/>
</div>
<span className="absolute left-3 top-3 rounded-md bg-neutral-900/70 px-2 py-1 text-xs font-semibold text-white backdrop-blur-sm">
Before
</span>
</div>
<div className="relative">
<div className="relative aspect-[4/5] sm:aspect-square">
<OptimizedImage
src="https://picsum.photos/seed/im-after/400/500"
alt="After editing with AI"
fill
priority
sizes="(max-width: 1024px) 50vw, 320px"
className="object-cover"
/>
</div>
<span className="absolute left-3 top-3 rounded-md bg-violet-600 px-2 py-1 text-xs font-semibold text-white">
After
</span>
</div>
</div>
<p className="border-t border-gray-100 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
AI-enhanced color, layout, and brand styling applied in one click
</p>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { SectionReveal } from "@/components/sections/SectionReveal";
import { Button } from "@/components/ui/button";
export function ImageMakerCta() {
return (
<section className="bg-violet-600 py-20 sm:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="font-heading text-3xl font-bold text-white sm:text-4xl">
Start designing your next visual today
</h2>
<p className="mt-4 text-lg text-violet-100">
Free plan includes exports and basic templates. Upgrade anytime for AI
generation and brand kits.
</p>
<Button
size="lg"
className="mt-8 bg-white text-violet-600 hover:bg-violet-50"
asChild
>
<Link href="/auth?tab=sign-up">Start Creating Images Free</Link>
</Button>
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,91 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
Files,
LayoutTemplate,
Maximize2,
Palette,
Sparkles,
} from "lucide-react";
import { SectionReveal } from "@/components/sections/SectionReveal";
interface Feature {
icon: LucideIcon;
title: string;
description: string;
}
const features: Feature[] = [
{
icon: Sparkles,
title: "AI image generation",
description:
"Describe your idea and get on-brand visuals, backgrounds, and product shots in seconds.",
},
{
icon: LayoutTemplate,
title: "Templates",
description:
"Start from layouts built for posts, stories, ads, and presentations—fully editable.",
},
{
icon: Maximize2,
title: "Resize for any platform",
description:
"One design, every size: Instagram, LinkedIn, banners, and print-ready exports.",
},
{
icon: Palette,
title: "Brand kit",
description:
"Lock logos, fonts, and colors so every asset stays consistent across your team.",
},
{
icon: Files,
title: "Batch export",
description:
"Export dozens of variations at once for campaigns, locales, and A/B tests.",
},
];
export function ImageMakerFeatures() {
return (
<section className="bg-neutral-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
Design smarter, not harder
</h2>
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
CreatorStudio Image Maker combines AI generation with pro layout tools
in one workflow.
</p>
</SectionReveal>
<SectionReveal className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => {
const Icon = feature.icon;
return (
<article
key={feature.title}
className="rounded-xl border border-gray-100 bg-white p-6 shadow-sm"
>
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-violet-600 text-white">
<Icon className="h-5 w-5" aria-hidden />
</div>
<h3 className="mt-4 font-heading text-lg font-semibold text-neutral-900">
{feature.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
{feature.description}
</p>
</article>
);
})}
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,41 @@
import { OptimizedImage } from "@/components/ui/optimized-image";
import { SectionReveal } from "@/components/sections/SectionReveal";
import { GALLERY_ITEMS } from "./image-maker-gallery-data";
export function ImageMakerGallery() {
return (
<section id="gallery" className="bg-neutral-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
Example outputs from creators
</h2>
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
Real-world layouts and styles you can recreateor use as inspiration
for your next project.
</p>
</SectionReveal>
<SectionReveal className="mt-12 columns-2 gap-4 sm:columns-3 lg:columns-4 lg:gap-5">
{GALLERY_ITEMS.map((item) => (
<article
key={item.id}
className="mb-4 break-inside-avoid overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm lg:mb-5"
>
<div className={`relative w-full ${item.aspectClass}`}>
<OptimizedImage
src={`https://picsum.photos/seed/${item.id}/600/800`}
alt={item.alt}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform duration-300 ease-out hover:scale-105"
/>
</div>
</article>
))}
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { ImageMakerBeforeAfter } from "./ImageMakerBeforeAfter";
export function ImageMakerHero() {
return (
<section className="relative overflow-hidden bg-white pb-16 pt-12 sm:pb-20 sm:pt-16">
<div className="pointer-events-none absolute -left-32 top-0 h-96 w-96 rounded-full bg-violet-200/40 blur-3xl" />
<div className="pointer-events-none absolute -right-32 top-20 h-80 w-80 rounded-full bg-violet-100/60 blur-3xl" />
<div className="relative mx-auto grid max-w-7xl items-center gap-12 px-4 lg:grid-cols-2 lg:gap-16 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="inline-flex rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
Image Maker
</span>
<h1 className="mt-4 font-heading text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl">
AI Image Maker Design professional visuals instantly
</h1>
<p className="mt-6 text-lg leading-relaxed text-neutral-600">
Generate, resize, and brand every asset for social, ads, and print
without switching tools or hiring a designer.
</p>
<div className="mt-8 flex flex-col gap-4 sm:flex-row">
<Button
size="lg"
className="bg-violet-600 hover:bg-violet-700"
asChild
>
<Link href="/sign-up">Start Creating Images Free</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="#gallery">View example gallery</Link>
</Button>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
>
<ImageMakerBeforeAfter />
</motion.div>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
Hexagon,
Image as ImageIcon,
RectangleHorizontal,
Share2,
} from "lucide-react";
import { SectionReveal } from "@/components/sections/SectionReveal";
interface UseCase {
title: string;
description: string;
icon: LucideIcon;
}
const useCases: UseCase[] = [
{
title: "Social Posts",
description:
"Square, portrait, and carousel layouts with bold typography and safe zones.",
icon: Share2,
},
{
title: "Thumbnails",
description:
"High-contrast covers for YouTube, podcasts, and courses that read at any size.",
icon: ImageIcon,
},
{
title: "Banners",
description:
"Website heroes, email headers, and ad banners with responsive crop guides.",
icon: RectangleHorizontal,
},
{
title: "Logos",
description:
"Vector-friendly marks and lockups with transparent exports for any background.",
icon: Hexagon,
},
];
export function ImageMakerUseCases() {
return (
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
Visuals for every use case
</h2>
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
From quick social graphics to polished brand assetsone tool, every
format.
</p>
</SectionReveal>
<SectionReveal className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{useCases.map((useCase) => {
const Icon = useCase.icon;
return (
<article
key={useCase.title}
className="rounded-xl border border-gray-100 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-50 text-violet-600">
<Icon className="h-6 w-6" aria-hidden />
</div>
<h3 className="mt-4 font-heading text-lg font-semibold text-neutral-900">
{useCase.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
{useCase.description}
</p>
</article>
);
})}
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,20 @@
export interface GalleryItem {
id: string;
alt: string;
aspectClass: string;
}
export const GALLERY_ITEMS: GalleryItem[] = [
{ id: "im-1", alt: "Social post design", aspectClass: "aspect-[4/5]" },
{ id: "im-2", alt: "Product banner", aspectClass: "aspect-square" },
{ id: "im-3", alt: "Brand thumbnail", aspectClass: "aspect-[3/2]" },
{ id: "im-4", alt: "Story layout", aspectClass: "aspect-[9/16]" },
{ id: "im-5", alt: "Ad creative", aspectClass: "aspect-[4/3]" },
{ id: "im-6", alt: "Logo mockup", aspectClass: "aspect-square" },
{ id: "im-7", alt: "Email header", aspectClass: "aspect-[21/9]" },
{ id: "im-8", alt: "Carousel slide", aspectClass: "aspect-[4/5]" },
{ id: "im-9", alt: "Presentation cover", aspectClass: "aspect-[3/2]" },
{ id: "im-10", alt: "Event poster", aspectClass: "aspect-[2/3]" },
{ id: "im-11", alt: "Profile banner", aspectClass: "aspect-[4/3]" },
{ id: "im-12", alt: "Sale graphic", aspectClass: "aspect-square" },
];
+130
View File
@@ -0,0 +1,130 @@
"use client";
import Link from "next/link";
import {
CirclePlay,
Link as LinkIcon,
Share2,
Sparkles,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
interface FooterLink {
label: string;
href: string;
}
interface FooterColumnProps {
title: string;
links: FooterLink[];
}
const socialIcons = [
{ key: "socialX" as const, href: "https://twitter.com", icon: X },
{ key: "socialInstagram" as const, href: "https://instagram.com", icon: Share2 },
{ key: "socialLinkedIn" as const, href: "https://linkedin.com", icon: LinkIcon },
{ key: "socialYouTube" as const, href: "https://youtube.com", icon: CirclePlay },
];
function FooterColumn({ title, links }: FooterColumnProps) {
return (
<div>
<h3 className="font-heading text-sm font-semibold text-white">{title}</h3>
<ul className="mt-4 space-y-3">
{links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-neutral-400 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 rounded-sm"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
);
}
export function Footer() {
const t = useTranslations("footer");
const year = new Date().getFullYear();
const productLinks: FooterLink[] = [
{ label: t("videoMaker"), href: "/video-maker" },
{ label: t("imageMaker"), href: "/image-maker" },
{ label: t("templates"), href: "#templates" },
{ label: t("pricingLink"), href: "#pricing" },
];
const companyLinks: FooterLink[] = [
{ label: t("about"), href: "/about" },
{ label: t("blog"), href: "/blog" },
{ label: t("careers"), href: "/careers" },
{ label: t("contact"), href: "/contact" },
];
const legalLinks: FooterLink[] = [
{ label: t("privacy"), href: "/privacy" },
{ label: t("terms"), href: "/terms" },
{ label: t("cookies"), href: "/cookies" },
];
return (
<footer className="bg-slate-900 text-neutral-300">
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4 lg:gap-12">
<div>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-white">
<Sparkles className="h-5 w-5" aria-hidden />
</span>
<span className="font-heading text-lg font-bold text-white">
{t("brandName")}
</span>
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-neutral-400">
{t("description")}
</p>
<div className="mt-6 flex items-center gap-3">
{socialIcons.map((social) => {
const Icon = social.icon;
return (
<a
key={social.key}
href={social.href}
target="_blank"
rel="noopener noreferrer"
aria-label={t(social.key)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border border-slate-700 text-neutral-400 transition-colors hover:border-slate-600 hover:bg-slate-800 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
)}
>
<Icon className="h-4 w-4" aria-hidden />
</a>
);
})}
</div>
</div>
<FooterColumn title={t("products")} links={productLinks} />
<FooterColumn title={t("company")} links={companyLinks} />
<FooterColumn title={t("legal")} links={legalLinks} />
</div>
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-slate-800 pt-8 sm:flex-row">
<p className="text-sm text-neutral-400">
{t("rights", { year })}
</p>
<p className="text-sm text-neutral-400">{t("madeWith")}</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useTransition } from "react";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { Globe } from "lucide-react";
import { routing, type Locale } from "@/i18n/routing";
import { cn } from "@/lib/utils";
export function LanguageSwitcher({ className }: { className?: string }) {
const locale = useLocale() as Locale;
const t = useTranslations("langSwitcher");
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const toggleLocale = () => {
const nextLocale: Locale = locale === "fa" ? "en" : "fa";
// Strip existing locale prefix from path, then prepend new one if needed
let newPath = pathname;
for (const loc of routing.locales) {
if (newPath.startsWith(`/${loc}/`)) {
newPath = newPath.slice(loc.length + 1); // remove /en
break;
} else if (newPath === `/${loc}`) {
newPath = "/";
break;
}
}
const prefix = nextLocale === routing.defaultLocale ? "" : `/${nextLocale}`;
const finalPath = prefix + (newPath.startsWith("/") ? newPath : `/${newPath}`);
startTransition(() => {
router.push(finalPath);
});
};
return (
<button
type="button"
onClick={toggleLocale}
disabled={isPending}
aria-label={t("label")}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:opacity-50",
className
)}
>
<Globe className="h-4 w-4 shrink-0" aria-hidden />
<span>{locale === "fa" ? "EN" : "FA"}</span>
</button>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import {
NavbarLearnDropdown,
NavbarMenuDropdown,
} from "@/components/layout/NavbarMenuDropdown";
import { NavbarMobileMenu } from "@/components/layout/NavbarMobileMenu";
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
export function Navbar() {
const t = useTranslations("nav");
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
const closeMobile = () => setMobileOpen(false);
/** Translated nav data consumed by dropdown components */
const videoMakerNav = {
browseLabel: t("videoMakerBrowse"),
browseHref: "/templates",
items: [
{ label: t("videoMakerItems.animation"), href: "/templates?category=animation" },
{ label: t("videoMakerItems.intros"), href: "/templates?category=intros" },
{ label: t("videoMakerItems.social"), href: "/templates?category=social" },
{ label: t("videoMakerItems.slideshow"), href: "/templates?category=slideshow" },
{ label: t("videoMakerItems.ads"), href: "/templates?category=ads" },
{ label: t("videoMakerItems.music"), href: "/templates?category=music" },
{ label: t("videoMakerItems.featured"), href: "/templates?category=featured" },
],
};
const imageMakerNav = {
browseLabel: t("imageMakerBrowse"),
browseHref: "/image-maker",
items: [
{ label: t("imageMakerItems.social"), href: "/image-maker?category=social" },
{ label: t("imageMakerItems.banners"), href: "/image-maker?category=banners" },
{ label: t("imageMakerItems.presentations"), href: "/image-maker?category=presentations" },
{ label: t("imageMakerItems.posters"), href: "/image-maker?category=posters" },
{ label: t("imageMakerItems.logos"), href: "/image-maker?category=logos" },
],
};
const learnItems = [
{ label: t("learnItems.blog"), href: "/blog" },
{ label: t("learnItems.tutorials"), href: "/tutorials" },
{ label: t("learnItems.help"), href: "/help" },
];
return (
<header className="sticky top-0 z-50 w-full border-b border-gray-100 bg-white/95 backdrop-blur-sm">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
{/* Logo */}
<Link
href="/"
className="flex shrink-0 items-center gap-2 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
aria-label={t("ariaLabel")}
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600">
<Sparkles className="h-5 w-5 text-white" aria-hidden />
</span>
<span className="font-heading text-lg font-bold text-neutral-900">
{t("brandName")}
</span>
</Link>
{/* Desktop navigation */}
<nav
className="hidden items-center gap-1 lg:flex"
aria-label="Main navigation"
>
<NavbarMenuDropdown
label={t("videoMaker")}
browseLabel={videoMakerNav.browseLabel}
browseHref={videoMakerNav.browseHref}
items={videoMakerNav.items}
/>
<NavbarMenuDropdown
label={t("imageMaker")}
browseLabel={imageMakerNav.browseLabel}
browseHref={imageMakerNav.browseHref}
items={imageMakerNav.items}
/>
<Link
href="/pricing"
className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
>
{t("pricing")}
</Link>
<NavbarLearnDropdown items={learnItems} label={t("learn")} />
</nav>
{/* Right-side actions */}
<div className="flex items-center gap-1">
{/* Language switcher — desktop */}
<LanguageSwitcher className="hidden sm:flex" />
<Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="/auth">{t("signIn")}</Link>
</Button>
<Button
asChild
className="hidden bg-blue-600 text-white hover:bg-blue-700 sm:inline-flex"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
{/* Mobile menu trigger */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 lg:hidden"
aria-label={t("openMenuAriaLabel")}
>
<Menu className="h-5 w-5" aria-hidden />
</Button>
</SheetTrigger>
<SheetContent side="right" className="flex w-full flex-col sm:max-w-sm">
<SheetHeader className="text-left">
<SheetTitle className="font-heading">{t("mobileMenuTitle")}</SheetTitle>
</SheetHeader>
<NavbarMobileMenu onNavigate={closeMobile} />
<div className="mt-auto flex flex-col gap-3 border-t border-gray-100 pb-8 pt-6">
<LanguageSwitcher className="w-full justify-center border border-gray-200" />
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/auth" onClick={closeMobile}>
{t("signIn")}
</Link>
</Button>
<Button
size="lg"
className="w-full bg-blue-600 text-white hover:bg-blue-700"
asChild
>
<Link href="/auth?tab=sign-up" onClick={closeMobile}>
{t("tryForFree")}
</Link>
</Button>
</div>
</SheetContent>
</Sheet>
{/* Mobile CTA (outside sheet) */}
<Button
asChild
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700 lg:hidden"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { ChevronDown, LayoutGrid } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { NavbarMenuLink } from "@/lib/navbar-menu-data";
import { cn } from "@/lib/utils";
interface NavbarMenuDropdownProps {
label: string;
browseLabel: string;
browseHref: string;
items: readonly NavbarMenuLink[];
}
const triggerClassName =
"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2";
const panelClassName =
"min-w-[220px] rounded-xl border border-gray-100 bg-white p-2 shadow-xl";
export function NavbarMenuDropdown({
label,
browseLabel,
browseHref,
items,
}: NavbarMenuDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger className={triggerClassName}>
{label}
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={panelClassName}>
<DropdownMenuItem asChild className="cursor-pointer p-0 focus:bg-transparent">
<Link
href={browseHref}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-blue-600 hover:bg-blue-50"
>
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
{browseLabel}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-2 bg-gray-100" />
{items.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="cursor-pointer rounded-lg px-3 py-2 text-sm text-gray-700 focus:bg-gray-50"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface NavbarLearnDropdownProps {
items: readonly NavbarMenuLink[];
label?: string;
}
export function NavbarLearnDropdown({ items, label = "Learn" }: NavbarLearnDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger className={triggerClassName}>
{label}
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn(panelClassName, "min-w-[180px]")}>
{items.map((item) => (
<DropdownMenuItem
key={item.href}
asChild
className="cursor-pointer rounded-lg px-3 py-2 text-sm text-gray-700 focus:bg-gray-50"
>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { LayoutGrid } from "lucide-react";
import {
IMAGE_MAKER_NAV,
LEARN_NAV_ITEMS,
VIDEO_MAKER_NAV,
} from "@/lib/navbar-menu-data";
interface NavbarMobileMenuProps {
onNavigate: () => void;
}
const linkClass =
"flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900";
export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
return (
<div className="flex flex-1 flex-col gap-6 overflow-y-auto">
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Video Maker
</p>
<Link
href={VIDEO_MAKER_NAV.browseHref}
onClick={onNavigate}
className="mt-1 flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-blue-600 hover:bg-blue-50"
>
<LayoutGrid className="h-4 w-4" aria-hidden />
{VIDEO_MAKER_NAV.browseLabel}
</Link>
<ul className="mt-1 space-y-0.5">
{VIDEO_MAKER_NAV.items.map((item) => (
<li key={item.href}>
<Link href={item.href} onClick={onNavigate} className={linkClass}>
{item.label}
</Link>
</li>
))}
</ul>
</section>
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Image Maker
</p>
<Link
href={IMAGE_MAKER_NAV.browseHref}
onClick={onNavigate}
className="mt-1 flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-blue-600 hover:bg-blue-50"
>
<LayoutGrid className="h-4 w-4" aria-hidden />
{IMAGE_MAKER_NAV.browseLabel}
</Link>
<ul className="mt-1 space-y-0.5">
{IMAGE_MAKER_NAV.items.map((item) => (
<li key={item.href}>
<Link href={item.href} onClick={onNavigate} className={linkClass}>
{item.label}
</Link>
</li>
))}
</ul>
</section>
<section>
<Link href="/pricing" onClick={onNavigate} className={linkClass}>
Pricing
</Link>
</section>
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Learn
</p>
<ul className="mt-1 space-y-0.5">
{LEARN_NAV_ITEMS.map((item) => (
<li key={item.href}>
<Link href={item.href} onClick={onNavigate} className={linkClass}>
{item.label}
</Link>
</li>
))}
</ul>
</section>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { usePathname } from "next/navigation";
import { Footer } from "@/components/layout/Footer";
import { Navbar } from "@/components/layout/Navbar";
interface SiteChromeProps {
children: React.ReactNode;
}
export function SiteChrome({ children }: SiteChromeProps) {
const pathname = usePathname();
const isAppShell =
pathname.startsWith("/dashboard") || pathname.startsWith("/studio");
if (isAppShell) {
return <>{children}</>;
}
return (
<>
<Navbar />
{children}
<Footer />
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useTranslations } from "next-intl";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import { SectionReveal } from "./SectionReveal";
export interface FAQProps {
className?: string;
}
const FAQ_IDS = ["q0","q1","q2","q3","q4","q5","q6","q7"] as const;
export function FAQ({ className }: FAQProps) {
const t = useTranslations("faq");
const items = FAQ_IDS.map((id) => ({
id,
question: t(id),
answer: t(id.replace("q", "a") as Parameters<typeof t>[0]),
}));
const columns = [items.slice(0, 4), items.slice(4, 8)];
return (
<section className={cn("w-full bg-white py-20 sm:py-28", className)}>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
{t("subtitle")}
</p>
</SectionReveal>
<SectionReveal className="mt-12 grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
{columns.map((column, columnIndex) => (
<Accordion
key={columnIndex}
type="single"
collapsible
className="w-full"
>
{column.map((item) => (
<AccordionItem key={item.id} value={item.id}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
))}
</SectionReveal>
</div>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import Link from "next/link";
import { motion, type Variants } from "framer-motion";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { HeroBackgroundBlobs } from "./HeroBackgroundBlobs";
import { HeroPreviewCards } from "./HeroPreviewCards";
export interface HeroProps {
className?: string;
}
const fadeUp: Variants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
export function Hero({ className }: HeroProps) {
const t = useTranslations("hero");
return (
<section
className={cn(
"relative w-full overflow-hidden bg-white",
className
)}
>
<HeroBackgroundBlobs />
<div className="relative mx-auto max-w-7xl px-4 pb-16 pt-14 sm:px-6 sm:pb-20 sm:pt-20 lg:px-8 lg:pt-24">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-40px" }}
variants={{
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
}}
className="mx-auto max-w-4xl text-center"
>
<motion.div variants={fadeUp}>
<span className="inline-flex items-center rounded-full border border-violet-100 bg-white/80 px-4 py-1.5 text-sm font-medium text-neutral-600 shadow-sm backdrop-blur-sm">
<span className="text-amber-500" aria-hidden>
</span>
<span className="ms-1.5">{t("badge")}</span>
</span>
</motion.div>
<motion.h1
variants={fadeUp}
className="mt-6 font-heading text-4xl font-bold leading-[1.1] tracking-tight text-neutral-900 sm:mt-8 sm:text-5xl lg:text-[3.25rem]"
>
{t.rich("title", {
highlight: (chunks) => (
<span className="bg-gradient-to-r from-blue-600 via-violet-500 to-blue-500 bg-clip-text text-transparent">
{chunks}
</span>
),
})}
</motion.h1>
<motion.p
variants={fadeUp}
className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-neutral-600 sm:text-lg"
>
{t("description")}
</motion.p>
<motion.div
variants={fadeUp}
className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4"
>
<Button
size="lg"
className="h-12 min-w-[11rem] rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90"
asChild
>
<Link href="/auth?tab=sign-up">{t("cta")}</Link>
</Button>
<Button
variant="outline"
size="lg"
className="h-12 min-w-[11rem] rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light"
asChild
>
<Link href="#templates">{t("browse")}</Link>
</Button>
</motion.div>
</motion.div>
<h2 className="sr-only">{t("previewsLabel")}</h2>
<HeroPreviewCards />
</div>
</section>
);
}
@@ -0,0 +1,54 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
const blobs = [
{
className: "left-[0%] top-[5%] h-96 w-96 bg-violet-200",
animate: { x: [0, 24, 0], y: [0, 16, 0], scale: [1, 1.05, 1] },
duration: 14,
},
{
className: "right-[0%] top-[8%] h-80 w-80 bg-sky-200",
animate: { x: [0, -20, 0], y: [0, 24, 0], scale: [1, 1.08, 1] },
duration: 16,
},
{
className: "bottom-[20%] left-[30%] h-72 w-72 bg-rose-100",
animate: { x: [0, 16, 0], y: [0, -20, 0], scale: [1, 1.06, 1] },
duration: 12,
},
{
className: "bottom-[10%] right-[25%] h-64 w-64 bg-amber-100",
animate: { x: [0, -12, 0], y: [0, 12, 0], scale: [1, 1.04, 1] },
duration: 18,
},
];
export function HeroBackgroundBlobs() {
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<div className="absolute inset-0 bg-gradient-to-b from-violet-50/90 via-white/80 to-white" />
{blobs.map((blob, index) => (
<motion.div
key={index}
className={cn(
"absolute rounded-full opacity-40 blur-3xl",
blob.className
)}
animate={blob.animate}
transition={{
duration: blob.duration,
repeat: Infinity,
ease: "easeInOut",
}}
/>
))}
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import Link from "next/link";
import { motion, type Variants } from "framer-motion";
import { VideoPlayOverlay } from "@/components/sections/VideoPlayOverlay";
import { getHeroPreviewVideoSrc } from "@/lib/template-preview-media";
import { cn } from "@/lib/utils";
const previewTemplates = [
{ id: "hero-3d", title: "Factory of 3D Animations" },
{ id: "hero-whiteboard", title: "Whiteboard Animation Toolkit" },
{ id: "hero-explainer", title: "3D Explainer Video Toolkit" },
{ id: "hero-trendy", title: "Trendy Explainer Toolkit" },
] as const;
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const cardVariants: Variants = {
hidden: { opacity: 0, y: 24 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
interface HeroVideoThumbProps {
videoSrc: string;
label: string;
}
function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
return (
<div className="group/thumb relative aspect-[4/3] overflow-hidden rounded-xl border border-neutral-200/80 bg-neutral-100 shadow-sm transition-shadow duration-300 hover:shadow-md">
<video
src={videoSrc}
autoPlay
muted
loop
playsInline
preload="metadata"
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover/thumb:scale-[1.02]"
aria-label={`${label} preview`}
/>
<VideoPlayOverlay
size="lg"
className="opacity-100 transition-opacity duration-300 ease-out group-hover/thumb:opacity-0"
/>
</div>
);
}
export function HeroPreviewCards() {
return (
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-40px" }}
className="mx-auto mt-14 w-full max-w-7xl sm:mt-16"
>
<p className="text-center font-heading text-xl font-bold tracking-tight text-neutral-900 sm:text-2xl">
Made by world-class motion designers
</p>
<div className="mt-8 grid grid-cols-2 gap-4 sm:gap-5 lg:grid-cols-4 lg:gap-6">
{previewTemplates.map((template, index) => (
<motion.div key={template.id} variants={cardVariants}>
<Link
href="/templates"
className={cn(
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
"transition-transform duration-300 hover:-translate-y-0.5"
)}
>
<HeroVideoThumb
videoSrc={getHeroPreviewVideoSrc(index)}
label={template.title}
/>
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
{template.title}
</p>
</Link>
</motion.div>
))}
</div>
</motion.div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useTranslations } from "next-intl";
import { LayoutTemplate, Share2, Wand2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { SectionReveal } from "./SectionReveal";
import { HowItWorksConnector } from "./HowItWorksConnector";
import { HowItWorksStep } from "./HowItWorksStep";
export interface HowItWorksProps {
className?: string;
}
const STEP_ICONS = [LayoutTemplate, Wand2, Share2];
const STEP_CLASSES = [
"bg-gradient-to-br from-primary-100 to-primary-50 border-primary-200 text-primary-600",
"bg-gradient-to-br from-violet-100 to-violet-50 border-violet-200 text-violet-600",
"bg-gradient-to-br from-neutral-100 to-neutral-50 border-neutral-200 text-neutral-600",
];
export function HowItWorks({ className }: HowItWorksProps) {
const t = useTranslations("howItWorks");
const steps = [
{ number: 1, title: t("step1Title"), description: t("step1Desc"), icon: STEP_ICONS[0], previewClassName: STEP_CLASSES[0] },
{ number: 2, title: t("step2Title"), description: t("step2Desc"), icon: STEP_ICONS[1], previewClassName: STEP_CLASSES[1] },
{ number: 3, title: t("step3Title"), description: t("step3Desc"), icon: STEP_ICONS[2], previewClassName: STEP_CLASSES[2] },
];
return (
<section className={cn("w-full bg-neutral-50 py-20 sm:py-28", className)}>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
{t("subtitle")}
</p>
</SectionReveal>
<div className="mx-auto mt-16 max-w-5xl">
{steps.map((step, index) => (
<div key={step.number}>
<HowItWorksStep
number={step.number}
title={step.title}
description={step.description}
icon={step.icon}
previewClassName={step.previewClassName}
reversed={index % 2 === 1}
/>
{index < steps.length - 1 && <HowItWorksConnector />}
</div>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,23 @@
"use client";
import { motion } from "framer-motion";
import { ArrowDown } from "lucide-react";
export function HowItWorksConnector() {
return (
<motion.div
initial={{ opacity: 0, scaleY: 0 }}
whileInView={{ opacity: 1, scaleY: 1 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex flex-col items-center py-6 sm:py-8"
aria-hidden
>
<div className="h-10 w-px bg-gradient-to-b from-primary-300 to-primary-500" />
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary-200 bg-primary-50 text-primary-600 shadow-sm">
<ArrowDown className="h-4 w-4" />
</div>
<div className="h-10 w-px bg-gradient-to-b from-primary-500 to-primary-300" />
</motion.div>
);
}
@@ -0,0 +1,72 @@
"use client";
import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface HowItWorksStepProps {
number: number;
title: string;
description: string;
icon: LucideIcon;
previewClassName: string;
reversed?: boolean;
}
export function HowItWorksStep({
number,
title,
description,
icon: Icon,
previewClassName,
reversed = false,
}: HowItWorksStepProps) {
const slideFrom = reversed ? 48 : -48;
return (
<div
className={cn(
"grid items-center gap-8 lg:grid-cols-2 lg:gap-16",
reversed && "lg:[&>*:first-child]:order-2 lg:[&>*:last-child]:order-1"
)}
>
<motion.div
initial={{ opacity: 0, x: slideFrom }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex gap-5"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-600 font-heading text-lg font-bold text-white shadow-sm">
{number}
</div>
<div>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-primary-50 text-primary-600">
<Icon className="h-5 w-5" aria-hidden />
</div>
<h3 className="font-heading text-xl font-bold text-neutral-900 sm:text-2xl">
{title}
</h3>
<p className="mt-3 text-base leading-relaxed text-neutral-600">
{description}
</p>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -slideFrom }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.4, ease: "easeOut", delay: 0.08 }}
className={cn(
"flex aspect-[4/3] items-center justify-center rounded-xl border shadow-sm",
previewClassName
)}
aria-hidden
>
<Icon className="h-20 w-20 opacity-30" />
</motion.div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
import { PricingCard } from "@/components/sections/PricingCard";
import { PricingCompareTable } from "@/components/sections/PricingCompareTable";
import { PricingFreeBanner } from "@/components/sections/PricingFreeBanner";
import { PricingSectionShell } from "@/components/sections/PricingBackground";
import { SectionReveal } from "@/components/sections/SectionReveal";
import type { BillingPeriod } from "@/components/sections/pricing-data";
import { PRICING_TIERS } from "@/components/sections/pricing-data";
export interface PricingProps {
className?: string;
}
export function Pricing({ className }: PricingProps) {
const [billing, setBilling] = useState<BillingPeriod>("annual");
return (
<PricingSectionShell className={className}>
<SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
Choose your FlatRender plan
</h2>
</SectionReveal>
<SectionReveal className="mt-8">
<PricingFreeBanner />
</SectionReveal>
<SectionReveal className="mt-10 flex justify-center">
<PricingBillingToggle billing={billing} onChange={setBilling} />
</SectionReveal>
<SectionReveal className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-5">
{PRICING_TIERS.map((tier) => (
<PricingCard key={tier.id} tier={tier} billing={billing} />
))}
</SectionReveal>
<SectionReveal className="mt-16">
<PricingCompareTable billing={billing} onBillingChange={setBilling} />
</SectionReveal>
</PricingSectionShell>
);
}
@@ -0,0 +1,60 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface PricingAnimatedPriceProps {
price: number;
compareAt: number | null;
billing: string;
size?: "default" | "compact";
}
function formatPrice(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
export function PricingAnimatedPrice({
price,
compareAt,
billing,
size = "default",
}: PricingAnimatedPriceProps) {
const isCompact = size === "compact";
return (
<div className={isCompact ? "mt-2" : "mt-4"}>
{compareAt != null ? (
<p className="mb-1 flex items-baseline gap-2">
<span
className={cn(
"text-neutral-400 line-through",
isCompact ? "text-sm" : "text-lg"
)}
>
${formatPrice(compareAt)}
</span>
</p>
) : null}
<div className="flex items-baseline justify-center gap-0.5">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={`${price}-${billing}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"font-heading font-bold tracking-tight text-rose-500",
isCompact ? "text-2xl" : "text-4xl sm:text-[2.75rem]"
)}
>
${formatPrice(price)}
</motion.span>
</AnimatePresence>
<span className="ml-1 text-sm font-normal text-neutral-500">/ month</span>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function PricingBackground() {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div className="absolute -left-20 top-10 h-72 w-72 rounded-full bg-rose-200/50 blur-3xl" />
<div className="absolute right-0 top-20 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl" />
<div className="absolute bottom-0 left-1/3 h-64 w-64 rounded-full bg-emerald-100/60 blur-3xl" />
<svg
className="absolute inset-0 h-full w-full opacity-[0.07]"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
>
<path
d="M0 120 Q200 80 400 140 T800 100 T1200 160 T1600 90"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-violet-500"
/>
<path
d="M0 280 Q300 240 600 300 T1200 260"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-rose-400"
/>
</svg>
</div>
);
}
export function PricingSectionShell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
id="pricing"
className={cn("relative w-full overflow-hidden bg-white py-16 sm:py-24", className)}
>
<PricingBackground />
<div className="relative mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
{children}
</div>
</section>
);
}
@@ -0,0 +1,57 @@
"use client";
import { motion } from "framer-motion";
import type { BillingPeriod } from "@/components/sections/pricing-data";
import { ANNUAL_SAVINGS_PERCENT } from "@/components/sections/pricing-data";
import { cn } from "@/lib/utils";
interface PricingBillingToggleProps {
billing: BillingPeriod;
onChange: (billing: BillingPeriod) => void;
layoutId?: string;
}
export function PricingBillingToggle({
billing,
onChange,
layoutId = "pricing-billing-pill",
}: PricingBillingToggleProps) {
return (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex rounded-full border border-gray-200 bg-white p-1 shadow-sm">
{(["monthly", "annual"] as const).map((period) => {
const isActive = billing === period;
const label = period === "monthly" ? "Monthly" : "Yearly";
return (
<button
key={period}
type="button"
onClick={() => onChange(period)}
className={cn(
"relative rounded-full px-6 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
isActive ? "text-white" : "text-neutral-600 hover:text-neutral-900"
)}
>
{isActive ? (
<motion.span
layoutId={layoutId}
className="absolute inset-0 rounded-full bg-rf-blue"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
) : null}
<span className="relative z-10">{label}</span>
</button>
);
})}
</div>
{billing === "annual" ? (
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
Save {ANNUAL_SAVINGS_PERCENT}%
</span>
) : (
<p className="text-sm text-neutral-400">Switch to Yearly to save more</p>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { Tag } from "lucide-react";
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButton";
import { PricingCreditsBanner } from "@/components/sections/PricingCreditsBanner";
import { PricingFeatureList } from "@/components/sections/PricingFeatureList";
import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data";
import {
getCompareAtPrice,
getDisplayPrice,
} from "@/components/sections/pricing-data";
import { Button } from "@/components/ui/button";
import type { PaidPlanId } from "@/lib/plans";
import { cn } from "@/lib/utils";
export interface PricingCardProps {
tier: PricingTier;
billing: BillingPeriod;
}
export function PricingCard({ tier, billing }: PricingCardProps) {
const price = getDisplayPrice(tier, billing);
const compareAt = getCompareAtPrice(tier, billing);
const highlighted = tier.highlighted ?? false;
const isStripePlan = tier.id === "pro" || tier.id === "business";
return (
<article
className={cn(
"relative flex flex-col overflow-hidden rounded-xl border bg-white shadow-sm",
highlighted
? "border-violet-300 shadow-md ring-1 ring-violet-200"
: "border-gray-100"
)}
>
{highlighted ? (
<div className="bg-gradient-to-r from-rose-400 via-violet-500 to-violet-600 px-4 py-2 text-center text-sm font-semibold text-white">
Most Popular
</div>
) : null}
<div className="flex flex-1 flex-col p-6 sm:p-7">
<h3 className="font-heading text-xl font-bold text-neutral-900">
{tier.name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
{tier.description}
</p>
{tier.promoLabel ? (
<p className="mt-3 flex items-center gap-1.5 text-sm font-medium text-rf-blue">
<Tag className="h-4 w-4" aria-hidden />
{tier.promoLabel}
</p>
) : null}
<PricingAnimatedPrice
price={price}
compareAt={compareAt}
billing={billing}
/>
{isStripePlan ? (
<PricingCheckoutButton
plan={tier.id as PaidPlanId}
billing={billing}
label={tier.cta}
className="mt-5 h-11 w-full rounded-lg bg-rf-blue text-base font-semibold hover:bg-rf-blue/90"
/>
) : (
<Button
className="mt-5 h-11 w-full rounded-lg bg-rf-blue text-base font-semibold hover:bg-rf-blue/90"
asChild
>
<Link href="/auth?tab=sign-up">{tier.cta}</Link>
</Button>
)}
<div className="mt-4">
<PricingCreditsBanner />
</div>
<PricingFeatureList
heading={tier.featuresHeading}
features={tier.features}
/>
</div>
</article>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import type { BillingPeriod } from "@/components/sections/pricing-data";
import { Button } from "@/components/ui/button";
import type { PaidPlanId } from "@/lib/plans";
import { cn } from "@/lib/utils";
export interface PricingCheckoutButtonProps {
plan: PaidPlanId;
billing: BillingPeriod;
label: string;
className?: string;
variant?: "default" | "secondary";
}
export function PricingCheckoutButton({
plan,
billing,
label,
className,
variant = "default",
}: PricingCheckoutButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCheckout = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, billing }),
});
const data: { url?: string; error?: string } = await response.json();
if (!response.ok) {
if (response.status === 401) {
router.push(`/auth?tab=sign-up&plan=${plan}`);
return;
}
throw new Error(data.error ?? "Checkout failed.");
}
if (data.url) {
window.location.href = data.url;
return;
}
throw new Error("No checkout URL returned.");
} catch (checkoutError) {
setError(
checkoutError instanceof Error
? checkoutError.message
: "Checkout failed."
);
} finally {
setLoading(false);
}
};
return (
<div className="w-full">
<Button
type="button"
variant={variant}
className={cn("w-full", className)}
disabled={loading}
onClick={handleCheckout}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : null}
{label}
</Button>
{error && (
<p className="mt-2 text-center text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { Fragment } from "react";
import Link from "next/link";
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButton";
import {
PricingCompareFeatureLabel,
PricingCompareValueCell,
} from "@/components/sections/PricingCompareValue";
import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data";
import {
COMPARE_ANNUAL_SAVINGS_BADGE,
COMPARE_SECTIONS,
getCompareAtPrice,
getDisplayPrice,
PRICING_TIERS,
} from "@/components/sections/pricing-data";
import { Button } from "@/components/ui/button";
import type { PaidPlanId } from "@/lib/plans";
import { cn } from "@/lib/utils";
interface PricingCompareTableProps {
billing: BillingPeriod;
onBillingChange: (billing: BillingPeriod) => void;
}
function SavingsArrowIcon() {
return (
<svg
width="28"
height="20"
viewBox="0 0 28 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-blue-600"
aria-hidden
>
<path
d="M2 14C8 6 14 4 22 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M18 4L23 6L21 11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlanHeaderCell({
tier,
billing,
}: {
tier: PricingTier;
billing: BillingPeriod;
}) {
const highlighted = tier.highlighted ?? false;
const isStripePlan = tier.id === "pro" || tier.id === "business";
return (
<th
className={cn(
"px-4 pb-4 pt-6 align-top",
highlighted && "bg-blue-50/30"
)}
>
{highlighted ? (
<span className="mb-2 inline-block rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
Most Popular
</span>
) : (
<span className="mb-2 block h-5" aria-hidden />
)}
<p className="font-heading text-base font-bold text-neutral-900">
{tier.name}
</p>
<PricingAnimatedPrice
price={getDisplayPrice(tier, billing)}
compareAt={getCompareAtPrice(tier, billing)}
billing={billing}
size="compact"
/>
{isStripePlan ? (
<PricingCheckoutButton
plan={tier.id as PaidPlanId}
billing={billing}
label={tier.cta}
className={cn(
"mt-3 h-9 w-full rounded-lg text-sm font-semibold",
highlighted
? "bg-rf-blue hover:bg-rf-blue/90"
: "border border-gray-300 bg-white text-neutral-800 hover:bg-gray-50"
)}
variant={highlighted ? "default" : "secondary"}
/>
) : (
<Button
variant="outline"
className="mt-3 h-9 w-full rounded-lg border-gray-300 text-sm font-semibold"
asChild
>
<Link href="/auth?tab=sign-up">{tier.cta}</Link>
</Button>
)}
</th>
);
}
export function PricingCompareTable({
billing,
onBillingChange,
}: PricingCompareTableProps) {
const lite = PRICING_TIERS.find((t) => t.id === "lite");
const pro = PRICING_TIERS.find((t) => t.id === "pro");
const business = PRICING_TIERS.find((t) => t.id === "business");
if (!lite || !pro || !business) return null;
return (
<div className="mx-auto w-full max-w-5xl overflow-x-auto rounded-2xl border border-gray-100 bg-white shadow-sm">
<table className="w-full min-w-[760px] border-collapse">
<thead className="sticky top-0 z-10 bg-white">
<tr className="border-b border-gray-100">
<th className="w-[38%] px-6 pb-4 pt-6 text-left align-top">
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent sm:text-xl">
Compare Plans &amp; Features
</h3>
<div className="mt-4 items-start">
<PricingBillingToggle
billing={billing}
onChange={onBillingChange}
layoutId="pricing-compare-billing-pill"
/>
</div>
<p className="mt-3 flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-blue-600">
Save up to {COMPARE_ANNUAL_SAVINGS_BADGE}%
<SavingsArrowIcon />
</p>
</th>
<PlanHeaderCell tier={lite} billing={billing} />
<PlanHeaderCell tier={pro} billing={billing} />
<PlanHeaderCell tier={business} billing={billing} />
</tr>
</thead>
<tbody>
{COMPARE_SECTIONS.map((section) => (
<Fragment key={section.title}>
<tr className="bg-gray-50">
<td
colSpan={4}
className="px-6 py-3 text-xs font-bold uppercase tracking-widest text-gray-500"
>
{section.title}
</td>
</tr>
{section.rows.map((row) => (
<tr
key={`${section.title}-${row.feature}`}
className="border-b border-gray-100 transition-colors hover:bg-gray-50/60"
>
<td className="px-6 py-3">
<PricingCompareFeatureLabel
feature={row.feature}
tooltip={row.tooltip}
/>
</td>
<PricingCompareValueCell value={row.lite} />
<PricingCompareValueCell value={row.pro} highlighted />
<PricingCompareValueCell value={row.business} />
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,52 @@
import { Check, Info, Minus } from "lucide-react";
import type { CompareValue } from "@/components/sections/pricing-data";
import { cn } from "@/lib/utils";
interface PricingCompareFeatureLabelProps {
feature: string;
tooltip?: string;
}
export function PricingCompareFeatureLabel({
feature,
tooltip,
}: PricingCompareFeatureLabelProps) {
return (
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
{feature}
{tooltip ? (
<span title={tooltip} className="inline-flex">
<Info className="h-3.5 w-3.5 text-gray-400" aria-label={tooltip} />
</span>
) : null}
</span>
);
}
interface PricingCompareValueCellProps {
value: CompareValue;
highlighted?: boolean;
}
export function PricingCompareValueCell({
value,
highlighted = false,
}: PricingCompareValueCellProps) {
return (
<td
className={cn(
"px-4 py-3 text-center",
highlighted && "bg-blue-50/30"
)}
>
{value === true ? (
<Check className="mx-auto h-4 w-4 text-blue-600" aria-hidden />
) : value === false ? (
<Minus className="mx-auto h-4 w-4 text-gray-300" aria-hidden />
) : (
<span className="text-sm text-gray-700">{value}</span>
)}
</td>
);
}
@@ -0,0 +1,12 @@
import { Zap } from "lucide-react";
export function PricingCreditsBanner() {
return (
<div className="flex items-start gap-2 rounded-lg bg-sky-50 px-3 py-2.5 text-left">
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-rf-blue" aria-hidden />
<p className="text-xs leading-snug text-neutral-700">
You can refill AI credits anytime with an active plan
</p>
</div>
);
}
@@ -0,0 +1,48 @@
import { Check, Info } from "lucide-react";
import type { PricingFeature } from "@/components/sections/pricing-data";
interface PricingFeatureListProps {
heading?: string;
features: PricingFeature[];
}
export function PricingFeatureList({
heading,
features,
}: PricingFeatureListProps) {
return (
<div className="mt-6">
{heading ? (
<p className="mb-3 text-sm font-medium text-neutral-800">
{heading}{" "}
<span className="text-rf-blue" aria-hidden>
</span>
</p>
) : null}
<ul className="space-y-2.5">
{features.map((feature) => (
<li
key={feature.label}
className="flex items-start gap-2 text-sm text-neutral-700"
>
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-neutral-400"
aria-hidden
/>
<span className="flex flex-1 items-center gap-1.5">
{feature.label}
{feature.info ? (
<Info
className="h-3.5 w-3.5 shrink-0 text-neutral-400"
aria-label="More information"
/>
) : null}
</span>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,27 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export function PricingFreeBanner() {
return (
<div className="flex flex-col items-start justify-between gap-6 rounded-xl border border-gray-100 bg-white px-6 py-6 shadow-sm sm:flex-row sm:items-center sm:px-8">
<div className="max-w-xl">
<h3 className="font-heading text-lg font-bold text-neutral-900 sm:text-xl">
Always Free to Try
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600 sm:text-[15px]">
Explore CreatorStudio with a Free plan create HD videos with a
watermark, try basic features, and experiment before you subscribe.
</p>
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 rounded-lg border-2 border-rf-blue bg-white px-8 text-rf-blue hover:bg-rf-blue-light"
asChild
>
<Link href="/auth?tab=sign-up">Get Started</Link>
</Button>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ProductShowcaseCardProps {
title: string;
description: string;
href: string;
linkLabel: string;
icon: LucideIcon;
badge: string;
gradientFrom: string;
gradientTo: string;
iconClassName: string;
previewClassName: string;
badgeClassName: string;
linkClassName: string;
}
export function ProductShowcaseCard({
title,
description,
href,
linkLabel,
icon: Icon,
badge,
gradientFrom,
gradientTo,
iconClassName,
previewClassName,
badgeClassName,
linkClassName,
}: ProductShowcaseCardProps) {
return (
<motion.article
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={cn(
"rounded-xl bg-gradient-to-br p-[1px] shadow-xl",
gradientFrom,
gradientTo
)}
>
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-gray-100 bg-white/80 shadow-xl backdrop-blur">
<div className="flex items-start justify-between p-6 pb-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl",
iconClassName
)}
>
<Icon className="h-6 w-6" aria-hidden />
</div>
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold",
badgeClassName
)}
>
{badge}
</span>
</div>
<div className="px-6">
<h3 className="font-heading text-xl font-bold text-neutral-900">
{title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
{description}
</p>
<Link
href={href}
className={cn(
"mt-4 inline-flex items-center gap-1 rounded-sm text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
linkClassName
)}
>
{linkLabel}
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</div>
<div
className={cn(
"mx-6 mb-6 mt-6 flex h-40 items-center justify-center rounded-xl border border-gray-100 sm:h-48",
previewClassName
)}
aria-hidden
>
<Icon className="h-16 w-16 opacity-20" />
</div>
</div>
</motion.article>
);
}
@@ -0,0 +1,96 @@
"use client";
import { Clapperboard, ImageIcon } from "lucide-react";
import { motion, type Variants } from "framer-motion";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { ProductShowcaseCard } from "./ProductShowcaseCard";
export interface ProductsShowcaseProps {
className?: string;
}
const fadeUp: Variants = {
hidden: { opacity: 0, y: 24 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
export function ProductsShowcase({ className }: ProductsShowcaseProps) {
const t = useTranslations("products");
const products = [
{
title: t("videoMakerTitle"),
description: t("videoMakerDesc"),
href: "/video-maker",
linkLabel: t("videoMakerLink"),
icon: Clapperboard,
badge: t("videoMakerBadge") as "Popular" | string,
gradientFrom: "from-primary-400",
gradientTo: "to-primary-600",
iconClassName: "bg-primary-600 text-white",
previewClassName:
"bg-gradient-to-br from-primary-50 to-primary-100 text-primary-600",
badgeClassName: "bg-primary-100 text-primary-700",
linkClassName:
"text-primary-600 hover:text-primary-700 focus-visible:ring-primary-600",
},
{
title: t("imageMakerTitle"),
description: t("imageMakerDesc"),
href: "/image-maker",
linkLabel: t("imageMakerLink"),
icon: ImageIcon,
badge: t("imageMakerBadge") as "New" | string,
gradientFrom: "from-violet-400",
gradientTo: "to-violet-600",
iconClassName: "bg-violet-600 text-white",
previewClassName:
"bg-gradient-to-br from-violet-50 to-violet-100 text-violet-600",
badgeClassName: "bg-violet-100 text-violet-700",
linkClassName:
"text-violet-600 hover:text-violet-700 focus-visible:ring-violet-600",
},
];
return (
<section
id="products"
className={cn("w-full bg-neutral-50 py-20 sm:py-28", className)}
>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-80px" }}
variants={{
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.12 } },
}}
>
<motion.h2
variants={fadeUp}
className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"
>
{t("heading")}
</motion.h2>
<motion.div
variants={fadeUp}
className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-2"
>
{products.map((product) => (
<ProductShowcaseCard key={product.title} {...product} />
))}
</motion.div>
</motion.div>
</div>
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { cn } from "@/lib/utils";
const defaultVariants: Variants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
export interface SectionRevealProps {
children: React.ReactNode;
className?: string;
variants?: Variants;
delay?: number;
}
export function SectionReveal({
children,
className,
variants = defaultVariants,
delay = 0,
}: SectionRevealProps) {
return (
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-80px" }}
variants={variants}
transition={{ delay }}
className={cn(className)}
>
{children}
</motion.div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { OptimizedImage } from "@/components/ui/optimized-image";
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
import { cn } from "@/lib/utils";
export interface TemplateCardProps {
templateId: string;
name: string;
category: string;
imageSrc: string;
/** Explicit Mixkit (or other) preview clip; falls back to hashed seed URL */
previewVideoUrl?: string;
/** Seed for picking a preview clip when previewVideoUrl is omitted */
previewSeed?: string;
className?: string;
priority?: boolean;
onUseTemplate?: () => void;
isUsingTemplate?: boolean;
/** Translated CTA labels (optional — component falls back to English) */
useTemplateLabel?: string;
openingLabel?: string;
}
const fadeTransition = { duration: 0.3, ease: "easeOut" as const };
export function TemplateCard({
templateId,
name,
category,
imageSrc,
previewVideoUrl,
previewSeed,
className,
priority = false,
onUseTemplate,
isUsingTemplate = false,
useTemplateLabel = "Use Template",
openingLabel = "Opening…",
}: TemplateCardProps) {
const [isHovered, setIsHovered] = useState(false);
const seed = previewSeed ?? name;
const videoSrc = previewVideoUrl ?? getTemplatePreviewVideoSrc(seed);
const detailHref = `/templates/${templateId}`;
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
return (
<article
className={cn(
"group relative overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm transition-shadow duration-300 hover:shadow-md",
className
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
<Link
href={detailHref}
className="absolute inset-0 z-0 block no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
aria-label={`View ${name} template`}
>
<OptimizedImage
src={imageSrc}
alt={name}
fill
priority={priority}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
</Link>
<AnimatePresence>
{isHovered ? (
<motion.div
key="preview-video"
className="pointer-events-none absolute inset-0 z-[1]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
>
<video
src={videoSrc}
autoPlay
muted
loop
playsInline
preload="metadata"
className="h-full w-full object-cover"
aria-hidden
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{isHovered ? (
<motion.div
key="use-template-cta"
className="absolute inset-x-0 bottom-0 z-[2] bg-gradient-to-t from-neutral-900/80 via-neutral-900/40 to-transparent p-3 pt-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
>
<Button
type="button"
size="sm"
disabled={isUsingTemplate}
className="w-full bg-blue-600 shadow-lg hover:bg-blue-700 focus-visible:ring-offset-neutral-900"
onClick={(event) => {
event.stopPropagation();
onUseTemplate?.();
}}
>
{isUsingTemplate ? openingLabel : useTemplateLabel}
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="flex items-start justify-between gap-3 p-4">
<Link
href={detailHref}
className="font-heading text-sm font-semibold text-gray-900 no-underline hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 rounded-sm"
>
{name}
</Link>
<span className="shrink-0 rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-600">
{category}
</span>
</div>
</article>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { createVideoProject } from "@/lib/create-video-project";
import { SectionReveal } from "./SectionReveal";
import { TemplateCard } from "./TemplateCard";
import {
FILTER_TABS,
filterTemplates,
getTemplateImageSrc,
type FilterTab,
type TemplateItem,
} from "./template-gallery-data";
export interface TemplateGalleryProps {
className?: string;
}
export function TemplateGallery({ className }: TemplateGalleryProps) {
const router = useRouter();
const t = useTranslations("templates");
const [activeTab, setActiveTab] = useState<FilterTab>("All");
const [usingTemplateId, setUsingTemplateId] = useState<string | null>(null);
const filtered = filterTemplates(activeTab);
/** Map filter tab key → translated label */
const tabLabel: Record<FilterTab, string> = {
All: t("tabAll"),
Videos: t("tabVideos"),
Images: t("tabImages"),
"Social Media": t("tabSocial"),
Business: t("tabBusiness"),
};
const handleUseTemplate = useCallback(
async (template: TemplateItem) => {
if (usingTemplateId) return;
setUsingTemplateId(template.id);
// Image templates → create an image project (future)
// All others → video project
const isImage = template.category === "Images";
if (isImage) {
router.push("/dashboard");
setUsingTemplateId(null);
return;
}
const result = await createVideoProject({ name: template.name });
setUsingTemplateId(null);
if (!result.ok) {
// Dev mode: Supabase not configured → go to new-project onboarding
if (result.error.includes("Supabase is not configured")) {
router.push("/studio/video/new");
return;
}
// Any other failure (unauth, server error) → send to sign-in
router.push(`/auth?next=${encodeURIComponent("/templates")}`);
return;
}
router.push(`/studio/video/${result.projectId}`);
},
[router, usingTemplateId]
);
return (
<section
id="templates"
className={cn("scroll-mt-20 w-full bg-white py-20 sm:py-28", className)}
>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")}
</h2>
</SectionReveal>
{/* Filter tabs */}
<SectionReveal className="mt-10 flex flex-wrap items-center justify-center gap-2">
{FILTER_TABS.map((tab) => {
const isActive = activeTab === tab;
return (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"relative rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
isActive
? "text-white"
: "text-neutral-600 hover:text-neutral-900"
)}
>
{isActive && (
<motion.span
layoutId="template-gallery-tab"
className="absolute inset-0 rounded-lg bg-primary-600 shadow-sm"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
<span className="relative z-10">{tabLabel[tab]}</span>
</button>
);
})}
</SectionReveal>
{/* Card grid — flex + justify-center so partial rows are centred */}
<motion.div
layout
className="mt-12 flex flex-wrap justify-center gap-6"
>
<AnimatePresence mode="popLayout">
{filtered.map((template) => (
<motion.div
key={template.id}
layout
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="w-full sm:w-[calc(50%-12px)] lg:w-[calc(25%-18px)] max-w-[320px]"
>
<TemplateCard
templateId={template.id}
name={template.name}
category={template.category}
imageSrc={getTemplateImageSrc(template.id)}
previewVideoUrl={template.previewVideoUrl}
previewSeed={template.id}
priority={filtered.indexOf(template) < 4}
onUseTemplate={() => void handleUseTemplate(template)}
isUsingTemplate={usingTemplateId === template.id}
useTemplateLabel={t("useTemplate")}
openingLabel={t("opening")}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
<SectionReveal className="mt-12 text-center">
<Link
href="/templates"
className="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 rounded-sm"
>
{t("browseAll")}
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,57 @@
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Testimonial } from "./testimonials-data";
export interface TestimonialCardProps {
testimonial: Testimonial;
className?: string;
}
export function TestimonialCard({
testimonial,
className,
}: TestimonialCardProps) {
const { name, role, company, quote, initials } = testimonial;
return (
<article
className={cn(
"flex flex-col rounded-xl border border-gray-100 bg-white p-6 shadow-sm transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-lg",
className
)}
>
<p className="sr-only">Rated 5 out of 5 stars</p>
<div className="flex items-center gap-1" aria-hidden>
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className="h-4 w-4 fill-amber-400 text-amber-400"
/>
))}
</div>
<blockquote className="mt-4 flex-1 text-sm leading-relaxed text-neutral-600">
&ldquo;{quote}&rdquo;
</blockquote>
<footer className="mt-6 flex items-center gap-3 border-t border-gray-100 pt-5">
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-sm font-semibold text-primary-700"
aria-hidden
>
{initials}
</div>
<div>
<cite className="not-italic font-heading text-sm font-semibold text-neutral-900">
{name}
</cite>
<p className="text-xs text-neutral-500">
{role} · {company}
</p>
</div>
</footer>
</article>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { SectionReveal } from "./SectionReveal";
import { TestimonialCard } from "./TestimonialCard";
export interface TestimonialsProps {
className?: string;
}
const TESTIMONIAL_INDICES = [0, 1, 2, 3, 4, 5] as const;
export function Testimonials({ className }: TestimonialsProps) {
const t = useTranslations("testimonials");
const testimonials = TESTIMONIAL_INDICES.map((i) => ({
id: `item${i}`,
name: t(`item${i}Name` as Parameters<typeof t>[0]),
role: t(`item${i}Role` as Parameters<typeof t>[0]),
company: t(`item${i}Company` as Parameters<typeof t>[0]),
quote: t(`item${i}Quote` as Parameters<typeof t>[0]),
initials: t(`item${i}Initials` as Parameters<typeof t>[0]),
}));
return (
<section className={cn("w-full bg-neutral-50 py-20 sm:py-28", className)}>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")}
</h2>
</SectionReveal>
<SectionReveal className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{testimonials.map((testimonial) => (
<TestimonialCard key={testimonial.id} testimonial={testimonial} />
))}
</SectionReveal>
</div>
</section>
);
}
@@ -0,0 +1,36 @@
import { Play } from "lucide-react";
import { cn } from "@/lib/utils";
interface VideoPlayOverlayProps {
className?: string;
size?: "sm" | "md" | "lg";
}
export function VideoPlayOverlay({
className,
size = "md",
}: VideoPlayOverlayProps) {
const icon =
size === "sm" ? "h-4 w-4" : size === "lg" ? "h-8 w-8" : "h-6 w-6";
const box =
size === "sm" ? "h-10 w-10" : size === "lg" ? "h-[4.5rem] w-[4.5rem]" : "h-14 w-14";
const buttonClass =
size === "lg"
? "rounded-full bg-neutral-700/45 text-white shadow-lg backdrop-blur-sm ring-1 ring-white/20"
: "rounded-full bg-white/95 text-blue-600 shadow-lg ring-1 ring-black/5";
return (
<span
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center",
className
)}
aria-hidden
>
<span className={cn("flex items-center justify-center", buttonClass, box)}>
<Play className={cn(icon, "ml-0.5 fill-current")} />
</span>
</span>
);
}
+61
View File
@@ -0,0 +1,61 @@
export interface FaqItem {
id: string;
question: string;
answer: string;
}
export const FAQ_ITEMS: FaqItem[] = [
{
id: "what-is",
question: "What is CreatorStudio?",
answer:
"CreatorStudio is an all-in-one creative platform for making professional videos and images. Choose from 1,200+ templates or start blank, customize with our AI-powered editor, and export in formats ready for social, ads, and presentations.",
},
{
id: "free",
question: "Is it free?",
answer:
"Yes. The Free plan includes 5 exports per month, 720p video exports, and access to basic templates—no credit card required. Upgrade to Pro or Business when you need unlimited exports, 4K quality, AI tools, and team features.",
},
{
id: "formats",
question: "What formats can I export?",
answer:
"Export videos as MP4 (up to 4K on paid plans), images as PNG or JPG, and animated assets as GIF or MP4. Presets are included for Instagram, TikTok, YouTube, LinkedIn, and standard print dimensions.",
},
{
id: "commercial",
question: "Can I use it for commercial use?",
answer:
"Yes. All paid plans include a commercial license for client work, ads, and branded content. Free plan exports are licensed for personal and non-commercial projects unless you upgrade.",
},
{
id: "video-maker",
question: "How does the Video Maker work?",
answer:
"Pick a video template or blank timeline, swap scenes and text, add music and motion graphics, then let AI suggest cuts and captions. Preview in real time and export a single file optimized for your target platform.",
},
{
id: "image-maker",
question: "How does the Image Maker work?",
answer:
"Start from a template or custom canvas, edit layers like text, photos, and shapes, and use AI to generate backgrounds or resize layouts for every channel. Export high-resolution stills in one click.",
},
{
id: "design-skills",
question: "Do I need design skills?",
answer:
"No. Templates and smart layouts handle typography, spacing, and color. The editor guides you with drag-and-drop controls and AI suggestions, so beginners and pros can both ship polished work quickly.",
},
{
id: "cancel",
question: "Can I cancel anytime?",
answer:
"Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects.",
},
];
export const FAQ_COLUMNS = [
FAQ_ITEMS.slice(0, 4),
FAQ_ITEMS.slice(4, 8),
] as const;
@@ -0,0 +1,40 @@
import type { LucideIcon } from "lucide-react";
import { LayoutTemplate, Share2, Wand2 } from "lucide-react";
export interface HowItWorksStepData {
number: number;
title: string;
description: string;
icon: LucideIcon;
previewClassName: string;
}
export const HOW_IT_WORKS_STEPS: HowItWorksStepData[] = [
{
number: 1,
title: "Choose a template or start blank",
description:
"Browse 1,200+ professional templates or open a blank canvas tailored for video, image, or social formats.",
icon: LayoutTemplate,
previewClassName:
"bg-gradient-to-br from-primary-100 to-primary-50 border-primary-200 text-primary-600",
},
{
number: 2,
title: "Customize with our AI-powered editor",
description:
"Drag, drop, and refine every layer. AI suggests copy, colors, and layouts so you ship polished work faster.",
icon: Wand2,
previewClassName:
"bg-gradient-to-br from-violet-100 to-violet-50 border-violet-200 text-violet-600",
},
{
number: 3,
title: "Export and share in one click",
description:
"Download HD exports or publish directly to social channels, ads, and presentations without leaving the app.",
icon: Share2,
previewClassName:
"bg-gradient-to-br from-neutral-100 to-neutral-50 border-neutral-200 text-neutral-600",
},
];
+259
View File
@@ -0,0 +1,259 @@
export type BillingPeriod = "monthly" | "annual";
export interface PricingFeature {
label: string;
info?: boolean;
}
export interface PricingTier {
id: "lite" | "pro" | "business";
name: string;
description: string;
monthlyPrice: number;
/** Per-month price when yearly billing is selected */
annualMonthlyPrice: number;
compareAtAnnualPrice?: number;
promoLabel?: string;
featuresHeading?: string;
features: PricingFeature[];
cta: string;
highlighted?: boolean;
}
export const ANNUAL_SAVINGS_PERCENT = 20;
export const PRICING_TIERS: PricingTier[] = [
{
id: "lite",
name: "Lite",
description: "Gain access to premium features for personal use.",
monthlyPrice: 8,
annualMonthlyPrice: 8,
features: [
{ label: "400 AI credits / month", info: true },
{ label: "5 HD720 template exports" },
{ label: "Access to Premium AI models" },
{ label: "Limited catalog of 1M+ stock footage, music, and photos" },
{ label: "1 premium website on your own domain" },
{ label: "10 GB cloud storage" },
],
cta: "Subscribe",
},
{
id: "pro",
name: "Pro",
description:
"Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
monthlyPrice: 29,
annualMonthlyPrice: 14.5,
compareAtAnnualPrice: 29,
promoLabel: "First year 50% OFF",
featuresHeading: "All Lite features, with",
highlighted: true,
features: [
{ label: "1600 AI credits / month" },
{ label: "Unlimited HD720 AI video creations" },
{ label: "Unlimited AI image processing" },
{ label: "Unlimited Full HD1080 template exports" },
{ label: "Full catalog of 5M+ stock footage, music, and photos" },
],
cta: "Subscribe",
},
{
id: "business",
name: "Business",
description:
"Advanced level solution for teams and businesses. Includes reseller license.",
monthlyPrice: 49,
annualMonthlyPrice: 34.3,
compareAtAnnualPrice: 49,
promoLabel: "First year 30% OFF",
featuresHeading: "All Pro features, with",
features: [
{ label: "3000 AI credits / month" },
{ label: "Unlimited HD1080 AI video creations" },
{ label: "Unlimited Ultra HD 4K template exports" },
{ label: "Reseller license & Team management" },
{ label: "Team members", info: true },
{ label: "100 GB cloud storage" },
],
cta: "Subscribe",
},
];
export function getDisplayPrice(
tier: PricingTier,
billing: BillingPeriod
): number {
if (billing === "annual") return tier.annualMonthlyPrice;
return tier.monthlyPrice;
}
export function getCompareAtPrice(
tier: PricingTier,
billing: BillingPeriod
): number | null {
if (billing === "annual" && tier.compareAtAnnualPrice != null) {
return tier.compareAtAnnualPrice;
}
return null;
}
export type CompareValue = true | false | string;
export interface CompareRow {
feature: string;
tooltip?: string;
lite: CompareValue;
pro: CompareValue;
business: CompareValue;
}
export interface CompareSection {
title: string;
rows: CompareRow[];
}
export const COMPARE_ANNUAL_SAVINGS_BADGE = 40;
export const COMPARE_SECTIONS: CompareSection[] = [
{
title: "Video",
rows: [
{
feature: "Projects",
lite: "Unlimited",
pro: "Unlimited",
business: "Unlimited",
},
{
feature: "Duration",
lite: "Unlimited",
pro: "Unlimited",
business: "Unlimited",
},
{
feature: "Export Quality",
lite: "HD 720",
pro: "HD 1080",
business: "UHD 4K",
},
{
feature: "Stock footage",
lite: "1M+ clips",
pro: "5M+ clips",
business: "5M+ clips",
},
{
feature: "No watermarks",
lite: "5 / month",
pro: true,
business: true,
},
{
feature: "Text to speech",
lite: "50 mins / month",
pro: "100 mins / month",
business: "300 mins / month",
},
{
feature: "Unlimited color palettes",
lite: true,
pro: true,
business: true,
},
{
feature: "Upload custom fonts",
lite: true,
pro: true,
business: true,
},
{
feature: "1M+ premium templates",
lite: true,
pro: true,
business: true,
},
{
feature: "Custom watermark",
lite: false,
pro: false,
business: true,
},
{
feature: "Reseller license",
lite: false,
pro: false,
business: "Up to 10 clients",
},
{
feature: "Team management",
lite: false,
pro: false,
business: true,
},
],
},
{
title: "AI Credits",
rows: [
{
feature: "Generate AI videos & images",
lite: "400 / month",
pro: "1,600 / month",
business: "3,000 / month",
},
],
},
{
title: "AI Video Generation",
rows: [
{
feature: "Quick AI videos (5 sec)",
lite: "40 videos",
pro: "Unlimited",
business: "Unlimited",
},
{
feature: "AI Image generation",
lite: "50 / month",
pro: "Unlimited",
business: "Unlimited",
},
],
},
{
title: "Image Tools",
rows: [
{
feature: "Background removal",
lite: "10 / month",
pro: "Unlimited",
business: "Unlimited",
},
{
feature: "Image export quality",
lite: "HD",
pro: "Full HD",
business: "4K",
},
],
},
{
title: "Storage & Export",
rows: [
{
feature: "Cloud storage",
lite: "10 GB",
pro: "50 GB",
business: "100 GB",
},
{
feature: "Download formats",
lite: "MP4",
pro: "MP4, GIF",
business: "MP4, GIF, WebM",
},
],
},
];
@@ -0,0 +1,137 @@
export const FILTER_TABS = [
"All",
"Videos",
"Images",
"Social Media",
"Business",
] as const;
export type FilterTab = (typeof FILTER_TABS)[number];
export type TemplateCategory = Exclude<FilterTab, "All">;
export interface TemplateItem {
id: string;
name: string;
category: TemplateCategory;
/** Mixkit CDN clip for hover preview on landing gallery cards */
previewVideoUrl?: string;
}
const MIXKIT = {
sunsetPlateaus:
"https://assets.mixkit.co/videos/preview/mixkit-set-of-plateaus-seen-from-the-heights-in-a-sunset-26070-large.mp4",
cloudsRunner:
"https://assets.mixkit.co/videos/preview/mixkit-woman-running-above-the-clouds-34096-large.mp4",
cityTraffic:
"https://assets.mixkit.co/videos/preview/mixkit-aerial-view-of-city-traffic-at-night-11-large.mp4",
meadow:
"https://assets.mixkit.co/videos/preview/mixkit-countryside-meadow-4075-large.mp4",
skyscrapers:
"https://assets.mixkit.co/videos/preview/mixkit-young-woman-walking-among-the-skyscrapers-42300-large.mp4",
} as const;
export const TEMPLATES: TemplateItem[] = [
{
id: "promo-reel",
name: "Promo Reel",
category: "Videos",
previewVideoUrl: MIXKIT.cityTraffic,
},
{
id: "product-launch",
name: "Product Launch",
category: "Videos",
previewVideoUrl: MIXKIT.cloudsRunner,
},
{
id: "brand-story",
name: "Brand Story",
category: "Videos",
previewVideoUrl: MIXKIT.sunsetPlateaus,
},
{ id: "hero-banner", name: "Hero Banner", category: "Images" },
{ id: "catalog-spread", name: "Catalog Spread", category: "Images" },
{
id: "instagram-carousel",
name: "Instagram Carousel",
category: "Social Media",
previewVideoUrl: MIXKIT.meadow,
},
{
id: "tiktok-hook",
name: "TikTok Hook",
category: "Social Media",
previewVideoUrl: MIXKIT.skyscrapers,
},
{ id: "pitch-deck", name: "Pitch Deck", category: "Business" },
];
export function filterTemplates(tab: FilterTab): TemplateItem[] {
if (tab === "All") return TEMPLATES;
return TEMPLATES.filter((template) => template.category === tab);
}
export function getTemplateImageSrc(id: string): string {
return `https://picsum.photos/seed/${id}/400/300`;
}
/** Video presets for /studio/video/new onboarding */
export interface TemplateGalleryItem {
id: string;
name: string;
imageSrc: string;
/** Mixkit CDN clip for muted hover preview */
previewVideoUrl?: string;
}
export const TEMPLATE_GALLERY_ITEMS: TemplateGalleryItem[] = [
{
id: "promo-reel",
name: "Animated Inspirational Video",
imageSrc: "https://picsum.photos/seed/promo-reel/400/280",
previewVideoUrl: MIXKIT.sunsetPlateaus,
},
{
id: "product-launch",
name: "Cybersecurity Company Promo",
imageSrc: "https://picsum.photos/seed/product-launch/400/280",
previewVideoUrl: MIXKIT.cityTraffic,
},
{
id: "brand-story",
name: "Get to Know Your Customers Day",
imageSrc: "https://picsum.photos/seed/brand-story/400/280",
previewVideoUrl: MIXKIT.cloudsRunner,
},
{
id: "instagram-carousel",
name: "SEO Agency Introduction",
imageSrc: "https://picsum.photos/seed/instagram/400/280",
previewVideoUrl: MIXKIT.meadow,
},
{
id: "tiktok-hook",
name: "Tech Startup Promo",
imageSrc: "https://picsum.photos/seed/tiktok/400/280",
previewVideoUrl: MIXKIT.skyscrapers,
},
{
id: "pitch-deck",
name: "Corporate Explainer",
imageSrc: "https://picsum.photos/seed/pitch-deck/400/280",
previewVideoUrl: MIXKIT.cityTraffic,
},
{
id: "hero-promo",
name: "Hero Product Launch",
imageSrc: "https://picsum.photos/seed/hero-promo/400/280",
previewVideoUrl: MIXKIT.sunsetPlateaus,
},
{
id: "event-recap",
name: "Event Recap Highlight",
imageSrc: "https://picsum.photos/seed/event-recap/400/280",
previewVideoUrl: MIXKIT.meadow,
},
];
@@ -0,0 +1,65 @@
export interface Testimonial {
id: string;
name: string;
role: string;
company: string;
quote: string;
initials: string;
}
export const TESTIMONIALS: Testimonial[] = [
{
id: "sarah",
name: "Sarah Chen",
role: "Content Director",
company: "Bloom Studio",
quote:
"We cut our promo turnaround from three days to a few hours. The AI editor suggestions are scary good.",
initials: "SC",
},
{
id: "marcus",
name: "Marcus Webb",
role: "Founder",
company: "Launchpad SaaS",
quote:
"Pitch decks, product demos, and social clips all live in one place now. Our team actually enjoys making assets.",
initials: "MW",
},
{
id: "elena",
name: "Elena Ruiz",
role: "Social Media Manager",
company: "North & Co.",
quote:
"Template quality beats what we paid agencies for last year. Resizing for every platform is basically automatic.",
initials: "ER",
},
{
id: "james",
name: "James Okonkwo",
role: "YouTube Creator",
company: "240K subscribers",
quote:
"I batch a week of thumbnails and shorts in one sitting. Exports are crisp and upload-ready every time.",
initials: "JO",
},
{
id: "priya",
name: "Priya Nair",
role: "Brand Designer",
company: "Studio Kite",
quote:
"The brand kit keeps colors and fonts locked so freelancers cannot drift off-brand. Huge for client work.",
initials: "PN",
},
{
id: "david",
name: "David Park",
role: "Marketing Lead",
company: "Harbor Retail",
quote:
"We rolled CreatorStudio out to 12 stores for local ads. Store managers need no design background to ship campaigns.",
initials: "DP",
},
];
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { FilePlus, LayoutTemplate, Plus } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface AddSceneMenuProps {
onAddBlank: () => void;
variant?: "header" | "footer";
}
export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuProps) {
const [open, setOpen] = useState(false);
const isHeader = variant === "header";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{isHeader ? (
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-[#8b91a7] transition-colors hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label="Add scene"
title="Add scene"
>
<Plus className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[#2a2d3e] bg-[#1a1d2e]/50 px-3 py-2 text-xs font-medium text-gray-300 transition-colors hover:border-[#3d4260] hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Plus className="h-3.5 w-3.5" aria-hidden />
Add Scene
</button>
)}
</PopoverTrigger>
<PopoverContent align={isHeader ? "end" : "center"} className="w-44">
<button
type="button"
onClick={() => {
onAddBlank();
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<FilePlus className="h-4 w-4 shrink-0" aria-hidden />
Blank Scene
</button>
<Link
href="/templates"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<LayoutTemplate className="h-4 w-4 shrink-0" aria-hidden />
From Template
</Link>
</PopoverContent>
</Popover>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Layer, Rect, Stage, Transformer } from "react-konva";
import type Konva from "konva";
import { CanvasLayerNode } from "@/components/studio/canvas/CanvasLayerNode";
import { useCanvasKeyboard } from "@/hooks/useCanvasKeyboard";
import { useCanvasPreviewPlayback } from "@/hooks/useCanvasPreviewPlayback";
import { useContainerSize } from "@/hooks/useContainerSize";
import { registerStudioStage, getStudioStage } from "@/lib/studio-canvas-stage";
import {
nodeTransformToLayer,
resetNodeScale,
} from "@/lib/canvas-transform";
import { getShapeProps } from "@/lib/studio-layer-props";
import { getActiveScene, useStudioStore } from "@/lib/studio-store";
import type { Layer as StudioLayer } from "@/lib/studio-types";
export const STAGE_WIDTH = 1280;
export const STAGE_HEIGHT = 720;
export function CanvasEditor() {
const { ref: containerRef, width: containerWidth } = useContainerSize();
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const transformerRef = useRef<Konva.Transformer>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
const scenes = useStudioStore((state) => state.scenes);
const activeSceneId = useStudioStore((state) => state.activeSceneId);
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
const isPlaying = useStudioStore((state) => state.isPlaying);
const sceneBackgroundColor = useStudioStore(
(state) => state.sceneBackgroundColor
);
const setSelectedLayer = useStudioStore((state) => state.setSelectedLayer);
const updateLayer = useStudioStore((state) => state.updateLayer);
const updateScene = useStudioStore((state) => state.updateScene);
const thumbTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useCanvasKeyboard();
useCanvasPreviewPlayback(canvasWrapperRef);
const activeScene = useMemo(
() => getActiveScene({ scenes, activeSceneId }),
[scenes, activeSceneId]
);
const sortedLayers = useMemo(
() =>
[...(activeScene?.layers ?? [])].sort((a, b) => a.zIndex - b.zIndex),
[activeScene?.layers]
);
const scale =
containerWidth > 0 ? containerWidth / STAGE_WIDTH : 1;
const stageHeight = STAGE_HEIGHT * scale;
const registerNode = useCallback((id: string, node: Konva.Node | null) => {
if (node) {
nodeRefs.current.set(id, node);
} else {
nodeRefs.current.delete(id);
}
}, []);
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer || isPlaying) return;
if (!selectedLayerId) {
transformer.nodes([]);
transformer.getLayer()?.batchDraw();
return;
}
// Text layers are fixed — no transform handles
const selectedLayer = sortedLayers.find((l) => l.id === selectedLayerId);
if (selectedLayer?.type === "text") {
transformer.nodes([]);
transformer.getLayer()?.batchDraw();
return;
}
const node = nodeRefs.current.get(selectedLayerId);
if (node) {
transformer.nodes([node]);
transformer.getLayer()?.batchDraw();
}
}, [selectedLayerId, sortedLayers, isPlaying]);
// Auto-capture scene thumbnail whenever layers or background change
useEffect(() => {
if (isPlaying || !activeSceneId) return;
if (thumbTimerRef.current) clearTimeout(thumbTimerRef.current);
thumbTimerRef.current = setTimeout(() => {
const stage = getStudioStage();
if (!stage) return;
// Temporarily clear transformer so handles don't appear in thumbnail
const transformer = transformerRef.current;
const prevNodes = transformer?.nodes() ?? [];
transformer?.nodes([]);
transformer?.getLayer()?.batchDraw();
stage.toDataURL({
pixelRatio: 0.25,
callback: (dataUrl: string) => {
updateScene(activeSceneId, { thumbnailUrl: dataUrl });
// Restore transformer
transformer?.nodes(prevNodes);
transformer?.getLayer()?.batchDraw();
},
});
}, 700);
return () => {
if (thumbTimerRef.current) clearTimeout(thumbTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedLayers, sceneBackgroundColor, activeSceneId, isPlaying]);
const applyTransform = useCallback(
(layer: StudioLayer, node: Konva.Node) => {
if (layer.type === "shape" && getShapeProps(layer.props).shape === "circle") {
const circle = node as Konva.Circle;
const scaledRadius = circle.radius() * circle.scaleX();
resetNodeScale(circle);
updateLayer(layer.id, {
x: circle.x() - scaledRadius,
y: circle.y() - scaledRadius,
width: scaledRadius * 2,
height: scaledRadius * 2,
rotation: circle.rotation(),
});
return;
}
resetNodeScale(node);
updateLayer(layer.id, nodeTransformToLayer(node));
},
[updateLayer]
);
const applyDrag = useCallback(
(layer: StudioLayer, x: number, y: number) => {
if (layer.type === "shape" && getShapeProps(layer.props).shape === "circle") {
const radius = Math.min(layer.width, layer.height) / 2;
updateLayer(layer.id, { x: x - radius, y: y - radius });
return;
}
updateLayer(layer.id, { x, y });
},
[updateLayer]
);
const handleStagePointerDown = (event: Konva.KonvaEventObject<MouseEvent>) => {
const target = event.target;
const clickedOnEmpty =
target === target.getStage() || target.name() === "background";
if (clickedOnEmpty) {
setSelectedLayer(null);
}
};
if (containerWidth <= 0) {
return <div ref={containerRef} className="h-full w-full" />;
}
return (
<div
ref={containerRef}
className="flex h-full w-full items-center justify-center"
>
<div className="overflow-hidden rounded-lg shadow-2xl ring-1 ring-gray-700/80">
<div ref={canvasWrapperRef} className="origin-center will-change-transform">
<Stage
ref={(node) => registerStudioStage(node)}
width={containerWidth}
height={stageHeight}
scaleX={scale}
scaleY={scale}
onMouseDown={isPlaying ? undefined : handleStagePointerDown}
>
<Layer>
<Rect
name="background"
x={0}
y={0}
width={STAGE_WIDTH}
height={STAGE_HEIGHT}
fill={sceneBackgroundColor}
/>
{sortedLayers.map((layer) => (
<CanvasLayerNode
key={layer.id}
layer={layer}
onSelect={() => setSelectedLayer(layer.id)}
onDragEnd={(x, y) => applyDrag(layer, x, y)}
onTransformEnd={(node) => applyTransform(layer, node)}
registerNode={registerNode}
/>
))}
{!isPlaying ? (
<Transformer
ref={transformerRef}
rotateEnabled
borderStroke="#2563EB"
anchorStroke="#2563EB"
anchorFill="#ffffff"
anchorSize={8}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 8 || newBox.height < 8) {
return oldBox;
}
return newBox;
}}
/>
) : null}
</Layer>
</Stage>
</div>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { SceneItemActions } from "@/components/studio/SceneItemActions";
import type { Scene } from "@/lib/studio-types";
import { cn } from "@/lib/utils";
export interface DraggableSceneItemProps {
scene: Scene;
isActive: boolean;
canDelete: boolean;
onSelect: () => void;
onDelete: () => void;
onDuplicate: () => void;
onRename: (name: string) => void;
}
export function DraggableSceneItem({
scene,
isActive,
canDelete,
onSelect,
onDelete,
onDuplicate,
onRename,
}: DraggableSceneItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(scene.name);
const inputRef = useRef<HTMLInputElement>(null);
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: scene.id });
useEffect(() => {
setEditName(scene.name);
}, [scene.name]);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
const commitRename = () => {
const trimmed = editName.trim();
if (trimmed && trimmed !== scene.name) {
onRename(trimmed);
} else {
setEditName(scene.name);
}
setIsEditing(false);
};
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(
"group flex gap-1 rounded-r-lg",
isActive && "border-l-4 border-l-[#4c6ef5] bg-[#252938]",
isDragging && "z-10 opacity-60"
)}
>
<button
type="button"
ref={setActivatorNodeRef}
className="flex w-6 shrink-0 cursor-grab items-center justify-center text-gray-500 hover:text-gray-300 active:cursor-grabbing"
aria-label={`Drag scene ${scene.name}`}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" aria-hidden />
</button>
<div className="min-w-0 flex-1 py-1 pr-1">
<button
type="button"
onClick={onSelect}
className="w-full rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<div className="relative h-14 w-full overflow-hidden rounded-md bg-[#1a1d2e]">
{scene.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={scene.thumbnailUrl}
alt=""
className="h-full w-full object-cover"
/>
) : null}
<SceneItemActions
sceneName={scene.name}
canDelete={canDelete}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>
<span className="absolute bottom-1 right-1 rounded bg-[#0f111a]/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-300">
{scene.duration}s
</span>
</div>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editName}
onClick={(event) => event.stopPropagation()}
onChange={(event) => setEditName(event.target.value)}
onBlur={commitRename}
onKeyDown={(event) => {
if (event.key === "Enter") commitRename();
if (event.key === "Escape") {
setEditName(scene.name);
setIsEditing(false);
}
}}
className="mt-1.5 w-full rounded border border-[#2a2d3e] bg-[#1a1d2e] px-1.5 py-0.5 text-xs text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4c6ef5]"
aria-label="Scene name"
/>
) : (
<p
className="mt-1.5 truncate text-xs font-medium text-gray-200"
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}}
>
{scene.name}
</p>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
"use client";
import { Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ProjectSaveStatus } from "@/lib/project-save-status";
import { cn } from "@/lib/utils";
interface ProjectSaveIndicatorProps {
status: ProjectSaveStatus;
onRetry?: () => void;
className?: string;
}
export function ProjectSaveIndicator({
status,
onRetry,
className,
}: ProjectSaveIndicatorProps) {
if (status === "idle") return null;
if (status === "pending" || status === "saving") {
return (
<span
className={cn(
"flex items-center gap-1.5 text-xs text-gray-400",
className
)}
aria-live="polite"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Saving
</span>
);
}
if (status === "saved") {
return (
<span
className={cn(
"flex items-center gap-1 text-xs font-medium text-green-400",
className
)}
aria-live="polite"
>
<Check className="h-3.5 w-3.5" aria-hidden />
Saved
</span>
);
}
if (status === "local") {
return (
<span
className={cn("text-xs font-medium text-gray-400", className)}
aria-live="polite"
>
Local save
</span>
);
}
return (
<span className={cn("flex items-center gap-2", className)} aria-live="polite">
<span className="text-xs text-red-400">Save failed</span>
{onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 border-[#2a2d3e] bg-[#1a1d2e] px-2 text-xs text-gray-200 hover:bg-[#252938]"
onClick={onRetry}
>
Retry
</Button>
) : null}
</span>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { MousePointer2, SlidersHorizontal } from "lucide-react";
import { CommonLayerControls } from "@/components/studio/properties/CommonLayerControls";
import { ImageLayerProperties } from "@/components/studio/properties/ImageLayerProperties";
import { ShapeLayerProperties } from "@/components/studio/properties/ShapeLayerProperties";
import { TextLayerProperties } from "@/components/studio/properties/TextLayerProperties";
import { getSelectedLayer, useStudioStore } from "@/lib/studio-store";
import { cn } from "@/lib/utils";
export interface PropertiesPanelProps {
className?: string;
}
export function PropertiesPanel({ className }: PropertiesPanelProps) {
const scenes = useStudioStore((state) => state.scenes);
const activeSceneId = useStudioStore((state) => state.activeSceneId);
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
const layer = getSelectedLayer({
scenes,
activeSceneId,
selectedLayerId,
});
return (
<aside
className={cn(
"flex h-full w-full flex-col overflow-hidden bg-white text-gray-900",
className
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-3 py-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Properties
</h2>
<SlidersHorizontal className="h-4 w-4 text-gray-400" aria-hidden />
</div>
{!layer ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50">
<MousePointer2 className="h-6 w-6 text-gray-300" aria-hidden />
</div>
<p className="text-xs text-gray-400">
Select a layer to edit properties
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto p-3">
<p className="text-[11px] font-medium capitalize text-gray-400">
{layer.type} layer
</p>
{layer.type === "text" ? <TextLayerProperties layer={layer} /> : null}
{layer.type === "image" ? (
<ImageLayerProperties layer={layer} />
) : null}
{layer.type === "shape" ? (
<ShapeLayerProperties layer={layer} />
) : null}
<CommonLayerControls layer={layer} />
</div>
</div>
)}
</aside>
);
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Download, Link2, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { RenderExportPreset } from "@/lib/render-presets";
import { RENDER_EXPORT_PRESETS } from "@/lib/render-presets";
import type { RenderSettings } from "@/lib/render-schemas";
import type { Scene } from "@/lib/studio-types";
import { cn } from "@/lib/utils";
interface RenderModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
scenes: Scene[];
preset?: RenderExportPreset | null;
}
type JobStatus = "idle" | "submitting" | "polling" | "completed" | "failed";
interface StatusResponse {
status: string;
progress: number;
outputUrl: string | null;
progressMessage?: string | null;
errorMessage?: string | null;
}
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
export function RenderModal({
open,
onOpenChange,
projectId,
scenes,
preset = null,
}: RenderModalProps) {
const [resolution, setResolution] =
useState<RenderSettings["resolution"]>("1080p");
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
const [presetLabel, setPresetLabel] = useState<string | null>(null);
const [jobStatus, setJobStatus] = useState<JobStatus>("idle");
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState("");
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const reset = useCallback(() => {
setJobStatus("idle");
setJobId(null);
setProgress(0);
setProgressMessage("");
setOutputUrl(null);
setErrorMessage(null);
}, []);
useEffect(() => {
if (!open) reset();
}, [open, reset]);
useEffect(() => {
if (!open || !preset) return;
const config = RENDER_EXPORT_PRESETS[preset];
setResolution(config.settings.resolution);
setFps(config.settings.fps);
setPresetLabel(config.label);
}, [open, preset]);
useEffect(() => {
if (jobStatus !== "polling" || !jobId) return;
const poll = async () => {
try {
const response = await fetch(`/api/render/${jobId}/status`);
const data = (await response.json()) as StatusResponse;
if (!response.ok) {
setJobStatus("failed");
setErrorMessage("Could not fetch render status.");
return;
}
setProgress(data.progress ?? 0);
setProgressMessage(
data.progressMessage ?? `Rendering… ${data.progress}%`
);
if (data.status === "completed" && data.outputUrl) {
setOutputUrl(data.outputUrl);
setJobStatus("completed");
setProgress(100);
return;
}
if (data.status === "failed") {
setJobStatus("failed");
setErrorMessage(data.errorMessage ?? "Render failed.");
}
} catch {
setJobStatus("failed");
setErrorMessage("Network error while polling status.");
}
};
poll();
const intervalId = window.setInterval(poll, 3000);
return () => window.clearInterval(intervalId);
}, [jobStatus, jobId]);
const startRender = async () => {
setJobStatus("submitting");
setErrorMessage(null);
try {
const response = await fetch("/api/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
scenes,
settings: {
resolution,
format: "mp4" as const,
fps,
},
}),
});
const data = (await response.json()) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
setJobStatus("failed");
setErrorMessage(data.error ?? "Failed to start render.");
return;
}
setJobId(data.jobId);
setJobStatus("polling");
setProgress(0);
setProgressMessage("Queued for rendering…");
} catch {
setJobStatus("failed");
setErrorMessage("Could not reach render API.");
}
};
const isBusy = jobStatus === "submitting" || jobStatus === "polling";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{presetLabel ?? "Export"}</DialogTitle>
<DialogDescription>
{preset
? RENDER_EXPORT_PRESETS[preset].description
: "Export your project as MP4 via the nexrender pipeline."}
</DialogDescription>
</DialogHeader>
{jobStatus === "completed" && outputUrl ? (
<div className="space-y-4">
<p className="text-sm text-green-400">Your video is ready.</p>
<div className="flex flex-col gap-2">
<a
href={outputUrl}
download
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Download className="h-4 w-4" />
Download MP4
</a>
<a
href={outputUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Link2 className="h-4 w-4" />
Share link
</a>
</div>
<Button
type="button"
variant="outline"
className="w-full border-[#2a2d3e]"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</div>
) : jobStatus === "failed" ? (
<div className="space-y-4">
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
{errorMessage ?? "Something went wrong."}
</p>
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={startRender}
>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
</div>
) : isBusy ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-gray-300">
<Loader2 className="h-4 w-4 animate-spin text-primary-400" />
{progressMessage || "Rendering…"}
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-gray-500">
<span>Progress</span>
<span>{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
<div
className="h-full rounded-full bg-primary-600 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<p className="mb-2 text-xs font-medium text-gray-400">
Resolution
</p>
<div className="flex gap-2">
{RESOLUTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setResolution(item)}
className={cn(
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
resolution === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">Format</p>
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
MP4
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">FPS</p>
<div className="flex gap-2">
{FPS_OPTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setFps(item)}
className={cn(
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
fps === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item}
</button>
))}
</div>
</div>
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={startRender}
disabled={scenes.length === 0}
>
Start Rendering
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { Check, Clock, ImageIcon, User } from "lucide-react";
import { getScenePreviewVideoSrc } from "@/lib/template-preview-media";
import type { BrowserSceneItem } from "@/lib/scene-browser-data";
import { cn } from "@/lib/utils";
interface SceneBrowserCardProps {
scene: BrowserSceneItem;
isSelected: boolean;
onToggle: () => void;
}
/** Derive CSS gradient from the Tailwind class names stored on the scene */
function gradientStyle(from: string, to: string): React.CSSProperties {
// Map Tailwind color class → hex so gradient renders even when Tailwind purges dynamic names
const colorMap: Record<string, string> = {
"from-sky-200": "#bae6fd", "to-sky-200": "#bae6fd",
"from-blue-200": "#bfdbfe", "to-blue-200": "#bfdbfe",
"from-blue-300": "#93c5fd", "to-blue-300": "#93c5fd",
"from-blue-400": "#60a5fa", "to-blue-400": "#60a5fa",
"from-indigo-200": "#c7d2fe", "to-indigo-200": "#c7d2fe",
"from-indigo-300": "#a5b4fc", "to-indigo-300": "#a5b4fc",
"from-violet-200": "#ddd6fe", "to-violet-200": "#ddd6fe",
"from-violet-300": "#c4b5fd", "to-violet-300": "#c4b5fd",
"from-purple-300": "#d8b4fe", "to-purple-300": "#d8b4fe",
"from-fuchsia-200": "#f5d0fe","to-fuchsia-200": "#f5d0fe",
"from-pink-200": "#fbcfe8", "to-pink-200": "#fbcfe8",
"from-rose-200": "#fecdd3", "to-rose-200": "#fecdd3",
"from-red-300": "#fca5a5", "to-red-300": "#fca5a5",
"from-amber-200": "#fde68a", "to-amber-200": "#fde68a",
"from-orange-300": "#fdba74", "to-orange-300": "#fdba74",
"from-yellow-200": "#fef08a", "to-yellow-200": "#fef08a",
"from-cyan-200": "#a5f3fc", "to-cyan-200": "#a5f3fc",
"from-teal-300": "#5eead4", "to-teal-300": "#5eead4",
"from-emerald-200": "#a7f3d0","to-emerald-200": "#a7f3d0",
"from-green-200": "#bbf7d0", "to-green-200": "#bbf7d0",
"from-green-300": "#86efac", "to-green-300": "#86efac",
"from-lime-200": "#d9f99d", "to-lime-200": "#d9f99d",
"from-slate-200": "#e2e8f0", "to-slate-200": "#e2e8f0",
"from-gray-300": "#d1d5db", "to-gray-300": "#d1d5db",
};
const c1 = colorMap[from] ?? "#bfdbfe";
const c2 = colorMap[to] ?? "#a5b4fc";
return { backgroundImage: `linear-gradient(135deg, ${c1}, ${c2})` };
}
export function SceneBrowserCard({
scene,
isSelected,
onToggle,
}: SceneBrowserCardProps) {
const [hovered, setHovered] = useState(false);
const videoSrc = getScenePreviewVideoSrc(scene.category, scene.id);
return (
<button
type="button"
onClick={onToggle}
aria-pressed={isSelected}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="group w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
>
{/* ── Thumbnail ── */}
<div
className={cn(
"relative aspect-video overflow-hidden rounded-xl transition-all duration-200",
isSelected
? "ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md"
: "group-hover:ring-2 group-hover:ring-blue-400 group-hover:shadow-md"
)}
style={gradientStyle(scene.gradientFrom, scene.gradientTo)}
>
{/* Animated layout mockup — visible when not showing video */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center gap-2 p-5 transition-opacity duration-300",
hovered ? "opacity-0" : "opacity-100"
)}
>
{/* Mock title bar */}
<div className="h-2.5 w-3/5 rounded-full bg-white/70 shadow" />
{/* Mock subtitle bar */}
<div className="h-2 w-2/5 rounded-full bg-white/45" />
{/* Mock image or icon block */}
<div className="mt-2 flex h-10 w-16 items-center justify-center rounded-lg bg-white/25">
<ImageIcon className="h-4 w-4 text-white/70" aria-hidden />
</div>
</div>
{/* Hover video preview */}
{hovered && (
<video
src={videoSrc}
autoPlay
muted
loop
playsInline
preload="metadata"
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
/>
)}
{/* Dark overlay on hover so CTA is readable */}
<div
className={cn(
"absolute inset-0 bg-black/0 transition-colors duration-200",
hovered && !isSelected && "bg-black/20"
)}
/>
{/* Duration + media type badges — bottom strip */}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-2.5 py-2 text-[10px] text-white">
{scene.characterCount > 0 && (
<span className="flex items-center gap-1">
<User className="h-2.5 w-2.5" aria-hidden />
{scene.characterCount}
</span>
)}
<span className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5" aria-hidden />
{scene.durationLabel}
</span>
<span className="ml-auto rounded bg-white/20 px-1 py-0.5 uppercase tracking-wide backdrop-blur-sm">
{scene.mediaType}
</span>
</div>
{/* Selected checkmark */}
{isSelected && (
<span className="absolute right-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 shadow-md">
<Check className="h-3.5 w-3.5 text-white" aria-hidden />
</span>
)}
{/* Hover CTA when NOT selected */}
{!isSelected && hovered && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-lg">
Select
</span>
</div>
)}
</div>
{/* ── Scene name ── */}
<p
className={cn(
"mt-1.5 truncate text-sm leading-tight",
isSelected ? "font-semibold text-blue-600" : "text-gray-700"
)}
>
{scene.name}
</p>
</button>
);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutGrid, Search, X } from "lucide-react";
import { SceneBrowserCard } from "@/components/studio/SceneBrowserCard";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
BROWSER_SCENES,
filterBrowserScenes,
SCENE_BROWSER_CATEGORIES,
type BrowserSceneItem,
type SceneBrowserCategoryId,
type SceneBrowserMediaFilter,
} from "@/lib/scene-browser-data";
import { cn } from "@/lib/utils";
export interface SceneBrowserModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with ALL selected scenes when user clicks "Add to Video" */
onScenesAdd: (scenes: BrowserSceneItem[]) => void;
}
export function SceneBrowserModal({
open,
onOpenChange,
onScenesAdd,
}: SceneBrowserModalProps) {
const [categoryId, setCategoryId] = useState<SceneBrowserCategoryId>("all");
const [mediaFilter, setMediaFilter] = useState<SceneBrowserMediaFilter>("all");
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filteredScenes = useMemo(
() =>
filterBrowserScenes(BROWSER_SCENES, { categoryId, mediaFilter, search }),
[categoryId, mediaFilter, search]
);
const toggleScene = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const deselectAll = () => setSelectedIds(new Set());
const handleAdd = () => {
const selected = BROWSER_SCENES.filter((s) => selectedIds.has(s.id));
if (selected.length === 0) return;
onScenesAdd(selected);
// Reset state
setSelectedIds(new Set());
setSearch("");
setCategoryId("all");
setMediaFilter("all");
onOpenChange(false);
};
const handleClose = () => {
setSelectedIds(new Set());
setSearch("");
onOpenChange(false);
};
const selectedCount = selectedIds.size;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent
className={cn(
"flex h-[88vh] max-h-[88vh] w-[calc(100%-2rem)] max-w-5xl flex-col gap-0 overflow-hidden",
"border-gray-200 bg-white p-0 text-gray-900",
"[&>button]:hidden" // hide default close — we have our own
)}
>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between gap-4">
<DialogTitle className="font-heading text-lg font-semibold text-gray-900">
Select Scenes
</DialogTitle>
<button
type="button"
onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Media tabs + search on same row */}
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<Tabs
value={mediaFilter}
onValueChange={(v) => setMediaFilter(v as SceneBrowserMediaFilter)}
>
<TabsList className="bg-gray-100">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="video">Video</TabsTrigger>
<TabsTrigger value="photo">Photo</TabsTrigger>
</TabsList>
</Tabs>
<div className="relative w-full max-w-52">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" aria-hidden />
<input
type="search"
placeholder="Search scenes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-9 pr-3 text-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
/>
</div>
</div>
</DialogHeader>
{/* Body: sidebar + grid */}
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Category sidebar */}
<aside className="w-[220px] shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 p-3">
<ul className="space-y-0.5">
{SCENE_BROWSER_CATEGORIES.map((cat) => (
<li key={cat.id}>
<button
type="button"
onClick={() => setCategoryId(cat.id)}
className={cn(
"w-full cursor-pointer rounded-lg px-4 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600",
categoryId === cat.id
? "bg-blue-50 font-semibold text-blue-600"
: "text-gray-700 hover:bg-gray-100"
)}
>
{cat.label}
</button>
</li>
))}
</ul>
</aside>
{/* Scene grid */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{filteredScenes.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-gray-500">
<LayoutGrid className="h-10 w-10 text-gray-300" aria-hidden />
No scenes match your filters.
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{filteredScenes.map((scene) => (
<SceneBrowserCard
key={scene.id}
scene={scene}
isSelected={selectedIds.has(scene.id)}
onToggle={() => toggleScene(scene.id)}
/>
))}
</div>
)}
</div>
</div>
</div>
{/* Footer — sticky "Add to Video" bar */}
<div className="flex shrink-0 items-center justify-between gap-4 border-t border-gray-200 bg-white px-6 py-3">
<div className="flex items-center gap-3">
{selectedCount > 0 && (
<span className="text-sm text-gray-600">
<span className="font-semibold text-gray-900">{selectedCount}</span>{" "}
scene{selectedCount !== 1 ? "s" : ""} selected
</span>
)}
{selectedCount > 0 && (
<button
type="button"
onClick={deselectAll}
className="text-sm text-gray-500 underline hover:text-gray-700 focus-visible:outline-none"
>
Deselect All
</button>
)}
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
<Button
type="button"
disabled={selectedCount === 0}
onClick={handleAdd}
className="min-w-[140px] bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{selectedCount === 0
? "Add to Video"
: `Add to Video (${selectedCount})`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
"use client";
import { Copy, Trash2 } from "lucide-react";
interface SceneItemActionsProps {
sceneName: string;
canDelete: boolean;
onDuplicate: () => void;
onDelete: () => void;
}
export function SceneItemActions({
sceneName,
canDelete,
onDuplicate,
onDelete,
}: SceneItemActionsProps) {
return (
<div
className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDuplicate();
}}
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label={`Duplicate ${sceneName}`}
>
<Copy className="h-3 w-3" aria-hidden />
</button>
{canDelete && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-red-600/90 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label={`Delete ${sceneName}`}
>
<Trash2 className="h-3 w-3" aria-hidden />
</button>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SCENE_TRANSITION_OPTIONS } from "@/lib/scene-transition-options";
import type { SceneTransition } from "@/lib/studio-types";
import { cn } from "@/lib/utils";
interface SceneTransitionPickerProps {
transitionType: SceneTransition;
onChange: (transition: SceneTransition) => void;
}
export function SceneTransitionPicker({
transitionType,
onChange,
}: SceneTransitionPickerProps) {
const [open, setOpen] = useState(false);
const activeOption =
SCENE_TRANSITION_OPTIONS.find((option) => option.id === transitionType) ??
SCENE_TRANSITION_OPTIONS[0];
const ActiveIcon = activeOption.icon;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
title="Transition"
aria-label="Transition"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border border-[#2a2d3e] bg-[#1a1d2e] text-[#8b91a7] transition-colors hover:border-[#3d4260] hover:bg-[#252938] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
transitionType !== "none" && "border-[#4c6ef5]/60 text-[#7b9eff]"
)}
>
<ActiveIcon className="h-3 w-3" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-2">
<p className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-[#5c6278]">
Transition
</p>
<ul className="space-y-0.5">
{SCENE_TRANSITION_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<li key={option.id}>
<button
type="button"
onClick={() => {
onChange(option.id);
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-gray-200 transition-colors hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
transitionType === option.id && "bg-[#252938] text-white"
)}
>
<Icon className="h-4 w-4 shrink-0 text-[#8b91a7]" aria-hidden />
{option.label}
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,45 @@
import Link from "next/link";
import { Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface StudioMobileGateProps {
/** Matches the studio shell background */
variant?: "video" | "image";
}
export function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
const title =
variant === "video"
? "The Video Studio requires a desktop browser."
: "The Image Editor requires a desktop browser.";
return (
<div
className={cn(
"flex h-screen w-screen flex-col items-center justify-center px-6 text-center text-white",
variant === "video" ? "bg-[#151823]" : "bg-[#0f111a]"
)}
>
<Monitor
className="h-24 w-24 text-gray-500"
strokeWidth={1.25}
aria-hidden
/>
<h1 className="mt-8 max-w-md font-heading text-xl font-bold leading-snug sm:text-2xl">
{title}
</h1>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-gray-400">
Please open this project on a desktop or laptop.
</p>
<Button
asChild
size="lg"
className="mt-8 bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500"
>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import {
ArrowRight,
Circle,
Clapperboard,
Image as ImageIcon,
Minus,
Square,
Type,
} from "lucide-react";
import { ToolbarIconButton } from "@/components/studio/ToolbarIconButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ShapeKind } from "@/lib/studio-layer-props";
import type { AddLayerInput } from "@/lib/studio-types";
import { useStudioStore } from "@/lib/studio-store";
const SHAPE_OPTIONS: {
kind: ShapeKind;
label: string;
icon: typeof Square;
config: AddLayerInput;
}[] = [
{
kind: "rect",
label: "Rectangle",
icon: Square,
config: {
type: "shape",
x: 100,
y: 100,
width: 320,
height: 180,
props: { shape: "rect", fill: "#2563EB", stroke: "#1E3A8A", strokeWidth: 0 },
},
},
{
kind: "circle",
label: "Circle",
icon: Circle,
config: {
type: "shape",
x: 100,
y: 100,
width: 180,
height: 180,
props: { shape: "circle", fill: "#7C3AED" },
},
},
{
kind: "line",
label: "Line",
icon: Minus,
config: {
type: "shape",
x: 100,
y: 200,
width: 240,
height: 8,
props: { shape: "line", stroke: "#FFFFFF", strokeWidth: 4, fill: "#FFFFFF" },
},
},
{
kind: "arrow",
label: "Arrow",
icon: ArrowRight,
config: {
type: "shape",
x: 100,
y: 200,
width: 200,
height: 40,
props: {
shape: "arrow",
stroke: "#FFFFFF",
strokeWidth: 4,
fill: "#FFFFFF",
},
},
},
];
export function StudioToolbar() {
const addLayer = useStudioStore((state) => state.addLayer);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const [shapeOpen, setShapeOpen] = useState(false);
const handleAddText = () => {
addLayer({
type: "text",
x: 100,
y: 100,
width: 300,
height: 60,
props: {
text: "Edit this text",
fontSize: 48,
fill: "#ffffff",
fontFamily: "Inter, sans-serif",
},
});
};
const handleImageFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
addLayer({
type: "image",
x: 100,
y: 100,
width: 400,
height: 300,
props: { src: reader.result },
});
}
};
reader.readAsDataURL(file);
event.target.value = "";
};
const handleVideoFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
addLayer({
type: "video",
x: 100,
y: 100,
width: 480,
height: 270,
props: { src: objectUrl, fileName: file.name },
});
event.target.value = "";
};
return (
<div className="flex items-center gap-1">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoFile}
/>
<ToolbarIconButton label="Add text" onClick={handleAddText}>
<Type className="h-4 w-4" aria-hidden />
</ToolbarIconButton>
<ToolbarIconButton
label="Add image"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" aria-hidden />
</ToolbarIconButton>
<ToolbarIconButton
label="Add video clip"
onClick={() => videoInputRef.current?.click()}
>
<Clapperboard className="h-4 w-4" aria-hidden />
</ToolbarIconButton>
<Popover open={shapeOpen} onOpenChange={setShapeOpen}>
<PopoverTrigger asChild>
<div className="group relative">
<button
type="button"
aria-label="Add shape"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Square className="h-4 w-4" aria-hidden />
</button>
<span
role="tooltip"
className="pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-[10px] font-medium text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
Add shape
</span>
</div>
</PopoverTrigger>
<PopoverContent align="center" className="w-40">
{SHAPE_OPTIONS.map(({ kind, label, icon: Icon, config }) => (
<button
key={kind}
type="button"
onClick={() => {
addLayer(config);
setShapeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{label}
</button>
))}
</PopoverContent>
</Popover>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { SceneThumbnailStrip } from "@/components/studio/timeline/SceneThumbnailStrip";
import { TimelineControlBar } from "@/components/studio/timeline/TimelineControlBar";
import { TimelineQuickActions } from "@/components/studio/timeline/TimelineQuickActions";
import { cn } from "@/lib/utils";
export interface TimelineProps {
className?: string;
onOpenTts?: () => void;
onOpenAudio?: () => void;
onSceneSelect?: () => void;
}
export function Timeline({
className,
onOpenTts,
onOpenAudio,
onSceneSelect,
}: TimelineProps) {
return (
<footer
className={cn(
// h = control-bar(44) + thumbnail-strip(136) + quick-actions(64) = 244px
"flex h-[244px] shrink-0 flex-col overflow-hidden border-t border-gray-200 bg-white text-gray-900",
className
)}
>
<TimelineControlBar />
<SceneThumbnailStrip onSceneSelect={onSceneSelect} />
<TimelineQuickActions onOpenTts={onOpenTts} onOpenAudio={onOpenAudio} />
</footer>
);
}
@@ -0,0 +1,40 @@
"use client";
import { forwardRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export const toolbarIconButtonClassName =
"flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500";
interface ToolbarIconButtonProps {
label: string;
onClick?: () => void;
children: ReactNode;
className?: string;
}
export const ToolbarIconButton = forwardRef<
HTMLButtonElement,
ToolbarIconButtonProps
>(function ToolbarIconButton({ label, onClick, children, className }, ref) {
return (
<div className="group relative">
<button
ref={ref}
type="button"
onClick={onClick}
aria-label={label}
className={cn(toolbarIconButtonClassName, className)}
>
{children}
</button>
<span
role="tooltip"
className="pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-[10px] font-medium text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
{label}
</span>
</div>
);
});
@@ -0,0 +1,32 @@
"use client";
import type Konva from "konva";
import { ImageLayerNode } from "@/components/studio/canvas/ImageLayerNode";
import { ShapeLayerNode } from "@/components/studio/canvas/ShapeLayerNode";
import { TextLayerNode } from "@/components/studio/canvas/TextLayerNode";
import { VideoLayerNode } from "@/components/studio/canvas/VideoLayerNode";
import type { Layer } from "@/lib/studio-types";
export interface CanvasLayerNodeProps {
layer: Layer;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function CanvasLayerNode(props: CanvasLayerNodeProps) {
switch (props.layer.type) {
case "text":
return <TextLayerNode {...props} />;
case "image":
return <ImageLayerNode {...props} />;
case "video":
return <VideoLayerNode {...props} />;
case "shape":
return <ShapeLayerNode {...props} />;
default:
return null;
}
}
@@ -0,0 +1,128 @@
"use client";
import { Image, Rect } from "react-konva";
import type Konva from "konva";
import useImage from "use-image";
import { getImageProps } from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
export interface ImageLayerNodeProps {
layer: Layer;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
function getFlipOffsets(
layer: Layer,
flipHorizontal: boolean,
flipVertical: boolean
) {
return {
offsetX: flipHorizontal ? layer.width : 0,
offsetY: flipVertical ? layer.height : 0,
scaleX: flipHorizontal ? -1 : 1,
scaleY: flipVertical ? -1 : 1,
};
}
function ImageLayerPlaceholder({
layer,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: ImageLayerNodeProps) {
const { cornerRadius } = getImageProps(layer.props);
return (
<Rect
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
cornerRadius={cornerRadius}
fill="#E5E7EB"
stroke="#9CA3AF"
strokeWidth={1}
dash={[8, 4]}
draggable
onMouseDown={(event) => {
event.cancelBubble = true;
onSelect();
}}
onTap={(event) => {
event.cancelBubble = true;
onSelect();
}}
onDragEnd={(event) => onDragEnd(event.target.x(), event.target.y())}
onTransformEnd={(event) => onTransformEnd(event.target)}
/>
);
}
function ImageLayerWithSrc({
layer,
src,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: ImageLayerNodeProps & { src: string }) {
const [image] = useImage(src, "anonymous");
const { flipHorizontal, flipVertical, cornerRadius } = getImageProps(
layer.props
);
const flip = getFlipOffsets(layer, flipHorizontal, flipVertical);
if (!image) {
return (
<ImageLayerPlaceholder
layer={layer}
onSelect={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
registerNode={registerNode}
/>
);
}
return (
<Image
ref={(node) => registerNode(layer.id, node)}
image={image}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
cornerRadius={cornerRadius}
{...flip}
draggable
onMouseDown={(event) => {
event.cancelBubble = true;
onSelect();
}}
onTap={(event) => {
event.cancelBubble = true;
onSelect();
}}
onDragEnd={(event) => onDragEnd(event.target.x(), event.target.y())}
onTransformEnd={(event) => onTransformEnd(event.target)}
/>
);
}
export function ImageLayerNode(props: ImageLayerNodeProps) {
const { src } = getImageProps(props.layer.props);
if (!src) {
return <ImageLayerPlaceholder {...props} />;
}
return <ImageLayerWithSrc {...props} src={src} />;
}
@@ -0,0 +1,110 @@
"use client";
import { Arrow, Circle, Line, Rect } from "react-konva";
import type Konva from "konva";
import { getShapeProps } from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
export interface ShapeLayerNodeProps {
layer: Layer;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function ShapeLayerNode({
layer,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: ShapeLayerNodeProps) {
const { shape, fill, stroke, strokeWidth, cornerRadius } = getShapeProps(
layer.props
);
const commonProps = {
rotation: layer.rotation,
opacity: layer.opacity,
draggable: true as const,
onMouseDown: (event: Konva.KonvaEventObject<MouseEvent>) => {
event.cancelBubble = true;
onSelect();
},
onTap: (event: Konva.KonvaEventObject<TouchEvent>) => {
event.cancelBubble = true;
onSelect();
},
onDragEnd: (event: Konva.KonvaEventObject<DragEvent>) => {
onDragEnd(event.target.x(), event.target.y());
},
onTransformEnd: (event: Konva.KonvaEventObject<Event>) => {
onTransformEnd(event.target);
},
};
if (shape === "circle") {
const radius = Math.min(layer.width, layer.height) / 2;
return (
<Circle
ref={(node) => registerNode(layer.id, node)}
x={layer.x + layer.width / 2}
y={layer.y + layer.height / 2}
radius={radius}
fill={fill}
stroke={strokeWidth > 0 ? stroke : undefined}
strokeWidth={strokeWidth > 0 ? strokeWidth : undefined}
{...commonProps}
/>
);
}
if (shape === "line") {
return (
<Line
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y}
points={[0, 0, layer.width, layer.height]}
stroke={stroke}
strokeWidth={Math.max(strokeWidth, 2)}
lineCap="round"
{...commonProps}
/>
);
}
if (shape === "arrow") {
return (
<Arrow
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y + layer.height / 2}
points={[0, 0, layer.width, 0]}
fill={fill}
stroke={stroke}
strokeWidth={Math.max(strokeWidth, 2)}
pointerLength={12}
pointerWidth={12}
{...commonProps}
/>
);
}
return (
<Rect
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
fill={fill}
stroke={strokeWidth > 0 ? stroke : undefined}
strokeWidth={strokeWidth > 0 ? strokeWidth : undefined}
cornerRadius={cornerRadius}
{...commonProps}
/>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Text } from "react-konva";
import type Konva from "konva";
import { getTextProps } from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
export interface TextLayerNodeProps {
layer: Layer;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function TextLayerNode({
layer,
onSelect,
// onDragEnd and onTransformEnd are intentionally unused — text layers are fixed
registerNode,
}: TextLayerNodeProps) {
const text = getTextProps(layer.props);
return (
<Text
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
text={text.text}
fontSize={text.fontSize}
fill={text.fill}
fontFamily={text.fontFamily}
fontStyle={text.fontStyle}
textDecoration={text.underline ? "underline" : ""}
align={text.align}
letterSpacing={text.letterSpacing}
lineHeight={text.lineHeight}
draggable={false}
onMouseDown={(event) => {
event.cancelBubble = true;
onSelect();
}}
onTap={(event) => {
event.cancelBubble = true;
onSelect();
}}
/>
);
}
@@ -0,0 +1,73 @@
"use client";
import { Rect, Text } from "react-konva";
import type Konva from "konva";
import type { Layer } from "@/lib/studio-types";
function getVideoSrc(props: Layer["props"]): string | undefined {
return typeof props.src === "string" && props.src.length > 0
? props.src
: undefined;
}
export interface VideoLayerNodeProps {
layer: Layer;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function VideoLayerNode({
layer,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: VideoLayerNodeProps) {
const hasVideo = Boolean(getVideoSrc(layer.props));
const fileName =
typeof layer.props.fileName === "string" ? layer.props.fileName : "Video";
return (
<>
<Rect
ref={(node) => registerNode(layer.id, node)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
fill={hasVideo ? "#1F2937" : "#374151"}
stroke="#6B7280"
strokeWidth={1}
dash={hasVideo ? undefined : [8, 4]}
draggable
onMouseDown={(event) => {
event.cancelBubble = true;
onSelect();
}}
onTap={(event) => {
event.cancelBubble = true;
onSelect();
}}
onDragEnd={(event) => onDragEnd(event.target.x(), event.target.y())}
onTransformEnd={(event) => onTransformEnd(event.target)}
/>
<Text
x={layer.x}
y={layer.y + layer.height / 2 - 10}
width={layer.width}
height={20}
rotation={layer.rotation}
text={hasVideo ? fileName : "Video clip"}
fontSize={14}
fill="#E5E7EB"
align="center"
listening={false}
/>
</>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useRef, useState } from "react";
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
import {
AspectRatioLockButton,
PanelSection,
PropertyNumberInput,
} from "@/components/studio/properties/PropertyControls";
import { useLayerUpdater } from "@/components/studio/properties/useLayerUpdater";
import type { Layer } from "@/lib/studio-types";
import { useStudioStore } from "@/lib/studio-store";
interface CommonLayerControlsProps {
layer: Layer;
}
export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
const [aspectLocked, setAspectLocked] = useState(false);
const aspectRatioRef = useRef(layer.width / layer.height || 1);
const { update } = useLayerUpdater(layer);
const deleteLayer = useStudioStore((state) => state.deleteLayer);
const moveLayerToFront = useStudioStore((state) => state.moveLayerToFront);
const moveLayerToBack = useStudioStore((state) => state.moveLayerToBack);
const aspectRatio = aspectRatioRef.current;
const setWidth = (width: number) => {
if (aspectLocked) {
update({ width, height: Math.max(8, width / aspectRatio) });
return;
}
update({ width: Math.max(8, width) });
};
const setHeight = (height: number) => {
if (aspectLocked) {
update({ height, width: Math.max(8, height * aspectRatio) });
return;
}
update({ height: Math.max(8, height) });
};
return (
<>
<PanelSection title="Transform">
<div className="grid grid-cols-2 gap-2">
<PropertyNumberInput
label="X"
value={layer.x}
onChange={(x) => update({ x })}
/>
<PropertyNumberInput
label="Y"
value={layer.y}
onChange={(y) => update({ y })}
/>
</div>
<div className="flex items-end gap-2">
<div className="grid flex-1 grid-cols-2 gap-2">
<PropertyNumberInput
label="Width"
value={layer.width}
min={8}
onChange={setWidth}
/>
<PropertyNumberInput
label="Height"
value={layer.height}
min={8}
onChange={setHeight}
/>
</div>
<AspectRatioLockButton
locked={aspectLocked}
onToggle={() => {
setAspectLocked((locked) => {
if (!locked) {
aspectRatioRef.current = layer.width / layer.height || 1;
}
return !locked;
});
}}
/>
</div>
<PropertyNumberInput
label="Rotation (°)"
value={layer.rotation}
onChange={(rotation) => update({ rotation })}
/>
</PanelSection>
<PanelSection title="Layer order">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => moveLayerToFront(layer.id)}
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<ArrowUp className="h-3.5 w-3.5" aria-hidden />
To front
</button>
<button
type="button"
onClick={() => moveLayerToBack(layer.id)}
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<ArrowDown className="h-3.5 w-3.5" aria-hidden />
To back
</button>
</div>
</PanelSection>
<button
type="button"
onClick={() => deleteLayer(layer.id)}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-xs font-medium text-red-500 transition-colors hover:border-red-300 hover:bg-red-100 hover:text-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
Delete layer
</button>
</>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useRef, type ChangeEvent } from "react";
import { FlipHorizontal, FlipVertical, ImagePlus } from "lucide-react";
import {
PanelSection,
PropertySlider,
} from "@/components/studio/properties/PropertyControls";
import { useLayerUpdater } from "@/components/studio/properties/useLayerUpdater";
import { getImageProps } from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
interface ImageLayerPropertiesProps {
layer: Layer;
}
export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { update, updateProps } = useLayerUpdater(layer);
const image = getImageProps(layer.props);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
updateProps({ src: reader.result });
}
};
reader.readAsDataURL(file);
event.target.value = "";
};
return (
<PanelSection title="Image">
<PropertySlider
label="Opacity"
min={0}
max={100}
value={Math.round(layer.opacity * 100)}
formatValue={(value) => `${value}%`}
onChange={(opacity) => update({ opacity: opacity / 100 })}
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() =>
updateProps({ flipHorizontal: !image.flipHorizontal })
}
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<FlipHorizontal className="h-3.5 w-3.5" aria-hidden />
Flip H
</button>
<button
type="button"
onClick={() => updateProps({ flipVertical: !image.flipVertical })}
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<FlipVertical className="h-3.5 w-3.5" aria-hidden />
Flip V
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<ImagePlus className="h-3.5 w-3.5" aria-hidden />
Replace image
</button>
<PropertySlider
label="Border radius"
min={0}
max={100}
value={image.cornerRadius}
onChange={(cornerRadius) => updateProps({ cornerRadius })}
/>
</PanelSection>
);
}
@@ -0,0 +1,214 @@
"use client";
import type { ReactNode } from "react";
import { Lock, Unlock } from "lucide-react";
import { cn } from "@/lib/utils";
export function PanelSection({
title,
children,
className,
}: {
title?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("space-y-3", className)}>
{title ? (
<h3 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400">
{title}
</h3>
) : null}
{children}
</div>
);
}
export function PropertyLabel({ children }: { children: ReactNode }) {
return (
<label className="mb-1 block text-[11px] font-medium text-gray-500">
{children}
</label>
);
}
export function PropertySlider({
label,
min,
max,
step = 1,
value,
onChange,
formatValue,
}: {
label: string;
min: number;
max: number;
step?: number;
value: number;
onChange: (value: number) => void;
formatValue?: (value: number) => string;
}) {
const display = formatValue ? formatValue(value) : String(value);
return (
<div>
<div className="mb-1 flex items-center justify-between">
<PropertyLabel>{label}</PropertyLabel>
<span className="text-[10px] tabular-nums text-gray-400">{display}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
);
}
export function PropertyNumberInput({
label,
value,
onChange,
min,
max,
step = 1,
}: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<div>
<PropertyLabel>{label}</PropertyLabel>
<input
type="number"
value={Math.round(value * 100) / 100}
min={min}
max={max}
step={step}
onChange={(event) => {
const next = Number(event.target.value);
if (!Number.isNaN(next)) onChange(next);
}}
className="w-full rounded-md border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
);
}
export function PropertySelect<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { label: string; value: T }[];
onChange: (value: T) => void;
}) {
return (
<div>
<PropertyLabel>{label}</PropertyLabel>
<select
value={value}
onChange={(event) => onChange(event.target.value as T)}
className="w-full rounded-md border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
export function PropertyColorInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<div>
<PropertyLabel>{label}</PropertyLabel>
<div className="flex items-center gap-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
<span className="text-[10px] uppercase text-gray-400">{value}</span>
</div>
</div>
);
}
export function AspectRatioLockButton({
locked,
onToggle,
}: {
locked: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex h-[30px] w-8 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-500 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label={locked ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-pressed={locked}
>
{locked ? (
<Lock className="h-3.5 w-3.5" aria-hidden />
) : (
<Unlock className="h-3.5 w-3.5" aria-hidden />
)}
</button>
);
}
export function ToggleIconButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
className={cn(
"flex h-8 flex-1 items-center justify-center rounded-md border text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
active
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-200 bg-gray-50 text-gray-500 hover:border-gray-300 hover:text-gray-900"
)}
>
{children}
</button>
);
}
@@ -0,0 +1,58 @@
"use client";
import {
PanelSection,
PropertyColorInput,
PropertySlider,
} from "@/components/studio/properties/PropertyControls";
import { useLayerUpdater } from "@/components/studio/properties/useLayerUpdater";
import { getShapeProps } from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
interface ShapeLayerPropertiesProps {
layer: Layer;
}
export function ShapeLayerProperties({ layer }: ShapeLayerPropertiesProps) {
const { update, updateProps } = useLayerUpdater(layer);
const shape = getShapeProps(layer.props);
return (
<PanelSection title="Shape">
<PropertyColorInput
label="Fill color"
value={shape.fill}
onChange={(fill) => updateProps({ fill })}
/>
<PropertyColorInput
label="Stroke color"
value={shape.stroke}
onChange={(stroke) => updateProps({ stroke })}
/>
<PropertySlider
label="Stroke width"
min={0}
max={40}
value={shape.strokeWidth}
onChange={(strokeWidth) => updateProps({ strokeWidth })}
/>
{shape.shape === "rect" ? (
<PropertySlider
label="Border radius"
min={0}
max={100}
value={shape.cornerRadius}
onChange={(cornerRadius) => updateProps({ cornerRadius })}
/>
) : null}
<PropertySlider
label="Opacity"
min={0}
max={100}
value={Math.round(layer.opacity * 100)}
formatValue={(value) => `${value}%`}
onChange={(opacity) => update({ opacity: opacity / 100 })}
/>
</PanelSection>
);
}
@@ -0,0 +1,141 @@
"use client";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Italic,
Underline,
} from "lucide-react";
import {
PanelSection,
PropertyColorInput,
PropertySelect,
PropertySlider,
ToggleIconButton,
} from "@/components/studio/properties/PropertyControls";
import { useLayerUpdater } from "@/components/studio/properties/useLayerUpdater";
import {
FONT_FAMILY_OPTIONS,
getTextProps,
TEXT_ANIMATION_OPTIONS,
type TextAlign,
type TextAnimation,
} from "@/lib/studio-layer-props";
import type { Layer } from "@/lib/studio-types";
interface TextLayerPropertiesProps {
layer: Layer;
}
export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
const { update, updateProps } = useLayerUpdater(layer);
const text = getTextProps(layer.props);
return (
<PanelSection title="Text">
<PropertySelect
label="Font family"
value={text.fontFamily}
options={FONT_FAMILY_OPTIONS.map((item) => ({
label: item.label,
value: item.value,
}))}
onChange={(fontFamily) => updateProps({ fontFamily })}
/>
<PropertySlider
label="Font size"
min={8}
max={200}
value={text.fontSize}
onChange={(fontSize) => updateProps({ fontSize })}
/>
<div className="flex gap-1">
<ToggleIconButton
label="Bold"
active={text.bold}
onClick={() => updateProps({ bold: !text.bold })}
>
<Bold className="h-3.5 w-3.5" />
</ToggleIconButton>
<ToggleIconButton
label="Italic"
active={text.italic}
onClick={() => updateProps({ italic: !text.italic })}
>
<Italic className="h-3.5 w-3.5" />
</ToggleIconButton>
<ToggleIconButton
label="Underline"
active={text.underline}
onClick={() => updateProps({ underline: !text.underline })}
>
<Underline className="h-3.5 w-3.5" />
</ToggleIconButton>
</div>
<PropertyColorInput
label="Text color"
value={text.fill}
onChange={(fill) => updateProps({ fill })}
/>
<div>
<p className="mb-1 text-[11px] font-medium text-gray-400">Alignment</p>
<div className="flex gap-1">
{(
[
{ value: "left" as TextAlign, icon: AlignLeft, label: "Left" },
{
value: "center" as TextAlign,
icon: AlignCenter,
label: "Center",
},
{ value: "right" as TextAlign, icon: AlignRight, label: "Right" },
] as const
).map(({ value, icon: Icon, label }) => (
<ToggleIconButton
key={value}
label={label}
active={text.align === value}
onClick={() => updateProps({ align: value })}
>
<Icon className="h-3.5 w-3.5" />
</ToggleIconButton>
))}
</div>
</div>
<PropertySlider
label="Letter spacing"
min={-5}
max={20}
step={0.5}
value={text.letterSpacing}
onChange={(letterSpacing) => updateProps({ letterSpacing })}
/>
<PropertySlider
label="Line height"
min={0.8}
max={3}
step={0.1}
value={text.lineHeight}
formatValue={(value) => value.toFixed(1)}
onChange={(lineHeight) => updateProps({ lineHeight })}
/>
<PropertySlider
label="Opacity"
min={0}
max={100}
value={Math.round(layer.opacity * 100)}
formatValue={(value) => `${value}%`}
onChange={(opacity) => update({ opacity: opacity / 100 })}
/>
<PropertySelect<TextAnimation>
label="Animation"
value={text.animation}
options={TEXT_ANIMATION_OPTIONS}
onChange={(animation) => updateProps({ animation })}
/>
</PanelSection>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import type { Layer, LayerProps } from "@/lib/studio-types";
import { mergeLayerProps } from "@/lib/studio-layer-props";
import { useStudioStore } from "@/lib/studio-store";
export function useLayerUpdater(layer: Layer) {
const updateLayer = useStudioStore((state) => state.updateLayer);
const update = useCallback(
(updates: Partial<Layer>) => {
updateLayer(layer.id, updates);
},
[layer.id, updateLayer]
);
const updateProps = useCallback(
(updates: LayerProps) => {
updateLayer(layer.id, {
props: mergeLayerProps(layer.props, updates),
});
},
[layer.id, layer.props, updateLayer]
);
return { update, updateProps };
}
@@ -0,0 +1,49 @@
"use client";
import { AudioSidebarMusicTab } from "@/components/studio/sidebar/AudioSidebarMusicTab";
import { AudioSidebarVoiceoverPane } from "@/components/studio/sidebar/AudioSidebarVoiceoverPane";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
export function AudioSidebarContent() {
return (
<aside
className={cn(
"flex h-full w-full flex-col overflow-hidden bg-white text-gray-900"
)}
>
<Tabs defaultValue="music" className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-gray-200 px-3 pt-3">
<TabsList className="grid h-9 w-full grid-cols-2 rounded-full bg-gray-100 p-1">
<TabsTrigger
value="music"
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
>
Music
</TabsTrigger>
<TabsTrigger
value="voiceover"
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
>
Voiceover
</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="music"
className="mt-0 min-h-0 flex-1 overflow-y-auto p-3 focus-visible:outline-none"
>
<AudioSidebarMusicTab />
</TabsContent>
<TabsContent
value="voiceover"
className="mt-0 min-h-0 flex-1 overflow-y-auto p-3 focus-visible:outline-none"
>
<AudioSidebarVoiceoverPane />
</TabsContent>
</Tabs>
</aside>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import { Box, HardDrive, Search, UploadCloud } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MUSIC_GENRE_CARDS } from "@/lib/audio-music-genres";
import { useStudioStore } from "@/lib/studio-store";
import { cn } from "@/lib/utils";
export function AudioSidebarMusicTab() {
const inputRef = useRef<HTMLInputElement>(null);
const setAudioTrack = useStudioStore((state) => state.setAudioTrack);
const [includeTemplateSfx, setIncludeTemplateSfx] = useState(true);
const [search, setSearch] = useState("");
const [activeGenre, setActiveGenre] = useState("all");
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setAudioTrack(file.name, reader.result);
}
};
reader.readAsDataURL(file);
event.target.value = "";
};
const query = search.trim().toLowerCase();
const genres = query
? MUSIC_GENRE_CARDS.filter((genre) =>
genre.name.toLowerCase().includes(query)
)
: MUSIC_GENRE_CARDS;
return (
<div className="space-y-4">
<input
ref={inputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="space-y-2">
<SourceButton
icon={UploadCloud}
label="Upload"
onClick={() => inputRef.current?.click()}
/>
<SourceButton icon={Box} label="Dropbox" onClick={() => undefined} />
<SourceButton
icon={HardDrive}
label="Google Drive"
onClick={() => undefined}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] leading-snug text-gray-500">
Include template sound effect
</span>
<Switch
checked={includeTemplateSfx}
onCheckedChange={setIncludeTemplateSfx}
aria-label="Include template sound effect"
/>
</div>
<div className="relative">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500"
aria-hidden
/>
<input
type="search"
placeholder="Search music"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-8 pr-3 text-xs text-white placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
<Tabs defaultValue="library" className="w-full">
<TabsList className="grid h-8 w-full grid-cols-2 rounded-full bg-gray-100 p-0.5">
<TabsTrigger
value="library"
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
>
Music library
</TabsTrigger>
<TabsTrigger
value="my-music"
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
>
My music
</TabsTrigger>
</TabsList>
<TabsContent value="library" className="mt-3">
<div className="grid grid-cols-2 gap-2">
{genres.map((genre) => (
<button
key={genre.id}
type="button"
onClick={() => setActiveGenre(genre.id)}
className={cn(
"flex h-[70px] items-center justify-center rounded-lg px-2 text-center transition-transform hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
activeGenre === genre.id && "ring-2 ring-blue-500 ring-offset-1 ring-offset-white"
)}
style={{
background: `linear-gradient(135deg, ${genre.gradientFrom}, ${genre.gradientTo})`,
}}
>
<span className="text-[11px] font-bold leading-tight text-white">
{genre.name}
</span>
</button>
))}
</div>
</TabsContent>
<TabsContent value="my-music" className="mt-3">
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-white/40 px-3 py-8 text-center">
<UploadCloud className="h-8 w-8 text-gray-500" aria-hidden />
<p className="mt-3 text-xs text-gray-500">Upload your own music</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3 h-8 border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50"
onClick={() => inputRef.current?.click()}
>
Upload
</Button>
</div>
</TabsContent>
</Tabs>
</div>
);
}
function SourceButton({
icon: Icon,
label,
onClick,
}: {
icon: typeof UploadCloud;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-left text-sm text-gray-200 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Icon className="h-4 w-4 shrink-0 text-gray-500" aria-hidden />
{label}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More