diff --git a/app/dashboard/sliders/page.tsx b/app/dashboard/sliders/page.tsx index 354b592..98afe72 100644 --- a/app/dashboard/sliders/page.tsx +++ b/app/dashboard/sliders/page.tsx @@ -17,6 +17,9 @@ import { } from "@/components/ui"; import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; import { toFa } from "@/lib/utils"; +import { MediaPreview } from "@/components/MediaPreview"; +import { ImagePicker } from "@/components/ImagePicker"; +import { pickUrl, IMAGE_KEYS } from "@/lib/media"; interface SliderAction { path?: string; @@ -29,6 +32,8 @@ interface Slider { description?: string; url?: string; action?: SliderAction; + image_id?: number | null; + image?: unknown; } const ACTION_TYPES = [ @@ -47,6 +52,8 @@ export default function SlidersPage() { const [url, setUrl] = useState(""); const [actionPath, setActionPath] = useState(""); const [actionType, setActionType] = useState("screen"); + const [imageId, setImageId] = useState(null); + const [imageFile, setImageFile] = useState(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(null); @@ -59,6 +66,8 @@ export default function SlidersPage() { setUrl(""); setActionPath(""); setActionType("screen"); + setImageId(null); + setImageFile(null); setOpen(true); } function openEdit(row: Slider) { @@ -68,6 +77,8 @@ export default function SlidersPage() { setUrl(row.url ?? ""); setActionPath(row.action?.path ?? ""); setActionType(row.action?.type ?? "screen"); + setImageId(row.image_id ?? null); + setImageFile(null); setOpen(true); } @@ -75,30 +86,23 @@ export default function SlidersPage() { e.preventDefault(); setSaving(true); try { + // Both create and edit go through multipart (the image is a file). The + // nested action uses literal bracket keys; edit posts to /slider/:id, + // which the backend also accepts as a multipart update. + const body = toFormData({ + title, + description, + url, + "action[path]": actionPath, + "action[type]": actionType, + image_id: imageId, + image: imageFile, + }); if (editing) { - // Update is JSON on /slider/:id with a nested action object. - await apiFetch(`/slider/${editing.id}`, { - method: "PUT", - body: { - title, - description, - url, - action: { path: actionPath, type: actionType }, - }, - }); + await apiFetch(`/slider/${editing.id}`, { method: "POST", body }); toast.success("اسلایدر ویرایش شد."); } else { - // Create is multipart with literal bracket keys for the nested action. - await apiFetch("/slider", { - method: "POST", - body: toFormData({ - title, - description, - "action[path]": actionPath, - "action[type]": actionType, - url, - }), - }); + await apiFetch("/slider", { method: "POST", body }); toast.success("اسلایدر افزوده شد."); } setOpen(false); @@ -127,6 +131,14 @@ export default function SlidersPage() { const columns: Column[] = [ { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" }, + { + key: "thumbnail", + header: "تصویر", + className: "w-20", + render: (r) => ( + + ), + }, { key: "title", header: "عنوان", render: (r) => r.title ?? "—" }, { key: "description", @@ -232,6 +244,16 @@ export default function SlidersPage() { /> + + + + ; + order?: number; + is_active?: boolean; +} + +// "#FF156395" (ARGB) or "#156395" → a CSS color the browser understands. +function cssColor(c?: string): string { + if (!c) return "transparent"; + const hex = c.replace("#", ""); + if (hex.length === 8) return `#${hex.slice(2)}${hex.slice(0, 2)}`; // ARGB → RGBA + return `#${hex}`; +} + +// The RGB part for a native (which can't represent alpha). +function toNativeRgb(argb?: string): string { + const hex = (argb ?? "").replace("#", ""); + if (hex.length === 8) return `#${hex.slice(2)}`; + if (hex.length === 6) return `#${hex}`; + return "#000000"; +} + +// Editable color cell: a native picker for the RGB part (preserving any alpha) +// plus a free-text field for full ARGB control. +function ColorInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const hex = (value ?? "").replace("#", ""); + const alpha = hex.length === 8 ? hex.slice(0, 2) : ""; + return ( +
+ + onChange(`#${alpha}${e.target.value.replace("#", "")}`.toUpperCase()) + } + className="h-9 w-9 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0.5" + /> + onChange(e.target.value)} + dir="ltr" + className="font-mono" + placeholder="#FFFFFFFF" + /> +
+ ); +} + +export default function ThemesPage() { + // include_inactive so the editor also lists themes that are turned off. + const { data, loading, error, reload } = useList("/themes", { + include_inactive: 1, + }); + const toast = useToast(); + + const [editing, setEditing] = useState(null); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + + const [name, setName] = useState(""); + const [order, setOrder] = useState(""); + const [isActive, setIsActive] = useState(true); + const [colors, setColors] = useState>({}); + + function openEdit(row: Theme) { + setEditing(row); + setName(row.name ?? ""); + setOrder(row.order != null ? String(row.order) : ""); + setIsActive(row.is_active ?? true); + // Clone so edits don't mutate the loaded row before saving. + setColors({ ...(row.colors ?? {}) }); + setOpen(true); + } + + function setColor(key: string, value: string) { + setColors((prev) => ({ ...prev, [key]: value })); + } + + async function save(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + setSaving(true); + try { + await apiFetch(`/themes/${editing.id}`, { + method: "PUT", + body: { + name, + order: order === "" ? undefined : Number(order), + is_active: isActive, + colors, + }, + }); + toast.success("تم ویرایش شد."); + setOpen(false); + reload(); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : "خطا در ذخیره‌سازی"); + } finally { + setSaving(false); + } + } + + const swatchKeys = [ + "themeUp", + "themeDown", + "lightColorGradient", + "darkColorGradient", + ]; + + const columns: Column[] = [ + { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" }, + { + key: "preview", + header: "پیش‌نمایش", + render: (r) => ( + + {swatchKeys.map((k) => ( + + ))} + + ), + className: "w-32", + }, + { key: "name", header: "نام" }, + { + key: "key", + header: "کلید", + render: (r) => {r.key}, + className: "w-28", + }, + { + key: "order", + header: "ترتیب", + render: (r) => toFa(r.order ?? 0), + className: "w-20", + }, + { + key: "is_active", + header: "وضعیت", + render: (r) => + r.is_active ? ( + فعال + ) : ( + غیرفعال + ), + className: "w-24", + }, + ]; + + return ( +
+ + + ( + + )} + /> + + setOpen(false)} + title={editing ? `ویرایش تم «${editing.name}»` : "ویرایش تم"} + footer={ + <> + + + + } + > +
+
+ + setName(e.target.value)} + required + /> + + + setOrder(e.target.value)} + dir="ltr" + /> + +
+ + + + + +
+

رنگ‌ها

+
+ {Object.keys(colors).map((key) => ( + + setColor(key, v)} + /> + + ))} +
+
+
+
+
+ ); +} diff --git a/lib/nav.ts b/lib/nav.ts index a472dcc..948aa0c 100644 --- a/lib/nav.ts +++ b/lib/nav.ts @@ -72,6 +72,7 @@ export const NAV: NavSection[] = [ items: [ { href: "/dashboard/scenes", label: "صحنه‌ها" }, { href: "/dashboard/scenes/settings", label: "تنظیمات صحنه" }, + { href: "/dashboard/themes", label: "تم‌ها" }, ], }, { diff --git a/package-lock.json b/package-lock.json index 84c9c16..7671d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -277,10 +276,32 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1560,7 +1581,6 @@ "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1620,7 +1640,6 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -2142,6 +2161,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -2190,7 +2243,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2534,7 +2586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3102,7 +3153,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3288,7 +3338,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5475,7 +5524,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5485,7 +5533,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6177,7 +6224,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6340,7 +6386,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6619,7 +6664,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }