81912cac66
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>
192 lines
12 KiB
TypeScript
192 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
|
||
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||
const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||
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 card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||
|
||
/** Per-user admin power-actions + CRM notes, opened as a modal from the Users table. */
|
||
export function UserActions({ row }: { row: Record<string, unknown>; reload?: () => void }) {
|
||
const id = String(row.id);
|
||
const [open, setOpen] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
// form state
|
||
const [balance, setBalance] = useState(""); const [balanceAdd, setBalanceAdd] = useState(true);
|
||
const [pw, setPw] = useState("");
|
||
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = 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");
|
||
// discount / affiliate
|
||
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
|
||
const [dcValue, setDcValue] = useState(""); const [dcProfit, setDcProfit] = useState(""); const [dcDays, setDcDays] = useState("30");
|
||
// videos
|
||
const [vids, setVids] = useState<Array<Record<string, unknown>> | null>(null);
|
||
|
||
const createDiscount = async () => {
|
||
setBusy(true); setMsg(null);
|
||
const expires = new Date(Date.now() + (Number(dcDays) || 30) * 864e5).toISOString();
|
||
const res = await fetch(`/api/admin/resource/discounts`, {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: dcCode || `user-${id.slice(0, 8)}`, code: dcCode, kind: dcKind, value: Number(dcValue) || 0,
|
||
owner_user_id: id, owner_profit_percentage: Number(dcProfit) || 0, expires_at: expires,
|
||
}),
|
||
});
|
||
const d = await res.json().catch(() => null);
|
||
setMsg(res.ok ? (Number(dcProfit) > 0 ? "کد افیلیت ساخته شد ✓" : "کد تخفیف ساخته شد ✓") : (d?.error?.message ?? d?.error ?? "خطا"));
|
||
setBusy(false);
|
||
};
|
||
|
||
const loadVideos = async () => {
|
||
const r = await fetch(`/api/admin/resource/saved-projects/by-user/${id}?pageSize=50`, { cache: "no-store" })
|
||
.then((x) => x.json()).catch(() => null);
|
||
setVids(r?.data ?? r?.items ?? (Array.isArray(r) ? r : []));
|
||
};
|
||
|
||
const call = async (path: string, body: object, ok: string) => {
|
||
setBusy(true); setMsg(null);
|
||
const res = await fetch(`/api/admin/resource/${path}`, {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||
});
|
||
const d = await res.json().catch(() => null);
|
||
setMsg(res.ok ? ok : (d?.error?.message ?? d?.error ?? "خطا"));
|
||
setBusy(false);
|
||
};
|
||
|
||
const loadCrm = async () => {
|
||
const r = await fetch(`/api/admin/resource/users/${id}/crm`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||
if (r) { setTags((r.tags ?? []).join(", ")); setNote(r.note ?? ""); setStatus(r.status ?? "new"); }
|
||
};
|
||
const saveCrm = async () => {
|
||
setBusy(true); setMsg(null);
|
||
const res = await fetch(`/api/admin/resource/users/${id}/crm`, {
|
||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ tags: tags.split(",").map((t) => t.trim()).filter(Boolean), note, status }),
|
||
});
|
||
setMsg(res.ok ? "یادداشت ذخیره شد ✓" : "خطا"); setBusy(false);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={() => { setOpen(true); setMsg(null); loadCrm(); }}>مدیریت</button>
|
||
{open && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 text-right" dir="rtl" onClick={() => setOpen(false)}>
|
||
<div className={`${card} max-h-[85vh] w-full max-w-lg overflow-y-auto p-5`} onClick={(e) => e.stopPropagation()}>
|
||
<h2 className="text-sm font-semibold text-white">مدیریت کاربر: {String(row.email ?? row.full_name ?? id)}</h2>
|
||
{msg && <p className="mt-2 rounded-lg bg-[#12152a] px-3 py-2 text-xs text-gray-300">{msg}</p>}
|
||
|
||
<div className="mt-4 space-y-4">
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>موجودی (ریال)</label>
|
||
<div className="flex gap-2">
|
||
<input className={inp} type="number" value={balance} onChange={(e) => setBalance(e.target.value)} />
|
||
<label className="flex items-center gap-1 whitespace-nowrap text-xs text-gray-400"><input type="checkbox" checked={balanceAdd} onChange={(e) => setBalanceAdd(e.target.checked)} /> افزودن</label>
|
||
<button className={btn} disabled={busy || !balance} onClick={() => call(`users/${id}/balance`, { amount_minor: Number(balance), add: balanceAdd }, "موجودی بهروزرسانی شد ✓")}>اعمال</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>شارژ رندر</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
<input className={`${inp} max-w-[120px]`} type="number" placeholder="ثانیه" value={seconds} onChange={(e) => setSeconds(e.target.value)} />
|
||
<input className={`${inp} max-w-[120px]`} type="number" placeholder="تعداد رندر" value={renders} onChange={(e) => setRenders(e.target.value)} />
|
||
<button className={btn} disabled={busy || (!seconds && !renders)} onClick={() => call(`users/${id}/charge`, { seconds: Number(seconds) || 0, render_count: Number(renders) || 0 }, "شارژ اضافه شد ✓")}>افزودن</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>تمدید پلن (روز)</label>
|
||
<div className="flex gap-2">
|
||
<input className={inp} type="number" value={planDays} onChange={(e) => setPlanDays(e.target.value)} />
|
||
<button className={btn} disabled={busy || !planDays} onClick={() => call(`users/${id}/grant-plan`, { plan_id: "00000000-0000-0000-0000-000000000000", days: Number(planDays) }, "پلن تمدید شد ✓")}>تمدید</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>تغییر رمز عبور</label>
|
||
<div className="flex gap-2">
|
||
<input className={inp} type="text" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="رمز جدید" />
|
||
<button className={btn} disabled={busy || pw.length < 8} onClick={() => call(`users/${id}/password`, { new_password: pw }, "رمز تغییر کرد ✓")}>تغییر</button>
|
||
</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`}>
|
||
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
||
<div className="flex gap-2">
|
||
<button className={btn} disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: true }, "مدیر شد ✓")}>اعطا</button>
|
||
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: false }, "لغو شد ✓")}>لغو</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>کد تخفیف / افیلیت (درصد سود > ۰ یعنی افیلیت)</label>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<input className={inp} placeholder="کد" value={dcCode} onChange={(e) => setDcCode(e.target.value)} />
|
||
<select className={inp} value={dcKind} onChange={(e) => setDcKind(e.target.value)}>
|
||
<option value="Percentage">درصدی</option>
|
||
<option value="FixedAmount">مبلغ ثابت</option>
|
||
<option value="RenderCredits">اعتبار رندر</option>
|
||
</select>
|
||
<input className={inp} type="number" placeholder="مقدار" value={dcValue} onChange={(e) => setDcValue(e.target.value)} />
|
||
<input className={inp} type="number" placeholder="٪ سود افیلیت" value={dcProfit} onChange={(e) => setDcProfit(e.target.value)} />
|
||
<input className={inp} type="number" placeholder="اعتبار (روز)" value={dcDays} onChange={(e) => setDcDays(e.target.value)} />
|
||
</div>
|
||
<button className={`${btn} mt-2`} disabled={busy || !dcCode || !dcValue} onClick={createDiscount}>ساخت کد</button>
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<div className="flex items-center justify-between">
|
||
<label className={lbl}>ویدیوهای کاربر</label>
|
||
<button className="rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={loadVideos}>بارگذاری</button>
|
||
</div>
|
||
{vids != null && (
|
||
vids.length === 0 ? <p className="text-xs text-gray-500">ویدیویی یافت نشد.</p> : (
|
||
<ul className="mt-1 max-h-40 space-y-1 overflow-y-auto text-sm text-gray-300">
|
||
{vids.map((v) => (
|
||
<li key={String(v.id)} className="flex items-center justify-between rounded bg-[#0c0e1a] px-2 py-1">
|
||
<span className="truncate">{String(v.name ?? v.original_project_name ?? "—")}</span>
|
||
<span className="text-[11px] text-gray-500">{String(v.type ?? "")} · {String(v.resolution ?? "")}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
<div className={`${card} p-3`}>
|
||
<label className={lbl}>یادداشت CRM</label>
|
||
<div className="grid gap-2">
|
||
<input className={inp} placeholder="برچسبها (با کاما)" value={tags} onChange={(e) => setTags(e.target.value)} />
|
||
<select className={inp} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||
<option value="new">جدید</option><option value="contacted">تماسگرفته</option><option value="customer">مشتری</option><option value="churned">ریزشکرده</option>
|
||
</select>
|
||
<textarea className={`${inp} min-h-[60px]`} placeholder="یادداشت" value={note} onChange={(e) => setNote(e.target.value)} />
|
||
<div><button className={btn} disabled={busy} onClick={saveCrm}>ذخیره یادداشت</button></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex justify-end">
|
||
<button className="rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e]" onClick={() => setOpen(false)}>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|