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
@@ -174,6 +174,63 @@ public class SceneColorService(ContentDbContext db)
p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
.FirstAsync();
// ── Scene content elements (editable inputs) ────────────────────────────
public async Task<List<ContentElementResponse>> GetContentElementsAsync(Guid sceneId) =>
await db.SceneContentElements.Where(e => e.SceneId == sceneId)
.OrderBy(e => e.PositionInContainer)
.Select(e => ToElementResponse(e)).ToListAsync();
public async Task<ContentElementResponse> CreateContentElementAsync(SaveContentElementRequest req)
{
var e = new SceneContentElement { SceneId = req.SceneId };
ApplyElement(e, req);
db.SceneContentElements.Add(e);
await db.SaveChangesAsync();
return ToElementResponse(e);
}
public async Task<ContentElementResponse> UpdateContentElementAsync(Guid id, SaveContentElementRequest req)
{
var e = await db.SceneContentElements.FindAsync(id)
?? throw new KeyNotFoundException($"Element {id} not found");
ApplyElement(e, req);
await db.SaveChangesAsync();
return ToElementResponse(e);
}
public async Task DeleteContentElementAsync(Guid id)
{
var e = await db.SceneContentElements.FindAsync(id)
?? throw new KeyNotFoundException($"Element {id} not found");
db.SceneContentElements.Remove(e);
await db.SaveChangesAsync();
}
private static void ApplyElement(SceneContentElement e, SaveContentElementRequest req)
{
e.Key = req.Key;
e.Title = req.Title;
e.Hint = req.Hint;
e.Type = Enum.TryParse<ContentElementType>(req.Type, true, out var t) ? t : ContentElementType.Text;
e.DefaultValue = req.DefaultValue;
e.PositionInContainer = req.PositionInContainer;
e.IsTextBox = req.IsTextBox;
e.MaxSize = req.MaxSize;
e.FontSize = req.FontSize;
e.IsFontChangeable = req.IsFontChangeable;
e.IsFontSizeChangeable = req.IsFontSizeChangeable;
e.VideoSupport = req.VideoSupport;
e.Width = req.Width;
e.Height = req.Height;
e.Thumbnail = req.Thumbnail;
}
private static ContentElementResponse ToElementResponse(SceneContentElement e) => new(
e.Id, e.SceneId, e.Key, e.Title, e.Hint, e.Type.ToString(), e.DefaultValue,
e.PositionInContainer, e.IsTextBox, e.MaxSize, e.FontSize, e.IsFontChangeable,
e.IsFontSizeChangeable, e.VideoSupport, e.Width, e.Height, e.Thumbnail);
// ── helpers ─────────────────────────────────────────────────────────────
private static SceneResponse ToSceneResponse(Scene s) => new(
@@ -32,6 +32,33 @@ public class ScenesController(SceneColorService svc) : ControllerBase
}
}
[ApiController]
[Route("v1/scene-elements")]
public class SceneElementsController(SceneColorService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "scene_id")] Guid sceneId) =>
Ok(await svc.GetContentElementsAsync(sceneId));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] SaveContentElementRequest req) =>
Ok(await svc.CreateContentElementAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] SaveContentElementRequest req) =>
Ok(await svc.UpdateContentElementAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteContentElementAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/shared-colors")]
public class SharedColorsController(SceneColorService svc) : ControllerBase
@@ -48,3 +48,21 @@ public record ColorPresetItemInput(string ElementKey, string Value, int Sort);
public record SaveColorPresetRequest(
Guid ProjectId, string? Name, int Sort, List<ColorPresetItemInput> Items
);
// ── Scene content elements (the editable inputs inside a scene) ───────────────
public record ContentElementResponse(
Guid Id, Guid SceneId, string Key, string Title, string? Hint,
string Type, string? DefaultValue, int PositionInContainer,
bool IsTextBox, int? MaxSize, int? FontSize, bool IsFontChangeable,
bool IsFontSizeChangeable, bool VideoSupport, int? Width, int? Height,
string? Thumbnail
);
public record SaveContentElementRequest(
Guid SceneId, string Key, string Title, string? Hint,
string Type, string? DefaultValue, int PositionInContainer,
bool IsTextBox, int? MaxSize, int? FontSize,
bool IsFontChangeable, bool IsFontSizeChangeable,
bool VideoSupport, int? Width, int? Height, string? Thumbnail
);
+1
View File
@@ -118,6 +118,7 @@ func main() {
v1.Any("/favorites/*path", apiRL, auth, content.Handler())
v1.Any("/ai/*path", apiRL, auth, content.Handler())
v1.Any("/scenes/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/scene-elements/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler())
+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>
);
}