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
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
import { AuthPageContent } from "@/components/auth/AuthPageContent";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Sign In",
description: "Sign in or create your CreatorStudio account.",
path: "/auth",
});
export default function AuthPage() {
return (
<main className="min-h-screen bg-neutral-50">
<Suspense
fallback={
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
<AuthLoadingSpinner label="Loading..." />
</div>
}
>
<AuthPageContent />
</Suspense>
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { DashboardShell } from "@/components/dashboard/DashboardShell";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export default async function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/auth");
}
const userName =
typeof user.user_metadata?.full_name === "string"
? user.user_metadata.full_name
: null;
return (
<DashboardShell
userEmail={user.email ?? ""}
userName={userName}
userId={user.id}
>
{children}
</DashboardShell>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { DashboardProjectsContent } from "@/components/dashboard/DashboardProjectsContent";
import { DashboardProjectsSection } from "@/components/dashboard/DashboardProjectsSection";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Dashboard",
description: "Your CreatorStudio workspace — projects, templates, and tools.",
path: "/dashboard",
});
export const dynamic = "force-dynamic";
export default function DashboardPage() {
return (
<Suspense fallback={<DashboardProjectsSection isLoading />}>
<DashboardProjectsContent />
</Suspense>
);
}
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Settings",
description: "Manage your CreatorStudio account and workspace preferences.",
path: "/dashboard/settings",
});
export default function DashboardSettingsPage() {
return (
<div className="flex flex-1 flex-col">
<header className="border-b border-gray-100 bg-white px-6 py-4">
<h1 className="font-heading text-xl font-bold text-neutral-900">
Settings
</h1>
</header>
<div className="flex-1 p-6">
<p className="text-sm text-neutral-600">
Account and workspace settings will be available here soon.
</p>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
// Surface to monitoring in production when configured
}, [error]);
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
Something went wrong
</h1>
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
An unexpected error occurred. Try reloading the page.
</p>
<Button
type="button"
className="mt-8 bg-blue-600 hover:bg-blue-700"
onClick={() => reset()}
>
Reload page
</Button>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta";
import { ImageMakerFeatures } from "@/components/image-maker/ImageMakerFeatures";
import { ImageMakerGallery } from "@/components/image-maker/ImageMakerGallery";
import { ImageMakerHero } from "@/components/image-maker/ImageMakerHero";
import { ImageMakerUseCases } from "@/components/image-maker/ImageMakerUseCases";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "AI Image Maker",
description:
"Design professional visuals instantly with AI generation, templates, brand kits, and batch export.",
path: "/image-maker",
});
export default function ImageMakerPage() {
return (
<main>
<ImageMakerHero />
<ImageMakerFeatures />
<ImageMakerUseCases />
<ImageMakerGallery />
<ImageMakerCta />
</main>
);
}
+118
View File
@@ -0,0 +1,118 @@
import type { Metadata } from "next";
import { Inter, Plus_Jakarta_Sans, Vazirmatn } from "next/font/google";
import { notFound } from "next/navigation";
import { getMessages, getTranslations } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { SiteChrome } from "@/components/layout/SiteChrome";
import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing";
import "../globals.css";
/* ── Fonts ─────────────────────────────────────────────────────── */
const vazirmatn = Vazirmatn({
subsets: ["arabic"],
variable: "--font-vazirmatn",
display: "swap",
weight: ["400", "500", "600", "700", "800"],
});
const plusJakartaSans = Plus_Jakarta_Sans({
subsets: ["latin"],
variable: "--font-heading",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
/* ── Metadata ───────────────────────────────────────────────────── */
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: {
default: t("homeTitle"),
template: `%s — FlatRender`,
},
description: t("homeDescription"),
metadataBase: new URL("https://flatrender.com"),
openGraph: {
siteName: "FlatRender",
},
};
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
/* ── Layout ─────────────────────────────────────────────────────── */
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: Locale }>;
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
// Validate locale — show 404 for unknown values
if (!(routing.locales as readonly string[]).includes(locale)) {
notFound();
}
const messages = await getMessages();
const isRtl = locale === "fa";
/**
* Font class strategy:
* - Persian (fa): Vazirmatn handles both Arabic/Persian + Latin fallback
* - English (en): Plus Jakarta Sans (headings) + Inter (body)
*/
const fontVars = isRtl
? `${vazirmatn.variable}`
: `${plusJakartaSans.variable} ${inter.variable} ${vazirmatn.variable}`;
return (
<html
lang={locale}
dir={isRtl ? "rtl" : "ltr"}
suppressHydrationWarning
className={fontVars}
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link rel="preconnect" href="https://picsum.photos" />
</head>
<body
className={`min-h-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 ${
isRtl ? "font-vazirmatn" : "font-body"
}`}
>
<NextIntlClientProvider messages={messages} locale={locale}>
<SiteChrome>{children}</SiteChrome>
</NextIntlClientProvider>
</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Page Not Found",
description: "The page you requested could not be found on FlatRender.",
path: "/404",
});
export default function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
Page not found
</h1>
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
The page you are looking for does not exist or may have been moved.
</p>
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
<Link href="/">Go home</Link>
</Button>
</main>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Hero } from "@/components/sections/Hero";
import { HowItWorks } from "@/components/sections/HowItWorks";
import { Pricing } from "@/components/sections/Pricing";
import { ProductsShowcase } from "@/components/sections/ProductsShowcase";
import { TemplateGallery } from "@/components/sections/TemplateGallery";
import { FAQ } from "@/components/sections/FAQ";
import { Testimonials } from "@/components/sections/Testimonials";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Create Pro Videos & Images with AI",
description:
"FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export.",
path: "/",
});
export default function Home() {
return (
<main>
<Hero />
<ProductsShowcase />
<TemplateGallery />
<HowItWorks />
<Pricing />
<Testimonials />
<FAQ />
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { Pricing } from "@/components/sections/Pricing";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Pricing",
description:
"Compare FlatRender Lite, Pro, and Business plans. Monthly or yearly billing with templates, exports, and AI tools for creators.",
path: "/pricing",
});
export default function PricingPage() {
return (
<main>
<Pricing className="pt-24" />
</main>
);
}
@@ -0,0 +1,28 @@
"use client";
import dynamic from "next/dynamic";
const ImageEditorLayout = dynamic(
() =>
import("@/components/image-editor/ImageEditorLayout").then(
(mod) => mod.ImageEditorLayout
),
{
ssr: false,
loading: () => (
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
Loading editor
</div>
),
}
);
interface ImageStudioPageProps {
params: {
projectId: string;
};
}
export default function ImageStudioPage({ params }: ImageStudioPageProps) {
return <ImageEditorLayout projectId={params.projectId} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Image Editor",
description:
"Edit images with layers, adjustments, filters, drawing tools, and AI background removal.",
path: "/studio/image",
});
export default function ImageEditorLayoutRoute({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ImageStudioIndexPage() {
redirect("/dashboard");
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Video Trimmer & Cropper",
description:
"Trim and crop videos in the browser with frame previews and FFmpeg export to MP4 or WebM.",
path: "/studio/trimmer",
});
export default function TrimmerLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, Scissors } from "lucide-react";
import { TrimmerExportSection } from "@/components/trimmer/TrimmerExportSection";
import { TrimmerStrip } from "@/components/trimmer/TrimmerStrip";
import { TrimmerUploadZone } from "@/components/trimmer/TrimmerUploadZone";
import { TrimmerVideoPreview } from "@/components/trimmer/TrimmerVideoPreview";
import {
preloadFfmpegWorker,
processTrimmedVideoInWorker,
} from "@/lib/ffmpeg-worker-client";
import type {
AspectRatioPreset,
CropBox,
ExportFormat,
} from "@/lib/trimmer-types";
import { parseFfmpegProgress } from "@/lib/trimmer-utils";
const INITIAL_CROP: CropBox = { x: 0, y: 0, w: 320, h: 180 };
export default function VideoTrimmerPage() {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [duration, setDuration] = useState(0);
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
const [cropBox, setCropBox] = useState<CropBox>(INITIAL_CROP);
const [aspectRatio, setAspectRatio] = useState<AspectRatioPreset>("free");
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
const [videoSize, setVideoSize] = useState({ width: 0, height: 0 });
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [ffmpegReady, setFfmpegReady] = useState(false);
const [ffmpegError, setFfmpegError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
preloadFfmpegWorker()
.then(() => {
if (!cancelled) setFfmpegReady(true);
})
.catch(() => {
if (!cancelled) {
setFfmpegError(
"Failed to load FFmpeg. Check your connection and try again."
);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
return () => {
if (videoUrl) URL.revokeObjectURL(videoUrl);
if (outputUrl) URL.revokeObjectURL(outputUrl);
};
}, [videoUrl, outputUrl]);
const handleFileSelect = useCallback(
(file: File) => {
if (videoUrl) URL.revokeObjectURL(videoUrl);
if (outputUrl) URL.revokeObjectURL(outputUrl);
const url = URL.createObjectURL(file);
setUploadedFile(file);
setVideoUrl(url);
setOutputUrl(null);
setProgress(0);
setTrimStart(0);
setTrimEnd(0);
setDuration(0);
setCropBox(INITIAL_CROP);
},
[videoUrl, outputUrl]
);
const handleVideoMetadata = useCallback(
(videoDuration: number, size: { width: number; height: number }) => {
setDuration(videoDuration);
setTrimStart(0);
setTrimEnd(videoDuration);
setVideoSize(size);
},
[]
);
const handleTrimChange = useCallback((start: number, end: number) => {
setTrimStart(Math.max(0, start));
setTrimEnd(end);
}, []);
const handleProcess = useCallback(async () => {
if (!uploadedFile || !ffmpegReady || displaySize.width <= 0) return;
setIsProcessing(true);
setProgress(0);
if (outputUrl) URL.revokeObjectURL(outputUrl);
setOutputUrl(null);
const clipDuration = trimEnd - trimStart;
try {
const blob = await processTrimmedVideoInWorker({
file: uploadedFile,
trimStart,
trimEnd,
cropBox,
displaySize,
videoSize,
exportFormat,
onProgress: setProgress,
onLog: (message) => {
const parsed = parseFfmpegProgress(message);
if (parsed !== null && clipDuration > 0) {
setProgress(
Math.min(99, Math.round((parsed / clipDuration) * 100))
);
}
},
});
setOutputUrl(URL.createObjectURL(blob));
} catch {
setFfmpegError("Processing failed. Try a shorter clip or different format.");
} finally {
setIsProcessing(false);
}
}, [
uploadedFile,
ffmpegReady,
displaySize,
trimStart,
trimEnd,
cropBox,
videoSize,
exportFormat,
outputUrl,
]);
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="border-b border-gray-800 bg-gray-950">
<div className="mx-auto flex max-w-5xl items-center gap-3 px-4 py-4">
<Link
href="/dashboard"
className="flex items-center gap-1 rounded-md text-sm text-gray-400 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Back
</Link>
<Scissors className="h-5 w-5 text-blue-500" aria-hidden />
<h1 className="font-heading text-lg font-semibold text-white">
Video Trimmer & Cropper
</h1>
</div>
</header>
<main className="mx-auto max-w-5xl space-y-6 px-4 py-8">
<TrimmerUploadZone
uploadedFile={uploadedFile}
onFileSelect={handleFileSelect}
/>
{ffmpegError ? (
<p className="rounded-lg border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-300">
{ffmpegError}
</p>
) : null}
{videoUrl ? (
<>
<TrimmerVideoPreview
videoUrl={videoUrl}
aspectRatio={aspectRatio}
cropBox={cropBox}
onCropChange={setCropBox}
onAspectRatioChange={setAspectRatio}
onVideoMetadata={handleVideoMetadata}
onDisplaySize={setDisplaySize}
/>
<TrimmerStrip
videoUrl={videoUrl}
duration={duration}
trimStart={trimStart}
trimEnd={trimEnd}
onTrimChange={handleTrimChange}
/>
<TrimmerExportSection
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
isProcessing={isProcessing}
progress={progress}
ffmpegReady={ffmpegReady}
hasVideo={Boolean(uploadedFile)}
outputUrl={outputUrl}
onProcess={handleProcess}
/>
</>
) : null}
</main>
</div>
);
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Video Studio",
description:
"Edit multi-scene video projects with layers, timeline, transitions, and export.",
path: "/studio/video",
});
export default function VideoStudioProjectLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
@@ -0,0 +1,28 @@
"use client";
import dynamic from "next/dynamic";
const VideoStudioLayout = dynamic(
() =>
import("@/components/studio/video/VideoStudioLayout").then(
(mod) => mod.VideoStudioLayout
),
{
ssr: false,
loading: () => (
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
Loading studio
</div>
),
}
);
interface VideoStudioPageProps {
params: {
projectId: string;
};
}
export default function VideoStudioPage({ params }: VideoStudioPageProps) {
return <VideoStudioLayout projectId={params.projectId} />;
}
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export default async function VideoProjectNewLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/auth");
}
return children;
}
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { VideoProjectNewContent } from "@/components/studio/video/VideoProjectNewContent";
import { Toaster } from "@/components/ui/toaster";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Create New Video",
description:
"Start a new video project from scenes, AI, or ready-made presets.",
path: "/studio/video/new",
});
export default function VideoProjectNewPage() {
return (
<main className="min-h-screen bg-gray-50">
<Toaster />
<VideoProjectNewContent />
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent";
import { VIDEO_TEMPLATES_CATALOG } from "@/lib/video-templates-catalog";
interface TemplateDetailPageProps {
params: { id: string };
}
export function generateStaticParams() {
return VIDEO_TEMPLATES_CATALOG.map((template) => ({ id: template.id }));
}
export function generateMetadata({ params }: TemplateDetailPageProps): Metadata {
const template = VIDEO_TEMPLATES_CATALOG.find((item) => item.id === params.id);
if (!template) return {};
return { title: `${template.name} — FlatRender` };
}
export default function TemplateDetailPage({ params }: TemplateDetailPageProps) {
const template = VIDEO_TEMPLATES_CATALOG.find((item) => item.id === params.id);
if (!template) notFound();
return (
<main className="min-h-screen bg-white">
<TemplateDetailContent template={template} />
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { TemplatesPageContent } from "@/components/templates/TemplatesPageContent";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Video Templates",
description:
"Search thousands of professional video templates. Filter by category, aspect ratio, duration, and premium features.",
path: "/templates",
});
export default function TemplatesPage() {
return (
<main className="min-h-screen bg-white">
<TemplatesPageContent />
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta";
import { VideoMakerFeatures } from "@/components/video-maker/VideoMakerFeatures";
import { VideoMakerHero } from "@/components/video-maker/VideoMakerHero";
import { VideoMakerTemplateCarousel } from "@/components/video-maker/VideoMakerTemplateCarousel";
import { VideoMakerUseCases } from "@/components/video-maker/VideoMakerUseCases";
import { createPageMetadata } from "@/lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "AI Video Maker",
description:
"Create stunning videos in minutes with AI scripts, auto-subtitles, 500+ templates, and 1-click export.",
path: "/video-maker",
});
export default function VideoMakerPage() {
return (
<main>
<VideoMakerHero />
<VideoMakerFeatures />
<VideoMakerUseCases />
<VideoMakerTemplateCarousel />
<VideoMakerCta />
</main>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import type { BillingPeriod } from "@/components/sections/pricing-data";
import { getStripePriceId, isPaidPlanId } from "@/lib/plans";
import { getStripe } from "@/lib/stripe";
import { createClient } from "@/lib/supabase/server";
const checkoutSchema = z.object({
plan: z.enum(["pro", "business"]),
billing: z.enum(["monthly", "annual"]),
});
export async function POST(request: Request) {
try {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.email) {
return NextResponse.json(
{ error: "You must be signed in to checkout." },
{ status: 401 }
);
}
const body: unknown = await request.json();
const parsed = checkoutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid plan or billing period." },
{ status: 400 }
);
}
const { plan, billing } = parsed.data;
if (!isPaidPlanId(plan)) {
return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
}
const priceId = getStripePriceId(plan, billing as BillingPeriod);
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${siteUrl}/dashboard?checkout=success`,
cancel_url: `${siteUrl}/#pricing`,
customer_email: user.email,
client_reference_id: user.id,
metadata: {
userId: user.id,
planId: plan,
billingPeriod: billing,
},
subscription_data: {
metadata: {
userId: user.id,
planId: plan,
billingPeriod: billing,
},
},
});
if (!session.url) {
return NextResponse.json(
{ error: "Failed to create checkout session." },
{ status: 500 }
);
}
return NextResponse.json({ url: session.url });
} catch (error) {
const message =
error instanceof Error ? error.message : "Checkout failed.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+159
View File
@@ -0,0 +1,159 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import type { ProjectRow } from "@/lib/projects";
import { isDevProjectId } from "@/lib/project-ids";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
const patchProjectSchema = z.object({
scene_data: z.record(z.string(), z.unknown()).optional(),
name: z.string().min(1).max(120).optional(),
});
interface RouteContext {
params: { projectId: string };
}
export async function GET(_request: Request, context: RouteContext) {
const { projectId } = context.params;
if (isDevProjectId(projectId)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!isSupabaseConfigured()) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{ error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 }
);
}
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data, error } = await supabase
.from("projects")
.select("*")
.eq("id", projectId)
.eq("user_id", user.id)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (!data) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const row = data as ProjectRow;
return NextResponse.json({
project: {
id: row.id,
name: row.name,
type: row.type,
scene_data: row.scene_data,
status: row.status,
updated_at: row.updated_at,
},
});
}
export async function PATCH(request: Request, context: RouteContext) {
const { projectId } = context.params;
if (isDevProjectId(projectId)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = patchProjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
if (!parsed.data.scene_data && !parsed.data.name) {
return NextResponse.json(
{ error: "Nothing to update" },
{ status: 400 }
);
}
if (!isSupabaseConfigured()) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{ error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 }
);
}
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const updates: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (parsed.data.scene_data !== undefined) {
updates.scene_data = parsed.data.scene_data;
}
if (parsed.data.name !== undefined) {
updates.name = parsed.data.name;
}
const { data, error } = await supabase
.from("projects")
.update(updates)
.eq("id", projectId)
.eq("user_id", user.id)
.select("*")
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (!data) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const row = data as ProjectRow;
return NextResponse.json({
project: {
id: row.id,
name: row.name,
type: row.type,
scene_data: row.scene_data,
status: row.status,
updated_at: row.updated_at,
},
});
}
+126
View File
@@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildMockProjectRow } from "@/lib/dev-mock-project";
import {
createDefaultSceneData,
defaultProjectName,
} from "@/lib/project-defaults";
import { mapProjectRow, type ProjectRow } from "@/lib/projects";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
const createProjectSchema = z.object({
name: z.string().min(1).max(120).optional(),
type: z.enum(["video", "image", "trimmer"]),
scene_data: z.record(z.string(), z.unknown()).optional(),
});
export async function GET() {
if (!isSupabaseConfigured()) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{
error: "Supabase is not configured",
code: "SUPABASE_NOT_CONFIGURED",
},
{ status: 503 }
);
}
return NextResponse.json({ projects: [] });
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data, error } = await supabase
.from("projects")
.select("*")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow);
return NextResponse.json({ projects });
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = createProjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { type } = parsed.data;
const name = parsed.data.name ?? defaultProjectName(type);
const scene_data =
parsed.data.scene_data ?? createDefaultSceneData(type);
if (!isSupabaseConfigured()) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{
error: "Supabase is not configured",
code: "SUPABASE_NOT_CONFIGURED",
},
{ status: 503 }
);
}
const project = mapProjectRow(
buildMockProjectRow({ name, type, scene_data })
);
return NextResponse.json({ project }, { status: 201 });
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data, error } = await supabase
.from("projects")
.insert({
user_id: user.id,
name,
type,
scene_data,
status: "draft",
})
.select("*")
.single();
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? "Failed to create project" },
{ status: 500 }
);
}
const project = mapProjectRow(data as ProjectRow);
return NextResponse.json({ project }, { status: 201 });
}
+91
View File
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
interface RemoveBgBody {
image?: string;
}
export async function POST(request: Request) {
const apiKey = process.env.REMOVE_BG_API_KEY;
const rembgUrl = process.env.REMBG_SERVICE_URL;
let body: RemoveBgBody;
try {
body = (await request.json()) as RemoveBgBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!body.image?.startsWith("data:image")) {
return NextResponse.json({ error: "image data URL required" }, { status: 400 });
}
const base64 = body.image.split(",")[1];
if (!base64) {
return NextResponse.json({ error: "Invalid data URL" }, { status: 400 });
}
const buffer = Buffer.from(base64, "base64");
if (rembgUrl) {
try {
const response = await fetch(rembgUrl, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: buffer,
});
if (!response.ok) {
return NextResponse.json(
{ error: "rembg service failed" },
{ status: 502 }
);
}
const resultBuffer = Buffer.from(await response.arrayBuffer());
return NextResponse.json({
image: `data:image/png;base64,${resultBuffer.toString("base64")}`,
});
} catch {
return NextResponse.json(
{ error: "Could not reach rembg service" },
{ status: 502 }
);
}
}
if (!apiKey) {
return NextResponse.json(
{
error:
"Background removal not configured. Set REMOVE_BG_API_KEY or REMBG_SERVICE_URL.",
},
{ status: 503 }
);
}
const formData = new FormData();
formData.append(
"image_file",
new Blob([buffer], { type: "image/png" }),
"upload.png"
);
formData.append("size", "auto");
const response = await fetch("https://api.remove.bg/v1.0/removebg", {
method: "POST",
headers: { "X-Api-Key": apiKey },
body: formData,
});
if (!response.ok) {
return NextResponse.json(
{ error: "remove.bg API request failed" },
{ status: response.status }
);
}
const resultBuffer = Buffer.from(await response.arrayBuffer());
return NextResponse.json({
image: `data:image/png;base64,${resultBuffer.toString("base64")}`,
});
}
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getRenderJob } from "@/lib/render-jobs";
export const runtime = "nodejs";
interface RouteContext {
params: { jobId: string };
}
export async function GET(_request: Request, context: RouteContext) {
const { jobId } = context.params;
if (!jobId) {
return NextResponse.json({ error: "jobId required" }, { status: 400 });
}
const job = await getRenderJob(jobId);
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
return NextResponse.json({
status: job.status,
progress: job.progress,
outputUrl: job.output_url,
progressMessage: job.progress_message,
errorMessage: job.error_message,
});
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { renderRequestSchema } from "@/lib/render-schemas";
import { createRenderJob, triggerRenderWorker } from "@/lib/render-jobs";
export const runtime = "nodejs";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = renderRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const result = await createRenderJob(parsed.data);
if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: 500 });
}
await triggerRenderWorker(result.jobId);
return NextResponse.json({ jobId: result.jobId });
}
+123
View File
@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { isPaidPlanId, type PlanId } from "@/lib/plans";
import { getStripe } from "@/lib/stripe";
import { createAdminClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
function resolvePlanId(metadata: Stripe.Metadata | null): PlanId | null {
const planId = metadata?.planId;
if (planId && isPaidPlanId(planId)) {
return planId;
}
return null;
}
async function upsertProfileFromSession(session: Stripe.Checkout.Session) {
const userId = session.client_reference_id ?? session.metadata?.userId;
if (!userId) {
return;
}
const plan = resolvePlanId(session.metadata);
if (!plan) {
return;
}
const admin = createAdminClient();
const { error } = await admin.from("profiles").upsert(
{
id: userId,
email: session.customer_email ?? session.customer_details?.email ?? null,
plan,
billing_period: session.metadata?.billingPeriod ?? null,
stripe_customer_id:
typeof session.customer === "string" ? session.customer : null,
stripe_subscription_id:
typeof session.subscription === "string"
? session.subscription
: null,
updated_at: new Date().toISOString(),
},
{ onConflict: "id" }
);
if (error) {
throw new Error(`Failed to update profile: ${error.message}`);
}
}
export async function POST(request: Request) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json(
{ error: "Webhook secret not configured." },
{ status: 500 }
);
}
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header." },
{ status: 400 }
);
}
const body = await request.text();
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (error) {
const message =
error instanceof Error ? error.message : "Webhook signature verification failed.";
return NextResponse.json({ error: message }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === "subscription") {
await upsertProfileFromSession(session);
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata?.userId;
if (userId) {
const admin = createAdminClient();
await admin
.from("profiles")
.update({
plan: "free",
billing_period: null,
stripe_subscription_id: null,
updated_at: new Date().toISOString(),
})
.eq("id", userId);
}
break;
}
default:
break;
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Webhook handler failed.";
return NextResponse.json({ error: message }, { status: 500 });
}
return NextResponse.json({ received: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/dashboard";
if (code && isSupabaseConfigured()) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/auth?error=auth_callback_failed`);
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
const supabase = await createClient();
await supabase.auth.signOut();
const { origin } = new URL(request.url);
return NextResponse.redirect(`${origin}/auth`, { status: 303 });
}
+69 -14
View File
@@ -2,26 +2,81 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
@layer base {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 221 83% 53%;
--radius: 0.75rem;
}
.dark {
--background: 0 0% 4%;
--foreground: 0 0% 98%;
--card: 0 0% 4%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 4%;
--popover-foreground: 0 0% 98%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 15%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 0 0% 64%;
--accent: 0 0% 15%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62% 30%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 15%;
--input: 0 0% 15%;
--ring: 221 83% 53%;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-body antialiased;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
.bg-checkerboard {
background-color: #1f2937;
background-image:
linear-gradient(45deg, #374151 25%, transparent 25%),
linear-gradient(-45deg, #374151 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #374151 75%),
linear-gradient(-45deg, transparent 75%, #374151 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
}
}
+7 -32
View File
@@ -1,35 +1,10 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
/**
* Root layout — minimal pass-through.
* The actual HTML structure (lang, dir, fonts) lives in [locale]/layout.tsx.
* This file exists only because Next.js requires a root layout.tsx.
*/
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}: Readonly<{ children: React.ReactNode }>) {
return children;
}
+59
View File
@@ -0,0 +1,59 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "CreatorStudio — AI Video & Image Maker";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OpenGraphImage() {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
background: "linear-gradient(135deg, #1e40af 0%, #2563EB 50%, #7c3aed 100%)",
padding: "80px",
}}
>
<div
style={{
fontSize: 28,
fontWeight: 600,
color: "rgba(255,255,255,0.85)",
marginBottom: 16,
}}
>
CreatorStudio
</div>
<div
style={{
fontSize: 64,
fontWeight: 700,
color: "white",
lineHeight: 1.1,
maxWidth: 900,
}}
>
Create pro videos & images with AI
</div>
<div
style={{
fontSize: 28,
color: "rgba(255,255,255,0.9)",
marginTop: 24,
maxWidth: 800,
}}
>
Templates, editors, and one-click export for every channel
</div>
</div>
),
{ ...size }
);
}
-101
View File
@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard", "/studio", "/api"],
},
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const PUBLIC_ROUTES = [
"/",
"/video-maker",
"/image-maker",
"/templates",
"/pricing",
] as const;
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return PUBLIC_ROUTES.map((path) => ({
url: new URL(path, siteUrl).toString(),
lastModified,
}));
}
@@ -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>
);
}

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