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