Compare commits

..

5 Commits

Author SHA1 Message Date
soroush.asadi 81912cac66 feat(render): full-screen render page, one-active-render limit, app-wide progress
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 1m43s
Build backend images / build identity-svc (push) Failing after 3m0s
Build backend images / build notification-svc (push) Failing after 51s
Build backend images / build render-svc (push) Failing after 1m3s
Build backend images / build studio-svc (push) Failing after 1m1s
Concurrent-render ceiling (a user runs 1 render at a time unless granted more):
- Identity: TokenService emits max_renders claim from User.ParallelRenderingCeiling
- Identity: admin POST /v1/users/{id}/render-slots (AdminService.SetRenderSlotsAsync,
  clamped 1..50) — gamification or admin raises a user's ceiling
- render-svc: middleware reads max_renders (default 1); CreateJob rejects with 409
  active_render_limit when active jobs >= ceiling
- render-svc: db.CountActiveJobs + ListActiveJobs; GET /v1/renders/active returns
  in-flight renders + can_start_new

Full-screen render page (replaces the modal):
- /studio/render/[projectId]: config (resolution/fps) → live preview + progress →
  download; resumes this project's in-flight render on mount; blocks when another
  render is active; reads ?preset=
- StudioTopBar export menu now navigates to the page; RenderModal deleted (dead)

App-wide minimal progress:
- GlobalRenderProgress pill mounted in the locale layout for authed users; polls
  /api/render/active every 4s, shows thumbnail + step + % on every page, click →
  the render page; hidden on the render page and when idle

Admin: UserActions gains a "concurrent render slots" control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:48:05 +03:30
soroush.asadi 2918b7acbf feat(admin/media): folders in the media library
- admin-files: fetchFolders / createFolder / deleteFolder + FolderItem; fetchFiles
  takes a folderId filter
- admin files upload route forwards target_folder_id so uploads land in the open folder
- FileManager: breadcrumb navigation, folder cards (open / delete), "+ new folder",
  folder-scoped file listing + upload. Folders hidden while searching (search spans all)

Uses the file-svc folder API (GET/POST/DELETE /v1/folders, folder_id list filter)
that already existed but had no UI. "Pick from library" was already wired via
FilePicker in FileUploadField.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:34:56 +03:30
soroush.asadi 1142c38c62 feat(editor+trimmer): save output to cloud account via V2 File service
- New /api/files/upload: generic user-scoped Browser→Next→MinIO upload
  (presign → PUT → confirm), 200MB cap, image+video only, returns public URL
- image-editor-export: stageToBlob() + saveStageToCloud(); "Save to my account"
  button in the Image Editor export popover
- Trimmer: "Save to my account" button uploads the trimmed clip blob
- i18n: saveToCloud/savingToCloud/savedToCloud/saveToCloudFailed in fa+en
  (parity 1002/1002)

Connects the two client-side editors to V2 storage — output now lands in the
user's account instead of only a local download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:29:03 +03:30
soroush.asadi 52be5be93f feat(node-agent): production ops kit — Windows service + WireGuard mesh
config:
- LoadEnvFile(): reads agent.env beside the exe (or $AGENT_ENV_FILE) before env,
  so the sc.exe service needs no per-service environment plumbing; real env wins

deploy/ (new):
- build-windows.ps1     cross-compile → dist\ + stage the deploy kit
- agent.env.example     fully documented config template
- install-service.ps1   register as auto-start Windows service (native sc.exe),
                        crash-restart 3×/5s, no NSSM dependency
- uninstall-service.ps1 stop + remove
- wireguard-node.conf.template + setup-wireguard.ps1  node dials out only, no
                        public IP / inbound rules; tunnel installed as boot service
- README.md             full control-plane + node walkthrough, ops table, troubleshooting

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:20:48 +03:30
soroush.asadi 67060c73b2 feat(admin): discount edit/delete + project-scoped scene/color editor
Identity (discounts):
- DiscountsController: PUT /v1/discounts/{id}, DELETE /v1/discounts/{id}
- DiscountService.UpdateAsync (partial update, code-clash guard) + DeleteAsync
- UpdateDiscountRequest record (all fields optional incl. is_active)
- Frontend discountsConfig: canEdit + canDelete + is_active field

Content (scenes/colors — UI for existing CRUD endpoints):
- New SceneColorEditor.tsx: 3-tab modal (scenes / shared-colors / color-presets),
  project-scoped, full add/edit/delete per tab, colour pickers + palette item editor
