feat(admin): full legacy controller set in scene-inputs editor
Build backend images / build content-svc (push) Failing after 2m19s
Build backend images / build file-svc (push) Failing after 1m18s
Build backend images / build gateway (push) Failing after 2m38s
Build backend images / build identity-svc (push) Failing after 6m44s
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 59s

The V2 scene-inputs editor only exposed ~15 of the content model's
~40 fields. Restore full parity with the legacy admin controller.

content-svc:
- SaveContentElementRequest + ContentElementResponse widened to the
  complete field set (text/font, direction/RTL, media, advanced, DP)
- ApplyElement / ToElementResponse map every field 1:1
  (Enum.TryParse for JustifyKind + AiInputType)

frontend (SceneInputsEditor):
- common fields up top; an "advanced" toggle reveals grouped sections:
  Text and Font, Direction (RTL/LTR), Media, Advanced, Design-Presets (DP)
- editing an element loads the full field set; rows show font/hidden badges
- nullable numbers sent as null, enums as named values (snake_case body)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 21:37:58 +03:30
parent bf6c04aba3
commit da3f92fbe8
3 changed files with 217 additions and 116 deletions
@@ -211,25 +211,60 @@ public class SceneColorService(ContentDbContext db)
{
e.Key = req.Key;
e.Title = req.Title;
e.LocalizedTitle = req.LocalizedTitle;
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;
// text + font
e.IsTextBox = req.IsTextBox;
e.MaxSize = req.MaxSize;
e.FontSize = req.FontSize;
e.DefaultFontSize = req.DefaultFontSize;
e.FontFace = req.FontFace;
e.FontFaceName = req.FontFaceName;
e.DefaultFontFace = req.DefaultFontFace;
e.IsFontChangeable = req.IsFontChangeable;
e.IsFontSizeChangeable = req.IsFontSizeChangeable;
if (req.Justify != null && Enum.TryParse<JustifyKind>(req.Justify, true, out var j)) e.Justify = j;
e.CanJustify = req.CanJustify;
// direction / RTL
e.DirectionLayerKey = req.DirectionLayerKey;
e.DirectionLayerValue = req.DirectionLayerValue;
// media
e.VideoSupport = req.VideoSupport;
e.Width = req.Width;
e.Height = req.Height;
e.Thumbnail = req.Thumbnail;
e.MinDurationSec = req.MinDurationSec;
e.MaxDurationSec = req.MaxDurationSec;
// advanced
e.MappedList = req.MappedList;
e.CounterMode = req.CounterMode;
if (req.AiInputType != null && Enum.TryParse<AiInputType>(req.AiInputType, true, out var ai)) e.AiInputType = ai;
e.IsHidden = req.IsHidden;
e.IsFocused = req.IsFocused;
e.OpacityControllerKey = req.OpacityControllerKey;
e.VirtualCount = req.VirtualCount;
// design-preset variants
e.Dp1Image = req.Dp1Image; e.Dp1Title = req.Dp1Title;
e.Dp2Image = req.Dp2Image; e.Dp2Title = req.Dp2Title;
e.Dp3Image = req.Dp3Image; e.Dp3Title = req.Dp3Title;
e.Dp4Image = req.Dp4Image; e.Dp4Title = req.Dp4Title;
}
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);
e.Id, e.SceneId, e.Key, e.Title, e.LocalizedTitle, e.Hint, e.Type.ToString(), e.DefaultValue,
e.PositionInContainer,
e.IsTextBox, e.MaxSize, e.FontSize, e.DefaultFontSize,
e.FontFace, e.FontFaceName, e.DefaultFontFace,
e.IsFontChangeable, e.IsFontSizeChangeable, e.Justify.ToString(), e.CanJustify,
e.DirectionLayerKey, e.DirectionLayerValue,
e.VideoSupport, e.Width, e.Height, e.Thumbnail, e.MinDurationSec, e.MaxDurationSec,
e.MappedList, e.CounterMode, e.AiInputType.ToString(),
e.IsHidden, e.IsFocused, e.OpacityControllerKey, e.VirtualCount,
e.Dp1Image, e.Dp1Title, e.Dp2Image, e.Dp2Title,
e.Dp3Image, e.Dp3Title, e.Dp4Image, e.Dp4Title);
// ── helpers ─────────────────────────────────────────────────────────────
@@ -52,17 +52,39 @@ public record SaveColorPresetRequest(
// ── Scene content elements (the editable inputs inside a scene) ───────────────
public record ContentElementResponse(
Guid Id, Guid SceneId, string Key, string Title, string? Hint,
Guid Id, Guid SceneId, string Key, string Title, string? LocalizedTitle, 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
// text + font
bool IsTextBox, int? MaxSize, int? FontSize, int? DefaultFontSize,
string? FontFace, string? FontFaceName, string? DefaultFontFace,
bool IsFontChangeable, bool IsFontSizeChangeable, string Justify, bool CanJustify,
// direction / RTL
string? DirectionLayerKey, int DirectionLayerValue,
// media
bool VideoSupport, int? Width, int? Height, string? Thumbnail,
decimal? MinDurationSec, decimal? MaxDurationSec,
// advanced
string? MappedList, string? CounterMode, string AiInputType,
bool IsHidden, bool IsFocused, string? OpacityControllerKey, int VirtualCount,
// design-preset (dp) variants
string? Dp1Image, string? Dp1Title, string? Dp2Image, string? Dp2Title,
string? Dp3Image, string? Dp3Title, string? Dp4Image, string? Dp4Title
);
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
Guid SceneId, string Key, string Title, string Type,
string? LocalizedTitle = null, string? Hint = null, string? DefaultValue = null,
int PositionInContainer = 0,
bool IsTextBox = false, int? MaxSize = null, int? FontSize = null, int? DefaultFontSize = null,
string? FontFace = null, string? FontFaceName = null, string? DefaultFontFace = null,
bool IsFontChangeable = false, bool IsFontSizeChangeable = false,
string? Justify = null, bool CanJustify = true,
string? DirectionLayerKey = null, int DirectionLayerValue = 0,
bool VideoSupport = false, int? Width = null, int? Height = null, string? Thumbnail = null,
decimal? MinDurationSec = null, decimal? MaxDurationSec = null,
string? MappedList = null, string? CounterMode = null, string? AiInputType = null,
bool IsHidden = false, bool IsFocused = false, string? OpacityControllerKey = null,
int VirtualCount = 1,
string? Dp1Image = null, string? Dp1Title = null, string? Dp2Image = null, string? Dp2Title = null,
string? Dp3Image = null, string? Dp3Title = null, string? Dp4Image = null, string? Dp4Title = null
);
+148 -104
View File
@@ -722,20 +722,39 @@ function PresetsTab({
// ── 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;
id: string; scene_id: string; key: string; title: string; localized_title?: string | null;
hint?: string | null; type: string; default_value?: string | null; position_in_container: number;
is_text_box: boolean; max_size?: number | null; font_size?: number | null; default_font_size?: number | null;
font_face?: string | null; font_face_name?: string | null; default_font_face?: string | null;
is_font_changeable: boolean; is_font_size_changeable: boolean; justify: string; can_justify: boolean;
direction_layer_key?: string | null; direction_layer_value: number;
video_support: boolean; width?: number | null; height?: number | null; thumbnail?: string | null;
min_duration_sec?: number | null; max_duration_sec?: number | null;
mapped_list?: string | null; counter_mode?: string | null; ai_input_type: string;
is_hidden: boolean; is_focused: boolean; opacity_controller_key?: string | null; virtual_count: number;
dp1_image?: string | null; dp1_title?: string | null; dp2_image?: string | null; dp2_title?: string | null;
dp3_image?: string | null; dp3_title?: string | null; dp4_image?: string | null; dp4_title?: string | null;
}
const ELEMENT_TYPES = [
"Text", "TextArea", "Media", "Audio", "Voiceover", "CheckBox",
"DropDown", "Fill", "Color", "Number", "Date", "Toggle", "Slider", "Counter", "Hidden",
];
const JUSTIFY_KINDS = ["LEFT_JUSTIFY", "CENTER_JUSTIFY", "RIGHT_JUSTIFY", "FULL_JUSTIFY"];
const AI_INPUT_TYPES = ["None", "TitleSuggest", "BodySuggest", "TranslateRtl", "TranslateLtr", "RemoveBG", "UpscaleImage", "TTS"];
type ElDraft = Record<string, string | number | boolean | null>;
const emptyEl: ElDraft = {
key: "", title: "", localized_title: "", type: "Text", default_value: "", hint: "", position_in_container: 0,
is_text_box: false, max_size: null, font_size: null, default_font_size: null,
font_face: "", font_face_name: "", default_font_face: "", is_font_changeable: false, is_font_size_changeable: false,
justify: "CENTER_JUSTIFY", can_justify: true,
direction_layer_key: "", direction_layer_value: 0,
video_support: false, width: null, height: null, thumbnail: "", min_duration_sec: null, max_duration_sec: null,
mapped_list: "", counter_mode: "", ai_input_type: "None", is_hidden: false, is_focused: false,
opacity_controller_key: "", virtual_count: 1,
dp1_image: "", dp1_title: "", dp2_image: "", dp2_title: "", dp3_image: "", dp3_title: "", dp4_image: "", dp4_title: "",
};
export function SceneInputsEditor({
sceneId,
@@ -746,17 +765,15 @@ export function SceneInputsEditor({
}) {
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 [draft, setDraft] = useState<ElDraft>({ ...emptyEl });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [advanced, setAdvanced] = 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());
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("بارگذاری ورودی‌ها ناموفق بود");
@@ -766,30 +783,22 @@ export function SceneInputsEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sceneId]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => { reload(); }, [reload]);
const set = (patch: ElDraft) => setDraft((d) => ({ ...d, ...patch }));
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 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),
body: JSON.stringify({ ...draft, scene_id: sceneId }),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
reload();
} else {
setError("ذخیرهٔ ورودی ناموفق بود");
}
if (res.ok) { setDraft({ ...emptyEl }); setEditId(null); reload(); }
else setError("ذخیرهٔ ورودی ناموفق بود");
};
const remove = async (el: ContentElement) => {
@@ -798,105 +807,140 @@ export function SceneInputsEditor({
if (res.ok) reload();
};
const startEdit = (el: ContentElement) => {
setEditId(el.id);
const next: ElDraft = { ...emptyEl };
for (const k of Object.keys(emptyEl)) {
const v = (el as unknown as Record<string, unknown>)[k];
if (v !== undefined && v !== null) next[k] = v as string | number | boolean;
}
setDraft(next);
setAdvanced(true);
};
// ── field render helpers (typed, no `any`) ──────────────────────────────────
const T = (label: string, k: string, span?: number, ltr?: boolean) => (
<div className={span === 2 ? "sm:col-span-2" : span === 3 ? "sm:col-span-3" : ""}>
<label className={lbl}>{label}</label>
<input className={inp} dir={ltr ? "ltr" : undefined} value={String(draft[k] ?? "")}
onChange={(e) => set({ [k]: e.target.value })} />
</div>
);
const N = (label: string, k: string) => (
<div>
<label className={lbl}>{label}</label>
<input className={inp} type="number"
value={draft[k] === null || draft[k] === undefined ? "" : String(draft[k])}
onChange={(e) => set({ [k]: e.target.value === "" ? null : Number(e.target.value) })} />
</div>
);
const Bx = (label: string, k: string) => (
<label className="flex items-center gap-2 py-1 text-[11px] text-gray-300">
<input type="checkbox" checked={Boolean(draft[k])} onChange={(e) => set({ [k]: e.target.checked })} />
{label}
</label>
);
const Sel = (label: string, k: string, opts: string[]) => (
<div>
<label className={lbl}>{label}</label>
<select className={inp} value={String(draft[k] ?? "")} onChange={(e) => set({ [k]: e.target.value })}>
{opts.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</div>
);
const Group = (title: string) => (
<p className="sm:col-span-3 mt-1 border-t border-[#1e2235] pt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">{title}</p>
);
return (
<div className="space-y-2">
<p className="text-[11px] font-medium text-gray-400">
ورودیهای قابل ویرایش این صحنه (متن، تصویر، رنگ )
</p>
<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.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>
<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>
{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>
)}
{el.default_value && <span className="max-w-[120px] truncate text-[10px] text-gray-500" dir="auto">{el.default_value}</span>}
{el.is_font_changeable && <span className="rounded bg-blue-500/15 px-1 text-[9px] text-blue-300">فونت</span>}
{el.is_hidden && <span className="rounded bg-gray-500/15 px-1 text-[9px] text-gray-400">مخفی</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>
<button className={ghost} onClick={() => startEdit(el)}>ویرایش</button>
<button className={del} onClick={() => remove(el)}>حذف</button>
</div>
))}
</div>
)}
{/* Add / edit input */}
{/* Add / edit input — full controller set */}
<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>
{T("کلید (یکتا)", "key", undefined, true)}
{T("عنوان", "title")}
{Sel("نوع", "type", ELEMENT_TYPES)}
{T("مقدار پیش‌فرض", "default_value", 2)}
{N("ترتیب", "position_in_container")}
{T("عنوان محلی (FA/EN JSON)", "localized_title", 2)}
{T("راهنما", "hint")}
{advanced && (<>
{Group("متن و فونت")}
{Bx("جعبهٔ متن (TextBox)", "is_text_box")}
{Bx("فونت قابل تغییر", "is_font_changeable")}
{Bx("اندازهٔ فونت قابل تغییر", "is_font_size_changeable")}
{N("حداکثر طول متن", "max_size")}
{N("اندازهٔ فونت", "font_size")}
{N("اندازهٔ فونت پیش‌فرض", "default_font_size")}
{T("فونت (face)", "font_face", undefined, true)}
{T("نام فونت", "font_face_name")}
{T("فونت پیش‌فرض", "default_font_face", undefined, true)}
{Sel("چینش متن", "justify", JUSTIFY_KINDS)}
{Bx("چینش قابل تغییر", "can_justify")}
{Group("جهت (RTL/LTR)")}
{T("کلید لایهٔ جهت", "direction_layer_key", undefined, true)}
{N("مقدار جهت", "direction_layer_value")}
{Group("رسانه")}
{Bx("پشتیبانی ویدیو", "video_support")}
{N("عرض", "width")}
{N("ارتفاع", "height")}
{N("حداقل مدت (ثانیه)", "min_duration_sec")}
{N("حداکثر مدت (ثانیه)", "max_duration_sec")}
{T("تصویر بندانگشتی", "thumbnail", 3)}
{Group("پیشرفته")}
{Sel("نوع ورودی هوش مصنوعی", "ai_input_type", AI_INPUT_TYPES)}
{T("حالت شمارنده", "counter_mode")}
{N("تعداد مجازی (Virtual)", "virtual_count")}
{Bx("مخفی", "is_hidden")}
{Bx("فوکوس", "is_focused")}
{T("کلید کنترل شفافیت", "opacity_controller_key", undefined, true)}
{T("لیست نگاشت (JSON)", "mapped_list", 3)}
{Group("حالت‌های طراحی (DP)")}
{T("DP1 تصویر", "dp1_image")} {T("DP1 عنوان", "dp1_title")}
{T("DP2 تصویر", "dp2_image")} {T("DP2 عنوان", "dp2_title")}
{T("DP3 تصویر", "dp3_image")} {T("DP3 عنوان", "dp3_title")}
{T("DP4 تصویر", "dp4_image")} {T("DP4 عنوان", "dp4_title")}
</>)}
</div>
<div className="mt-2 flex gap-2">
<div className="mt-2 flex items-center 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>
<button className={ghost} onClick={() => { setEditId(null); setDraft({ ...emptyEl }); }}>انصراف</button>
)}
<button type="button" className="ms-auto text-[11px] text-indigo-300 hover:underline" onClick={() => setAdvanced((v) => !v)}>
{advanced ? "پنهان کردن تنظیمات پیشرفته ▲" : "تنظیمات پیشرفته (فونت، جهت، رسانه، DP …) ▼"}
</button>
</div>
</div>
</div>