Compare commits

..

4 Commits

Author SHA1 Message Date
soroush.asadi 077b5ac5d5 fix(render): export INSERT used wrong column + lowercase enum labels (the real 500)
Build backend images / build content-svc (push) Failing after 59s
Build backend images / build file-svc (push) Failing after 48s
Build backend images / build gateway (push) Failing after 59s
Build backend images / build identity-svc (push) Failing after 53s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 54s
The output-upload-url 500 was NOT the enum cast — it was:
1. INSERT referenced original_project_id; the exports table column is project_id
2. file_type/create_type literals were lowercase ('video'/'render') but the
   export_file_type/export_create_type enums are PascalCase ('Video'/'Render')

Verified the corrected INSERT against the live schema. Now real renders produce
a downloadable export instead of completing with export=nil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:31:15 +03:30
soroush.asadi ddc0a2d0d9 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>
2026-06-06 06:54:22 +03:30
soroush.asadi 9d499a89de fix(render): real AE render — pass -comp, fix export insert, ensure exports bucket
Three bugs surfaced bringing up a real After Effects node (verified: AE 2026
claimed + ran, but produced no usable output):

1. aerender got no -comp/-rqindex → "output argument ignored", nothing rendered.
   - Claim now returns comp_name from content.projects.render_aep_comp (e.g. "frfinal")
     via new Store.GetTemplateCompName; threaded through ClaimedJob → runner.Job →
     aerender args (`-comp <name>`, or `-rqindex 1` fallback when unknown).

2. CreateExportForJob INSERT passed render_quality as a bare param into an enum
   column → 500 ("output-upload-url HTTP 500"), so completed renders had no export.
   - Cast $8::render.render_quality (+ explicit casts for file_type/create_type enums).

3. flatrender-exports bucket didn't exist → uploads would fail anyway.
   - render-svc now MakeBucket(exports, templates) idempotently at startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:40:20 +03:30
