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:
+2
-1
@@ -319,7 +319,8 @@
|
|||||||
"blogs": "Blog",
|
"blogs": "Blog",
|
||||||
"slides": "Slides",
|
"slides": "Slides",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"plans": "Plans"
|
"plans": "Plans",
|
||||||
|
"templates": "Templates"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+2
-1
@@ -319,7 +319,8 @@
|
|||||||
"blogs": "بلاگ",
|
"blogs": "بلاگ",
|
||||||
"slides": "اسلایدها",
|
"slides": "اسلایدها",
|
||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"plans": "پلنها"
|
"plans": "پلنها",
|
||||||
|
"templates": "قالبها"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -199,6 +199,42 @@ public class TemplateService(ContentDbContext db)
|
|||||||
return await GetProjectDetailAsync(project.Id);
|
return await GetProjectDetailAsync(project.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Partial update — only applies supplied (non-null) fields, so editing an
|
||||||
|
/// aspect/resolution never clears render/colour data the full update would require.</summary>
|
||||||
|
public async Task<ProjectDetailResponse> PatchProjectAsync(Guid id, PatchProjectRequest req)
|
||||||
|
{
|
||||||
|
var project = await db.Projects.FindAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
||||||
|
|
||||||
|
if (req.Name != null) project.Name = req.Name;
|
||||||
|
if (req.Description != null) project.Description = req.Description;
|
||||||
|
if (req.Aspect != null) project.Aspect = req.Aspect;
|
||||||
|
if (req.Resolution != null)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
|
||||||
|
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
|
||||||
|
project.Resolution = resolution;
|
||||||
|
}
|
||||||
|
if (req.ChooseMode != null)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
|
||||||
|
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
|
||||||
|
project.ChooseMode = chooseMode;
|
||||||
|
}
|
||||||
|
if (req.OriginalWidth.HasValue) project.OriginalWidth = req.OriginalWidth.Value;
|
||||||
|
if (req.OriginalHeight.HasValue) project.OriginalHeight = req.OriginalHeight.Value;
|
||||||
|
if (req.ProjectDurationSec.HasValue) project.ProjectDurationSec = req.ProjectDurationSec.Value;
|
||||||
|
if (req.MinDurationSec.HasValue) project.MinDurationSec = req.MinDurationSec;
|
||||||
|
if (req.MaxDurationSec.HasValue) project.MaxDurationSec = req.MaxDurationSec;
|
||||||
|
if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value;
|
||||||
|
if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value;
|
||||||
|
if (req.Sort.HasValue) project.Sort = req.Sort.Value;
|
||||||
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return await GetProjectDetailAsync(project.Id);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteProjectAsync(Guid id)
|
public async Task DeleteProjectAsync(Guid id)
|
||||||
{
|
{
|
||||||
var project = await db.Projects.FindAsync(id)
|
var project = await db.Projects.FindAsync(id)
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public class ProjectsController(TemplateService svc) : ControllerBase
|
|||||||
public async Task<IActionResult> UpdateProject(Guid id, [FromBody] UpdateProjectRequest req) =>
|
public async Task<IActionResult> UpdateProject(Guid id, [FromBody] UpdateProjectRequest req) =>
|
||||||
Ok(await svc.UpdateProjectAsync(id, req));
|
Ok(await svc.UpdateProjectAsync(id, req));
|
||||||
|
|
||||||
|
// Partial update (aspect / resolution / dimensions / duration) without wiping other fields.
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpPatch("{id:guid}")]
|
||||||
|
public async Task<IActionResult> PatchProject(Guid id, [FromBody] PatchProjectRequest req) =>
|
||||||
|
Ok(await svc.PatchProjectAsync(id, req));
|
||||||
|
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> DeleteProject(Guid id)
|
public async Task<IActionResult> DeleteProject(Guid id)
|
||||||
|
|||||||
@@ -215,6 +215,24 @@ public record UpdateProjectRequest(
|
|||||||
int Sort
|
int Sort
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Partial update — only non-null fields are applied, so editing an aspect/resolution
|
||||||
|
// never wipes render/colour data that the full UpdateProjectRequest would require.
|
||||||
|
public record PatchProjectRequest(
|
||||||
|
string? Name,
|
||||||
|
string? Description,
|
||||||
|
string? Aspect,
|
||||||
|
string? Resolution,
|
||||||
|
string? ChooseMode,
|
||||||
|
int? OriginalWidth,
|
||||||
|
int? OriginalHeight,
|
||||||
|
decimal? ProjectDurationSec,
|
||||||
|
decimal? MinDurationSec,
|
||||||
|
decimal? MaxDurationSec,
|
||||||
|
int? FreeFps,
|
||||||
|
bool? IsPublished,
|
||||||
|
int? Sort
|
||||||
|
);
|
||||||
|
|
||||||
// ── CMS ──────────────────────────────────────────────────────────────────────
|
// ── CMS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public record CreateBlogRequest(
|
public record CreateBlogRequest(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default async function AdminLayout({
|
|||||||
const t = await getTranslations("auto.appAdminLayout");
|
const t = await getTranslations("auto.appAdminLayout");
|
||||||
const links: { href: string; label: string }[] = [
|
const links: { href: string; label: string }[] = [
|
||||||
{ href: "/admin/categories", label: t("categories") },
|
{ href: "/admin/categories", label: t("categories") },
|
||||||
|
{ href: "/admin/templates", label: t("templates") },
|
||||||
{ href: "/admin/tags", label: t("tags") },
|
{ href: "/admin/tags", label: t("tags") },
|
||||||
{ href: "/admin/fonts", label: t("fonts") },
|
{ href: "/admin/fonts", label: t("fonts") },
|
||||||
{ href: "/admin/blogs", label: t("blogs") },
|
{ href: "/admin/blogs", label: t("blogs") },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TemplatesAdmin } from "@/components/admin/TemplatesAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <TemplatesAdmin />;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export const dynamic = "force-dynamic";
|
|||||||
async function forward(
|
async function forward(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
path: string[],
|
path: string[],
|
||||||
method: "GET" | "POST" | "PUT" | "DELETE"
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -37,7 +37,7 @@ async function forward(
|
|||||||
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
|
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
|
||||||
|
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
if (method === "POST" || method === "PUT") {
|
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||||
const json = await req.json().catch(() => ({}));
|
const json = await req.json().catch(() => ({}));
|
||||||
body = JSON.stringify(json);
|
body = JSON.stringify(json);
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,9 @@ export async function POST(req: NextRequest, ctx: { params: { path: string[] } }
|
|||||||
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
|
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
return forward(req, ctx.params.path, "PUT");
|
return forward(req, ctx.params.path, "PUT");
|
||||||
}
|
}
|
||||||
|
export async function PATCH(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
|
return forward(req, ctx.params.path, "PATCH");
|
||||||
|
}
|
||||||
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
|
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
return forward(req, ctx.params.path, "DELETE");
|
return forward(req, ctx.params.path, "DELETE");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
fields: [
|
||||||
{ key: "name", label: "Name", required: true },
|
{ key: "name", label: "Name", required: true },
|
||||||
{ key: "slug", label: "Slug", 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: "image_url", label: "Image URL" },
|
||||||
{ key: "icon", label: "Icon" },
|
{ 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