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

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