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>
);
}