feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 recreate—or 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 assets—one 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" },
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user