- Wired into TemplatesAdmin: "صحنه‌ها و رنگ‌ها" button per template variant row
- Routes through the generic admin proxy with ?project_id=

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:16:13 +03:30
39 changed files with 2366 additions and 366 deletions
+10 -2
View File
@@ -671,7 +671,11 @@
"trimAndCrop": "Trim & Crop", "trimAndCrop": "Trim & Crop",
"loadingEngine": "Loading FFmpeg engine…", "loadingEngine": "Loading FFmpeg engine…",
"progress": "Progress", "progress": "Progress",
"download": "Download {format}" "download": "Download {format}",
"saveToCloud": "Save to my account",
"savingToCloud": "Saving…",
"savedToCloud": "Saved to your account",
"saveToCloudFailed": "Could not save"
}, },
"componentsTrimmerTrimmerStrip": { "componentsTrimmerTrimmerStrip": {
"heading": "Trim", "heading": "Trim",
@@ -866,7 +870,11 @@
"quality": "Quality", "quality": "Quality",
"download": "Download", "download": "Download",
"canvasNotReady": "Canvas not ready.", "canvasNotReady": "Canvas not ready.",
"exportStarted": "Export started" "exportStarted": "Export started",
"saveToCloud": "Save to my account",
"savingToCloud": "Saving…",
"savedToCloud": "Saved to your account",
"saveToCloudFailed": "Could not save to your account"
}, },
"componentsImageEditorPanelsAdjustPanel": { "componentsImageEditorPanelsAdjustPanel": {
"emptyState": "Open an image to use adjustments.", "emptyState": "Open an image to use adjustments.",
+10 -2
View File
@@ -671,7 +671,11 @@
"trimAndCrop": "برش و کراپ", "trimAndCrop": "برش و کراپ",
"loadingEngine": "در حال بارگذاری موتور FFmpeg…", "loadingEngine": "در حال بارگذاری موتور FFmpeg…",
"progress": "پیشرفت", "progress": "پیشرفت",
"download": "دانلود {format}" "download": "دانلود {format}",
"saveToCloud": "ذخیره در حساب",
"savingToCloud": "در حال ذخیره…",
"savedToCloud": "در حساب شما ذخیره شد",
"saveToCloudFailed": "ذخیره ناموفق بود"
}, },
"componentsTrimmerTrimmerStrip": { "componentsTrimmerTrimmerStrip": {
"heading": "برش", "heading": "برش",
@@ -866,7 +870,11 @@
"quality": "کیفیت", "quality": "کیفیت",
"download": "دانلود", "download": "دانلود",
"canvasNotReady": "بوم آماده نیست.", "canvasNotReady": "بوم آماده نیست.",
"exportStarted": "خروجی‌گیری آغاز شد" "exportStarted": "خروجی‌گیری آغاز شد",
"saveToCloud": "ذخیره در حساب",
"savingToCloud": "در حال ذخیره…",
"savedToCloud": "در حساب شما ذخیره شد",
"saveToCloudFailed": "ذخیره در حساب ناموفق بود"
}, },
"componentsImageEditorPanelsAdjustPanel": { "componentsImageEditorPanelsAdjustPanel": {
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.", "emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
@@ -182,6 +182,19 @@ public class AdminService(IdentityDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
/// <summary>
/// Set how many renders a user may run concurrently. The render service reads
/// this from the JWT (max_renders claim); takes effect on the user's next token
/// refresh. Clamped to [1, 50].
/// </summary>
public async Task SetRenderSlotsAsync(Guid userId, int ceiling)
{
var u = await RequireUser(userId);
u.ParallelRenderingCeiling = Math.Clamp(ceiling, 1, 50);
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days) public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
{ {
var plan = await db.UserPlans var plan = await db.UserPlans
@@ -85,6 +85,52 @@ public class DiscountService(IdentityDbContext db) : IDiscountService
return MapResponse(discount); return MapResponse(discount);
} }
public async Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return null;
if (request.Name != null) discount.Name = request.Name;
if (request.Code != null)
{
var newCode = request.Code.ToUpper();
if (newCode != discount.Code)
{
var clash = await db.Discounts.AnyAsync(d =>
d.TenantId == tenantId && d.Code == newCode && d.Id != id);
if (clash) throw new InvalidOperationException("Discount code already exists");
discount.Code = newCode;
}
}
if (request.Kind != null)
{
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
throw new ArgumentException("Invalid discount kind");
discount.Kind = kind;
}
if (request.Value.HasValue) discount.Value = request.Value.Value;
if (request.OwnerUserId.HasValue) discount.OwnerUserId = request.OwnerUserId;
if (request.OwnerProfitPercentage.HasValue) discount.OwnerProfitPercentage = request.OwnerProfitPercentage.Value;
if (request.MaxUseCount.HasValue) discount.MaxUseCount = request.MaxUseCount;
if (request.AppliesToPlanIds != null) discount.AppliesToPlanIds = request.AppliesToPlanIds;
if (request.StartsAt.HasValue) discount.StartsAt = request.StartsAt;
if (request.ExpiresAt.HasValue) discount.ExpiresAt = request.ExpiresAt;
if (request.IsActive.HasValue) discount.IsActive = request.IsActive.Value;
discount.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapResponse(discount);
}
public async Task<bool> DeleteAsync(Guid tenantId, Guid id)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return false;
db.Discounts.Remove(discount);
await db.SaveChangesAsync();
return true;
}
private static DiscountResponse MapResponse(Discount d) => new( private static DiscountResponse MapResponse(Discount d) => new(
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value, d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
@@ -8,4 +8,6 @@ public interface IDiscountService
Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId); Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId);
Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize); Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize);
Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request); Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request);
Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request);
Task<bool> DeleteAsync(Guid tenantId, Guid id);
} }
@@ -33,6 +33,9 @@ public class TokenService(IConfiguration config) : ITokenService
new("is_admin", user.IsAdmin.ToString().ToLower()), new("is_admin", user.IsAdmin.ToString().ToLower()),
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()), new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
new("role", role), new("role", role),
// Concurrent-render ceiling — render-svc enforces "active renders < max_renders".
// Admin grants or gamification raise ParallelRenderingCeiling; default is 1.
new("max_renders", Math.Max(1, user.ParallelRenderingCeiling).ToString()),
}; };
if (!string.IsNullOrEmpty(user.Email)) if (!string.IsNullOrEmpty(user.Email))
@@ -79,6 +79,14 @@ public class AdminController(AdminService svc) : ControllerBase
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
// Grant a user extra concurrent render slots (takes effect on next token refresh).
[HttpPost("v1/users/{userId:guid}/render-slots")]
public async Task<IActionResult> RenderSlots(Guid userId, [FromBody] SetRenderSlotsRequest req)
{
await svc.SetRenderSlotsAsync(userId, req.Ceiling);
return Ok(new { ok = true });
}
[HttpPost("v1/users/{userId:guid}/grant-plan")] [HttpPost("v1/users/{userId:guid}/grant-plan")]
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req) public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
{ {
@@ -37,6 +37,22 @@ public class DiscountsController(IDiscountService discountService) : ControllerB
return StatusCode(201, result); return StatusCode(201, result);
} }
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(DiscountResponse), 200)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateDiscountRequest request)
{
var result = await discountService.UpdateAsync(GetTenantId(), id, request);
return result == null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[ProducesResponseType(204)]
public async Task<IActionResult> Delete(Guid id)
{
var ok = await discountService.DeleteAsync(GetTenantId(), id);
return ok ? NoContent() : NotFound();
}
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException()); ?? throw new UnauthorizedAccessException());
@@ -37,3 +37,4 @@ public record ResetPasswordRequest(string NewPassword);
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
public record GrantPlanDaysRequest(Guid PlanId, int Days); public record GrantPlanDaysRequest(Guid PlanId, int Days);
public record SetFlagRequest(bool Enabled); public record SetFlagRequest(bool Enabled);
public record SetRenderSlotsRequest(int Ceiling); // concurrent-render ceiling
@@ -23,6 +23,21 @@ public record CreateDiscountRequest(
DateTime? ExpiresAt = null DateTime? ExpiresAt = null
); );
// Partial update — every field optional; only non-null values are applied.
public record UpdateDiscountRequest(
string? Name = null,
string? Code = null,
string? Kind = null,
decimal? Value = null,
Guid? OwnerUserId = null,
decimal? OwnerProfitPercentage = null,
int? MaxUseCount = null,
Guid[]? AppliesToPlanIds = null,
DateTime? StartsAt = null,
DateTime? ExpiresAt = null,
bool? IsActive = null
);
public record IssueRefundRequest( public record IssueRefundRequest(
[Required] Guid PaymentId, [Required] Guid PaymentId,
long? AmountMinor, long? AmountMinor,
+161
View File
@@ -0,0 +1,161 @@
# FlatRender Node Agent — Deployment
This folder turns a Windows machine with After Effects into a FlatRender render
node: connected to the control plane over an encrypted WireGuard mesh, running
the agent as an auto-restarting Windows service.
```
deploy/
├── build-windows.ps1 Cross-compile the agent → dist\ (run on your dev box)
├── agent.env.example Agent config template → copy to agent.env
├── install-service.ps1 Register the agent as a Windows service (sc.exe)
├── uninstall-service.ps1 Remove the service
├── wireguard-node.conf.template WireGuard client config → fill in → wg-flatrender.conf
└── setup-wireguard.ps1 Install + start the WireGuard tunnel as a boot service
```
The node only ever dials **out** to the control plane. It needs **no public IP**
and **no inbound firewall rules** — home ADSL, CGNAT, or any datacenter all work.
---
## Architecture
```
WireGuard mesh 10.66.0.0/24
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ Control plane 10.66.0.1 │◄───────►│ Render node 10.66.0.11 │
│ ─ gateway :8088 │ encrypted ─ wireguard tunnel (svc) │
│ ─ render-svc (orchestrator)│ tunnel │ ─ node-agent (svc) │
│ ─ MinIO (templates/exports)│ │ ─ After Effects + aerender │
└─────────────────────────────┘ └──────────────────────────────┘
```
The agent: claims a job → downloads the `.aep` bundle from MinIO → binds user
customisations (JSX) → renders with `aerender.exe` → uploads the MP4 → reports
complete. It heartbeats every 5 s and streams live preview frames while rendering.
---
## 1. Control plane: one-time WireGuard server setup
On the Linux host that runs the V2 stack (gateway + MinIO):
```bash
# Install
sudo apt install -y wireguard
# Generate the server keypair
wg genkey | tee server.key | wg pubkey > server.pub
# /etc/wireguard/wg0.conf
cat >/etc/wireguard/wg0.conf <<'EOF'
[Interface]
Address = 10.66.0.1/24
ListenPort = 51820
PrivateKey = <contents of server.key>
# One [Peer] block per render node (append as you add nodes):
# [Peer]
# PublicKey = <node-1 public key>
# AllowedIPs = 10.66.0.11/32
EOF
sudo systemctl enable --now wg-quick@wg0
sudo wg show # prints the server public key for the node config
```
Open UDP **51820** to the internet (the only inbound port the control plane needs
for the mesh). The gateway (:8088) and MinIO stay bound to the WG interface — they
are never exposed publicly.
> Each time you add a node, append its `[Peer]` block and `sudo wg syncconf wg0 <(wg-quick strip wg0)`.
---
## 2. Build the agent (on your dev machine)
```powershell
# Requires Go 1.25+. Produces dist\flatrender-node-agent.exe + the deploy kit.
cd services\node-agent\deploy
.\build-windows.ps1
```
Copy the whole `dist\` folder to each render node (e.g. `C:\flatrender\`).
---
## 3. Render node: WireGuard
```powershell
# On the node, generate its keypair (WireGuard GUI → Add Tunnel → it shows the keys,
# or use the bundled wg.exe): wg genkey | wg pubkey
```
1. Copy `wireguard-node.conf.template``wg-flatrender.conf`.
2. Fill the four placeholders:
- `NODE_PRIVATE_KEY` — this node's private key
- `NODE_NUMBER` — unique mesh octet (11, 12, 13, …) → `Address = 10.66.0.11/32`
- `SERVER_PUBLIC_KEY` — from `wg show` on the control plane
- `SERVER_PUBLIC_ENDPOINT` — the control plane's public IP/host
3. Add this node's **public** key + `AllowedIPs = 10.66.0.11/32` as a `[Peer]` on the server (step 1).
4. Install the tunnel (elevated PowerShell):
```powershell
.\setup-wireguard.ps1 -ConfigPath .\wg-flatrender.conf
ping 10.66.0.1 # should reply over the tunnel
```
---
## 4. Render node: agent service
```powershell
# Configure
Copy-Item agent.env.example agent.env
notepad agent.env # set NODE_ID, NODE_HMAC_SECRET, ORCHESTRATOR_URL=http://10.66.0.1:8088, AE_PATH
```
Get `NODE_ID` by creating the node in the admin panel (**/admin/nodes → add**), or
via `POST /v1/nodes`. `NODE_HMAC_SECRET` must equal the render-svc value in `.env.v2`.
```powershell
# Install + start the service (elevated)
.\install-service.ps1
# Verify
curl http://localhost:7777/health
Get-Service FlatRenderNodeAgent
```
The node now appears **Ready** in `/admin/nodes` and starts claiming jobs.
---
## Operations
| Task | Command |
|---|---|
| Health | `curl http://localhost:7777/health` |
| Service status | `Get-Service FlatRenderNodeAgent` |
| Restart | `Restart-Service FlatRenderNodeAgent` |
| Stop | `Stop-Service FlatRenderNodeAgent` |
| Update binary | Stop service → replace exe → Start service |
| Change config | Edit `agent.env``Restart-Service FlatRenderNodeAgent` |
| Remove | `.\uninstall-service.ps1` |
| Tunnel status | `& 'C:\Program Files\WireGuard\wireguard.exe' show` |
The service auto-restarts on crash (3× at 5 s intervals) and auto-starts at boot.
WireGuard comes up first, so the agent always has a path to the gateway.
### Mock mode
Leave `AE_PATH` empty in `agent.env` to run the **mock renderer** — useful to smoke-test
the claim → download → upload → complete pipeline on a node without an AE licence.
### Troubleshooting
- **Node never goes Ready**: tunnel down (`wireguard.exe show`) or wrong `ORCHESTRATOR_URL`.
- **401 / signature errors**: `NODE_HMAC_SECRET` mismatch with render-svc.
- **Jobs claim but fail at download**: MinIO not reachable over the mesh — confirm MinIO
is bound to `10.66.0.1` and the presigned host in render-svc points at the mesh IP.
- **AE hangs**: a stale `aerender.exe`/`AfterFX.exe` — the agent force-kills these before
each launch; confirm AE opens manually and isn't stuck on a "Crash Repair" dialog.
@@ -0,0 +1,35 @@
# FlatRender Node Agent configuration.
# Copy to `agent.env` and place next to flatrender-node-agent.exe.
# The agent reads this file on startup (env vars still override any line here).
# ── Required ─────────────────────────────────────────────────────────────────
# UUID of this node, pre-created in render.render_nodes (admin → /admin/nodes).
NODE_ID=00000000-0000-0000-0000-000000000000
# ── Connectivity ─────────────────────────────────────────────────────────────
# Gateway base URL. Over the WireGuard mesh this is the control-plane's WG IP.
# Example (WG): http://10.66.0.1:8088 Local dev: http://localhost:8088
ORCHESTRATOR_URL=http://10.66.0.1:8088
# Shared secret for the X-Node-Signature header. MUST match NODE_HMAC_SECRET
# in the render-svc environment (.env.v2). Change from the default!
NODE_HMAC_SECRET=change-me-to-a-long-random-secret
# Region label — the orchestrator routes region-preferred jobs here.
NODE_REGION=iran-tehran-1
# ── After Effects ────────────────────────────────────────────────────────────
# Full path to aerender.exe. Leave empty to run the MOCK renderer (no AE needed).
AE_PATH=C:\Program Files\Adobe\Adobe After Effects 2024\Support Files\aerender.exe
AE_VERSION=2024
# ── Local paths / tuning ─────────────────────────────────────────────────────
# Scratch dir for downloaded .aep bundles + render output. Use a fast NVMe drive.
WORK_DIR=C:\flatrender\work
# Health endpoint port (the orchestrator and you can curl http://<node>:7777/health).
LISTEN_PORT=7777
# Loop cadences (seconds).
HEARTBEAT_INTERVAL_SEC=5
POLL_INTERVAL_SEC=3
@@ -0,0 +1,47 @@
<#
.SYNOPSIS
Cross-compile the FlatRender Node Agent to a Windows .exe.
.DESCRIPTION
Produces dist\flatrender-node-agent.exe and stages agent.env.example + the
deploy scripts alongside it, ready to copy to a render node.
Requires Go 1.25+ installed locally (works on Windows, macOS, or Linux).
.EXAMPLE
.\build-windows.ps1
#>
param(
[string]$OutDir = (Join-Path $PSScriptRoot "dist")
)
$ErrorActionPreference = "Stop"
$agentRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$exe = Join-Path $OutDir "flatrender-node-agent.exe"
Write-Host "Building Windows binary from $agentRoot ..."
$env:GOOS = "windows"
$env:GOARCH = "amd64"
$env:CGO_ENABLED = "0"
Push-Location $agentRoot
try {
& go build -trimpath -ldflags="-s -w" -o $exe ./cmd/agent
if ($LASTEXITCODE -ne 0) { throw "go build failed ($LASTEXITCODE)" }
} finally {
Pop-Location
}
# Stage the deploy kit next to the exe
Copy-Item (Join-Path $PSScriptRoot "agent.env.example") $OutDir -Force
Copy-Item (Join-Path $PSScriptRoot "install-service.ps1") $OutDir -Force
Copy-Item (Join-Path $PSScriptRoot "uninstall-service.ps1") $OutDir -Force
Copy-Item (Join-Path $PSScriptRoot "setup-wireguard.ps1") $OutDir -Force
Copy-Item (Join-Path $PSScriptRoot "wireguard-node.conf.template") $OutDir -Force
Copy-Item (Join-Path $PSScriptRoot "README.md") $OutDir -Force
Write-Host ""
Write-Host "✓ Built: $exe" -ForegroundColor Green
Write-Host " Deploy kit staged in: $OutDir"
Write-Host " Copy that folder to each render node, then follow README.md."
@@ -0,0 +1,82 @@
<#
.SYNOPSIS
Install the FlatRender Node Agent as a Windows service (native sc.exe — no NSSM).
.DESCRIPTION
Registers flatrender-node-agent.exe as an auto-start service that survives reboots
and auto-restarts on crash. Configuration is read from `agent.env` placed next to
the exe (see agent.env.example), so no per-service environment plumbing is needed.
Run from an ELEVATED PowerShell prompt (Administrator).
.PARAMETER ExePath
Path to flatrender-node-agent.exe. Defaults to the exe beside this script.
.PARAMETER ServiceName
Windows service name. Default: FlatRenderNodeAgent.
.EXAMPLE
.\install-service.ps1
.\install-service.ps1 -ExePath C:\flatrender\flatrender-node-agent.exe
#>
param(
[string]$ExePath = (Join-Path $PSScriptRoot "flatrender-node-agent.exe"),
[string]$ServiceName = "FlatRenderNodeAgent",
[string]$DisplayName = "FlatRender Node Agent"
)
$ErrorActionPreference = "Stop"
# ── Elevation check ───────────────────────────────────────────────────────────
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "This script must be run as Administrator. Right-click PowerShell → Run as administrator."
exit 1
}
# ── Validate exe + config ─────────────────────────────────────────────────────
if (-not (Test-Path $ExePath)) {
Write-Error "Executable not found: $ExePath`nBuild it first (see README) and copy it here."
exit 1
}
$ExePath = (Resolve-Path $ExePath).Path
$envFile = Join-Path (Split-Path $ExePath) "agent.env"
if (-not (Test-Path $envFile)) {
Write-Warning "No agent.env found next to the exe at: $envFile"
Write-Warning "Copy agent.env.example → agent.env and fill in NODE_ID / NODE_HMAC_SECRET before the service will work."
}
# ── Remove any existing instance ──────────────────────────────────────────────
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Service '$ServiceName' already exists — stopping and removing it first..."
if ($existing.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue }
& sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
# ── Create the service ────────────────────────────────────────────────────────
# binPath must quote the exe path (spaces). start=auto → launches at boot.
Write-Host "Creating service '$ServiceName'..."
& sc.exe create $ServiceName binPath= "`"$ExePath`"" start= auto DisplayName= "$DisplayName" | Out-Null
& sc.exe description $ServiceName "FlatRender render-node agent: claims and renders After Effects jobs." | Out-Null
# ── Crash recovery: restart after 5s, three times, reset window 1 day ─────────
& sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
# ── Start it ──────────────────────────────────────────────────────────────────
Write-Host "Starting service..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 2
$svc = Get-Service -Name $ServiceName
Write-Host ""
Write-Host "✓ Installed and $($svc.Status)." -ForegroundColor Green
Write-Host " Service : $ServiceName"
Write-Host " Exe : $ExePath"
Write-Host " Config : $envFile"
Write-Host ""
Write-Host " Health : curl http://localhost:7777/health"
Write-Host " Logs : Get-WinEvent -ProviderName 'Service Control Manager' | Select-Object -First 5"
Write-Host " Stop : Stop-Service $ServiceName"
Write-Host " Remove : .\uninstall-service.ps1"
@@ -0,0 +1,88 @@
<#
.SYNOPSIS
Install WireGuard and bring up the FlatRender mesh tunnel as a persistent service.
.DESCRIPTION
- Verifies WireGuard is installed (downloads the MSI if missing and -Download is set).
- Installs the given .conf as a permanent WireGuard tunnel service (survives reboot).
- The tunnel auto-connects on boot, BEFORE the node-agent service starts, so the
agent can always reach the gateway over 10.66.0.0/24.
Run ELEVATED (Administrator).
.PARAMETER ConfigPath
Path to the filled-in WireGuard config (from wireguard-node.conf.template).
Default: wg-flatrender.conf beside this script.
.PARAMETER Download
If set and WireGuard is not installed, download + silently install the MSI.
.EXAMPLE
.\setup-wireguard.ps1 -ConfigPath .\wg-flatrender.conf
#>
param(
[string]$ConfigPath = (Join-Path $PSScriptRoot "wg-flatrender.conf"),
[switch]$Download
)
$ErrorActionPreference = "Stop"
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "This script must be run as Administrator."
exit 1
}
# ── Ensure WireGuard is installed ─────────────────────────────────────────────
$wg = "C:\Program Files\WireGuard\wireguard.exe"
if (-not (Test-Path $wg)) {
if ($Download) {
Write-Host "WireGuard not found — downloading installer..."
$msi = Join-Path $env:TEMP "wireguard.msi"
Invoke-WebRequest -Uri "https://download.wireguard.com/windows-client/wireguard-installer.exe" -OutFile $msi
Write-Host "Installing WireGuard silently..."
Start-Process -FilePath $msi -ArgumentList "/S" -Wait
} else {
Write-Error "WireGuard is not installed. Install it from https://www.wireguard.com/install/ or re-run with -Download."
exit 1
}
}
# ── Validate config ───────────────────────────────────────────────────────────
if (-not (Test-Path $ConfigPath)) {
Write-Error "Config not found: $ConfigPath`nCopy wireguard-node.conf.template, fill the placeholders, save as wg-flatrender.conf."
exit 1
}
$ConfigPath = (Resolve-Path $ConfigPath).Path
if ((Get-Content $ConfigPath -Raw) -match '<[A-Z_]+>') {
Write-Error "Config still contains <PLACEHOLDERS>. Fill in all four values before installing."
exit 1
}
$tunnelName = [System.IO.Path]::GetFileNameWithoutExtension($ConfigPath)
# ── Remove existing tunnel of the same name ───────────────────────────────────
$svcName = "WireGuardTunnel`$$tunnelName"
if (Get-Service -Name $svcName -ErrorAction SilentlyContinue) {
Write-Host "Removing existing tunnel '$tunnelName'..."
& $wg /uninstalltunnelservice $tunnelName | Out-Null
Start-Sleep -Seconds 2
}
# ── Install the tunnel as a service ───────────────────────────────────────────
Write-Host "Installing WireGuard tunnel '$tunnelName' as a boot service..."
& $wg /installtunnelservice $ConfigPath
Start-Sleep -Seconds 3
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
Write-Host ""
Write-Host "✓ WireGuard tunnel '$tunnelName' is up." -ForegroundColor Green
Write-Host " Verify : & '$wg' show"
Write-Host " Ping CP: ping 10.66.0.1"
Write-Host ""
Write-Host " Next : install the node agent service (install-service.ps1) and point"
Write-Host " ORCHESTRATOR_URL in agent.env at the control plane's mesh IP."
} else {
Write-Warning "Tunnel service did not reach Running state. Check: & '$wg' show"
}
@@ -0,0 +1,32 @@
<#
.SYNOPSIS
Stop and remove the FlatRender Node Agent Windows service.
.EXAMPLE
.\uninstall-service.ps1
#>
param(
[string]$ServiceName = "FlatRenderNodeAgent"
)
$ErrorActionPreference = "Stop"
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "This script must be run as Administrator."
exit 1
}
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc) {
Write-Host "Service '$ServiceName' is not installed — nothing to do."
exit 0
}
if ($svc.Status -ne 'Stopped') {
Write-Host "Stopping '$ServiceName'..."
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
}
& sc.exe delete $ServiceName | Out-Null
Write-Host "✓ Service '$ServiceName' removed." -ForegroundColor Green
@@ -0,0 +1,29 @@
# WireGuard tunnel for a FlatRender render node.
#
# The render node only ever dials OUT to the control plane — it never needs a
# public IP or any inbound firewall rule. All traffic to the gateway / MinIO
# rides this encrypted tunnel, so nodes can live behind NAT, on home ADSL, or
# in any datacenter.
#
# Fill in the four <PLACEHOLDERS> below, save as `wg-flatrender.conf`, then run
# setup-wireguard.ps1 (or import it in the WireGuard GUI).
[Interface]
# This node's private key (generate on the node: `wg genkey`).
PrivateKey = <NODE_PRIVATE_KEY>
# This node's address inside the mesh. Pick a unique 10.66.0.x per node.
Address = 10.66.0.<NODE_NUMBER>/32
# Optional: keep DNS on the LAN; the tunnel only carries mesh traffic (see AllowedIPs).
# DNS = 1.1.1.1
[Peer]
# Control plane (gateway + MinIO host) public key (from the server: `wg show`).
PublicKey = <SERVER_PUBLIC_KEY>
# Public endpoint of the control plane: <public-ip-or-host>:51820
Endpoint = <SERVER_PUBLIC_ENDPOINT>:51820
# Only route the mesh subnet through the tunnel — everything else uses the normal
# internet path. 10.66.0.0/24 = the FlatRender control + render mesh.
AllowedIPs = 10.66.0.0/24
# Hold the NAT mapping open so the orchestrator can reach the node's :7777 health
# port and so long-poll claims stay alive behind home routers / CGNAT.
PersistentKeepalive = 25
@@ -2,12 +2,58 @@
package config package config
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
) )
// LoadEnvFile reads a simple KEY=VALUE file and sets any variables that are not
// already present in the environment. This lets the Windows service (installed
// via sc.exe, which has no per-service env support) be configured by dropping an
// `agent.env` file next to the executable — no registry edits required.
//
// Lookup order: $AGENT_ENV_FILE, then `agent.env` beside the exe, then `./agent.env`.
// Lines starting with # and blank lines are ignored. Existing env vars win, so an
// operator can still override any single value at the process level.
func LoadEnvFile() {
candidates := []string{}
if p := os.Getenv("AGENT_ENV_FILE"); p != "" {
candidates = append(candidates, p)
}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "agent.env"))
}
candidates = append(candidates, "agent.env")
for _, path := range candidates {
f, err := os.Open(path)
if err != nil {
continue
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
f.Close()
return // first file found wins
}
}
// Config holds all runtime settings for the node agent. // Config holds all runtime settings for the node agent.
type Config struct { type Config struct {
// NodeID is the UUID of this render node, registered in the orchestrator. // NodeID is the UUID of this render node, registered in the orchestrator.
@@ -59,6 +105,8 @@ type Config struct {
// Load reads configuration from environment variables, returning an error // Load reads configuration from environment variables, returning an error
// if any required variable is missing. // if any required variable is missing.
func Load() (*Config, error) { func Load() (*Config, error) {
// Pull in agent.env (if present) before reading the environment.
LoadEnvFile()
c := &Config{ c := &Config{
NodeID: os.Getenv("NODE_ID"), NodeID: os.Getenv("NODE_ID"),
OrchestratorURL: getEnv("ORCHESTRATOR_URL", "http://localhost:8088"), OrchestratorURL: getEnv("ORCHESTRATOR_URL", "http://localhost:8088"),
+1
View File
@@ -94,6 +94,7 @@ func main() {
{ {
renders.GET("", renderH.List) renders.GET("", renderH.List)
renders.POST("", renderH.Create) renders.POST("", renderH.Create)
renders.GET("/active", renderH.Active)
renders.GET("/:job_id", renderH.Get) renders.GET("/:job_id", renderH.Get)
renders.POST("/:job_id/cancel", renderH.Cancel) renders.POST("/:job_id/cancel", renderH.Cancel)
renders.POST("/:job_id/stop", admin, renderH.Stop) renders.POST("/:job_id/stop", admin, renderH.Stop)
+39
View File
@@ -371,6 +371,45 @@ func (s *Store) GetJobByID(ctx context.Context, id, userID uuid.UUID) (*models.R
return jobs[0], nil return jobs[0], nil
} }
// CountActiveJobs returns how many non-terminal render jobs the user currently has.
// Used to enforce the per-user concurrent-render ceiling.
func (s *Store) CountActiveJobs(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM render.render_jobs
WHERE user_id = $1
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
userID).Scan(&n)
return n, err
}
// ListActiveJobs returns the user's in-flight render jobs (most recent first),
// lightweight projection for the app-wide mini progress widget.
func (s *Store) ListActiveJobs(ctx context.Context, userID uuid.UUID) ([]*models.RenderJob, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, saved_project_id, name, step, render_progress, image_preview_b64, created_at
FROM render.render_jobs
WHERE user_id = $1
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)
ORDER BY created_at DESC`,
userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.RenderJob
for rows.Next() {
j := &models.RenderJob{}
if err := rows.Scan(&j.ID, &j.SavedProjectID, &j.Name, &j.Step,
&j.RenderProgress, &j.ImagePreviewB64, &j.CreatedAt); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) { func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) {
priceType := "Free" priceType := "Free"
if req.PriceType != nil { if req.PriceType != nil {
@@ -54,6 +54,39 @@ func (h *RenderHandler) List(c *gin.Context) {
}) })
} }
// GET /v1/renders/active
// Lightweight list of the user's in-flight renders + their ceiling — powers the
// app-wide mini progress widget and the "can I start another render?" check.
func (h *RenderHandler) Active(c *gin.Context) {
userID := middleware.GetUserID(c)
jobs, err := h.store.ListActiveJobs(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
if jobs == nil {
jobs = []*models.RenderJob{}
}
out := make([]gin.H, 0, len(jobs))
for _, j := range jobs {
out = append(out, gin.H{
"id": j.ID,
"saved_project_id": j.SavedProjectID,
"name": j.Name,
"step": j.Step,
"render_progress": j.RenderProgress,
"preview_b64": j.ImagePreviewB64,
"created_at": j.CreatedAt,
})
}
maxRenders := middleware.GetMaxRenders(c)
c.JSON(http.StatusOK, gin.H{
"active": out,
"max_renders": maxRenders,
"can_start_new": len(out) < maxRenders,
})
}
// POST /v1/renders // POST /v1/renders
func (h *RenderHandler) Create(c *gin.Context) { func (h *RenderHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c) userID := middleware.GetUserID(c)
@@ -65,6 +98,20 @@ func (h *RenderHandler) Create(c *gin.Context) {
return return
} }
// Concurrent-render ceiling: a user may run only `max_renders` renders at once
// (default 1; raised by gamification level or an admin grant via the JWT claim).
maxRenders := middleware.GetMaxRenders(c)
active, err := h.store.CountActiveJobs(c.Request.Context(), userID)
if err != nil {
log.Printf("count active jobs failed (allowing render): %v", err)
} else if active >= maxRenders {
c.JSON(http.StatusConflict, models.APIError{
Code: "active_render_limit",
Message: "شما یک رندر در حال انجام دارید. برای شروع رندر جدید صبر کنید تا رندر فعلی کامل شود.",
})
return
}
// Daily render-limit: consume one render charge (0 max = unlimited). // Daily render-limit: consume one render charge (0 max = unlimited).
allowed, err := h.identity.Consume(c.Request.Context(), userID) allowed, err := h.identity.Consume(c.Request.Context(), userID)
if err != nil { if err != nil {
+34 -4
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/flatrender/render-svc/internal/models" "github.com/flatrender/render-svc/internal/models"
@@ -12,10 +13,11 @@ import (
) )
const ( const (
CtxUserID = "user_id" CtxUserID = "user_id"
CtxTenantID = "tenant_id" CtxTenantID = "tenant_id"
CtxIsAdmin = "is_admin" CtxIsAdmin = "is_admin"
CtxRole = "role" CtxRole = "role"
CtxMaxRenders = "max_renders"
) )
func JWTAuth(secret string) gin.HandlerFunc { func JWTAuth(secret string) gin.HandlerFunc {
@@ -54,10 +56,25 @@ func JWTAuth(secret string) gin.HandlerFunc {
} }
role, _ := claims["role"].(string) role, _ := claims["role"].(string)
// max_renders: concurrent-render ceiling. Identity emits it as a string;
// also accept a JSON number. Default 1 when absent/unparseable.
maxRenders := 1
switch v := claims["max_renders"].(type) {
case string:
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxRenders = n
}
case float64:
if v >= 1 {
maxRenders = int(v)
}
}
c.Set(CtxUserID, userID) c.Set(CtxUserID, userID)
c.Set(CtxTenantID, tenantID) c.Set(CtxTenantID, tenantID)
c.Set(CtxIsAdmin, isAdmin) c.Set(CtxIsAdmin, isAdmin)
c.Set(CtxRole, role) c.Set(CtxRole, role)
c.Set(CtxMaxRenders, maxRenders)
c.Next() c.Next()
} }
} }
@@ -111,3 +128,16 @@ func GetTenantID(c *gin.Context) uuid.UUID {
id, _ := v.(uuid.UUID) id, _ := v.(uuid.UUID)
return id return id
} }
// GetMaxRenders returns the user's concurrent-render ceiling (default 1).
func GetMaxRenders(c *gin.Context) int {
v, ok := c.Get(CtxMaxRenders)
if !ok {
return 1
}
n, _ := v.(int)
if n < 1 {
return 1
}
return n
}
+2
View File
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider"; import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome"; import { SiteChrome } from "@/components/layout/SiteChrome";
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
import { getNavUser } from "@/lib/auth/session"; import { getNavUser } from "@/lib/auth/session";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing"; import type { Locale } from "@/i18n/routing";
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
<NextIntlClientProvider messages={messages} locale={locale}> <NextIntlClientProvider messages={messages} locale={locale}>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}> <DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome user={navUser}>{children}</SiteChrome> <SiteChrome user={navUser}>{children}</SiteChrome>
<GlobalRenderProgress authed={!!navUser} />
</DirectionProvider> </DirectionProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
@@ -0,0 +1,341 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle2,
Download,
Link2,
Loader2,
RefreshCw,
} from "lucide-react";
import { apiFetch } from "@/lib/api/fetch";
import { RENDER_EXPORT_PRESETS, type RenderExportPreset } from "@/lib/render-presets";
import type { RenderSettings } from "@/lib/render-schemas";
import { cn } from "@/lib/utils";
type Phase = "config" | "submitting" | "polling" | "completed" | "failed";
interface StatusResponse {
status: string;
progress: number;
outputUrl: string | null;
progressMessage?: string | null;
errorMessage?: string | null;
previewB64?: string | null;
}
interface ActiveRender {
id: string;
saved_project_id: string;
step: string;
render_progress: number;
}
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
export default function RenderPage() {
const router = useRouter();
const params = useParams<{ projectId: string }>();
const search = useSearchParams();
const projectId = params.projectId;
const presetKey = search.get("preset") as RenderExportPreset | null;
const [resolution, setResolution] = useState<RenderSettings["resolution"]>("1080p");
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
const [phase, setPhase] = useState<Phase>("config");
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState("");
const [previewB64, setPreviewB64] = useState<string | null>(null);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// An active render that belongs to a DIFFERENT project (blocks starting a new one).
const [blockingJobId, setBlockingJobId] = useState<string | null>(null);
// Apply preset from the query (?preset=full)
useEffect(() => {
if (!presetKey || !RENDER_EXPORT_PRESETS[presetKey]) return;
const cfg = RENDER_EXPORT_PRESETS[presetKey];
setResolution(cfg.settings.resolution);
setFps(cfg.settings.fps);
}, [presetKey]);
// On mount: resume this project's render if one is in flight, or flag a render
// running on another project so we can block starting a new one.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch("/api/render/active");
const data = (await res.json()) as { active?: ActiveRender[] };
if (cancelled) return;
const mine = data.active?.find((a) => a.saved_project_id === projectId);
if (mine) {
setJobId(mine.id);
setPhase("polling");
setProgress(mine.render_progress ?? 0);
return;
}
const other = data.active?.[0];
if (other) setBlockingJobId(other.id);
} catch {
/* ignore — config view will show */
}
})();
return () => {
cancelled = true;
};
}, [projectId]);
// Poll status while rendering.
useEffect(() => {
if (phase !== "polling" || !jobId) return;
const poll = async () => {
try {
const res = await apiFetch(`/api/render/${jobId}/status`);
const data = (await res.json()) as StatusResponse;
if (!res.ok) {
setPhase("failed");
setErrorMessage("Could not fetch render status.");
return;
}
setProgress(data.progress ?? 0);
setProgressMessage(data.progressMessage ?? `Rendering… ${data.progress}%`);
if (data.previewB64) setPreviewB64(data.previewB64);
if (data.status === "completed" && data.outputUrl) {
setOutputUrl(data.outputUrl);
setProgress(100);
setPhase("completed");
} else if (data.status === "failed") {
setPhase("failed");
setErrorMessage(data.errorMessage ?? "Render failed.");
}
} catch {
setPhase("failed");
setErrorMessage("Network error while polling status.");
}
};
poll();
const id = window.setInterval(poll, 2500);
return () => window.clearInterval(id);
}, [phase, jobId]);
const startRender = useCallback(async () => {
setPhase("submitting");
setErrorMessage(null);
try {
const res = await apiFetch("/api/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
settings: { resolution, format: "mp4" as const, fps },
}),
});
const data = (await res.json()) as { jobId?: string; error?: string; code?: string };
if (res.status === 409 || data.code === "active_render_limit") {
setPhase("config");
setErrorMessage(
data.error ?? "You already have an active render. Wait for it to finish."
);
return;
}
if (!res.ok || !data.jobId) {
setPhase("failed");
setErrorMessage(data.error ?? "Failed to start render.");
return;
}
setJobId(data.jobId);
setProgress(0);
setProgressMessage("Queued for rendering…");
setPhase("polling");
} catch {
setPhase("failed");
setErrorMessage("Could not reach the render service.");
}
}, [projectId, resolution, fps]);
const backToStudio = `/studio/video/${projectId}`;
const isBusy = phase === "submitting" || phase === "polling";
return (
<div className="flex min-h-screen flex-col bg-[#070811] text-gray-100">
{/* Top bar */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#1a1d2e] px-4">
<Link
href={backToStudio}
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
بازگشت به استودیو
</Link>
<span className="text-sm font-medium text-gray-300">خروجی رندر</span>
<span className="w-28" />
</header>
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center gap-6 p-6">
{/* Preview / hero */}
<div className="relative aspect-video w-full max-w-3xl overflow-hidden rounded-2xl border border-[#1a1d2e] bg-[#0c0e1a]">
{previewB64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${previewB64}`}
alt="Render preview"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
{isBusy ? (
<Loader2 className="h-10 w-10 animate-spin text-primary-500/40" />
) : phase === "completed" ? (
<CheckCircle2 className="h-12 w-12 text-emerald-400" />
) : (
<span className="text-sm text-gray-600">پیشنمایش رندر اینجا نمایش داده میشود</span>
)}
</div>
)}
{isBusy && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<div className="mb-1.5 flex justify-between text-xs text-gray-300">
<span>{progressMessage || "در حال رندر…"}</span>
<span>{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-primary-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
{/* State-specific panel */}
{phase === "completed" && outputUrl ? (
<div className="w-full max-w-md space-y-3 text-center">
<p className="text-lg font-semibold text-emerald-400">ویدیوی شما آماده است!</p>
<div className="flex flex-col gap-2">
<a
href={outputUrl}
download
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-3 text-sm font-medium text-white hover:bg-primary-700"
>
<Download className="h-4 w-4" />
دانلود MP4
</a>
<a
href={outputUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#161a2b]"
>
<Link2 className="h-4 w-4" />
لینک اشتراکگذاری
</a>
<Link
href={backToStudio}
className="mt-1 text-xs text-gray-500 hover:text-gray-300"
>
بازگشت به استودیو
</Link>
</div>
</div>
) : phase === "failed" ? (
<div className="w-full max-w-md space-y-3 text-center">
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-4 py-3 text-sm text-red-300">
{errorMessage ?? "خطایی رخ داد."}
</p>
<button
type="button"
onClick={startRender}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
>
<RefreshCw className="h-4 w-4" />
تلاش دوباره
</button>
</div>
) : isBusy ? (
<p className="text-sm text-gray-400">
میتوانید این صفحه را ببندید؛ رندر در پسزمینه ادامه مییابد و از هر صفحهای قابل پیگیری است.
</p>
) : (
// Config
<div className="w-full max-w-md space-y-5">
{errorMessage && (
<p className="rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2 text-sm text-amber-300">
{errorMessage}
</p>
)}
{blockingJobId && (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2.5 text-sm text-amber-200">
شما یک رندر فعال دارید.{" "}
<button
className="underline hover:text-white"
onClick={() => router.push(`/studio/render/${projectId}`)}
>
ابتدا آن را کامل کنید.
</button>
</div>
)}
<div>
<p className="mb-2 text-xs font-medium text-gray-400">کیفیت</p>
<div className="flex gap-2">
{RESOLUTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setResolution(item)}
className={cn(
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
resolution === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">نرخ فریم</p>
<div className="flex gap-2">
{FPS_OPTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setFps(item)}
className={cn(
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
fps === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item} fps
</button>
))}
</div>
</div>
<button
type="button"
onClick={startRender}
disabled={!!blockingJobId}
className="w-full rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
شروع رندر
</button>
</div>
)}
</main>
</div>
);
}
+5
View File
@@ -29,6 +29,10 @@ export async function POST(req: NextRequest) {
if (!(file instanceof File)) { if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 }); return NextResponse.json({ error: "No file provided" }, { status: 400 });
} }
// Optional: drop the upload into a specific media-library folder.
const folderId = form?.get("folder_id");
const targetFolderId =
typeof folderId === "string" && folderId ? folderId : undefined;
const auth = { Authorization: `Bearer ${token}` }; const auth = { Authorization: `Bearer ${token}` };
@@ -41,6 +45,7 @@ export async function POST(req: NextRequest) {
filename: file.name, filename: file.name,
mime_type: file.type || "application/octet-stream", mime_type: file.type || "application/octet-stream",
size_bytes: file.size, size_bytes: file.size,
target_folder_id: targetFolderId,
}), }),
}); });
const presign = await presignRes.json().catch(() => null); const presign = await presignRes.json().catch(() => null);
+89
View File
@@ -0,0 +1,89 @@
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { MINIO_PUBLIC_URL } from "@/lib/files";
export const dynamic = "force-dynamic";
// Generous cap for editor/trimmer output (trimmed clips, high-res exports).
const MAX_BYTES = 200 * 1024 * 1024; // 200 MB
/**
* Generic user-scoped upload: Browser → Next → MinIO (presign → PUT → confirm).
*
* Unlike /api/profile/upload (avatar-only, persists to Identity), this just stores
* the file in the user's `user-uploads` bucket and returns the public URL. Used by
* the Image Editor "Save to cloud" and the Video Trimmer "Save to cloud" actions so
* a user's work lands in their account instead of only a local download.
*/
export async function POST(req: NextRequest) {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData().catch(() => null);
const file = form?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "File too large (max 200MB)" }, { status: 413 });
}
const mime = file.type || "application/octet-stream";
if (!mime.startsWith("image/") && !mime.startsWith("video/")) {
return NextResponse.json(
{ error: "Only image or video files are allowed" },
{ status: 415 }
);
}
const auth = { Authorization: `Bearer ${token}` };
// 1. presigned PUT URL
const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), {
method: "POST",
cache: "no-store",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
mime_type: mime,
size_bytes: file.size,
}),
});
const presign = await presignRes.json().catch(() => null);
if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) {
return NextResponse.json(
{ error: presign?.error?.message ?? "Could not start upload" },
{ status: presignRes.status || 502 }
);
}
// 2. PUT the bytes to MinIO (server-side; reaches minio:9000)
const put = await fetch(presign.upload_url, {
method: "PUT",
headers: { "Content-Type": mime },
body: Buffer.from(await file.arrayBuffer()),
});
if (!put.ok) {
return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 });
}
// 3. confirm
await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), {
method: "POST",
cache: "no-store",
headers: auth,
});
// 4. resolve the public URL
const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), {
cache: "no-store",
headers: auth,
});
const detail = await detailRes.json().catch(() => null);
const bucket = detail?.minio_bucket ?? "user-uploads";
const key = detail?.minio_key;
const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null;
return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: mime, url });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export interface ActiveRender {
id: string;
saved_project_id: string;
name: string | null;
step: string;
render_progress: number;
preview_b64: string | null;
created_at: string;
}
export interface ActiveRendersResponse {
active: ActiveRender[];
max_renders: number;
can_start_new: boolean;
}
/**
* The user's in-flight renders + their concurrent-render ceiling. Powers the
* app-wide mini progress widget and the "can I start another render?" gate.
* Returns an empty set (not 401) when signed out, so the widget can mount globally.
*/
export async function GET() {
const token = await getAccessToken();
const empty: ActiveRendersResponse = { active: [], max_renders: 1, can_start_new: true };
if (!token) return NextResponse.json(empty);
const res = await fetch(gatewayUrl("/v1/renders/active"), {
cache: "no-store",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
if (!res.ok) return NextResponse.json(empty);
const data = (await res.json().catch(() => null)) as ActiveRendersResponse | null;
return NextResponse.json(data ?? empty);
}
+98 -7
View File
@@ -2,13 +2,29 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FILE_TYPE_TABS, fetchFiles, fileUrl, humanSize, isImage, isVideo, type FileItem } from "@/lib/admin-files"; import {
FILE_TYPE_TABS,
fetchFiles,
fetchFolders,
createFolder,
deleteFolder,
fileUrl,
humanSize,
isImage,
isVideo,
type FileItem,
type FolderItem,
} from "@/lib/admin-files";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]"; const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50"; const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
interface Crumb { id: string | null; name: string }
export function FileManager() { export function FileManager() {
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [folders, setFolders] = useState<FolderItem[]>([]);
const [crumbs, setCrumbs] = useState<Crumb[]>([{ id: null, name: "کتابخانه" }]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -18,6 +34,8 @@ export function FileManager() {
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const folderId = crumbs[crumbs.length - 1].id;
const toggleSel = (id: string) => const toggleSel = (id: string) =>
setSelected((s) => { setSelected((s) => {
const n = new Set(s); const n = new Set(s);
@@ -29,13 +47,18 @@ export function FileManager() {
const reload = useCallback(async () => { const reload = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setFiles(await fetchFiles(search, type)); const [fl, fd] = await Promise.all([
fetchFiles(search, type, folderId),
fetchFolders(folderId),
]);
setFiles(fl);
setFolders(fd);
} catch { } catch {
setError("بارگذاری فایل‌ها ناموفق بود"); setError("بارگذاری فایل‌ها ناموفق بود");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [search, type]); }, [search, type, folderId]);
useEffect(() => { reload(); }, [reload]); useEffect(() => { reload(); }, [reload]);
@@ -45,6 +68,7 @@ export function FileManager() {
for (const file of Array.from(list)) { for (const file of Array.from(list)) {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
if (folderId) fd.append("folder_id", folderId);
const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd }); const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
if (!res.ok) { if (!res.ok) {
const d = await res.json().catch(() => null); const d = await res.json().catch(() => null);
@@ -56,6 +80,24 @@ export function FileManager() {
reload(); reload();
}; };
const enterFolder = (f: FolderItem) =>
setCrumbs((c) => [...c, { id: f.id, name: f.name }]);
const goToCrumb = (idx: number) => setCrumbs((c) => c.slice(0, idx + 1));
const newFolder = async () => {
const name = prompt("نام پوشهٔ جدید؟")?.trim();
if (!name) return;
if (await createFolder(name, folderId)) reload();
else setError("ساخت پوشه ناموفق بود");
};
const removeFolder = async (f: FolderItem) => {
if (!confirm(`پوشهٔ «${f.name}» حذف شود؟ (فایل‌های داخل آن حذف نمی‌شوند)`)) return;
if (await deleteFolder(f.id)) reload();
else setError("حذف پوشه ناموفق بود");
};
const remove = async (f: FileItem) => { const remove = async (f: FileItem) => {
if (!confirm(`«${f.name}» حذف شود؟`)) return; if (!confirm(`«${f.name}» حذف شود؟`)) return;
const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" }); const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" });
@@ -83,12 +125,33 @@ export function FileManager() {
<h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1> <h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1>
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p> <p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
</div> </div>
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}> <div className="flex gap-2">
{uploading ? "در حال آپلود…" : "+ آپلود فایل"} <button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs font-medium text-gray-200 hover:bg-[#161a2e]" onClick={newFolder}>
</button> + پوشهٔ جدید
</button>
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
</button>
</div>
<input ref={inputRef} type="file" multiple className="hidden" accept="image/*,video/*,audio/*,.aep,.aepx,.zip" onChange={(e) => e.target.files && uploadFiles(e.target.files)} /> <input ref={inputRef} type="file" multiple className="hidden" accept="image/*,video/*,audio/*,.aep,.aepx,.zip" onChange={(e) => e.target.files && uploadFiles(e.target.files)} />
</div> </div>
{/* Breadcrumb */}
<div className="flex flex-wrap items-center gap-1 text-xs text-gray-400">
{crumbs.map((c, i) => (
<span key={`${c.id ?? "root"}-${i}`} className="flex items-center gap-1">
{i > 0 && <span className="text-gray-600">/</span>}
<button
className={i === crumbs.length - 1 ? "text-gray-200" : "text-indigo-300 hover:underline"}
onClick={() => goToCrumb(i)}
disabled={i === crumbs.length - 1}
>
{c.name}
</button>
</span>
))}
</div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILE_TYPE_TABS.map((t) => ( {FILE_TYPE_TABS.map((t) => (
@@ -116,10 +179,38 @@ export function FileManager() {
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }} onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }}
> >
{/* Folders (hidden while searching, since search spans the whole library) */}
{!search && folders.length > 0 && (
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{folders.map((fd) => (
<div
key={fd.id}
className="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-[#262b40] bg-[#0c0e1a] p-3 hover:border-indigo-500"
onDoubleClick={() => enterFolder(fd)}
onClick={() => enterFolder(fd)}
>
<span className="text-lg" aria-hidden>📁</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-gray-200" title={fd.name}>{fd.name}</p>
<p className="text-[10px] text-gray-600">{(fd.file_count ?? 0).toLocaleString("fa-IR")} فایل</p>
</div>
<button
className="absolute end-1.5 top-1.5 rounded border border-red-500/30 px-1 py-0.5 text-[10px] text-red-300 opacity-0 hover:bg-red-500/10 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); removeFolder(fd); }}
>
حذف
</button>
</div>
))}
</div>
)}
{loading ? ( {loading ? (
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری</p> <p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید.</p> <p className="py-10 text-center text-sm text-gray-500">
{folders.length > 0 ? "این پوشه فایلی ندارد." : "فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید."}
</p>
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{files.map((f) => { {files.map((f) => {
+707
View File
@@ -0,0 +1,707 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Project-scoped editor for scenes, shared colors, and color presets.
* Opened from a template variant (project) row in TemplatesAdmin.
*
* All three resources live in content-svc and are keyed by `project_id`:
* GET/POST/PUT/DELETE /v1/scenes?project_id=
* GET/POST/PUT/DELETE /v1/shared-colors?project_id=
* GET/POST/PUT/DELETE /v1/color-presets?project_id=
*/
interface Scene {
id: string;
project_id: string;
key: string;
title: string;
scene_type: string;
default_duration_sec?: number | null;
sort: number;
is_active: boolean;
}
interface SharedColor {
id: string;
project_id: string;
element_key: string;
title: string;
default_color: string;
sort: number;
}
interface PresetItem {
id?: string;
element_key: string;
value: string;
sort: number;
}
interface ColorPreset {
id: string;
project_id: string;
name?: string | null;
sort: number;
items: PresetItem[];
}
type Tab = "scenes" | "colors" | "presets";
const inp =
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-xs text-gray-200 placeholder:text-gray-600 focus:border-indigo-500 focus:outline-none";
const btn =
"rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const ghost =
"rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2b]";
const del =
"rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-300 hover:bg-red-500/10";
const lbl = "mb-0.5 block text-[10px] text-gray-500";
const SCENE_TYPES = ["Intro", "Main", "Outro", "Transition", "Logo", "Lower-Third"];
export function SceneColorEditor({
projectId,
projectName,
onClose,
}: {
projectId: string;
projectName: string;
onClose: () => void;
}) {
const [tab, setTab] = useState<Tab>("scenes");
const [scenes, setScenes] = useState<Scene[]>([]);
const [colors, setColors] = useState<SharedColor[]>([]);
const [presets, setPresets] = useState<ColorPreset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = (resource: string, suffix = "") =>
`/api/admin/resource/${resource}${suffix}?project_id=${projectId}`;
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, c, p] = await Promise.all([
fetch(api("scenes"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("shared-colors"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("color-presets"), { cache: "no-store" }).then((r) => r.json()),
]);
setScenes(Array.isArray(s) ? s : (s?.items ?? []));
setColors(Array.isArray(c) ? c : (c?.items ?? []));
setPresets(Array.isArray(p) ? p : (p?.items ?? []));
} catch {
setError("بارگذاری اطلاعات ناموفق بود");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
useEffect(() => {
reload();
}, [reload]);
// The proxy appends ?project_id= via api(); for writes the body also carries it.
const writeUrl = (resource: string, id?: string) =>
id ? api(resource, `/${id}`) : api(resource);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
<div className="flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-[#1e2235] bg-[#0a0c16]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<div>
<h3 className="text-sm font-semibold text-gray-100">صحنهها و رنگها</h3>
<p className="text-[11px] text-gray-500">نسخه: {projectName}</p>
</div>
<button className={ghost} onClick={onClose}>
بستن
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-[#1e2235] px-3 pt-2">
{(
[
["scenes", `صحنه‌ها (${scenes.length})`],
["colors", `رنگ‌های مشترک (${colors.length})`],
["presets", `پالت‌ها (${presets.length})`],
] as [Tab, string][]
).map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={
tab === key
? "rounded-t-lg border-b-2 border-indigo-500 px-3 py-2 text-xs font-medium text-white"
: "px-3 py-2 text-xs text-gray-500 hover:text-gray-300"
}
>
{label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<p className="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</p>
)}
{loading ? (
<p className="py-8 text-center text-xs text-gray-500">در حال بارگذاری</p>
) : tab === "scenes" ? (
<ScenesTab
projectId={projectId}
scenes={scenes}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
) : tab === "colors" ? (
<ColorsTab
projectId={projectId}
colors={colors}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
) : (
<PresetsTab
projectId={projectId}
presets={presets}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
)}
</div>
</div>
</div>
);
}
// ── Scenes ────────────────────────────────────────────────────────────────────
function ScenesTab({
projectId,
scenes,
writeUrl,
onChange,
setError,
}: {
projectId: string;
scenes: Scene[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const empty = { key: "", title: "", scene_type: "Main", default_duration_sec: 5, sort: 0, is_active: true };
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
const body = { project_id: projectId, ...draft };
const res = await fetch(writeUrl("scenes", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
onChange();
} else {
setError("ذخیرهٔ صحنه ناموفق بود");
}
};
const remove = async (s: Scene) => {
if (!confirm(`صحنهٔ «${s.title}» حذف شود؟`)) return;
const res = await fetch(writeUrl("scenes", s.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{scenes.length === 0 && (
<p className="text-xs text-gray-600">هنوز صحنهای تعریف نشده است.</p>
)}
{scenes
.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})
</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={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>
{/* Add / edit form */}
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش صحنه" : "افزودن صحنه"}
</p>
<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.scene_type}
onChange={(e) => setDraft({ ...draft, scene_type: e.target.value })}
>
{SCENE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className={lbl}>مدت پیشفرض (ثانیه)</label>
<input
className={inp}
type="number"
step="0.5"
value={draft.default_duration_sec}
onChange={(e) =>
setDraft({ ...draft, default_duration_sec: Number(e.target.value) })
}
/>
</div>
<div>
<label className={lbl}>ترتیب</label>
<input
className={inp}
type="number"
value={draft.sort}
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
/>
</div>
<div className="flex items-end gap-1.5 pb-1">
<input
id="scene-active"
type="checkbox"
checked={draft.is_active}
onChange={(e) => setDraft({ ...draft, is_active: e.target.checked })}
/>
<label htmlFor="scene-active" className="text-xs text-gray-400">
فعال
</label>
</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>
);
}
// ── Shared colors ───────────────────────────────────────────────────────────
function ColorsTab({
projectId,
colors,
writeUrl,
onChange,
setError,
}: {
projectId: string;
colors: SharedColor[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const empty = { element_key: "", title: "", default_color: "#4c6ef5", sort: 0 };
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
const body = { project_id: projectId, attr_value: draft.default_color, ...draft };
const res = await fetch(writeUrl("shared-colors", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
onChange();
} else {
setError("ذخیرهٔ رنگ ناموفق بود");
}
};
const remove = async (c: SharedColor) => {
if (!confirm(`رنگ «${c.title}» حذف شود؟`)) return;
const res = await fetch(writeUrl("shared-colors", c.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{colors.length === 0 && (
<p className="text-xs text-gray-600">هنوز رنگ مشترکی تعریف نشده است.</p>
)}
{colors
.slice()
.sort((a, b) => a.sort - b.sort)
.map((c) => (
<div
key={c.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span
className="h-5 w-5 shrink-0 rounded border border-white/10"
style={{ background: c.default_color }}
/>
<span className="flex-1 truncate text-xs text-gray-200">
{c.title}{" "}
<span className="text-gray-600" dir="ltr">
({c.element_key})
</span>
</span>
<span className="text-[11px] text-gray-500" dir="ltr">
{c.default_color}
</span>
<span className="text-[11px] text-gray-600">#{c.sort}</span>
<button
className={ghost}
onClick={() => {
setEditId(c.id);
setDraft({
element_key: c.element_key,
title: c.title,
default_color: c.default_color,
sort: c.sort,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(c)}>
حذف
</button>
</div>
))}
</div>
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش رنگ" : "افزودن رنگ مشترک"}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className={lbl}>کلید عنصر</label>
<input
className={inp}
dir="ltr"
value={draft.element_key}
onChange={(e) => setDraft({ ...draft, element_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>
<div className="flex gap-1">
<input
type="color"
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
value={draft.default_color}
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
/>
<input
className={inp}
dir="ltr"
value={draft.default_color}
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
/>
</div>
</div>
<div>
<label className={lbl}>ترتیب</label>
<input
className={inp}
type="number"
value={draft.sort}
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
/>
</div>
</div>
<div className="mt-2 flex gap-2">
<button
className={btn}
onClick={submit}
disabled={busy || !draft.element_key || !draft.title}
>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن"}
</button>
{editId && (
<button
className={ghost}
onClick={() => {
setEditId(null);
setDraft({ ...empty });
}}
>
انصراف
</button>
)}
</div>
</div>
</div>
);
}
// ── Color presets ───────────────────────────────────────────────────────────
function PresetsTab({
projectId,
presets,
writeUrl,
onChange,
setError,
}: {
projectId: string;
presets: ColorPreset[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const [name, setName] = useState("");
const [items, setItems] = useState<PresetItem[]>([{ element_key: "", value: "#ffffff", sort: 0 }]);
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reset = () => {
setName("");
setItems([{ element_key: "", value: "#ffffff", sort: 0 }]);
setEditId(null);
};
const submit = async () => {
setBusy(true);
setError(null);
const body = {
project_id: projectId,
name: name || null,
sort: 0,
items: items
.filter((i) => i.element_key)
.map((i, idx) => ({ element_key: i.element_key, value: i.value, sort: idx })),
};
const res = await fetch(writeUrl("color-presets", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
reset();
onChange();
} else {
setError("ذخیرهٔ پالت ناموفق بود");
}
};
const remove = async (p: ColorPreset) => {
if (!confirm(`پالت حذف شود؟`)) return;
const res = await fetch(writeUrl("color-presets", p.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{presets.length === 0 && (
<p className="text-xs text-gray-600">هنوز پالتی تعریف نشده است.</p>
)}
{presets.map((p) => (
<div
key={p.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span className="flex-1 truncate text-xs text-gray-200">
{p.name || "بدون نام"}
</span>
<div className="flex gap-1">
{p.items?.slice(0, 8).map((it, i) => (
<span
key={i}
title={`${it.element_key}: ${it.value}`}
className="h-4 w-4 rounded-sm border border-white/10"
style={{ background: it.value }}
/>
))}
</div>
<button
className={ghost}
onClick={() => {
setEditId(p.id);
setName(p.name ?? "");
setItems(
p.items?.length
? p.items.map((i) => ({ element_key: i.element_key, value: i.value, sort: i.sort }))
: [{ element_key: "", value: "#ffffff", sort: 0 }]
);
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(p)}>
حذف
</button>
</div>
))}
</div>
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش پالت" : "افزودن پالت رنگ"}
</p>
<div className="mb-2">
<label className={lbl}>نام پالت (اختیاری)</label>
<input className={inp} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
{items.map((it, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<input
className={inp}
dir="ltr"
placeholder="element_key"
value={it.element_key}
onChange={(e) =>
setItems((arr) =>
arr.map((x, i) => (i === idx ? { ...x, element_key: e.target.value } : x))
)
}
/>
<input
type="color"
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
value={it.value}
onChange={(e) =>
setItems((arr) =>
arr.map((x, i) => (i === idx ? { ...x, value: e.target.value } : x))
)
}
/>
<button
className={del}
onClick={() => setItems((arr) => arr.filter((_, i) => i !== idx))}
>
×
</button>
</div>
))}
</div>
<button
className={`${ghost} mt-2`}
onClick={() => setItems((arr) => [...arr, { element_key: "", value: "#ffffff", sort: arr.length }])}
>
+ رنگ
</button>
<div className="mt-2 flex gap-2">
<button className={btn} onClick={submit} disabled={busy}>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن پالت"}
</button>
{editId && (
<button className={ghost} onClick={reset}>
انصراف
</button>
)}
</div>
</div>
</div>
);
}
+15
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { slugify } from "@/components/admin/AdminResource"; import { slugify } from "@/components/admin/AdminResource";
import { FileUploadField } from "@/components/admin/FileUploadField"; import { FileUploadField } from "@/components/admin/FileUploadField";
import { AdminThumb } from "@/components/admin/AdminThumb"; import { AdminThumb } from "@/components/admin/AdminThumb";
import { SceneColorEditor } from "@/components/admin/SceneColorEditor";
interface Container { interface Container {
id: string; id: string;
@@ -74,6 +75,7 @@ export function TemplatesAdmin() {
const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" }; const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
const [newProj, setNewProj] = useState({ ...emptyNewProj }); const [newProj, setNewProj] = useState({ ...emptyNewProj });
const [addingProj, setAddingProj] = useState(false); const [addingProj, setAddingProj] = useState(false);
const [scEditor, setScEditor] = useState<{ id: string; name: string } | null>(null);
const api = (p: string) => `/api/admin/resource/${p}`; const api = (p: string) => `/api/admin/resource/${p}`;
@@ -352,6 +354,11 @@ export function TemplatesAdmin() {
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button> <button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
</div> </div>
</div> </div>
<div className="flex items-end">
<button type="button" className="rounded-lg border border-indigo-500/40 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/10" onClick={() => setScEditor({ id: p.id, name: p.name })}>
صحنهها و رنگها
</button>
</div>
</div> </div>
</div> </div>
))} ))}
@@ -388,6 +395,14 @@ export function TemplatesAdmin() {
</div> </div>
</div> </div>
)} )}
{scEditor && (
<SceneColorEditor
projectId={scEditor.id}
projectName={scEditor.name}
onClose={() => setScEditor(null)}
/>
)}
</div> </div>
); );
} }
+10
View File
@@ -19,6 +19,7 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
const [pw, setPw] = useState(""); const [pw, setPw] = useState("");
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState(""); const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
const [planDays, setPlanDays] = useState(""); const [planDays, setPlanDays] = useState("");
const [slots, setSlots] = useState(String(row.parallel_rendering_ceiling ?? "1"));
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new"); const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
// discount / affiliate // discount / affiliate
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage"); const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
@@ -114,6 +115,15 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
</div> </div>
</div> </div>
<div className={`${card} p-3`}>
<label className={lbl}>تعداد رندر همزمان مجاز</label>
<p className="mb-1.5 text-[11px] text-gray-500">پیشفرض ۱. با افزایش این مقدار، کاربر میتواند چند رندر همزمان اجرا کند (پس از تازهسازی توکن اعمال میشود).</p>
<div className="flex gap-2">
<input className={`${inp} max-w-[120px]`} type="number" min={1} max={50} value={slots} onChange={(e) => setSlots(e.target.value)} />
<button className={btn} disabled={busy || !slots} onClick={() => call(`users/${id}/render-slots`, { ceiling: Number(slots) || 1 }, "ظرفیت رندر هم‌زمان به‌روزرسانی شد ✓")}>اعمال</button>
</div>
</div>
<div className={`${card} flex items-center justify-between p-3`}> <div className={`${card} flex items-center justify-between p-3`}>
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span> <span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
<div className="flex gap-2"> <div className="flex gap-2">
+4 -1
View File
@@ -302,11 +302,13 @@ export const plansConfig: ResourceConfig = {
export const discountsConfig: ResourceConfig = { export const discountsConfig: ResourceConfig = {
title: "تخفیف‌ها", title: "تخفیف‌ها",
description: "کدهای تخفیف / کوپن. (کدها اینجا ساخته می‌شوند؛ هنوز API ویرایش/حذف وجود ندارد.)", description: "کدهای تخفیف / کوپن. ساخت، ویرایش و حذف.",
basePath: "discounts", basePath: "discounts",
listQuery: "pageSize=500&page_size=500", listQuery: "pageSize=500&page_size=500",
listKey: "data", listKey: "data",
canCreate: true, canCreate: true,
canEdit: true,
canDelete: true,
columns: [ columns: [
{ key: "code", label: "کد" }, { key: "code", label: "کد" },
{ key: "kind", label: "نوع" }, { key: "kind", label: "نوع" },
@@ -332,5 +334,6 @@ export const discountsConfig: ResourceConfig = {
{ key: "value", label: "مقدار", type: "number", required: true }, { key: "value", label: "مقدار", type: "number", required: true },
{ key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" }, { key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" },
{ key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" }, { key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" },
{ key: "is_active", label: "فعال", type: "checkbox", defaultValue: true },
], ],
}; };
@@ -3,13 +3,13 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { Download, FolderOpen, Sparkles } from "lucide-react"; import { CloudUpload, Download, FolderOpen, Sparkles } from "lucide-react";
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator"; import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { downloadStageImage } from "@/lib/image-editor-export"; import { downloadStageImage, saveStageToCloud } from "@/lib/image-editor-export";
import { getImageEditorStage } from "@/lib/image-editor-stage-ref"; import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
import type { ExportImageFormat } from "@/lib/image-editor-types"; import type { ExportImageFormat } from "@/lib/image-editor-types";
import type { ProjectSaveStatus } from "@/lib/project-save-status"; import type { ProjectSaveStatus } from "@/lib/project-save-status";
@@ -32,6 +32,7 @@ export function ImageEditorTopBar({
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar"); const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [savingCloud, setSavingCloud] = useState(false);
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage); const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
const exportFormat = useImageEditorStore((s) => s.exportFormat); const exportFormat = useImageEditorStore((s) => s.exportFormat);
@@ -62,6 +63,24 @@ export function ImageEditorTopBar({
setExportOpen(false); setExportOpen(false);
}; };
const handleSaveToCloud = async () => {
const stage = getImageEditorStage();
if (!stage) {
toast({ title: t("canvasNotReady") });
return;
}
setSavingCloud(true);
try {
const url = await saveStageToCloud(stage, exportFormat, exportQuality);
toast({ title: url ? t("savedToCloud") : t("saveToCloudFailed") });
if (url) setExportOpen(false);
} catch {
toast({ title: t("saveToCloudFailed") });
} finally {
setSavingCloud(false);
}
};
return ( return (
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4"> <header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -154,8 +173,19 @@ export function ImageEditorTopBar({
className="w-full bg-primary-600 hover:bg-primary-700" className="w-full bg-primary-600 hover:bg-primary-700"
onClick={handleExport} onClick={handleExport}
> >
<Download className="h-4 w-4" />
{t("download")} {t("download")}
</Button> </Button>
<Button
type="button"
variant="outline"
className="mt-2 w-full border-gray-700 bg-gray-800 text-gray-200"
onClick={handleSaveToCloud}
disabled={savingCloud}
>
<CloudUpload className="h-4 w-4" />
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
</Button>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Film, Loader2, X } from "lucide-react";
interface ActiveRender {
id: string;
saved_project_id: string;
name: string | null;
step: string;
render_progress: number;
preview_b64: string | null;
}
/**
* App-wide minimal render progress pill. Mounted globally for authenticated users;
* polls /api/render/active and shows a small floating indicator whenever a render
* is in flight, on ANY page. Click → the full-screen render page for that project.
*
* Self-hides on the render page itself (which already shows full progress) and when
* there is no active render.
*/
export function GlobalRenderProgress({ authed }: { authed: boolean }) {
const router = useRouter();
const pathname = usePathname();
const [active, setActive] = useState<ActiveRender | null>(null);
const [dismissed, setDismissed] = useState<string | null>(null);
// Don't show on the render page itself.
const onRenderPage = /\/studio\/render\//.test(pathname);
useEffect(() => {
if (!authed) return;
let stop = false;
const poll = async () => {
if (document.visibilityState === "hidden") return;
try {
const res = await fetch("/api/render/active", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as { active?: ActiveRender[] };
if (stop) return;
setActive(data.active?.[0] ?? null);
} catch {
/* ignore transient errors */
}
};
poll();
const id = window.setInterval(poll, 4000);
return () => {
stop = true;
window.clearInterval(id);
};
}, [authed]);
if (!authed || !active || onRenderPage) return null;
if (dismissed === active.id) return null;
const pct = Math.max(0, Math.min(100, active.render_progress ?? 0));
return (
<button
type="button"
onClick={() => router.push(`/studio/render/${active.saved_project_id}`)}
dir="rtl"
className="fixed bottom-4 end-4 z-[70] flex w-64 items-center gap-3 rounded-xl border border-[#262b40] bg-[#0c0e1a]/95 p-3 text-start shadow-2xl backdrop-blur transition-colors hover:border-primary-500"
aria-label="مشاهده پیشرفت رندر"
>
{/* Thumbnail / spinner */}
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-[#070811]">
{active.preview_b64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${active.preview_b64}`}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Film className="h-5 w-5 text-gray-600" />
</div>
)}
</div>
{/* Text + progress */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin text-primary-400" />
<span className="truncate text-xs font-medium text-gray-200">
{active.name?.trim() || "در حال رندر"}
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-primary-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-gray-400">{pct}%</span>
</div>
</div>
{/* Dismiss (hides until job id changes) */}
<span
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
setDismissed(active.id);
}}
className="shrink-0 rounded p-1 text-gray-600 hover:text-gray-300"
aria-label="پنهان کردن"
>
<X className="h-3.5 w-3.5" />
</span>
</button>
);
}
-325
View File
@@ -1,325 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Download, Link2, Loader2, RefreshCw } from "lucide-react";
import { apiFetch } from "@/lib/api/fetch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { RenderExportPreset } from "@/lib/render-presets";
import { RENDER_EXPORT_PRESETS } from "@/lib/render-presets";
import type { RenderSettings } from "@/lib/render-schemas";
import type { Scene } from "@/lib/studio-types";
import { cn } from "@/lib/utils";
interface RenderModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
scenes: Scene[];
preset?: RenderExportPreset | null;
}
type JobStatus = "idle" | "submitting" | "polling" | "completed" | "failed";
interface StatusResponse {
status: string;
progress: number;
outputUrl: string | null;
progressMessage?: string | null;
errorMessage?: string | null;
/** Base64-encoded PNG preview frame from the node agent. */
previewB64?: string | null;
}
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
export function RenderModal({
open,
onOpenChange,
projectId,
scenes,
preset = null,
}: RenderModalProps) {
const t = useTranslations("auto.componentsStudioRenderModal");
const [resolution, setResolution] =
useState<RenderSettings["resolution"]>("1080p");
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
const [presetLabel, setPresetLabel] = useState<string | null>(null);
const [jobStatus, setJobStatus] = useState<JobStatus>("idle");
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState("");
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [previewB64, setPreviewB64] = useState<string | null>(null);
const reset = useCallback(() => {
setJobStatus("idle");
setJobId(null);
setProgress(0);
setProgressMessage("");
setOutputUrl(null);
setErrorMessage(null);
setPreviewB64(null);
}, []);
useEffect(() => {
if (!open) reset();
}, [open, reset]);
useEffect(() => {
if (!open || !preset) return;
const config = RENDER_EXPORT_PRESETS[preset];
setResolution(config.settings.resolution);
setFps(config.settings.fps);
setPresetLabel(config.label);
}, [open, preset]);
useEffect(() => {
if (jobStatus !== "polling" || !jobId) return;
const poll = async () => {
try {
const response = await apiFetch(`/api/render/${jobId}/status`);
const data = (await response.json()) as StatusResponse;
if (!response.ok) {
setJobStatus("failed");
setErrorMessage(t("errorFetchStatus"));
return;
}
setProgress(data.progress ?? 0);
setProgressMessage(
data.progressMessage ?? t("renderingProgress", { progress: data.progress })
);
if (data.previewB64) setPreviewB64(data.previewB64);
if (data.status === "completed" && data.outputUrl) {
setOutputUrl(data.outputUrl);
setJobStatus("completed");
setProgress(100);
return;
}
if (data.status === "failed") {
setJobStatus("failed");
setErrorMessage(data.errorMessage ?? t("errorRenderFailed"));
}
} catch {
setJobStatus("failed");
setErrorMessage(t("errorNetworkPolling"));
}
};
poll();
const intervalId = window.setInterval(poll, 3000);
return () => window.clearInterval(intervalId);
}, [jobStatus, jobId, t]);
const startRender = async () => {
setJobStatus("submitting");
setErrorMessage(null);
try {
const response = await apiFetch("/api/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
settings: {
resolution,
format: "mp4" as const,
fps,
},
}),
});
const data = (await response.json()) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
setJobStatus("failed");
setErrorMessage(data.error ?? t("errorStartRender"));
return;
}
setJobId(data.jobId);
setJobStatus("polling");
setProgress(0);
setProgressMessage(t("queued"));
} catch {
setJobStatus("failed");
setErrorMessage(t("errorReachApi"));
}
};
const isBusy = jobStatus === "submitting" || jobStatus === "polling";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{presetLabel ?? t("dialogTitle")}</DialogTitle>
<DialogDescription>
{preset
? RENDER_EXPORT_PRESETS[preset].description
: t("dialogDescription")}
</DialogDescription>
</DialogHeader>
{jobStatus === "completed" && outputUrl ? (
<div className="space-y-4">
<p className="text-sm text-green-400">{t("videoReady")}</p>
<div className="flex flex-col gap-2">
<a
href={outputUrl}
download
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Download className="h-4 w-4" />
{t("downloadMp4")}
</a>
<a
href={outputUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Link2 className="h-4 w-4" />
{t("shareLink")}
</a>
</div>
<Button
type="button"
variant="outline"
className="w-full border-[#2a2d3e]"
onClick={() => onOpenChange(false)}
>
{t("close")}
</Button>
</div>
) : jobStatus === "failed" ? (
<div className="space-y-4">
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
{errorMessage ?? t("errorGeneric")}
</p>
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={startRender}
>
<RefreshCw className="h-4 w-4" />
{t("retry")}
</Button>
</div>
) : isBusy ? (
<div className="space-y-4">
{/* Live preview frame from node agent */}
<div className="relative overflow-hidden rounded-lg bg-[#0f111e] aspect-video">
{previewB64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${previewB64}`}
alt={t("previewAlt")}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary-400/40" />
</div>
)}
{/* Step badge */}
<div className="absolute bottom-2 right-2 rounded-md bg-black/60 px-2 py-0.5 text-[10px] font-medium text-gray-300 backdrop-blur-sm">
{progressMessage || t("rendering")}
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-gray-500">
<span>{t("progress")}</span>
<span>{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
<div
className="h-full rounded-full bg-primary-600 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<p className="mb-2 text-xs font-medium text-gray-400">
{t("resolution")}
</p>
<div className="flex gap-2">
{RESOLUTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setResolution(item)}
className={cn(
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
resolution === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">{t("format")}</p>
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
MP4
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">{t("fps")}</p>
<div className="flex gap-2">
{FPS_OPTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setFps(item)}
className={cn(
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
fps === item
? "border-primary-500 bg-primary-600/20 text-white"
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
)}
>
{item}
</button>
))}
</div>
</div>
<Button
type="button"
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={startRender}
disabled={scenes.length === 0}
>
{t("startRendering")}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
+5 -20
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { import {
Camera, Camera,
ChevronDown, ChevronDown,
@@ -14,7 +14,6 @@ import {
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { RenderModal } from "@/components/studio/RenderModal";
import { StudioToolbar } from "@/components/studio/StudioToolbar"; import { StudioToolbar } from "@/components/studio/StudioToolbar";
import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge"; import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge";
import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls"; import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls";
@@ -58,17 +57,14 @@ export function StudioTopBar({
const isPlaying = useStudioStore((state) => state.isPlaying); const isPlaying = useStudioStore((state) => state.isPlaying);
const startPlayback = useStudioStore((state) => state.startPlayback); const startPlayback = useStudioStore((state) => state.startPlayback);
const stopPlayback = useStudioStore((state) => state.stopPlayback); const stopPlayback = useStudioStore((state) => state.stopPlayback);
const scenes = useStudioStore((state) => state.scenes);
const selectedLayer = useStudioStore((state) => getSelectedLayer(state)); const selectedLayer = useStudioStore((state) => getSelectedLayer(state));
const [renderOpen, setRenderOpen] = useState(false); const router = useRouter();
const [renderPreset, setRenderPreset] = useState<RenderExportPreset | null>(
null
);
// Render is now a full-screen page (not a modal). Navigate with the chosen
// export preset; the page handles config → progress → download.
const openExport = (preset: RenderExportPreset) => { const openExport = (preset: RenderExportPreset) => {
setRenderPreset(preset); router.push(`/studio/render/${projectId}?preset=${preset}`);
setRenderOpen(true);
}; };
const handleSnapshot = () => { const handleSnapshot = () => {
@@ -212,17 +208,6 @@ export function StudioTopBar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<RenderModal
open={renderOpen}
onOpenChange={(open) => {
setRenderOpen(open);
if (!open) setRenderPreset(null);
}}
projectId={projectId}
scenes={scenes}
preset={renderPreset}
/>
</header> </header>
); );
} }
@@ -1,9 +1,11 @@
"use client"; "use client";
import { Download, Loader2 } from "lucide-react"; import { useState } from "react";
import { CloudUpload, Download, Loader2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import type { ExportFormat } from "@/lib/trimmer-types"; import type { ExportFormat } from "@/lib/trimmer-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -29,6 +31,29 @@ export function TrimmerExportSection({
onProcess, onProcess,
}: TrimmerExportSectionProps) { }: TrimmerExportSectionProps) {
const t = useTranslations("auto.componentsTrimmerTrimmerExportSection"); const t = useTranslations("auto.componentsTrimmerTrimmerExportSection");
const [savingCloud, setSavingCloud] = useState(false);
const handleSaveToCloud = async () => {
if (!outputUrl) return;
setSavingCloud(true);
try {
const blob = await fetch(outputUrl).then((r) => r.blob());
const fd = new FormData();
const mime = exportFormat === "webm" ? "video/webm" : "video/mp4";
fd.append(
"file",
new File([blob], `trimmed-${Date.now()}.${exportFormat}`, { type: mime })
);
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
const data = (await res.json().catch(() => null)) as { url?: string } | null;
toast({ title: res.ok && data?.url ? t("savedToCloud") : t("saveToCloudFailed") });
} catch {
toast({ title: t("saveToCloudFailed") });
} finally {
setSavingCloud(false);
}
};
return ( return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm"> <section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-white">{t("heading")}</h2> <h2 className="mb-4 text-sm font-semibold text-white">{t("heading")}</h2>
@@ -104,6 +129,20 @@ export function TrimmerExportSection({
<Download className="h-4 w-4" aria-hidden /> <Download className="h-4 w-4" aria-hidden />
{t("download", { format: exportFormat.toUpperCase() })} {t("download", { format: exportFormat.toUpperCase() })}
</a> </a>
<Button
type="button"
variant="outline"
className="w-full border-gray-700 bg-gray-800 text-white hover:bg-gray-700"
onClick={handleSaveToCloud}
disabled={savingCloud}
>
{savingCloud ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<CloudUpload className="h-4 w-4" aria-hidden />
)}
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
</Button>
</div> </div>
) : null} ) : null}
</section> </section>
+45 -2
View File
@@ -48,11 +48,54 @@ export function humanSize(n?: number): string {
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
} }
/** Fetch files from the admin gateway with optional search + type filter. */ /** Fetch files from the admin gateway with optional search + type + folder filter. */
export async function fetchFiles(search: string, fileType: string, page = 1, pageSize = 60): Promise<FileItem[]> { export async function fetchFiles(
search: string,
fileType: string,
folderId: string | null = null,
page = 1,
pageSize = 60
): Promise<FileItem[]> {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) });
if (search) qs.set("search", search); if (search) qs.set("search", search);
if (fileType) qs.set("file_type", fileType); if (fileType) qs.set("file_type", fileType);
if (folderId) qs.set("folder_id", folderId);
const r = await fetch(`/api/admin/resource/files?${qs.toString()}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null); const r = await fetch(`/api/admin/resource/files?${qs.toString()}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
return r?.items ?? r?.data ?? (Array.isArray(r) ? r : []); return r?.items ?? r?.data ?? (Array.isArray(r) ? r : []);
} }
// ── Folders ───────────────────────────────────────────────────────────────────
export interface FolderItem {
id: string;
name: string;
folder_type?: string;
parent_folder_id?: string | null;
file_count?: number;
total_size_bytes?: number;
}
/** List folders under a parent (null = root). */
export async function fetchFolders(parentId: string | null = null): Promise<FolderItem[]> {
const qs = parentId ? `?parent_id=${parentId}` : "";
const r = await fetch(`/api/admin/resource/folders${qs}`, { cache: "no-store" })
.then((x) => x.json())
.catch(() => null);
return Array.isArray(r) ? r : (r?.items ?? r?.data ?? []);
}
/** Create a folder (optionally nested under a parent). */
export async function createFolder(name: string, parentId: string | null = null): Promise<boolean> {
const res = await fetch(`/api/admin/resource/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, parent_folder_id: parentId }),
});
return res.ok;
}
/** Delete a folder by id. */
export async function deleteFolder(id: string): Promise<boolean> {
const res = await fetch(`/api/admin/resource/folders/${id}`, { method: "DELETE" });
return res.ok;
}
+43
View File
@@ -25,3 +25,46 @@ export function downloadStageImage(
link.href = dataUrl; link.href = dataUrl;
link.click(); link.click();
} }
/** Render the stage to a Blob at 2× pixel ratio for upload. */
export async function stageToBlob(
stage: Konva.Stage,
format: ExportImageFormat,
quality: number
): Promise<Blob | null> {
const mimeType =
format === "png"
? "image/png"
: format === "jpg"
? "image/jpeg"
: "image/webp";
const dataUrl = stage.toDataURL({
pixelRatio: 2,
mimeType,
quality: format === "png" ? 1 : quality / 100,
});
const res = await fetch(dataUrl);
return res.blob();
}
/**
* Render the stage and upload it to the user's cloud library (user-uploads bucket).
* Returns the public URL on success, or null on failure.
*/
export async function saveStageToCloud(
stage: Konva.Stage,
format: ExportImageFormat,
quality: number
): Promise<string | null> {
const blob = await stageToBlob(stage, format, quality);
if (!blob) return null;
const fd = new FormData();
fd.append("file", blob, `design-${Date.now()}.${format}`);
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as { url?: string } | null;
return data?.url ?? null;
}