feat(admin): manually edit scene inputs (content elements)

Scene content elements (the editable Text/Media/Color/… inputs inside a scene)
had no CRUD — only AEP-import created them, so admins couldn't define or edit
them. Added full management:

content-svc:
- SceneElementsController: GET/POST/PUT/DELETE /v1/scene-elements?scene_id=
- SceneColorService: Get/Create/Update/DeleteContentElementAsync
- ContentElementResponse + SaveContentElementRequest (key, title, type,
  default_value, hint, position, text-box/font/media flags)
gateway: route /v1/scene-elements/*path → content
frontend: SceneColorEditor scenes tab → per-scene "ورودی‌ها" expander with full
  add/edit/delete of inputs (15 element types: Text/Media/Color/Number/…)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 06:54:22 +03:30
parent 9d499a89de
commit ddc0a2d0d9
5 changed files with 336 additions and 36 deletions
+233 -36
View File
@@ -204,6 +204,7 @@ function ScenesTab({
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [inputsFor, setInputsFor] = useState<string | null>(null);
const submit = async () => {
setBusy(true);
@@ -240,45 +241,57 @@ function ScenesTab({
.slice()
.sort((a, b) => a.sort - b.sort)
.map((s) => (
<div
key={s.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] text-indigo-300">
{s.scene_type}
</span>
<span className="flex-1 truncate text-xs text-gray-200">
{s.title}{" "}
<span className="text-gray-600" dir="ltr">
({s.key})
<div key={s.id} className="rounded-lg border border-[#1e2235] bg-[#0c0e1a]">
<div className="flex flex-wrap items-center gap-2 p-2">
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] text-indigo-300">
{s.scene_type}
</span>
</span>
<span className="text-[11px] text-gray-500">{s.default_duration_sec ?? ""}s</span>
<span className="text-[11px] text-gray-600">#{s.sort}</span>
{!s.is_active && (
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">
غیرفعال
<span className="flex-1 truncate text-xs text-gray-200">
{s.title}{" "}
<span className="text-gray-600" dir="ltr">
({s.key})
</span>
</span>
<span className="text-[11px] text-gray-500">{s.default_duration_sec ?? "—"}s</span>
<span className="text-[11px] text-gray-600">#{s.sort}</span>
{!s.is_active && (
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">
غیرفعال
</span>
)}
<button
className={inputsFor === s.id
? "rounded-lg border border-indigo-500 bg-indigo-500/15 px-2.5 py-1 text-xs text-indigo-200"
: "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2b]"}
onClick={() => setInputsFor((cur) => (cur === s.id ? null : s.id))}
>
ورودیها
</button>
<button
className={ghost}
onClick={() => {
setEditId(s.id);
setDraft({
key: s.key,
title: s.title,
scene_type: s.scene_type,
default_duration_sec: s.default_duration_sec ?? 5,
sort: s.sort,
is_active: s.is_active,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(s)}>
حذف
</button>
</div>
{inputsFor === s.id && (
<div className="border-t border-[#1e2235] p-2">
<SceneInputsEditor sceneId={s.id} setError={setError} />
</div>
)}
<button
className={ghost}
onClick={() => {
setEditId(s.id);
setDraft({
key: s.key,
title: s.title,
scene_type: s.scene_type,
default_duration_sec: s.default_duration_sec ?? 5,
sort: s.sort,
is_active: s.is_active,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(s)}>
حذف
</button>
</div>
))}
</div>
@@ -705,3 +718,187 @@ function PresetsTab({
</div>
);
}
// ── Scene inputs (content elements) ───────────────────────────────────────────
interface ContentElement {
id: string;
scene_id: string;
key: string;
title: string;
hint?: string | null;
type: string;
default_value?: string | null;
position_in_container: number;
}
const ELEMENT_TYPES = [
"Text", "TextArea", "Media", "Audio", "Voiceover", "CheckBox",
"DropDown", "Fill", "Color", "Number", "Date", "Toggle", "Slider", "Counter", "Hidden",
];
function SceneInputsEditor({
sceneId,
setError,
}: {
sceneId: string;
setError: (s: string | null) => void;
}) {
const [items, setItems] = useState<ContentElement[]>([]);
const [loading, setLoading] = useState(true);
const empty = { key: "", title: "", type: "Text", default_value: "", hint: "", position_in_container: 0 };
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
setLoading(true);
try {
const r = await fetch(`/api/admin/resource/scene-elements?scene_id=${sceneId}`, {
cache: "no-store",
}).then((x) => x.json());
setItems(Array.isArray(r) ? r : (r?.items ?? []));
} catch {
setError("بارگذاری ورودی‌ها ناموفق بود");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sceneId]);
useEffect(() => {
reload();
}, [reload]);
const submit = async () => {
setBusy(true);
setError(null);
const body = { scene_id: sceneId, is_text_box: draft.type === "TextArea", ...draft };
const url = editId
? `/api/admin/resource/scene-elements/${editId}`
: `/api/admin/resource/scene-elements`;
const res = await fetch(url, {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
reload();
} else {
setError("ذخیرهٔ ورودی ناموفق بود");
}
};
const remove = async (el: ContentElement) => {
if (!confirm(`ورودی «${el.title}» حذف شود؟`)) return;
const res = await fetch(`/api/admin/resource/scene-elements/${el.id}`, { method: "DELETE" });
if (res.ok) reload();
};
return (
<div className="space-y-2">
<p className="text-[11px] font-medium text-gray-400">
ورودیهای قابل ویرایش این صحنه (متن، تصویر، رنگ )
</p>
{loading ? (
<p className="text-[11px] text-gray-600">در حال بارگذاری</p>
) : (
<div className="space-y-1">
{items.length === 0 && (
<p className="text-[11px] text-gray-600">هنوز ورودیای تعریف نشده است.</p>
)}
{items.map((el) => (
<div
key={el.id}
className="flex flex-wrap items-center gap-2 rounded border border-[#1e2235] bg-[#070811] px-2 py-1.5"
>
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-300">
{el.type}
</span>
<span className="flex-1 truncate text-[11px] text-gray-200">
{el.title}{" "}
<span className="text-gray-600" dir="ltr">({el.key})</span>
</span>
{el.default_value && (
<span className="max-w-[120px] truncate text-[10px] text-gray-500" dir="auto">
{el.default_value}
</span>
)}
<span className="text-[10px] text-gray-600">#{el.position_in_container}</span>
<button
className={ghost}
onClick={() => {
setEditId(el.id);
setDraft({
key: el.key,
title: el.title,
type: el.type,
default_value: el.default_value ?? "",
hint: el.hint ?? "",
position_in_container: el.position_in_container,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(el)}>
حذف
</button>
</div>
))}
</div>
)}
{/* Add / edit input */}
<div className="rounded border border-dashed border-[#262b40] p-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div>
<label className={lbl}>کلید (یکتا)</label>
<input className={inp} dir="ltr" value={draft.key}
onChange={(e) => setDraft({ ...draft, key: e.target.value })} />
</div>
<div>
<label className={lbl}>عنوان</label>
<input className={inp} value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })} />
</div>
<div>
<label className={lbl}>نوع</label>
<select className={inp} value={draft.type}
onChange={(e) => setDraft({ ...draft, type: e.target.value })}>
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="sm:col-span-2">
<label className={lbl}>مقدار پیشفرض</label>
<input className={inp} value={draft.default_value}
onChange={(e) => setDraft({ ...draft, default_value: e.target.value })} />
</div>
<div>
<label className={lbl}>ترتیب</label>
<input className={inp} type="number" value={draft.position_in_container}
onChange={(e) => setDraft({ ...draft, position_in_container: Number(e.target.value) })} />
</div>
<div className="sm:col-span-3">
<label className={lbl}>راهنما (اختیاری)</label>
<input className={inp} value={draft.hint}
onChange={(e) => setDraft({ ...draft, hint: e.target.value })} />
</div>
</div>
<div className="mt-2 flex gap-2">
<button className={btn} onClick={submit} disabled={busy || !draft.key || !draft.title}>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن ورودی"}
</button>
{editId && (
<button className={ghost} onClick={() => { setEditId(null); setDraft({ ...empty }); }}>
انصراف
</button>
)}
</div>
</div>
</div>
);
}