From b1de8cfdc1c0240af6dd83c5e4172a260a3d5307 Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Tue, 16 Jun 2026 18:50:40 +0330 Subject: [PATCH] feat: add breath colors --- app/dashboard/breathing/colors/page.tsx | 293 ++++++++++++++++++++++++ app/dashboard/breathing/page.tsx | 81 +++++++ lib/nav.ts | 5 +- 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 app/dashboard/breathing/colors/page.tsx diff --git a/app/dashboard/breathing/colors/page.tsx b/app/dashboard/breathing/colors/page.tsx new file mode 100644 index 0000000..8c129a3 --- /dev/null +++ b/app/dashboard/breathing/colors/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useState } from "react"; +import { apiFetch, ApiError } from "@/lib/api"; +import { useList } from "@/lib/useResource"; +import { useToast } from "@/components/toast"; +import { DataTable, type Column } from "@/components/DataTable"; +import { + Badge, + Button, + ConfirmDialog, + Field, + Input, + Modal, + PageHeader, + Switch, +} from "@/components/ui"; +import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; +import { toFa } from "@/lib/utils"; + +interface BreathingColor { + id: number; + name?: string | null; + colors: string[]; + order?: number; + is_active?: boolean; +} + +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)}`; + return `#${hex}`; +} + +function gradientStyle(colors: string[]): React.CSSProperties { + const cs = (colors.length ? colors : ["#00000000"]).map(cssColor); + if (cs.length === 1) return { background: cs[0] }; + return { background: `linear-gradient(135deg, ${cs.join(", ")})` }; +} + +export default function BreathingColorsPage() { + const { data, loading, error, reload } = + useList("/breathing-colors?include_inactive=1"); + const toast = useToast(); + + const [editing, setEditing] = useState(null); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [order, setOrder] = useState(""); + const [isActive, setIsActive] = useState(true); + const [colors, setColors] = useState(["#5360FC"]); + const [saving, setSaving] = useState(false); + + const [deleting, setDeleting] = useState(null); + const [removing, setRemoving] = useState(false); + + function openCreate() { + setEditing(null); + setName(""); + setOrder(""); + setIsActive(true); + setColors(["#5360FC"]); + setOpen(true); + } + function openEdit(row: BreathingColor) { + setEditing(row); + setName(row.name ?? ""); + setOrder(row.order != null ? String(row.order) : ""); + setIsActive(row.is_active ?? true); + setColors(Array.isArray(row.colors) && row.colors.length ? row.colors : ["#5360FC"]); + setOpen(true); + } + + async function save(e: React.FormEvent) { + e.preventDefault(); + const clean = colors.map((c) => c.trim()).filter(Boolean); + if (!clean.length) { + toast.error("حداقل یک رنگ وارد کنید."); + return; + } + setSaving(true); + try { + const payload = { + name: name || null, + colors: clean, + order: order === "" ? undefined : Number(order), + is_active: isActive, + }; + if (editing) { + await apiFetch(`/breathing-colors/${editing.id}`, { method: "PUT", body: payload }); + toast.success("رنگ ویرایش شد."); + } else { + await apiFetch("/breathing-colors", { method: "POST", body: payload }); + toast.success("رنگ افزوده شد."); + } + setOpen(false); + reload(); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : "خطا در ذخیره‌سازی"); + } finally { + setSaving(false); + } + } + + async function confirmDelete() { + if (!deleting) return; + setRemoving(true); + try { + await apiFetch(`/breathing-colors/${deleting.id}`, { method: "DELETE" }); + toast.success("رنگ حذف شد."); + setDeleting(null); + reload(); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : "خطا در حذف"); + } finally { + setRemoving(false); + } + } + + const columns: Column[] = [ + { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" }, + { + key: "preview", + header: "رنگ", + className: "w-24", + render: (r) => ( + + ), + }, + { key: "name", header: "نام", render: (r) => r.name || "—" }, + { + key: "codes", + header: "کدها", + render: (r) => ( + + {(r.colors ?? []).join("، ")} + + ), + }, + { key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? 0), className: "w-20" }, + { + key: "is_active", + header: "وضعیت", + className: "w-24", + render: (r) => + r.is_active ? فعال : غیرفعال, + }, + ]; + + return ( +
+ } onClick={openCreate}> + رنگ جدید + + } + /> + + } onClick={openCreate}> + رنگ جدید + + } + actions={(row) => ( + <> + + + + )} + /> + + setOpen(false)} + title={editing ? "ویرایش رنگ" : "رنگ جدید"} + footer={ + <> + + + + } + > +
+ + setName(e.target.value)} + placeholder="مثلاً بنفش" + autoFocus + /> + + + +
+ پیش‌نمایش: + +
+
+ {colors.map((c, i) => ( +
+ + setColors((prev) => prev.map((x, j) => (j === i ? e.target.value : x))) + } + className="h-9 w-10 cursor-pointer rounded border border-border bg-transparent" + /> + + setColors((prev) => prev.map((x, j) => (j === i ? e.target.value : x))) + } + placeholder="#RRGGBB" + dir="ltr" + /> + {colors.length > 1 && ( + + )} +
+ ))} +
+ +
+
+
+ + + setOrder(e.target.value)} + dir="ltr" + /> + + + + +
+ + setDeleting(null)} + /> +
+ ); +} diff --git a/app/dashboard/breathing/page.tsx b/app/dashboard/breathing/page.tsx index 37f124c..dbba18b 100644 --- a/app/dashboard/breathing/page.tsx +++ b/app/dashboard/breathing/page.tsx @@ -26,6 +26,29 @@ interface BreathingTemplate { duration?: number; description?: string; image_id?: number; + breathing_color_id?: number | null; + breathing_color?: BreathingColor | null; +} + +interface BreathingColor { + id: number; + name?: string | null; + colors: string[]; +} + +// "#AARRGGBB" (ARGB) or "#RRGGBB" → a CSS color string. +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→RGB(drop alpha) + return `#${hex}`; +} + +// Linear gradient (or solid) preview for a list of colors. +function gradientStyle(colors: string[]): React.CSSProperties { + const cs = (colors.length ? colors : ["#00000000"]).map(cssColor); + if (cs.length === 1) return { background: cs[0] }; + return { background: `linear-gradient(135deg, ${cs.join(", ")})` }; } interface BreathingForm { @@ -57,11 +80,13 @@ function numOrUndefined(v: string): number | undefined { export default function BreathingPage() { const { data, loading, error, reload } = useList("/breathing-templates"); + const { data: palette } = useList("/breathing-colors"); const toast = useToast(); const [editing, setEditing] = useState(null); const [open, setOpen] = useState(false); const [form, setForm] = useState(emptyForm); + const [breathingColorId, setBreathingColorId] = useState(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(null); @@ -74,6 +99,7 @@ export default function BreathingPage() { function openCreate() { setEditing(null); setForm(emptyForm); + setBreathingColorId(null); setOpen(true); } function openEdit(row: BreathingTemplate) { @@ -87,6 +113,7 @@ export default function BreathingPage() { description: row.description ?? "", image_id: row.image_id != null ? String(row.image_id) : "", }); + setBreathingColorId(row.breathing_color_id ?? null); setOpen(true); } @@ -102,6 +129,7 @@ export default function BreathingPage() { duration: numOrUndefined(form.duration), description: form.description, image_id: numOrUndefined(form.image_id), + breathing_color_id: breathingColorId ?? undefined, }; if (editing) { // Update is JSON on /breathing-templates/:id @@ -167,6 +195,21 @@ export default function BreathingPage() { header: "مدت زمان", render: (r) => (r.duration != null ? formatDuration(r.duration) : "—"), }, + { + key: "color", + header: "رنگ", + className: "w-20", + render: (r) => + r.breathing_color?.colors?.length ? ( + + ) : ( + "—" + ), + }, ]; return ( @@ -288,6 +331,44 @@ export default function BreathingPage() { placeholder="توضیح کوتاه درباره قالب" /> + + +
+ {/* "no color" option */} + + {palette.map((p) => { + const selected = breathingColorId === p.id; + return ( +
+
diff --git a/lib/nav.ts b/lib/nav.ts index a8cb5a7..957225a 100644 --- a/lib/nav.ts +++ b/lib/nav.ts @@ -87,7 +87,10 @@ export const NAV: NavSection[] = [ { label: "تمرین تنفس", icon: BreathIcon, - items: [{ href: "/dashboard/breathing", label: "قالب‌های تنفس" }], + items: [ + { href: "/dashboard/breathing", label: "قالب‌های تنفس" }, + { href: "/dashboard/breathing/colors", label: "رنگ‌های تنفس" }, + ], }, { label: "پرسش‌ها",