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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { slugify } from "@/components/admin/AdminResource";
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||
import { SceneColorEditor } from "@/components/admin/SceneColorEditor";
|
||||
|
||||
interface Container {
|
||||
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 [newProj, setNewProj] = useState({ ...emptyNewProj });
|
||||
const [addingProj, setAddingProj] = useState(false);
|
||||
const [scEditor, setScEditor] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
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>
|
||||
</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>
|
||||
))}
|
||||
@@ -388,6 +395,14 @@ export function TemplatesAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scEditor && (
|
||||
<SceneColorEditor
|
||||
projectId={scEditor.id}
|
||||
projectName={scEditor.name}
|
||||
onClose={() => setScEditor(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,11 +302,13 @@ export const plansConfig: ResourceConfig = {
|
||||
|
||||
export const discountsConfig: ResourceConfig = {
|
||||
title: "تخفیفها",
|
||||
description: "کدهای تخفیف / کوپن. (کدها اینجا ساخته میشوند؛ هنوز API ویرایش/حذف وجود ندارد.)",
|
||||
description: "کدهای تخفیف / کوپن. ساخت، ویرایش و حذف.",
|
||||
basePath: "discounts",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "data",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "code", label: "کد" },
|
||||
{ key: "kind", label: "نوع" },
|
||||
@@ -332,5 +334,6 @@ export const discountsConfig: ResourceConfig = {
|
||||
{ key: "value", label: "مقدار", type: "number", required: true },
|
||||
{ key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" },
|
||||
{ key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" },
|
||||
{ key: "is_active", label: "فعال", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user