Compare commits

...
6 Commits
Author SHA1 Message Date
Amirmahdi b1de8cfdc1 feat: add breath colors 2026-06-16 18:50:40 +03:30
Amirmahdi 90e00bc08c feat: add is_premium 2026-06-16 17:19:46 +03:30
Amirmahdi cfcddb55bb feat: add theme 2026-06-14 15:15:03 +03:30
Amirmahdi 034a345521 fix: deploy fixed 2026-06-13 21:06:31 +03:30
Amirmahdi 504b1e2c8e feat: musci category image 2026-06-13 19:21:23 +03:30
Amirmahdi 60a054710a feat: add pagination music 2026-06-12 15:34:19 +03:30
9 changed files with 620 additions and 23 deletions
+293
View File
@@ -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<BreathingColor>("/breathing-colors?include_inactive=1");
const toast = useToast();
const [editing, setEditing] = useState<BreathingColor | null>(null);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [order, setOrder] = useState("");
const [isActive, setIsActive] = useState(true);
const [colors, setColors] = useState<string[]>(["#5360FC"]);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<BreathingColor | null>(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<BreathingColor>[] = [
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
{
key: "preview",
header: "رنگ",
className: "w-24",
render: (r) => (
<span
className="inline-block h-7 w-12 rounded-md border border-border"
style={gradientStyle(r.colors ?? [])}
/>
),
},
{ key: "name", header: "نام", render: (r) => r.name || "—" },
{
key: "codes",
header: "کدها",
render: (r) => (
<span dir="ltr" className="text-xs text-muted">
{(r.colors ?? []).join("، ")}
</span>
),
},
{ key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? 0), className: "w-20" },
{
key: "is_active",
header: "وضعیت",
className: "w-24",
render: (r) =>
r.is_active ? <Badge tone="success">فعال</Badge> : <Badge tone="neutral">غیرفعال</Badge>,
},
];
return (
<div>
<PageHeader
title="رنگ‌های تنفس"
subtitle="پالت رنگی که کاربر هنگام ساخت تمرین تنفس انتخاب می‌کند"
action={
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
رنگ جدید
</Button>
}
/>
<DataTable
columns={columns}
rows={data}
loading={loading}
error={error}
onRetry={reload}
emptyMessage="هنوز رنگی ثبت نشده است."
emptyAction={
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
رنگ جدید
</Button>
}
actions={(row) => (
<>
<Button variant="ghost" onClick={() => openEdit(row)} aria-label="ویرایش">
<EditIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
onClick={() => setDeleting(row)}
aria-label="حذف"
className="text-danger"
>
<TrashIcon className="h-4 w-4" />
</Button>
</>
)}
/>
<Modal
open={open}
onClose={() => setOpen(false)}
title={editing ? "ویرایش رنگ" : "رنگ جدید"}
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button form="bc-form" type="submit" loading={saving}>
ذخیره
</Button>
</>
}
>
<form id="bc-form" onSubmit={save} className="flex flex-col gap-4">
<Field label="نام">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً بنفش"
autoFocus
/>
</Field>
<Field label="رنگ‌ها" hint="یک رنگ = تک‌رنگ، چند رنگ = گرادینت.">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted">پیشنمایش:</span>
<span
className="h-9 w-20 rounded-lg border border-border"
style={gradientStyle(colors)}
/>
</div>
<div className="flex flex-col gap-2">
{colors.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="color"
value={cssColor(c)}
onChange={(e) =>
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"
/>
<Input
value={c}
onChange={(e) =>
setColors((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))
}
placeholder="#RRGGBB"
dir="ltr"
/>
{colors.length > 1 && (
<Button
type="button"
variant="ghost"
className="text-danger"
onClick={() => setColors((prev) => prev.filter((_, j) => j !== i))}
>
</Button>
)}
</div>
))}
<div>
<Button
type="button"
variant="secondary"
onClick={() => setColors((prev) => [...prev, "#5360FC"])}
>
+ افزودن رنگ
</Button>
</div>
</div>
</Field>
<Field label="ترتیب">
<Input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
dir="ltr"
/>
</Field>
<Switch checked={isActive} onChange={setIsActive} label="فعال" />
</form>
</Modal>
<ConfirmDialog
open={!!deleting}
message={`آیا از حذف این رنگ مطمئن هستید؟`}
loading={removing}
onConfirm={confirmDelete}
onClose={() => setDeleting(null)}
/>
</div>
);
}
+81
View File
@@ -26,6 +26,29 @@ interface BreathingTemplate {
duration?: number; duration?: number;
description?: string; description?: string;
image_id?: number; 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 { interface BreathingForm {
@@ -57,11 +80,13 @@ function numOrUndefined(v: string): number | undefined {
export default function BreathingPage() { export default function BreathingPage() {
const { data, loading, error, reload } = const { data, loading, error, reload } =
useList<BreathingTemplate>("/breathing-templates"); useList<BreathingTemplate>("/breathing-templates");
const { data: palette } = useList<BreathingColor>("/breathing-colors");
const toast = useToast(); const toast = useToast();
const [editing, setEditing] = useState<BreathingTemplate | null>(null); const [editing, setEditing] = useState<BreathingTemplate | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [form, setForm] = useState<BreathingForm>(emptyForm); const [form, setForm] = useState<BreathingForm>(emptyForm);
const [breathingColorId, setBreathingColorId] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<BreathingTemplate | null>(null); const [deleting, setDeleting] = useState<BreathingTemplate | null>(null);
@@ -74,6 +99,7 @@ export default function BreathingPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
setForm(emptyForm); setForm(emptyForm);
setBreathingColorId(null);
setOpen(true); setOpen(true);
} }
function openEdit(row: BreathingTemplate) { function openEdit(row: BreathingTemplate) {
@@ -87,6 +113,7 @@ export default function BreathingPage() {
description: row.description ?? "", description: row.description ?? "",
image_id: row.image_id != null ? String(row.image_id) : "", image_id: row.image_id != null ? String(row.image_id) : "",
}); });
setBreathingColorId(row.breathing_color_id ?? null);
setOpen(true); setOpen(true);
} }
@@ -102,6 +129,7 @@ export default function BreathingPage() {
duration: numOrUndefined(form.duration), duration: numOrUndefined(form.duration),
description: form.description, description: form.description,
image_id: numOrUndefined(form.image_id), image_id: numOrUndefined(form.image_id),
breathing_color_id: breathingColorId ?? undefined,
}; };
if (editing) { if (editing) {
// Update is JSON on /breathing-templates/:id // Update is JSON on /breathing-templates/:id
@@ -167,6 +195,21 @@ export default function BreathingPage() {
header: "مدت زمان", header: "مدت زمان",
render: (r) => (r.duration != null ? formatDuration(r.duration) : "—"), render: (r) => (r.duration != null ? formatDuration(r.duration) : "—"),
}, },
{
key: "color",
header: "رنگ",
className: "w-20",
render: (r) =>
r.breathing_color?.colors?.length ? (
<span
className="inline-block h-6 w-10 rounded-md border border-border"
style={gradientStyle(r.breathing_color.colors)}
title={r.breathing_color.name ?? ""}
/>
) : (
"—"
),
},
]; ];
return ( return (
@@ -288,6 +331,44 @@ export default function BreathingPage() {
placeholder="توضیح کوتاه درباره قالب" placeholder="توضیح کوتاه درباره قالب"
/> />
</Field> </Field>
<Field
label="رنگ تمرین"
hint={
palette.length
? "یک رنگ از پالت انتخاب کنید (در «رنگ‌های تنفس» مدیریت می‌شود)."
: "هنوز رنگی در «رنگ‌های تنفس» تعریف نشده است."
}
>
<div className="flex flex-wrap items-center gap-2">
{/* "no color" option */}
<button
type="button"
title="بدون رنگ"
onClick={() => setBreathingColorId(null)}
className={`flex h-9 w-9 items-center justify-center rounded-full border-2 text-muted ${
breathingColorId === null ? "border-primary ring-2 ring-primary/40" : "border-border"
}`}
>
</button>
{palette.map((p) => {
const selected = breathingColorId === p.id;
return (
<button
key={p.id}
type="button"
title={p.name ?? ""}
onClick={() => setBreathingColorId(p.id)}
style={gradientStyle(p.colors)}
className={`h-9 w-9 rounded-full border-2 transition ${
selected ? "border-primary ring-2 ring-primary/40" : "border-border"
}`}
/>
);
})}
</div>
</Field>
</form> </form>
</Modal> </Modal>
+36 -18
View File
@@ -17,6 +17,9 @@ import {
PageHeader, PageHeader,
} from "@/components/ui"; } from "@/components/ui";
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { ImagePicker } from "@/components/ImagePicker";
import { MediaPreview } from "@/components/MediaPreview";
import { pickUrl, IMAGE_KEYS } from "@/lib/media";
import { toFa } from "@/lib/utils"; import { toFa } from "@/lib/utils";
interface MusicCategory { interface MusicCategory {
@@ -25,6 +28,8 @@ interface MusicCategory {
description?: string; description?: string;
order?: number; order?: number;
is_active?: boolean; is_active?: boolean;
image_id?: number | null;
image?: unknown;
} }
export default function MusicCategoriesPage() { export default function MusicCategoriesPage() {
@@ -39,7 +44,8 @@ export default function MusicCategoriesPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [order, setOrder] = useState(""); const [order, setOrder] = useState("");
const [imageId, setImageId] = useState(""); const [imageId, setImageId] = useState<number | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
const [deleting, setDeleting] = useState<MusicCategory | null>(null); const [deleting, setDeleting] = useState<MusicCategory | null>(null);
@@ -50,7 +56,8 @@ export default function MusicCategoriesPage() {
setName(""); setName("");
setDescription(""); setDescription("");
setOrder(""); setOrder("");
setImageId(""); setImageId(null);
setImageFile(null);
setIsActive(true); setIsActive(true);
setOpen(true); setOpen(true);
} }
@@ -59,7 +66,8 @@ export default function MusicCategoriesPage() {
setName(row.name ?? ""); setName(row.name ?? "");
setDescription(row.description ?? ""); setDescription(row.description ?? "");
setOrder(row.order != null ? String(row.order) : ""); setOrder(row.order != null ? String(row.order) : "");
setImageId(""); setImageId(row.image_id ?? null);
setImageFile(null);
setIsActive(row.is_active ?? true); setIsActive(row.is_active ?? true);
setOpen(true); setOpen(true);
} }
@@ -69,15 +77,17 @@ export default function MusicCategoriesPage() {
setSaving(true); setSaving(true);
try { try {
if (editing) { if (editing) {
// Update is JSON on /music-categories/:id // Update is multipart (POST) so an image file can be uploaded.
await apiFetch(`/music-categories/${editing.id}`, { await apiFetch(`/music-categories/${editing.id}`, {
method: "PUT", method: "POST",
body: { body: toFormData({
name, name,
description, description,
order: order === "" ? undefined : Number(order), order: order === "" ? undefined : Number(order),
is_active: isActive, is_active: isActive,
}, image_id: imageId,
image: imageFile,
}),
}); });
toast.success("دسته‌بندی ویرایش شد."); toast.success("دسته‌بندی ویرایش شد.");
} else { } else {
@@ -88,8 +98,9 @@ export default function MusicCategoriesPage() {
name, name,
description, description,
order, order,
image_id: imageId,
is_active: isActive, is_active: isActive,
image_id: imageId,
image: imageFile,
}), }),
}); });
toast.success("دسته‌بندی افزوده شد."); toast.success("دسته‌بندی افزوده شد.");
@@ -120,6 +131,14 @@ export default function MusicCategoriesPage() {
const columns: Column<MusicCategory>[] = [ const columns: Column<MusicCategory>[] = [
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" }, { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
{
key: "image",
header: "تصویر",
className: "w-20",
render: (r) => (
<MediaPreview kind="image" src={pickUrl(r, IMAGE_KEYS)} label={r.name} />
),
},
{ key: "name", header: "نام" }, { key: "name", header: "نام" },
{ {
key: "description", key: "description",
@@ -223,16 +242,15 @@ export default function MusicCategoriesPage() {
dir="ltr" dir="ltr"
/> />
</Field> </Field>
{!editing && ( <Field label="تصویر">
<Field label="شناسه تصویر"> <ImagePicker
<Input imageId={imageId}
value={imageId} onPickId={setImageId}
onChange={(e) => setImageId(e.target.value)} file={imageFile}
dir="ltr" onPickFile={setImageFile}
placeholder="image_id" existingUrl={editing ? pickUrl(editing, IMAGE_KEYS) : null}
/> />
</Field> </Field>
)}
<Switch checked={isActive} onChange={setIsActive} label="فعال" /> <Switch checked={isActive} onChange={setIsActive} label="فعال" />
</form> </form>
</Modal> </Modal>
+13 -2
View File
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { apiFetch, ApiError, toFormData } from "@/lib/api"; import { apiFetch, ApiError, toFormData } from "@/lib/api";
import { useList } from "@/lib/useResource"; import { useList, usePaginated } from "@/lib/useResource";
import { useToast } from "@/components/toast"; import { useToast } from "@/components/toast";
import { DataTable, type Column } from "@/components/DataTable"; import { DataTable, type Column } from "@/components/DataTable";
import { import {
@@ -13,6 +13,7 @@ import {
Select, Select,
Modal, Modal,
PageHeader, PageHeader,
Pagination,
} from "@/components/ui"; } from "@/components/ui";
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { formatMinutes, fileNameToTitle } from "@/lib/utils"; import { formatMinutes, fileNameToTitle } from "@/lib/utils";
@@ -45,7 +46,8 @@ interface Track {
} }
export default function MusicTracksPage() { export default function MusicTracksPage() {
const { data, loading, error, reload } = useList<Track>("/music"); const { data, meta, page, setPage, loading, error, reload } =
usePaginated<Track>("/music", { perPage: 20 });
const { data: playlists } = useList<Playlist>("/music-playlists"); const { data: playlists } = useList<Playlist>("/music-playlists");
const toast = useToast(); const toast = useToast();
@@ -256,6 +258,15 @@ export default function MusicTracksPage() {
)} )}
/> />
{meta && (
<Pagination
page={page}
lastPage={meta.last_page}
total={meta.total}
onChange={setPage}
/>
)}
<Modal <Modal
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
+83
View File
@@ -13,6 +13,7 @@ import {
Input, Input,
Modal, Modal,
PageHeader, PageHeader,
Select,
Switch, Switch,
} from "@/components/ui"; } from "@/components/ui";
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
@@ -20,22 +21,62 @@ import { toFa } from "@/lib/utils";
import { MediaPreview } from "@/components/MediaPreview"; import { MediaPreview } from "@/components/MediaPreview";
import { pickUrl, SOUND_KEYS, VIDEO_KEYS, IMAGE_KEYS } from "@/lib/media"; import { pickUrl, SOUND_KEYS, VIDEO_KEYS, IMAGE_KEYS } from "@/lib/media";
interface Theme {
id: number;
key: string;
name: string;
colors: Record<string, string>;
}
interface Scene { interface Scene {
id: number; id: number;
name: string; name: string;
order?: number; order?: number;
is_active?: boolean; is_active?: boolean;
is_premium?: boolean;
theme_id?: number | null;
theme?: Theme | null;
}
// "#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}`;
}
function ThemeSwatch({ theme }: { theme?: Theme | null }) {
if (!theme) return <span className="text-muted"></span>;
const keys = ["themeUp", "themeDown", "lightColorGradient", "darkColorGradient"];
return (
<span className="inline-flex items-center gap-2">
<span className="inline-flex overflow-hidden rounded-full border border-border">
{keys.map((k) => (
<span
key={k}
className="h-4 w-4"
style={{ backgroundColor: cssColor(theme.colors?.[k]) }}
/>
))}
</span>
{theme.name}
</span>
);
} }
export default function ScenesPage() { export default function ScenesPage() {
const { data, loading, error, reload } = useList<Scene>("/scenes"); const { data, loading, error, reload } = useList<Scene>("/scenes");
const { data: themes } = useList<Theme>("/themes");
const toast = useToast(); const toast = useToast();
const [editing, setEditing] = useState<Scene | null>(null); const [editing, setEditing] = useState<Scene | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [order, setOrder] = useState(""); const [order, setOrder] = useState("");
const [themeId, setThemeId] = useState("");
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
const [isPremium, setIsPremium] = useState(false);
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
const [video, setVideo] = useState<File | null>(null); const [video, setVideo] = useState<File | null>(null);
const [sound, setSound] = useState<File | null>(null); const [sound, setSound] = useState<File | null>(null);
@@ -48,7 +89,9 @@ export default function ScenesPage() {
setEditing(null); setEditing(null);
setName(""); setName("");
setOrder(""); setOrder("");
setThemeId("");
setIsActive(true); setIsActive(true);
setIsPremium(false);
setImage(null); setImage(null);
setVideo(null); setVideo(null);
setSound(null); setSound(null);
@@ -58,7 +101,9 @@ export default function ScenesPage() {
setEditing(row); setEditing(row);
setName(row.name ?? ""); setName(row.name ?? "");
setOrder(row.order != null ? String(row.order) : ""); setOrder(row.order != null ? String(row.order) : "");
setThemeId(row.theme_id != null ? String(row.theme_id) : "");
setIsActive(!!row.is_active); setIsActive(!!row.is_active);
setIsPremium(!!row.is_premium);
setImage(null); setImage(null);
setVideo(null); setVideo(null);
setSound(null); setSound(null);
@@ -73,7 +118,9 @@ export default function ScenesPage() {
const body = toFormData({ const body = toFormData({
name, name,
order, order,
theme_id: themeId,
is_active: isActive, is_active: isActive,
is_premium: isPremium,
image, image,
video, video,
sound, sound,
@@ -112,6 +159,11 @@ export default function ScenesPage() {
const columns: Column<Scene>[] = [ const columns: Column<Scene>[] = [
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" }, { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
{ key: "name", header: "نام صحنه" }, { key: "name", header: "نام صحنه" },
{
key: "theme",
header: "قالب رنگی",
render: (r) => <ThemeSwatch theme={r.theme} />,
},
{ {
key: "image", key: "image",
header: "تصویر", header: "تصویر",
@@ -153,6 +205,17 @@ export default function ScenesPage() {
), ),
className: "w-28", className: "w-28",
}, },
{
key: "is_premium",
header: "نوع",
render: (r) =>
r.is_premium ? (
<Badge tone="primary">ویژه</Badge>
) : (
<Badge tone="neutral">رایگان</Badge>
),
className: "w-24",
},
]; ];
return ( return (
@@ -236,8 +299,28 @@ export default function ScenesPage() {
/> />
</Field> </Field>
<Field label="قالب رنگی">
<Select value={themeId} onChange={(e) => setThemeId(e.target.value)}>
<option value=""> بدون قالب </option>
{themes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
{themeId && (
<div className="mt-2">
<ThemeSwatch
theme={themes.find((t) => String(t.id) === themeId) ?? null}
/>
</div>
)}
</Field>
<Switch checked={isActive} onChange={setIsActive} label="فعال" /> <Switch checked={isActive} onChange={setIsActive} label="فعال" />
<Switch checked={isPremium} onChange={setIsPremium} label="ویژه (پولی)" />
<Field <Field
label="تصویر" label="تصویر"
hint={editing ? "در صورت عدم انتخاب، تصویر قبلی حفظ می‌شود." : undefined} hint={editing ? "در صورت عدم انتخاب، تصویر قبلی حفظ می‌شود." : undefined}
+39 -1
View File
@@ -8,7 +8,7 @@ import {
type TextareaHTMLAttributes, type TextareaHTMLAttributes,
useEffect, useEffect,
} from "react"; } from "react";
import { cn } from "@/lib/utils"; import { cn, toFa } from "@/lib/utils";
import { CloseIcon, SpinnerIcon } from "./icons"; import { CloseIcon, SpinnerIcon } from "./icons";
/* ------------------------------- Button -------------------------------- */ /* ------------------------------- Button -------------------------------- */
@@ -192,6 +192,44 @@ export function Badge({
); );
} }
export function Pagination({
page,
lastPage,
total,
onChange,
}: {
page: number;
lastPage: number;
total?: number;
onChange: (p: number) => void;
}) {
if (lastPage <= 1) return null;
return (
<div className="mt-4 flex items-center justify-between gap-3 text-sm">
<span className="text-muted">
{total != null && `${toFa(total)} مورد · `}
صفحه {toFa(page)} از {toFa(lastPage)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
قبلی
</Button>
<Button
variant="secondary"
onClick={() => onChange(page + 1)}
disabled={page >= lastPage}
>
بعدی
</Button>
</div>
</div>
);
}
export function EmptyState({ export function EmptyState({
title, title,
message, message,
+4 -1
View File
@@ -87,7 +87,10 @@ export const NAV: NavSection[] = [
{ {
label: "تمرین تنفس", label: "تمرین تنفس",
icon: BreathIcon, icon: BreathIcon,
items: [{ href: "/dashboard/breathing", label: "قالب‌های تنفس" }], items: [
{ href: "/dashboard/breathing", label: "قالب‌های تنفس" },
{ href: "/dashboard/breathing/colors", label: "رنگ‌های تنفس" },
],
}, },
{ {
label: "پرسش‌ها", label: "پرسش‌ها",
+67
View File
@@ -51,6 +51,73 @@ export function useList<T = unknown>(
return { data, loading, error, reload }; return { data, loading, error, reload };
} }
export interface PageMeta {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
// Paginated GET-list hook for Laravel paginator responses
// ({ data, current_page, last_page, per_page, total }).
export function usePaginated<T = unknown>(
path: string | null,
options?: { perPage?: number; query?: Query },
): {
data: T[];
meta: PageMeta | null;
page: number;
setPage: (p: number) => void;
loading: boolean;
error: string | null;
reload: () => void;
} {
const perPage = options?.perPage ?? 20;
const query = options?.query;
const [page, setPage] = useState(1);
const [data, setData] = useState<T[]>([]);
const [meta, setMeta] = useState<PageMeta | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryKey = JSON.stringify(query ?? {});
const reload = useCallback(() => {
if (!path) {
setData([]);
setMeta(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch(path, { query: { ...query, page, per_page: perPage } })
.then((res) => {
const r = (res ?? {}) as Record<string, unknown>;
const rows = Array.isArray(r.data) ? (r.data as T[]) : [];
setData(rows);
setMeta({
current_page: Number(r.current_page ?? page),
last_page: Number(r.last_page ?? 1),
per_page: Number(r.per_page ?? perPage),
total: Number(r.total ?? rows.length),
});
})
.catch((e: unknown) => {
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات");
setData([]);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, page, perPage, queryKey]);
useEffect(() => {
reload();
}, [reload]);
return { data, meta, page, setPage, loading, error, reload };
}
// Single-record GET hook. // Single-record GET hook.
export function useItem<T = unknown>(path: string | null): { export function useItem<T = unknown>(path: string | null): {
data: T | null; data: T | null;
+4 -1
View File
@@ -22,7 +22,10 @@ DEPLOY_USER="${DEPLOY_USER:-ubuntu}"
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/aramland-admin}" DEPLOY_PATH="${DEPLOY_PATH:-/var/www/aramland-admin}"
DEPLOY_PASSWORD="${DEPLOY_PASSWORD:-}" DEPLOY_PASSWORD="${DEPLOY_PASSWORD:-}"
echo "▸ Building locally…" echo "▸ Building locally (clean)…"
# Remove caches so the production build never type-checks a stale `.next/dev`
# validator (tsconfig includes .next/dev/types) and never ships stale out/ files.
rm -rf .next out
npm run build npm run build
if [ ! -d out ]; then if [ ! -d out ]; then