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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user