feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s

AI SEO content generator
- content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints
  (settings GET/PUT, seo-post) with SEO-expert prompt → structured article
- admin UI to configure token/base-url/model and generate + save as blog
- configurable base URL for restricted networks

Full data-driven admin panel
- generic /api/admin/resource proxy + reusable AdminResource component
- categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides
- AI content section; nav + i18n

i18n localization sweep
- localized 116 user-facing + studio/editor components to next-intl (fa+en)
  under the auto.* namespace; merge tooling in scripts/merge-i18n.js

Branding + assets
- Monoline F logo (LogoMark + favicon)
- offline SVG placeholder generator (/api/placeholder), dropped picsum.photos

Fixes
- JWT issuer mismatch on content/studio (flatrender → flatrender-identity)
- missing role claim → [Authorize(Roles="Admin")] now works (RBAC)
- Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE
- Radix RTL via DirectionProvider (right-aligned menus in fa)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 09:35:14 +03:30
parent bcc69f0a2e
commit 3fc7bf2b97
160 changed files with 4397 additions and 767 deletions
+7
View File
@@ -0,0 +1,7 @@
import { AiContentStudio } from "@/components/admin/AiContentStudio";
export const dynamic = "force-dynamic";
export default function AdminAiPage() {
return <AiContentStudio />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { blogsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={blogsConfig} />;
}
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { categoriesConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={categoriesConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { fontsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={fontsConfig} />;
}
+27 -16
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getCurrentUser } from "@/lib/auth/session";
@@ -13,28 +14,38 @@ export default async function AdminLayout({
if (!user || !user.is_admin) {
redirect("/dashboard");
}
const t = await getTranslations("auto.appAdminLayout");
const links: { href: string; label: string }[] = [
{ href: "/admin/categories", label: t("categories") },
{ href: "/admin/tags", label: t("tags") },
{ href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/blogs", label: t("blogs") },
{ href: "/admin/slides", label: t("slides") },
{ href: "/admin/ai", label: t("aiContent") },
{ href: "/admin/users", label: t("users") },
{ href: "/admin/plans", label: t("plans") },
{ href: "/admin/nodes", label: t("nodes") },
{ href: "/admin/renders", label: t("renderQueue") },
];
return (
<div className="min-h-screen bg-[#0c0e1a] text-gray-200">
<nav className="border-b border-[#1e2235] bg-[#0f1120] px-6 py-3">
<div className="mx-auto flex max-w-7xl items-center gap-6">
<span className="text-sm font-semibold text-white">FlatRender Admin</span>
<a
href="/admin/nodes"
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Nodes
</a>
<a
href="/admin/renders"
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Render Queue
</a>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-5 gap-y-2">
<span className="text-sm font-semibold text-white">{t("brand")}</span>
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="text-sm text-gray-400 transition-colors hover:text-white"
>
{l.label}
</a>
))}
<a
href="/dashboard"
className="ml-auto text-xs text-gray-500 hover:text-gray-300 transition-colors"
className="ml-auto text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Back to Dashboard
{t("backToDashboard")}
</a>
</div>
</nav>
+5 -2
View File
@@ -1,3 +1,5 @@
import { getTranslations } from "next-intl/server";
import { adminGet } from "@/lib/api/admin-gateway";
import { NodesTable } from "@/components/admin/NodesTable";
@@ -24,14 +26,15 @@ interface V2NodeList {
export default async function AdminNodesPage() {
const data = await adminGet<V2NodeList>("/v1/nodes?pageSize=100");
const nodes = data?.items ?? [];
const t = await getTranslations("auto.appAdminNodesPage");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Render Nodes</h1>
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
<p className="mt-1 text-sm text-gray-500">
{nodes.length} node{nodes.length !== 1 ? "s" : ""} registered
{t("registered", { count: nodes.length })}
</p>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { plansConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={plansConfig} />;
}
+16 -4
View File
@@ -1,3 +1,5 @@
import { getTranslations } from "next-intl/server";
import { adminGet } from "@/lib/api/admin-gateway";
import { RenderQueueTable } from "@/components/admin/RenderQueueTable";
@@ -35,15 +37,25 @@ export default async function AdminRendersPage({
const data = await adminGet<V2RenderList>(`/v1/renders${qs}`);
const jobs = data?.items ?? [];
const total = data?.total ?? 0;
const t = await getTranslations("auto.appAdminRendersPage");
const steps = ["Queued", "Preparing", "Rendering", "Uploading", "Done", "Failed", "Cancelled"];
const stepLabels: Record<string, string> = {
Queued: t("stepQueued"),
Preparing: t("stepPreparing"),
Rendering: t("stepRendering"),
Uploading: t("stepUploading"),
Done: t("stepDone"),
Failed: t("stepFailed"),
Cancelled: t("stepCancelled"),
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Render Queue</h1>
<p className="mt-1 text-sm text-gray-500">{total} total jobs</p>
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
<p className="mt-1 text-sm text-gray-500">{t("totalJobs", { total })}</p>
</div>
</div>
@@ -57,7 +69,7 @@ export default async function AdminRendersPage({
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
}`}
>
All
{t("filterAll")}
</a>
{steps.map((s) => (
<a
@@ -69,7 +81,7 @@ export default async function AdminRendersPage({
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
}`}
>
{s}
{stepLabels[s] ?? s}
</a>
))}
</div>
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { slidesConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={slidesConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { tagsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={tagsConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { usersConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={usersConfig} />;
}
+12 -7
View File
@@ -1,23 +1,28 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
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 async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("auto.appAuthPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/auth",
});
}
export default function AuthPage() {
export default async function AuthPage() {
const t = await getTranslations("auto.appAuthPage");
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..." />
<AuthLoadingSpinner label={t("loading")} />
</div>
}
>
+7 -5
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { SettingsBilling } from "@/components/dashboard/settings/SettingsBilling";
import { SettingsNotifications } from "@/components/dashboard/settings/SettingsNotifications";
@@ -17,6 +18,7 @@ export const metadata: Metadata = createPageMetadata({
export const dynamic = "force-dynamic";
export default async function DashboardSettingsPage() {
const t = await getTranslations("auto.appDashboardSettingsPage");
// Auth is served by the V2 Identity service (JWT cookie), not Supabase.
const user = await getCurrentUser();
@@ -31,9 +33,9 @@ export default async function DashboardSettingsPage() {
<div className="flex flex-1 flex-col">
{/* Page header */}
<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>
<h1 className="font-heading text-xl font-bold text-neutral-900">{t("title")}</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Manage your account, security, and notification preferences.
{t("subtitle")}
</p>
</header>
@@ -47,15 +49,15 @@ export default async function DashboardSettingsPage() {
{/* Danger zone */}
<div className="rounded-xl border border-red-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-red-600">Danger zone</h2>
<h2 className="font-heading text-base font-semibold text-red-600">{t("dangerZoneTitle")}</h2>
<p className="mt-1 text-sm text-neutral-500">
Permanently delete your account and all your projects. This cannot be undone.
{t("dangerZoneDescription")}
</p>
<button
type="button"
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Delete account
{t("deleteAccount")}
</button>
</div>
</div>
+6 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
@@ -10,6 +11,8 @@ interface ErrorPageProps {
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
const t = useTranslations("auto.appError");
useEffect(() => {
// Surface to monitoring in production when configured
}, [error]);
@@ -17,17 +20,17 @@ export default function ErrorPage({ error, reset }: ErrorPageProps) {
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
{t("title")}
</h1>
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
An unexpected error occurred. Try reloading the page.
{t("description")}
</p>
<Button
type="button"
className="mt-8 bg-blue-600 hover:bg-blue-700"
onClick={() => reset()}
>
Reload page
{t("reloadButton")}
</Button>
</main>
);
+9 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta";
import { ImageMakerFeatures } from "@/components/image-maker/ImageMakerFeatures";
@@ -7,12 +8,14 @@ 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 async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("auto.appImageMakerPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/image-maker",
});
}
export default function ImageMakerPage() {
return (
+4 -2
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { getMessages, getTranslations } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome";
import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing";
@@ -102,7 +103,6 @@ export default async function LocaleLayout({
>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<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 ${
@@ -110,7 +110,9 @@ export default async function LocaleLayout({
}`}
>
<NextIntlClientProvider messages={messages} locale={locale}>
<SiteChrome>{children}</SiteChrome>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome>{children}</SiteChrome>
</DirectionProvider>
</NextIntlClientProvider>
</body>
</html>
+7 -4
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -10,17 +11,19 @@ export const metadata: Metadata = createPageMetadata({
path: "/404",
});
export default function NotFoundPage() {
export default async function NotFoundPage() {
const t = await getTranslations("auto.appNotFound");
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
{t("title")}
</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.
{t("description")}
</p>
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
<Link href="/">Go home</Link>
<Link href="/">{t("goHome")}</Link>
</Button>
</main>
);
+9 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Hero } from "@/components/sections/Hero";
import { HowItWorks } from "@/components/sections/HowItWorks";
@@ -10,12 +11,14 @@ import { Testimonials } from "@/components/sections/Testimonials";
import { createPageMetadata } from "@/lib/metadata";
import { fetchProjects } from "@/lib/admin-api";
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 async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("auto.appPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/",
});
}
export default async function Home() {
// Fetch up to 8 published projects from the admin service.
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
const ImageEditorLayout = dynamic(
() =>
@@ -9,14 +10,19 @@ const ImageEditorLayout = dynamic(
),
{
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>
),
loading: () => <ImageEditorLoading />,
}
);
function ImageEditorLoading() {
const t = useTranslations("auto.appStudioImageProjectIdPage");
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
{t("loadingEditor")}
</div>
);
}
interface ImageStudioPageProps {
params: {
projectId: string;
+8 -7
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ArrowLeft, Scissors } from "lucide-react";
import { TrimmerExportSection } from "@/components/trimmer/TrimmerExportSection";
@@ -22,6 +23,7 @@ import { parseFfmpegProgress } from "@/lib/trimmer-utils";
const INITIAL_CROP: CropBox = { x: 0, y: 0, w: 320, h: 180 };
export default function VideoTrimmerPage() {
const t = useTranslations("auto.appStudioTrimmerPage");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [duration, setDuration] = useState(0);
@@ -47,16 +49,14 @@ export default function VideoTrimmerPage() {
})
.catch(() => {
if (!cancelled) {
setFfmpegError(
"Failed to load FFmpeg. Check your connection and try again."
);
setFfmpegError(t("ffmpegLoadError"));
}
});
return () => {
cancelled = true;
};
}, []);
}, [t]);
useEffect(() => {
return () => {
@@ -130,7 +130,7 @@ export default function VideoTrimmerPage() {
setOutputUrl(URL.createObjectURL(blob));
} catch {
setFfmpegError("Processing failed. Try a shorter clip or different format.");
setFfmpegError(t("processingError"));
} finally {
setIsProcessing(false);
}
@@ -144,6 +144,7 @@ export default function VideoTrimmerPage() {
videoSize,
exportFormat,
outputUrl,
t,
]);
return (
@@ -155,11 +156,11 @@ export default function VideoTrimmerPage() {
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
{t("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
{t("title")}
</h1>
</div>
</header>
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
const VideoStudioLayout = dynamic(
() =>
@@ -9,14 +10,19 @@ const VideoStudioLayout = dynamic(
),
{
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>
),
loading: () => <VideoStudioLoading />,
}
);
function VideoStudioLoading() {
const t = useTranslations("auto.appStudioVideoProjectIdPage");
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
{t("loading")}
</div>
);
}
interface VideoStudioPageProps {
params: {
projectId: string;
+9 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta";
import { VideoMakerFeatures } from "@/components/video-maker/VideoMakerFeatures";
@@ -7,12 +8,14 @@ import { VideoMakerTemplateCarousel } from "@/components/video-maker/VideoMakerT
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 async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("auto.appVideoMakerPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/video-maker",
});
}
export default function VideoMakerPage() {
return (