feat(admin): category SEO fields, Templates admin, safe project PATCH
Build backend images / build content-svc (push) Failing after 21s
Build backend images / build file-svc (push) Failing after 3m49s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m1s
Build backend images / build notification-svc (push) Failing after 1m2s
Build backend images / build render-svc (push) Failing after 1m0s
Build backend images / build studio-svc (push) Failing after 58s
Build backend images / build content-svc (push) Failing after 21s
Build backend images / build file-svc (push) Failing after 3m49s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m1s
Build backend images / build notification-svc (push) Failing after 1m2s
Build backend images / build render-svc (push) Failing after 1m0s
Build backend images / build studio-svc (push) Failing after 58s
- categories/tags admin forms: add meta title/description/keywords, bot-follow,
sort, is_active (backend already supported these)
- new Templates admin (/admin/templates): container CRUD with description,
keywords, publishing, premium, primary mode, category/tag assignment, plus
editable per-variant aspect & resolution
- content-svc: PATCH /v1/projects/{id} partial update so aspect/resolution edits
never wipe render/colour data (SharedColorsSvg, RenderAepComp, Folder)
- admin resource proxy: add PATCH passthrough
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface Container {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_published?: boolean;
|
||||
is_premium?: boolean;
|
||||
is_mockup?: boolean;
|
||||
primary_mode?: string;
|
||||
sort?: number;
|
||||
category_slugs?: string[];
|
||||
}
|
||||
interface Ref { id: string; name: string; slug?: string }
|
||||
interface Detail extends Container {
|
||||
keywords?: string | null;
|
||||
news_text?: string | null;
|
||||
categories?: Ref[];
|
||||
tags?: Ref[];
|
||||
projects?: { id: string; name: string; aspect?: string | null; resolution?: string }[];
|
||||
}
|
||||
|
||||
const PRIMARY_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
|
||||
const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
|
||||
|
||||
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 ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
|
||||
const inp = "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 lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||||
|
||||
interface FormState {
|
||||
slug: string; name: string; description: string; keywords: string; news_text: string;
|
||||
is_published: boolean; is_premium: boolean; is_mockup: boolean; primary_mode: string; sort: number;
|
||||
category_ids: string[]; tag_ids: string[];
|
||||
}
|
||||
const emptyForm: FormState = {
|
||||
slug: "", name: "", description: "", keywords: "", news_text: "",
|
||||
is_published: false, is_premium: false, is_mockup: false, primary_mode: "FLEXIBLE", sort: 0,
|
||||
category_ids: [], tag_ids: [],
|
||||
};
|
||||
|
||||
export function TemplatesAdmin() {
|
||||
const [rows, setRows] = useState<Container[]>([]);
|
||||
const [cats, setCats] = useState<Ref[]>([]);
|
||||
const [tags, setTags] = useState<Ref[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [projects, setProjects] = useState<NonNullable<Detail["projects"]>>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingProj, setSavingProj] = useState<string | null>(null);
|
||||
|
||||
const api = (p: string) => `/api/admin/resource/${p}`;
|
||||
|
||||
const updateProj = (id: string, patch: Partial<{ aspect: string; resolution: string }>) =>
|
||||
setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, ...patch } : p)));
|
||||
|
||||
const saveProj = async (p: NonNullable<Detail["projects"]>[number]) => {
|
||||
setSavingProj(p.id);
|
||||
setError(null);
|
||||
const res = await fetch(api(`projects/${p.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aspect: p.aspect ?? "", resolution: p.resolution }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => null);
|
||||
setError(d?.error ?? "Failed to save variant");
|
||||
}
|
||||
setSavingProj(null);
|
||||
};
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, ct, tg] = await Promise.all([
|
||||
fetch(api("templates"), { cache: "no-store" }).then((r) => r.json()),
|
||||
fetch(api("categories"), { cache: "no-store" }).then((r) => r.json()),
|
||||
fetch(api("tags"), { cache: "no-store" }).then((r) => r.json()),
|
||||
]);
|
||||
setRows(c?.items ?? (Array.isArray(c) ? c : []));
|
||||
setCats(Array.isArray(ct) ? ct : ct?.items ?? []);
|
||||
setTags(tg?.items ?? (Array.isArray(tg) ? tg : []));
|
||||
} catch {
|
||||
setError("Failed to load templates");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const openNew = () => { setForm(emptyForm); setEditId(null); setProjects([]); setOpen(true); };
|
||||
|
||||
const openEdit = async (row: Container) => {
|
||||
setError(null);
|
||||
const d: Detail = await fetch(api(`templates/${row.slug}`), { cache: "no-store" }).then((r) => r.json());
|
||||
setEditId(d.id);
|
||||
setProjects(d.projects ?? []);
|
||||
setForm({
|
||||
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
|
||||
news_text: d.news_text ?? "", is_published: !!d.is_published, is_premium: !!d.is_premium,
|
||||
is_mockup: !!d.is_mockup, primary_mode: d.primary_mode ?? "FLEXIBLE", sort: d.sort ?? 0,
|
||||
category_ids: (d.categories ?? []).map((c) => c.id), tag_ids: (d.tags ?? []).map((t) => t.id),
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const toggle = (key: "category_ids" | "tag_ids", id: string) =>
|
||||
setForm((f) => ({ ...f, [key]: f[key].includes(id) ? f[key].filter((x) => x !== id) : [...f[key], id] }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true); setError(null);
|
||||
const body = { ...form, sort: Number(form.sort) || 0 };
|
||||
const res = await fetch(editId ? api(`templates/${editId}`) : api("templates"), {
|
||||
method: editId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok) { setOpen(false); reload(); }
|
||||
else setError(data?.error ?? "Save failed");
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const remove = async (row: Container) => {
|
||||
if (!confirm(`Delete template "${row.name}"?`)) return;
|
||||
const res = await fetch(api(`templates/${row.id}`), { method: "DELETE" });
|
||||
if (res.ok) reload(); else setError("Delete failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Templates</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">Template packs — name, description, keywords, categories, tags, publishing.</p>
|
||||
</div>
|
||||
<button className={btn} onClick={openNew}>+ New template</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">
|
||||
<th className="px-4 py-3">Name</th><th className="px-4 py-3">Slug</th>
|
||||
<th className="px-4 py-3">Status</th><th className="px-4 py-3">Mode</th>
|
||||
<th className="px-4 py-3">Sort</th><th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">No templates.</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||
<td className="px-4 py-3 text-gray-200">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{r.slug}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={r.is_published ? "rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300" : "rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400"}>
|
||||
{r.is_published ? "published" : "draft"}
|
||||
</span>
|
||||
{r.is_premium ? <span className="ms-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-300">premium</span> : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400">{r.primary_mode}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{r.sort}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button className={ghost} onClick={() => openEdit(r)}>Edit</button>
|
||||
<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(r)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
|
||||
<div className={`${card} w-full max-w-2xl p-5`} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-sm font-semibold text-white">{editId ? "Edit" : "New"} template</h2>
|
||||
<div className="mt-4 grid max-h-[65vh] gap-3 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>Name *</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><label className={lbl}>Slug *</label><input className={inp} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><label className={lbl}>Description</label><textarea className={`${inp} min-h-[80px]`} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||
<div><label className={lbl}>Keywords (SEO)</label><input className={inp} value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} /></div>
|
||||
<div><label className={lbl}>News / announcement text</label><textarea className={`${inp} min-h-[50px]`} value={form.news_text} onChange={(e) => setForm({ ...form, news_text: e.target.value })} /></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={lbl}>Primary mode</label>
|
||||
<select className={inp} value={form.primary_mode} onChange={(e) => setForm({ ...form, primary_mode: e.target.value })}>
|
||||
{PRIMARY_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={lbl}>Sort</label><input type="number" className={inp} value={form.sort} onChange={(e) => setForm({ ...form, sort: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-300">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_published} onChange={(e) => setForm({ ...form, is_published: e.target.checked })} /> Published</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_premium} onChange={(e) => setForm({ ...form, is_premium: e.target.checked })} /> Premium</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_mockup} onChange={(e) => setForm({ ...form, is_mockup: e.target.checked })} /> Mockup</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Categories</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cats.map((c) => (
|
||||
<button key={c.id} type="button" onClick={() => toggle("category_ids", c.id)}
|
||||
className={form.category_ids.includes(c.id) ? "rounded-full bg-indigo-600 px-2.5 py-1 text-xs text-white" : "rounded-full border border-[#262b40] px-2.5 py-1 text-xs text-gray-400"}>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
{cats.length === 0 && <span className="text-xs text-gray-600">No categories yet.</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Tags</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tg) => (
|
||||
<button key={tg.id} type="button" onClick={() => toggle("tag_ids", tg.id)}
|
||||
className={form.tag_ids.includes(tg.id) ? "rounded-full bg-indigo-600 px-2.5 py-1 text-xs text-white" : "rounded-full border border-[#262b40] px-2.5 py-1 text-xs text-gray-400"}>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
{tags.length === 0 && <span className="text-xs text-gray-600">No tags yet.</span>}
|
||||
</div>
|
||||
</div>
|
||||
{editId && projects.length > 0 && (
|
||||
<div>
|
||||
<label className={lbl}>Variants — aspect & resolution</label>
|
||||
<div className="space-y-2 rounded-lg border border-[#262b40] p-2">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-gray-300">{p.name}</span>
|
||||
<input
|
||||
className={`${inp} w-24 py-1 text-xs`}
|
||||
placeholder="16:9"
|
||||
value={p.aspect ?? ""}
|
||||
onChange={(e) => updateProj(p.id, { aspect: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={`${inp} w-28 py-1 text-xs`}
|
||||
value={p.resolution ?? "FullHD"}
|
||||
onChange={(e) => updateProj(p.id, { resolution: e.target.value })}
|
||||
>
|
||||
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button type="button" className={ghost} onClick={() => saveProj(p)} disabled={savingProj === p.id}>
|
||||
{savingProj === p.id ? "…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-600">Edits use a partial update — other render/colour data is preserved.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<button className={ghost} onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button className={btn} onClick={save} disabled={saving || !form.name || !form.slug}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,9 +50,16 @@ export const categoriesConfig: ResourceConfig = {
|
||||
fields: [
|
||||
{ key: "name", label: "Name", required: true },
|
||||
{ key: "slug", label: "Slug", required: true },
|
||||
{ key: "description", label: "Description", type: "textarea" },
|
||||
{ key: "description", label: "Description / content", type: "textarea" },
|
||||
{ key: "image_url", label: "Image URL" },
|
||||
{ key: "icon", label: "Icon" },
|
||||
// SEO
|
||||
{ key: "meta_title", label: "SEO · Meta title" },
|
||||
{ key: "meta_description", label: "SEO · Meta description", type: "textarea" },
|
||||
{ key: "meta_keywords", label: "SEO · Meta keywords (comma separated)" },
|
||||
{ key: "bot_follow", label: "Allow search engines to follow", type: "checkbox", defaultValue: true },
|
||||
{ key: "sort", label: "Sort order", type: "number", defaultValue: 0 },
|
||||
{ key: "is_active", label: "Active (visible on site)", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user