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
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
export interface FieldDef {
key: string;
label: string;
type?: "text" | "textarea" | "number" | "checkbox" | "select";
options?: { value: string; label: string }[];
required?: boolean;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ColumnDef {
key: string;
label: string;
render?: (row: Record<string, unknown>) => ReactNode;
}
export interface ResourceConfig {
title: string;
description?: string;
basePath: string; // e.g. "categories"
idKey?: string; // default "id"
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
columns: ColumnDef[];
fields?: FieldDef[];
canCreate?: boolean;
canEdit?: boolean;
canDelete?: boolean;
rowActions?: (row: Record<string, unknown>, reload: () => void) => ReactNode;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
export function AdminResource({ config }: { config: ResourceConfig }) {
const idKey = config.idKey ?? "id";
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Record<string, unknown> | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url(), { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "Failed to load");
const list = config.listKey ? data?.[config.listKey] : data;
setRows(Array.isArray(list) ? list : (data?.items ?? []));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.basePath, config.listKey]);
useEffect(() => {
reload();
}, [reload]);
const openCreate = () => {
const init: Record<string, unknown> = {};
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
setForm(init);
setCreating(true);
setEditing(null);
};
const openEdit = (row: Record<string, unknown>) => {
const init: Record<string, unknown> = {};
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
setForm(init);
setEditing(row);
setCreating(false);
};
const closeForm = () => {
setCreating(false);
setEditing(null);
setForm({});
};
const submit = async () => {
setSaving(true);
setError(null);
try {
const isEdit = !!editing;
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? "Save failed");
closeForm();
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
};
const remove = async (row: Record<string, unknown>) => {
if (!confirm(`Delete this ${config.title.replace(/s$/, "").toLowerCase()}?`)) return;
const res = await fetch(url(`/${row[idKey]}`), { method: "DELETE" });
if (res.ok) reload();
else {
const d = await res.json().catch(() => null);
setError(d?.error ?? "Delete failed");
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">{config.title}</h1>
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
</div>
{config.canCreate && config.fields && (
<button className={btn} onClick={openCreate}>+ New</button>
)}
</div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
<div className={`${card} overflow-hidden`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
{config.columns.map((c) => (
<th key={c.key} className="px-4 py-3 font-medium">{c.label}</th>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<th className="px-4 py-3 text-right font-medium">Actions</th>
)}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>Loading</td></tr>
) : rows.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>No records.</td></tr>
) : (
rows.map((row, i) => (
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
{config.columns.map((c) => (
<td key={c.key} className="px-4 py-3 text-gray-200">
{c.render ? c.render(row) : formatCell(row[c.key])}
</td>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{config.rowActions?.(row, reload)}
{config.canEdit && config.fields && (
<button className={btnGhost} onClick={() => openEdit(row)}>Edit</button>
)}
{config.canDelete && (
<button
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
onClick={() => remove(row)}
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{(creating || editing) && config.fields && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={closeForm}>
<div className={`${card} w-full max-w-lg p-5`} onClick={(e) => e.stopPropagation()}>
<h2 className="text-sm font-semibold text-white">
{editing ? "Edit" : "New"} {config.title.replace(/s$/, "")}
</h2>
<div className="mt-4 grid max-h-[60vh] gap-3 overflow-y-auto pr-1">
{config.fields.map((f) => (
<div key={f.key}>
{f.type !== "checkbox" && (
<label className="mb-1 block text-xs font-medium text-gray-400">
{f.label}{f.required && <span className="text-red-400"> *</span>}
</label>
)}
{f.type === "textarea" ? (
<textarea className={`${inputCls} min-h-[80px]`} placeholder={f.placeholder}
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
) : f.type === "select" ? (
<select className={inputCls} value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}>
<option value=""></option>
{f.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.type === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm({ ...form, [f.key]: e.target.checked })} />
{f.label}
</label>
) : (
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: f.type === "number" ? Number(e.target.value) : e.target.value })} />
)}
</div>
))}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<button className={btnGhost} onClick={closeForm}>Cancel</button>
<button className={btn} onClick={submit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
)}
</div>
);
}
function formatCell(v: unknown): ReactNode {
if (v === null || v === undefined || v === "") return <span className="text-gray-600"></span>;
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "object") return JSON.stringify(v).slice(0, 40);
const s = String(v);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface AiSettings {
provider: string;
base_url: string;
model: string;
enabled: boolean;
has_api_key: boolean;
api_key_masked: string | null;
}
interface SeoPost {
title: string;
slug: string;
meta_title: string;
meta_description: string;
keywords: string[];
short_description: string;
content_html: string;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
const label = "block text-xs font-medium text-gray-400 mb-1";
const input =
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const btn =
"rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const btnGhost =
"rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
export function AiContentStudio() {
const t = useTranslations("auto.adminAi");
const [settings, setSettings] = useState<AiSettings | null>(null);
const [apiKey, setApiKey] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [settingsMsg, setSettingsMsg] = useState<string | null>(null);
// Generation form
const [description, setDescription] = useState("");
const [title, setTitle] = useState("");
const [type, setType] = useState("");
const [tags, setTags] = useState("");
const [keyword, setKeyword] = useState("");
const [audience, setAudience] = useState("");
const [locale, setLocale] = useState("fa");
const [generating, setGenerating] = useState(false);
const [genError, setGenError] = useState<string | null>(null);
const [post, setPost] = useState<SeoPost | null>(null);
const [publishNow, setPublishNow] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const loadSettings = useCallback(async () => {
const res = await fetch("/api/admin/ai/settings", { cache: "no-store" });
if (res.ok) setSettings(await res.json());
}, []);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = async () => {
if (!settings) return;
setSavingSettings(true);
setSettingsMsg(null);
const res = await fetch("/api/admin/ai/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: settings.provider,
base_url: settings.base_url,
model: settings.model,
enabled: settings.enabled,
api_key: apiKey.trim() === "" ? null : apiKey.trim(),
}),
});
if (res.ok) {
setSettings(await res.json());
setApiKey("");
setSettingsMsg(t("settingsSaved"));
} else {
setSettingsMsg(t("settingsError"));
}
setSavingSettings(false);
};
const generate = async () => {
setGenerating(true);
setGenError(null);
setSaveMsg(null);
const res = await fetch("/api/admin/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description,
title: title || null,
type: type || null,
tags: tags ? tags.split(",").map((s) => s.trim()).filter(Boolean) : null,
keyword: keyword || null,
audience: audience || null,
locale,
}),
});
const data = await res.json().catch(() => null);
if (res.ok) setPost(data);
else setGenError(data?.error ?? t("generateError"));
setGenerating(false);
};
const saveAsBlog = async () => {
if (!post) return;
setSaving(true);
setSaveMsg(null);
const res = await fetch("/api/admin/ai/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug: post.slug,
title: post.title,
short_description: post.short_description,
content: post.content_html,
meta_title: post.meta_title,
meta_description: post.meta_description,
meta_keywords: post.keywords.join(", "),
include_in_site_map: true,
is_published: publishNow,
}),
});
setSaveMsg(res.ok ? t("savedAsBlog") : t("saveError"));
setSaving(false);
};
const setPostField = (k: keyof SeoPost, v: string) =>
setPost((p) => (p ? { ...p, [k]: v } : p));
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-white">{t("pageTitle")}</h1>
<p className="mt-1 text-sm text-gray-400">{t("pageDesc")}</p>
</div>
{/* ── Settings ─────────────────────────────────────────── */}
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("settingsTitle")}</h2>
<p className="mt-1 text-xs text-gray-500">{t("settingsDesc")}</p>
{settings && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className={label}>{t("apiKeyLabel")}</label>
<input
className={input}
type="password"
placeholder={settings.api_key_masked ?? t("apiKeyPlaceholder")}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="mt-1 text-[11px] text-gray-500">
{settings.has_api_key ? `${t("keyConfigured")}` : t("noKey")}
</p>
</div>
<div>
<label className={label}>{t("baseUrlLabel")}</label>
<input
className={input}
value={settings.base_url}
onChange={(e) => setSettings({ ...settings, base_url: e.target.value })}
/>
</div>
<div>
<label className={label}>{t("modelLabel")}</label>
<input
className={input}
value={settings.model}
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
{t("enabledLabel")}
</label>
<div className="flex items-center gap-3 sm:col-span-2">
<button className={btn} onClick={saveSettings} disabled={savingSettings}>
{savingSettings ? t("saving") : t("saveSettings")}
</button>
{settingsMsg && <span className="text-xs text-gray-400">{settingsMsg}</span>}
</div>
</div>
)}
</section>
{/* ── Generator ────────────────────────────────────────── */}
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("generateTitle")}</h2>
<p className="mt-1 text-xs text-gray-500">{t("generateDesc")}</p>
{settings && !settings.enabled && (
<p className="mt-3 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
{t("mustConfigure")}
</p>
)}
<div className="mt-4 grid gap-4">
<div>
<label className={label}>{t("descriptionLabel")}</label>
<textarea
className={`${input} min-h-[100px]`}
placeholder={t("descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={label}>{t("titleLabel")}</label>
<input className={input} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<label className={label}>{t("typeLabel")}</label>
<input className={input} placeholder={t("typePlaceholder")} value={type} onChange={(e) => setType(e.target.value)} />
</div>
<div>
<label className={label}>{t("keywordLabel")}</label>
<input className={input} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div>
<label className={label}>{t("audienceLabel")}</label>
<input className={input} value={audience} onChange={(e) => setAudience(e.target.value)} />
</div>
<div>
<label className={label}>{t("tagsLabel")}</label>
<input className={input} value={tags} onChange={(e) => setTags(e.target.value)} />
</div>
<div>
<label className={label}>{t("localeLabel")}</label>
<select className={input} value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="fa">{t("localeFa")}</option>
<option value="en">{t("localeEn")}</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<button className={btn} onClick={generate} disabled={generating || !description.trim()}>
{generating ? t("generating") : t("generate")}
</button>
{genError && <span className="text-xs text-red-400">{genError}</span>}
</div>
</div>
</section>
{/* ── Result ───────────────────────────────────────────── */}
{post && (
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("resultTitle")}</h2>
<div className="mt-4 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={label}>{t("fTitle")}</label>
<input className={input} value={post.title} onChange={(e) => setPostField("title", e.target.value)} />
</div>
<div>
<label className={label}>{t("fSlug")}</label>
<input className={input} value={post.slug} onChange={(e) => setPostField("slug", e.target.value)} />
</div>
<div>
<label className={label}>{t("fMetaTitle")}</label>
<input className={input} value={post.meta_title} onChange={(e) => setPostField("meta_title", e.target.value)} />
</div>
<div>
<label className={label}>{t("fKeywords")}</label>
<input className={input} value={post.keywords.join(", ")} onChange={(e) => setPost({ ...post, keywords: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} />
</div>
</div>
<div>
<label className={label}>{t("fMetaDesc")}</label>
<textarea className={`${input} min-h-[60px]`} value={post.meta_description} onChange={(e) => setPostField("meta_description", e.target.value)} />
</div>
<div>
<label className={label}>{t("fShortDesc")}</label>
<textarea className={`${input} min-h-[60px]`} value={post.short_description} onChange={(e) => setPostField("short_description", e.target.value)} />
</div>
<div>
<label className={label}>{t("fContent")}</label>
<textarea className={`${input} min-h-[200px] font-mono text-xs`} value={post.content_html} onChange={(e) => setPostField("content_html", e.target.value)} />
</div>
<div>
<label className={label}>{t("preview")}</label>
<div
className="prose prose-invert max-w-none rounded-lg border border-[#262b40] bg-white p-4 text-black"
dangerouslySetInnerHTML={{ __html: post.content_html }}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={publishNow} onChange={(e) => setPublishNow(e.target.checked)} />
{t("publishNow")}
</label>
<button className={btnGhost} onClick={saveAsBlog} disabled={saving}>
{saving ? t("saving") : t("saveAsBlog")}
</button>
{saveMsg && <span className="text-xs text-gray-400">{saveMsg}</span>}
</div>
</div>
</section>
)}
</div>
);
}
+12 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { useRouter } from "next/navigation";
@@ -31,6 +32,7 @@ function heartbeatAge(iso: string): string {
}
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const t = useTranslations("auto.componentsAdminNodesTable");
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
@@ -47,7 +49,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
if (nodes.length === 0) {
return (
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
No nodes registered. Start the node agent on a render machine to see it here.
{t("emptyState")}
</div>
);
}
@@ -57,13 +59,13 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Node</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Slots</th>
<th className="px-4 py-3">Heartbeat</th>
<th className="px-4 py-3">Active Job</th>
<th className="px-4 py-3">Tags</th>
<th className="px-4 py-3">Actions</th>
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colStatus")}</th>
<th className="px-4 py-3">{t("colSlots")}</th>
<th className="px-4 py-3">{t("colHeartbeat")}</th>
<th className="px-4 py-3">{t("colActiveJob")}</th>
<th className="px-4 py-3">{t("colTags")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
@@ -103,14 +105,14 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
disabled={loading[node.id] || node.status === "Offline"}
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Drain
{t("actionDrain")}
</button>
<button
onClick={() => action(node.id, "release")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Release
{t("actionRelease")}
</button>
</div>
</td>
+13 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api/fetch";
import type { V2RenderJob } from "@/app/[locale]/admin/renders/page";
@@ -33,6 +34,7 @@ function relativeTime(iso: string): string {
}
export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
const t = useTranslations("auto.componentsAdminRenderQueueTable");
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
@@ -59,7 +61,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
No render jobs found for the selected filter.
{t("emptyState")}
</div>
);
}
@@ -69,14 +71,14 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Job ID</th>
<th className="px-4 py-3">Project</th>
<th className="px-4 py-3">Step</th>
<th className="px-4 py-3">Progress</th>
<th className="px-4 py-3">Quality</th>
<th className="px-4 py-3">Node</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Actions</th>
<th className="px-4 py-3">{t("colJobId")}</th>
<th className="px-4 py-3">{t("colProject")}</th>
<th className="px-4 py-3">{t("colStep")}</th>
<th className="px-4 py-3">{t("colProgress")}</th>
<th className="px-4 py-3">{t("colQuality")}</th>
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colCreated")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
@@ -133,7 +135,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
disabled={loading[job.id]}
className="rounded px-2.5 py-1 text-xs text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Retry
{t("actionRetry")}
</button>
)}
{canCancel && (
@@ -142,7 +144,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
disabled={loading[job.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Cancel
{t("actionCancel")}
</button>
)}
</div>
+170
View File
@@ -0,0 +1,170 @@
"use client";
import type { ResourceConfig } from "@/components/admin/AdminResource";
const badge = (ok: boolean, yes: string, no: string) =>
ok ? (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">{yes}</span>
) : (
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400">{no}</span>
);
const banAction = (row: Record<string, unknown>, reload: () => void) => {
const banned = !!row.ban_account;
return (
<button
className={
banned
? "rounded-lg border border-emerald-500/30 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
: "rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
}
onClick={async () => {
const reason = banned ? "" : prompt("Ban reason?") ?? "";
if (!banned && !reason) return;
const res = await fetch(`/api/admin/resource/users/${row.id}/ban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: banned ? "unban" : reason, unbanned: banned }),
});
if (res.ok) reload();
}}
>
{banned ? "Unban" : "Ban"}
</button>
);
};
export const categoriesConfig: ResourceConfig = {
title: "Categories",
description: "Taxonomy used across templates and the public site.",
basePath: "categories",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "slug", label: "Slug" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
{ key: "sort", label: "Sort" },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "slug", label: "Slug", required: true },
{ key: "description", label: "Description", type: "textarea" },
{ key: "image_url", label: "Image URL" },
{ key: "icon", label: "Icon" },
],
};
export const tagsConfig: ResourceConfig = {
title: "Tags",
description: "Keyword tags for templates and content.",
basePath: "tags",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "slug", label: "Slug" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "latin_name", label: "Latin name" },
{ key: "slug", label: "Slug", required: true },
{ key: "is_active", label: "Active", type: "checkbox", defaultValue: true },
],
};
export const fontsConfig: ResourceConfig = {
title: "Fonts",
description: "Fonts available in the studio editors.",
basePath: "fonts",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "family", label: "Family" },
{ key: "weight", label: "Weight" },
{ key: "style", label: "Style" },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "original_name", label: "Original name" },
{ key: "system_name", label: "System name" },
{ key: "family", label: "Family" },
{ key: "weight", label: "Weight", type: "number" },
{ key: "style", label: "Style" },
],
};
export const blogsConfig: ResourceConfig = {
title: "Blog Posts",
description: "CMS articles (also created by the AI SEO generator).",
basePath: "blogs",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "title", label: "Title" },
{ key: "slug", label: "Slug" },
{ key: "is_published", label: "Published", render: (r) => badge(!!r.is_published, "live", "draft") },
{ key: "view_count", label: "Views" },
],
fields: [
{ key: "title", label: "Title", required: true },
{ key: "slug", label: "Slug", required: true },
{ key: "short_description", label: "Short description", type: "textarea" },
{ key: "content", label: "Content (HTML)", type: "textarea", required: true },
{ key: "meta_title", label: "Meta title" },
{ key: "meta_description", label: "Meta description", type: "textarea" },
{ key: "meta_keywords", label: "Meta keywords" },
{ key: "is_published", label: "Published", type: "checkbox" },
{ key: "include_in_site_map", label: "Include in sitemap", type: "checkbox", defaultValue: true },
],
};
export const slidesConfig: ResourceConfig = {
title: "Home Slides",
description: "Hero/promo slides on the homepage.",
basePath: "slides",
canDelete: true,
columns: [
{ key: "title", label: "Title" },
{ key: "slide_type", label: "Type" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
],
};
export const usersConfig: ResourceConfig = {
title: "Users",
description: "Accounts in this tenant. Ban or unban below.",
basePath: "users",
listKey: "data",
columns: [
{ key: "email", label: "Email" },
{ key: "full_name", label: "Name" },
{ key: "is_admin", label: "Admin", render: (r) => badge(!!r.is_admin, "admin", "—") },
{ key: "register_mode", label: "Source" },
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
],
rowActions: banAction,
};
export const plansConfig: ResourceConfig = {
title: "Plans",
description: "Subscription plans (read-only view).",
basePath: "plans",
listKey: "data",
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "price_minor", label: "Price (minor)" },
{ key: "billing_period", label: "Period" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
],
};