Compare commits
4 Commits
2a6bbcd408
...
077b5ac5d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 077b5ac5d5 | |||
| ddc0a2d0d9 | |||
| 9d499a89de | |||
| d8d0f6c363 |
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user