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
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:
@@ -0,0 +1,7 @@
|
||||
import { AiContentStudio } from "@/components/admin/AiContentStudio";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminAiPage() {
|
||||
return <AiContentStudio />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { gatewayUrl } from "@/lib/api/gateway";
|
||||
import { getAccessToken } from "@/lib/auth/session";
|
||||
import { decodeJwt } from "@/lib/auth/jwt";
|
||||
|
||||
/**
|
||||
* Forward an admin AI request to the V2 gateway, passing the request body through
|
||||
* and returning the gateway's JSON response (status preserved). Admin-gated.
|
||||
*/
|
||||
export async function aiProxy(
|
||||
req: NextRequest,
|
||||
gatewayPath: string,
|
||||
method: "GET" | "PUT" | "POST"
|
||||
): Promise<NextResponse> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const claims = decodeJwt(token);
|
||||
const isAdmin =
|
||||
String(claims?.is_admin) === "true" ||
|
||||
claims?.is_admin === true ||
|
||||
String(claims?.is_tenant_admin) === "true";
|
||||
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
let body: string | undefined;
|
||||
if (method !== "GET") {
|
||||
const json = await req.json().catch(() => ({}));
|
||||
body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const res = await fetch(gatewayUrl(gatewayPath), {
|
||||
method,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(data && (data.error?.message ?? data.message)) || "Gateway error";
|
||||
return NextResponse.json({ error: message }, { status: res.status });
|
||||
}
|
||||
return NextResponse.json(data ?? {}, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { aiProxy } from "../_aiProxy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return aiProxy(req, "/v1/ai/seo-post", "POST");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { aiProxy } from "../_aiProxy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Save a generated article as a blog post (content-svc BlogsController, admin-gated).
|
||||
export async function POST(req: NextRequest) {
|
||||
return aiProxy(req, "/v1/blogs", "POST");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { aiProxy } from "../_aiProxy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return aiProxy(req, "/v1/ai/settings", "GET");
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
return aiProxy(req, "/v1/ai/settings", "PUT");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { gatewayUrl } from "@/lib/api/gateway";
|
||||
import { getAccessToken } from "@/lib/auth/session";
|
||||
import { decodeJwt } from "@/lib/auth/jwt";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Generic admin proxy: forwards GET/POST/PUT/DELETE for any admin resource to the V2
|
||||
* gateway under /v1/<path>, attaching the admin's bearer token. Admin-gated server-side.
|
||||
*
|
||||
* /api/admin/resource/categories → /v1/categories
|
||||
* /api/admin/resource/categories/<id> → /v1/categories/<id>
|
||||
* /api/admin/resource/users?page=1 → /v1/users?page=1
|
||||
*
|
||||
* Query string is preserved.
|
||||
*/
|
||||
async function forward(
|
||||
req: NextRequest,
|
||||
path: string[],
|
||||
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
): Promise<NextResponse> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const claims = decodeJwt(token);
|
||||
const isAdmin =
|
||||
String(claims?.is_admin) === "true" ||
|
||||
claims?.is_admin === true ||
|
||||
String(claims?.is_tenant_admin) === "true";
|
||||
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const search = req.nextUrl.search ?? "";
|
||||
// Trailing slash on the collection root avoids the gateway's 307 redirect.
|
||||
const joined = path.join("/");
|
||||
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
|
||||
|
||||
let body: string | undefined;
|
||||
if (method === "POST" || method === "PUT") {
|
||||
const json = await req.json().catch(() => ({}));
|
||||
body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const res = await fetch(gatewayUrl(gwPath), {
|
||||
method,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? safeJson(text) : null;
|
||||
if (!res.ok) {
|
||||
const errObj = data?.error;
|
||||
const message =
|
||||
(typeof errObj === "object" && errObj?.message) ||
|
||||
(typeof errObj === "string" ? errObj : undefined) ||
|
||||
data?.message ||
|
||||
"Gateway error";
|
||||
return NextResponse.json({ error: message }, { status: res.status });
|
||||
}
|
||||
return NextResponse.json(data ?? {}, { status: 200 });
|
||||
}
|
||||
|
||||
interface GatewayResponse {
|
||||
error?: { message?: string } | string;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function safeJson(t: string): GatewayResponse | null {
|
||||
try {
|
||||
return JSON.parse(t) as GatewayResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||
return forward(req, ctx.params.path, "GET");
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||
return forward(req, ctx.params.path, "POST");
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||
return forward(req, ctx.params.path, "PUT");
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||
return forward(req, ctx.params.path, "DELETE");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
// Deterministic, dependency-free placeholder image generator. Returns an SVG gradient
|
||||
// derived from the `seed` query param so each placeholder is stable and distinct.
|
||||
// Same-origin and offline — replaces external picsum.photos in restricted networks.
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h << 5) - h + s.charCodeAt(i);
|
||||
h |= 0; // force 32-bit
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function clampDim(raw: string, fallback: number): number {
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(2000, Math.max(1, n));
|
||||
}
|
||||
|
||||
export function GET(
|
||||
req: NextRequest,
|
||||
context: { params: { width: string; height: string } }
|
||||
) {
|
||||
const { width, height } = context.params;
|
||||
const w = clampDim(width, 400);
|
||||
const h = clampDim(height, 300);
|
||||
const seed = req.nextUrl.searchParams.get("seed") ?? "flatrender";
|
||||
|
||||
const hash = hashString(seed);
|
||||
const hue1 = hash % 360;
|
||||
const hue2 = (hue1 + 40 + (hash % 80)) % 360;
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="hsl(${hue1} 64% 58%)"/>
|
||||
<stop offset="100%" stop-color="hsl(${hue2} 58% 42%)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
</svg>`;
|
||||
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user