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