soroush.asadi d8d0f6c363 chore: gitignore node-agent local build dir + agent.env secrets 2026-06-05 22:26:20 +03:30
13 changed files with 392 additions and 42 deletions
+4
View File
@@ -51,3 +51,7 @@ next-env.d.ts
# built node-agent binary (large) # built node-agent binary (large)
node-agent.exe node-agent.exe
# node-agent local build + secrets
services/node-agent/dist/
agent.env
@@ -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())) p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
.FirstAsync(); .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 ───────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────
private static SceneResponse ToSceneResponse(Scene s) => new( 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] [ApiController]
[Route("v1/shared-colors")] [Route("v1/shared-colors")]
public class SharedColorsController(SceneColorService svc) : ControllerBase public class SharedColorsController(SceneColorService svc) : ControllerBase
@@ -48,3 +48,21 @@ public record ColorPresetItemInput(string ElementKey, string Value, int Sort);
public record SaveColorPresetRequest( public record SaveColorPresetRequest(
Guid ProjectId, string? Name, int Sort, List<ColorPresetItemInput> Items 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("/favorites/*path", apiRL, auth, content.Handler())
v1.Any("/ai/*path", apiRL, auth, content.Handler()) v1.Any("/ai/*path", apiRL, auth, content.Handler())
v1.Any("/scenes/*path", apiRL, optionalAuth, 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("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler())
+1
View File
@@ -433,6 +433,7 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
HasMusic: job.HasMusic, HasMusic: job.HasMusic,
HasVoiceover: job.HasVoiceover, HasVoiceover: job.HasVoiceover,
AEPFilePath: aepPath, AEPFilePath: aepPath,
CompName: job.CompName,
} }
onProgress := func(ctx context.Context, pct int, msg string) error { onProgress := func(ctx context.Context, pct int, msg string) error {
@@ -159,6 +159,9 @@ type ClaimedJob struct {
// BundleMD5 identifies the bundle content; used as a local cache key so repeated // BundleMD5 identifies the bundle content; used as a local cache key so repeated
// renders of the same template download + extract it only once. // renders of the same template download + extract it only once.
BundleMD5 string `json:"bundle_md5,omitempty"` BundleMD5 string `json:"bundle_md5,omitempty"`
// CompName is the AE composition to render (-comp), e.g. "frfinal". Empty → the
// node falls back to the project's render queue (-rqindex 1).
CompName string `json:"comp_name,omitempty"`
} }
// OutputUploadURLResponse is returned by GetOutputUploadURL. // OutputUploadURLResponse is returned by GetOutputUploadURL.
+11 -3
View File
@@ -32,6 +32,9 @@ type Job struct {
// AEPFilePath is the local path to the downloaded .aep project file. // AEPFilePath is the local path to the downloaded .aep project file.
// In a full implementation the agent downloads this from MinIO before calling Run. // In a full implementation the agent downloads this from MinIO before calling Run.
AEPFilePath string AEPFilePath string
// CompName is the composition to render (-comp), e.g. "frfinal". When empty the
// node renders the project's render queue (-rqindex 1) instead.
CompName string
} }
// Run executes the render job, calling onProgress and onPreview as it advances. // Run executes the render job, calling onProgress and onPreview as it advances.
@@ -103,11 +106,16 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o
// aerender flags: // aerender flags:
// -project <path.aep> // -project <path.aep>
// -comp <name> (or -rqindex 1 when no comp name is known)
// -output <output.mp4> // -output <output.mp4>
args := []string{ // Without -comp/-rqindex, aerender ignores -output and renders nothing.
"-project", job.AEPFilePath, args := []string{"-project", job.AEPFilePath}
"-output", outputPath, if job.CompName != "" {
args = append(args, "-comp", job.CompName)
} else {
args = append(args, "-rqindex", "1")
} }
args = append(args, "-output", outputPath)
log.Printf("[ae] running: %s %v", aePath, args) log.Printf("[ae] running: %s %v", aePath, args)
cmd := exec.CommandContext(ctx, aePath, args...) cmd := exec.CommandContext(ctx, aePath, args...)
+10
View File
@@ -60,6 +60,16 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("minio client: %v", err) log.Fatalf("minio client: %v", err)
} }
// Ensure the render output bucket exists (node agents PUT exports here).
for _, b := range []string{minioBucket, minioTemplatesBucket} {
if exists, berr := mc.BucketExists(context.Background(), b); berr == nil && !exists {
if merr := mc.MakeBucket(context.Background(), b, minio.MakeBucketOptions{}); merr != nil {
log.Printf("warning: could not create bucket %q: %v", b, merr)
} else {
log.Printf("created bucket %q", b)
}
}
}
// ── Store + handlers ────────────────────────────────────────────────────── // ── Store + handlers ──────────────────────────────────────────────────────
store := db.NewStore(pool) store := db.NewStore(pool)
+19 -3
View File
@@ -626,6 +626,22 @@ func (s *Store) ClaimJob(ctx context.Context, nodeID uuid.UUID, region string) (
// The export starts with a placeholder path `exports/{export_id}/output.mp4`. // The export starts with a placeholder path `exports/{export_id}/output.mp4`.
// The node agent uploads the MP4 to that MinIO path, then calls CompleteJob // The node agent uploads the MP4 to that MinIO path, then calls CompleteJob
// with the returned export_id. // with the returned export_id.
// GetTemplateCompName returns the After Effects composition to render for a
// template (content.projects.render_aep_comp), e.g. "frfinal". aerender needs
// this via -comp; without it AE opens the project but renders nothing.
func (s *Store) GetTemplateCompName(ctx context.Context, originalProjectID uuid.UUID) (string, error) {
var comp *string
err := s.pool.QueryRow(ctx,
`SELECT render_aep_comp FROM content.projects WHERE id = $1`, originalProjectID).Scan(&comp)
if err != nil {
return "", err
}
if comp == nil {
return "", nil
}
return *comp, nil
}
func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) { func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) {
// Look up the job to get tenant/user/project context // Look up the job to get tenant/user/project context
job, err := s.getJobByIDInternal(ctx, jobID) job, err := s.getJobByIDInternal(ctx, jobID)
@@ -640,14 +656,14 @@ func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*model
_, err = s.pool.Exec(ctx, ` _, err = s.pool.Exec(ctx, `
INSERT INTO render.exports INSERT INTO render.exports
(id, tenant_id, user_id, saved_project_id, original_project_id, (id, tenant_id, user_id, saved_project_id, project_id,
render_job_id, path, file_extension, file_type, render_quality, render_job_id, path, file_extension, file_type, render_quality,
create_type, size_bytes, produce_date, auto_delete_date, create_type, size_bytes, produce_date, auto_delete_date,
delete_notified, created_at) delete_notified, created_at)
VALUES VALUES
($1, $2, $3, $4, $5, ($1, $2, $3, $4, $5,
$6, $7, 'mp4', 'video', $8, $6, $7, 'mp4', 'Video'::render.export_file_type, $8::render.render_quality,
'render', 0, $9, $10, 'Render'::render.export_create_type, 0, $9, $10,
false, $9)`, false, $9)`,
exportID, job.TenantID, job.UserID, job.SavedProjectID, job.OriginalProjectID, exportID, job.TenantID, job.UserID, job.SavedProjectID, job.OriginalProjectID,
job.ID, path, job.Quality, job.ID, path, job.Quality,
@@ -289,6 +289,9 @@ func (h *InternalHandler) Claim(c *gin.Context) {
} }
} }
// Composition to render (-comp). Non-fatal: empty → node uses the render queue.
compName, _ := h.store.GetTemplateCompName(c.Request.Context(), job.OriginalProjectID)
c.JSON(http.StatusOK, models.ClaimedJob{ c.JSON(http.StatusOK, models.ClaimedJob{
JobID: job.ID, JobID: job.ID,
SavedProjectID: job.SavedProjectID, SavedProjectID: job.SavedProjectID,
@@ -300,6 +303,7 @@ func (h *InternalHandler) Claim(c *gin.Context) {
AEPDownloadURL: aepURL, AEPDownloadURL: aepURL,
IsBundle: isBundle, IsBundle: isBundle,
BundleMD5: bundleMD5, BundleMD5: bundleMD5,
CompName: compName,
}) })
} }
@@ -428,6 +428,10 @@ type ClaimedJob struct {
// BundleMD5 is the stored object's ETag/MD5 — the node uses it as a cache key so // BundleMD5 is the stored object's ETag/MD5 — the node uses it as a cache key so
// repeated renders of the same template download + extract the bundle only once. // repeated renders of the same template download + extract the bundle only once.
BundleMD5 string `json:"bundle_md5,omitempty"` BundleMD5 string `json:"bundle_md5,omitempty"`
// CompName is the After Effects composition to render (-comp). From the
// template's render_aep_comp (e.g. "frfinal"). Empty → node falls back to the
// project's render queue.
CompName string `json:"comp_name,omitempty"`
} }
// OutputUploadURLResponse is returned by POST .../output-upload-url. // OutputUploadURLResponse is returned by POST .../output-upload-url.
+233 -36
View File
@@ -204,6 +204,7 @@ function ScenesTab({
const [draft, setDraft] = useState<typeof empty>({ ...empty }); const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [inputsFor, setInputsFor] = useState<string | null>(null);
const submit = async () => { const submit = async () => {
setBusy(true); setBusy(true);
@@ -240,45 +241,57 @@ function ScenesTab({
.slice() .slice()
.sort((a, b) => a.sort - b.sort) .sort((a, b) => a.sort - b.sort)
.map((s) => ( .map((s) => (
<div <div key={s.id} className="rounded-lg border border-[#1e2235] bg-[#0c0e1a]">
key={s.id} <div className="flex flex-wrap items-center gap-2 p-2">
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 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})
</span> </span>
</span> <span className="flex-1 truncate text-xs text-gray-200">
<span className="text-[11px] text-gray-500">{s.default_duration_sec ?? ""}s</span> {s.title}{" "}
<span className="text-[11px] text-gray-600">#{s.sort}</span> <span className="text-gray-600" dir="ltr">
{!s.is_active && ( ({s.key})
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400"> </span>
غیرفعال
</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>
))} ))}
</div> </div>
@@ -705,3 +718,187 @@ function PresetsTab({
</div> </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>
);
}