feat: initial
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa, formatDuration } from "@/lib/utils";
|
||||
|
||||
interface BreathingTemplate {
|
||||
id: number;
|
||||
name?: string;
|
||||
inhale?: number;
|
||||
exhale?: number;
|
||||
breath_hold?: number;
|
||||
duration?: number;
|
||||
description?: string;
|
||||
image_id?: number;
|
||||
}
|
||||
|
||||
interface BreathingForm {
|
||||
name: string;
|
||||
inhale: string;
|
||||
exhale: string;
|
||||
breath_hold: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
image_id: string;
|
||||
}
|
||||
|
||||
const emptyForm: BreathingForm = {
|
||||
name: "",
|
||||
inhale: "",
|
||||
exhale: "",
|
||||
breath_hold: "",
|
||||
duration: "",
|
||||
description: "",
|
||||
image_id: "",
|
||||
};
|
||||
|
||||
function numOrUndefined(v: string): number | undefined {
|
||||
if (v === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
export default function BreathingPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<BreathingTemplate>("/breathing-templates");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<BreathingTemplate | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<BreathingForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<BreathingTemplate | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function set<K extends keyof BreathingForm>(key: K, value: string) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: BreathingTemplate) {
|
||||
setEditing(row);
|
||||
setForm({
|
||||
name: row.name ?? "",
|
||||
inhale: row.inhale != null ? String(row.inhale) : "",
|
||||
exhale: row.exhale != null ? String(row.exhale) : "",
|
||||
breath_hold: row.breath_hold != null ? String(row.breath_hold) : "",
|
||||
duration: row.duration != null ? String(row.duration) : "",
|
||||
description: row.description ?? "",
|
||||
image_id: row.image_id != null ? String(row.image_id) : "",
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
inhale: numOrUndefined(form.inhale),
|
||||
exhale: numOrUndefined(form.exhale),
|
||||
breath_hold: numOrUndefined(form.breath_hold),
|
||||
duration: numOrUndefined(form.duration),
|
||||
description: form.description,
|
||||
image_id: numOrUndefined(form.image_id),
|
||||
};
|
||||
if (editing) {
|
||||
// Update is JSON on /breathing-templates/:id
|
||||
await apiFetch(`/breathing-templates/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: payload,
|
||||
});
|
||||
toast.success("قالب تنفس ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /breathing-templates
|
||||
await apiFetch("/breathing-templates", {
|
||||
method: "POST",
|
||||
body: toFormData(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-templates/${deleting.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
toast.success("قالب تنفس حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<BreathingTemplate>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "name", header: "نام", render: (r) => r.name ?? "—" },
|
||||
{
|
||||
key: "inhale",
|
||||
header: "دم",
|
||||
render: (r) => (r.inhale != null ? toFa(r.inhale) : "—"),
|
||||
},
|
||||
{
|
||||
key: "exhale",
|
||||
header: "بازدم",
|
||||
render: (r) => (r.exhale != null ? toFa(r.exhale) : "—"),
|
||||
},
|
||||
{
|
||||
key: "breath_hold",
|
||||
header: "حبس نفس",
|
||||
render: (r) => (r.breath_hold != null ? toFa(r.breath_hold) : "—"),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "مدت زمان",
|
||||
render: (r) => (r.duration != null ? formatDuration(r.duration) : "—"),
|
||||
},
|
||||
];
|
||||
|
||||
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="breathing-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="breathing-form"
|
||||
onSubmit={save}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="مثلاً تنفس آرامبخش"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="دم (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.inhale}
|
||||
onChange={(e) => set("inhale", e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="بازدم (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.exhale}
|
||||
onChange={(e) => set("exhale", e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="حبس نفس (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.breath_hold}
|
||||
onChange={(e) => set("breath_hold", e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="مدت زمان (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.duration}
|
||||
onChange={(e) => set("duration", e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="شناسه تصویر">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.image_id}
|
||||
onChange={(e) => set("image_id", e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
placeholder="توضیح کوتاه درباره قالب"
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name ?? deleting?.id}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"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 {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
PageHeader,
|
||||
Card,
|
||||
} from "@/components/ui";
|
||||
import { TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Comment {
|
||||
id?: number;
|
||||
content?: string;
|
||||
text?: string;
|
||||
body?: string;
|
||||
created_at?: string;
|
||||
user?: {
|
||||
name?: string;
|
||||
};
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function CommentsPage() {
|
||||
const toast = useToast();
|
||||
|
||||
// Form (uncommitted) state
|
||||
const [type, setType] = useState("media");
|
||||
const [id, setId] = useState("");
|
||||
|
||||
// Committed query state used to build the request path
|
||||
const [query, setQuery] = useState<{ type: string; id: string } | null>(null);
|
||||
|
||||
const path =
|
||||
query && query.type && query.id
|
||||
? `/comments/${query.type}/${query.id}`
|
||||
: null;
|
||||
|
||||
const { data, loading, error, reload } = useList<Comment>(path);
|
||||
|
||||
const [deleting, setDeleting] = useState<Comment | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!type || !id.trim()) {
|
||||
toast.error("نوع و شناسه را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
setQuery({ type, id: id.trim() });
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting || !query) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await apiFetch(`/comments/${query.type}/${query.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
toast.success("نظر حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Comment>[] = [
|
||||
{
|
||||
key: "user",
|
||||
header: "کاربر",
|
||||
render: (r) => r.user?.name ?? r.name ?? "—",
|
||||
},
|
||||
{
|
||||
key: "content",
|
||||
header: "متن",
|
||||
render: (r) => r.content ?? r.text ?? r.body ?? "—",
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "تاریخ",
|
||||
render: (r) =>
|
||||
r.created_at
|
||||
? toFa(new Date(r.created_at).toLocaleDateString("fa-IR"))
|
||||
: "—",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="نظرات"
|
||||
subtitle="مشاهده و مدیریت نظرات بر اساس نوع و شناسه محتوا"
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="flex flex-wrap items-end gap-4"
|
||||
>
|
||||
<div className="min-w-40">
|
||||
<Field label="نوع">
|
||||
<Select value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="media">رسانه</option>
|
||||
<option value="music">موسیقی</option>
|
||||
<option value="playlist">پلیلیست</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="min-w-40">
|
||||
<Field label="شناسه">
|
||||
<Input
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="شناسه محتوا"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="submit">نمایش</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{path ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={reload}
|
||||
emptyMessage={
|
||||
path
|
||||
? "نظری برای این مورد ثبت نشده است."
|
||||
: "برای مشاهده نظرات، نوع و شناسه را انتخاب و روی «نمایش» بزنید."
|
||||
}
|
||||
actions={(row) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleting(row)}
|
||||
aria-label="حذف"
|
||||
className="text-danger"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-muted">
|
||||
برای مشاهده نظرات، نوع و شناسه را انتخاب و دکمه نمایش را بزنید.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message="آیا از حذف این نظر مطمئن هستید؟"
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, IMAGE_KEYS } from "@/lib/media";
|
||||
|
||||
interface ImageItem {
|
||||
id: number;
|
||||
title?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
type ImageType = "public" | "private";
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
public: "عمومی",
|
||||
private: "خصوصی",
|
||||
};
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { data, loading, error, reload } = useList<ImageItem>("/images/all");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<ImageItem | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState<ImageType>("public");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<ImageItem | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setTitle("");
|
||||
setType("public");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: ImageItem) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setType(row.type === "private" ? "private" : "public");
|
||||
setDescription(row.description ?? "");
|
||||
setFile(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /images/:id
|
||||
await apiFetch(`/images/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { title, description, type },
|
||||
});
|
||||
toast.success("تصویر ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /images
|
||||
await apiFetch("/images", {
|
||||
method: "POST",
|
||||
body: toFormData({ image: file, title, type, description }),
|
||||
});
|
||||
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(`/images/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("تصویر حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<ImageItem>[] = [
|
||||
{
|
||||
key: "preview",
|
||||
header: "پیشنمایش",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="image" src={pickUrl(r, IMAGE_KEYS)} label={r.title} />
|
||||
),
|
||||
},
|
||||
{ key: "title", header: "عنوان", render: (r) => r.title ?? "—" },
|
||||
{
|
||||
key: "type",
|
||||
header: "نوع",
|
||||
render: (r) =>
|
||||
r.type ? (
|
||||
<Badge tone={r.type === "private" ? "danger" : "primary"}>
|
||||
{typeLabels[r.type] ?? r.type}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "توضیحات",
|
||||
render: (r) => r.description ?? "—",
|
||||
},
|
||||
];
|
||||
|
||||
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="image-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="image-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
{!editing && (
|
||||
<Field label="فایل تصویر" required>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="عنوان">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثلاً پسزمینه آرامش"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="نوع">
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as ImageType)}
|
||||
>
|
||||
<option value="public">عمومی</option>
|
||||
<option value="private">خصوصی</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="توضیح کوتاه درباره تصویر"
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title ?? deleting?.id}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { Spinner, Button } from "@/components/ui";
|
||||
import { LogoutIcon, MenuIcon, CloseIcon } from "@/components/icons";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { token, ready, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Client-side route protection (static export has no server to gate on).
|
||||
useEffect(() => {
|
||||
if (ready && !token) router.replace("/login");
|
||||
}, [ready, token, router]);
|
||||
|
||||
if (!ready || !token) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner className="h-10 w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
logout();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-l border-border bg-surface lg:block">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-64 bg-surface shadow-xl">
|
||||
<Sidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur">
|
||||
<button
|
||||
className="rounded-lg p-2 text-muted hover:bg-surface-muted lg:hidden"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label="منو"
|
||||
>
|
||||
{mobileOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" icon={<LogoutIcon className="h-4 w-4" />} onClick={onLogout}>
|
||||
خروج
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import { PageHeader } from "@/components/ui";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
id?: number;
|
||||
rank?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
score?: number;
|
||||
points?: number;
|
||||
user?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<LeaderboardEntry>("/leader-board");
|
||||
|
||||
const columns: Column<LeaderboardEntry>[] = [
|
||||
{
|
||||
key: "rank",
|
||||
header: "رتبه",
|
||||
className: "w-20",
|
||||
render: (r) =>
|
||||
r.rank != null ? toFa(r.rank) : toFa(data.indexOf(r) + 1),
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
header: "کاربر",
|
||||
render: (r) => r.user?.name ?? r.name ?? r.username ?? "—",
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
header: "امتیاز",
|
||||
render: (r) => {
|
||||
const value = r.score ?? r.points;
|
||||
return value != null ? toFa(value) : "—";
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جدول امتیازات"
|
||||
subtitle="رتبهبندی کاربران بر اساس امتیاز"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={reload}
|
||||
emptyTitle="جدول خالی است"
|
||||
emptyMessage="هنوز امتیازی ثبت نشده است."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function MediaCategoriesPage() {
|
||||
const { data, loading, error, reload } = useList<Category>("/categories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Category) {
|
||||
setEditing(row);
|
||||
setName(row.name);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /categories/:id
|
||||
await apiFetch(`/categories/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { name },
|
||||
});
|
||||
toast.success("دستهبندی ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /categories
|
||||
await apiFetch("/categories", {
|
||||
method: "POST",
|
||||
body: toFormData({ name }),
|
||||
});
|
||||
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(`/categories/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("دستهبندی حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Category>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام دستهبندی" },
|
||||
];
|
||||
|
||||
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="category-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="category-form" onSubmit={save}>
|
||||
<Field label="نام دستهبندی" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً کودکان"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, SOUND_KEYS, VIDEO_KEYS, IMAGE_KEYS } from "@/lib/media";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Media {
|
||||
id: number;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
category_id?: number;
|
||||
category?: { id?: number; name?: string };
|
||||
type?: string;
|
||||
duration?: number;
|
||||
visibility?: string;
|
||||
is_premium?: boolean;
|
||||
}
|
||||
|
||||
export default function MediaPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, loading, error, reload } = useList<Media>("/media", { search });
|
||||
const { data: categories } = useList<Category>("/categories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Media | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [caption, setCaption] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [type, setType] = useState("audio");
|
||||
const [duration, setDuration] = useState("");
|
||||
const [visibility, setVisibility] = useState("public");
|
||||
const [isPremium, setIsPremium] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const [deleting, setDeleting] = useState<Media | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setCaption("");
|
||||
setCategoryId("");
|
||||
setType("audio");
|
||||
setDuration("");
|
||||
setVisibility("public");
|
||||
setIsPremium(false);
|
||||
setFile(null);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: Media) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setCaption(row.caption ?? "");
|
||||
setCategoryId(
|
||||
row.category_id != null
|
||||
? String(row.category_id)
|
||||
: row.category?.id != null
|
||||
? String(row.category.id)
|
||||
: "",
|
||||
);
|
||||
setType(row.type ?? "audio");
|
||||
setDuration(row.duration != null ? String(row.duration) : "");
|
||||
setVisibility(row.visibility ?? "public");
|
||||
setIsPremium(!!row.is_premium);
|
||||
setFile(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = toFormData({
|
||||
title,
|
||||
caption,
|
||||
category_id: categoryId,
|
||||
type,
|
||||
duration,
|
||||
visibility,
|
||||
is_premium: isPremium,
|
||||
file,
|
||||
});
|
||||
if (editing) {
|
||||
// File-bearing update: POST /media/:id (multipart, file optional)
|
||||
await apiFetch(`/media/${editing.id}`, { method: "POST", body });
|
||||
toast.success("رسانه ویرایش شد.");
|
||||
} else {
|
||||
// Create: POST /media (multipart)
|
||||
await apiFetch("/media", { method: "POST", body });
|
||||
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(`/media/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("رسانه حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Media>[] = [
|
||||
{
|
||||
key: "play",
|
||||
header: "پخش",
|
||||
className: "w-20",
|
||||
render: (r) =>
|
||||
r.type === "video" ? (
|
||||
<MediaPreview kind="video" src={pickUrl(r, VIDEO_KEYS)} label={r.title} />
|
||||
) : (
|
||||
<MediaPreview kind="audio" src={pickUrl(r, SOUND_KEYS)} label={r.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "thumbnail",
|
||||
header: "تصویر",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="image" src={pickUrl(r, IMAGE_KEYS)} label={r.title} />
|
||||
),
|
||||
},
|
||||
{ key: "title", header: "عنوان", render: (r) => r.title ?? "—" },
|
||||
{
|
||||
key: "category",
|
||||
header: "دستهبندی",
|
||||
render: (r) => r.category?.name ?? "—",
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "نوع",
|
||||
render: (r) =>
|
||||
r.type === "video" ? "ویدیو" : r.type === "audio" ? "صوت" : (r.type ?? "—"),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "مدت",
|
||||
render: (r) => formatDuration(r.duration),
|
||||
},
|
||||
{
|
||||
key: "is_premium",
|
||||
header: "ویژه",
|
||||
render: (r) =>
|
||||
r.is_premium ? (
|
||||
<Badge tone="primary">ویژه</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">رایگان</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="فهرست رسانهها"
|
||||
subtitle="مدیریت محتوای صوتی و تصویری"
|
||||
action={
|
||||
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
|
||||
رسانه جدید
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 max-w-sm">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستجو در رسانهها..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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="media-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="media-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="عنوان" required>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثلاً مدیتیشن صبحگاهی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="توضیحات">
|
||||
<Input
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="توضیح کوتاه"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="دستهبندی" required>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">انتخاب دستهبندی</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name ?? `#${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="نوع" required>
|
||||
<Select value={type} onChange={(e) => setType(e.target.value)} required>
|
||||
<option value="audio">صوت</option>
|
||||
<option value="video">ویدیو</option>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="مدت (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="مثلاً ۳۰۰"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="دسترسی" required>
|
||||
<Select
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="public">عمومی</option>
|
||||
<option value="private">خصوصی</option>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={editing ? "فایل (در صورت تغییر)" : "فایل"}
|
||||
hint={
|
||||
editing
|
||||
? "در صورت خالی بودن، فایل قبلی حفظ میشود."
|
||||
: undefined
|
||||
}
|
||||
required={!editing}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required={!editing}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Switch
|
||||
checked={isPremium}
|
||||
onChange={setIsPremium}
|
||||
label="محتوای ویژه (پولی)"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SubCategory {
|
||||
id: number;
|
||||
name?: string;
|
||||
category_id?: number;
|
||||
category?: { id?: number; name?: string };
|
||||
}
|
||||
|
||||
export default function SubCategoriesPage() {
|
||||
const { data, loading, error, reload } = useList<SubCategory>("/sub-categories");
|
||||
const { data: categories } = useList<Category>("/categories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<SubCategory | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
const [deleting, setDeleting] = useState<SubCategory | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setCategoryId("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: SubCategory) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setCategoryId(
|
||||
row.category_id != null
|
||||
? String(row.category_id)
|
||||
: row.category?.id != null
|
||||
? String(row.category.id)
|
||||
: "",
|
||||
);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /sub-categories/:id
|
||||
await apiFetch(`/sub-categories/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { name, category_id: categoryId },
|
||||
});
|
||||
toast.success("زیردسته ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /sub-categories
|
||||
await apiFetch("/sub-categories", {
|
||||
method: "POST",
|
||||
body: toFormData({ category_id: categoryId, name }),
|
||||
});
|
||||
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(`/sub-categories/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("زیردسته حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<SubCategory>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام", render: (r) => r.name ?? "—" },
|
||||
{
|
||||
key: "category",
|
||||
header: "دستهبندی والد",
|
||||
render: (r) => r.category?.name ?? "—",
|
||||
},
|
||||
];
|
||||
|
||||
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="sub-category-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="sub-category-form"
|
||||
onSubmit={save}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Field label="دستهبندی والد" required>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">انتخاب دستهبندی</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name ?? `#${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً تمرکز"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import { PageHeader, Card } from "@/components/ui";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Mood {
|
||||
id: number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
emoji?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default function MoodsPage() {
|
||||
const { data, loading, error, reload } = useList<Mood>("/moods");
|
||||
|
||||
const columns: Column<Mood>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "name", header: "نام", render: (r) => r.name ?? r.title ?? "—" },
|
||||
{
|
||||
key: "emoji",
|
||||
header: "نماد",
|
||||
render: (r) => r.emoji ?? r.icon ?? "—",
|
||||
className: "w-24",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="حالتها"
|
||||
subtitle="فهرست حالتهای احساسی کاربران"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={reload}
|
||||
emptyTitle="حالتی تعریف نشده"
|
||||
emptyMessage="هیچ حالتی برای نمایش وجود ندارد."
|
||||
/>
|
||||
|
||||
<Card className="mt-4">
|
||||
<p className="text-sm text-muted">
|
||||
حالتها از پیش تعریف شدهاند و قابل ویرایش نیستند.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } 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,
|
||||
Textarea,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface MusicCategory {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export default function MusicCategoriesPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<MusicCategory>("/music-categories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<MusicCategory | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [imageId, setImageId] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const [deleting, setDeleting] = useState<MusicCategory | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setOrder("");
|
||||
setImageId("");
|
||||
setIsActive(true);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: MusicCategory) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setDescription(row.description ?? "");
|
||||
setOrder(row.order != null ? String(row.order) : "");
|
||||
setImageId("");
|
||||
setIsActive(row.is_active ?? true);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /music-categories/:id
|
||||
await apiFetch(`/music-categories/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
order: order === "" ? undefined : Number(order),
|
||||
is_active: isActive,
|
||||
},
|
||||
});
|
||||
toast.success("دستهبندی ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /music-categories
|
||||
await apiFetch("/music-categories", {
|
||||
method: "POST",
|
||||
body: toFormData({
|
||||
name,
|
||||
description,
|
||||
order,
|
||||
image_id: imageId,
|
||||
is_active: isActive,
|
||||
}),
|
||||
});
|
||||
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(`/music-categories/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("دستهبندی حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<MusicCategory>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "description",
|
||||
header: "توضیحات",
|
||||
render: (r) => r.description ?? "—",
|
||||
},
|
||||
{ key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? 0) },
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
render: (r) =>
|
||||
r.is_active ? (
|
||||
<Badge tone="success">فعال</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">غیرفعال</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="music-category-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="music-category-form"
|
||||
onSubmit={save}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً طبیعت"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
{!editing && (
|
||||
<Field label="شناسه تصویر">
|
||||
<Input
|
||||
value={imageId}
|
||||
onChange={(e) => setImageId(e.target.value)}
|
||||
dir="ltr"
|
||||
placeholder="image_id"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Switch checked={isActive} onChange={setIsActive} label="فعال" />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MusicCategory {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function MusicPlaylistsPage() {
|
||||
const { data, loading, error, reload } = useList<Playlist>("/music-playlists");
|
||||
const { data: categories } = useList<MusicCategory>("/music-categories");
|
||||
const { data: subcategories } = useList<MusicCategory>("/music-subcategories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Playlist | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||
const [subcategoryIds, setSubcategoryIds] = useState<number[]>([]);
|
||||
|
||||
const [deleting, setDeleting] = useState<Playlist | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function toggle(list: number[], id: number): number[] {
|
||||
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCategoryIds([]);
|
||||
setSubcategoryIds([]);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Playlist) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setDescription(row.description ?? "");
|
||||
setCategoryIds([]);
|
||||
setSubcategoryIds([]);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /music-playlists/:id
|
||||
await apiFetch(`/music-playlists/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { name, description },
|
||||
});
|
||||
toast.success("پلیلیست ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /music-playlists
|
||||
await apiFetch("/music-playlists", {
|
||||
method: "POST",
|
||||
body: toFormData({
|
||||
name,
|
||||
description,
|
||||
category_ids: categoryIds,
|
||||
subcategory_ids: subcategoryIds,
|
||||
}),
|
||||
});
|
||||
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(`/music-playlists/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("پلیلیست حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Playlist>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "description",
|
||||
header: "توضیحات",
|
||||
render: (r) => r.description ?? "—",
|
||||
},
|
||||
];
|
||||
|
||||
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="playlist-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="playlist-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً تمرکز عمیق"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="توضیح کوتاه درباره پلیلیست"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!editing && (
|
||||
<>
|
||||
<Field label="دستهبندیها">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border p-3">
|
||||
{categories.length === 0 && (
|
||||
<span className="text-xs text-muted">موردی موجود نیست</span>
|
||||
)}
|
||||
{categories.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryIds.includes(c.id)}
|
||||
onChange={() =>
|
||||
setCategoryIds((prev) => toggle(prev, c.id))
|
||||
}
|
||||
/>
|
||||
{c.name ?? `#${c.id}`}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="زیردستهها">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border p-3">
|
||||
{subcategories.length === 0 && (
|
||||
<span className="text-xs text-muted">موردی موجود نیست</span>
|
||||
)}
|
||||
{subcategories.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subcategoryIds.includes(c.id)}
|
||||
onChange={() =>
|
||||
setSubcategoryIds((prev) => toggle(prev, c.id))
|
||||
}
|
||||
/>
|
||||
{c.name ?? `#${c.id}`}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } 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,
|
||||
Textarea,
|
||||
Select,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface MusicCategory {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface MusicSubcategory {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
is_active?: boolean;
|
||||
category_id?: number;
|
||||
category?: MusicCategory | null;
|
||||
}
|
||||
|
||||
export default function MusicSubcategoriesPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<MusicSubcategory>("/music-subcategories");
|
||||
const { data: categories } = useList<MusicCategory>("/music-categories");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<MusicSubcategory | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [imageId, setImageId] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const [deleting, setDeleting] = useState<MusicSubcategory | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setCategoryId("");
|
||||
setName("");
|
||||
setOrder("");
|
||||
setDescription("");
|
||||
setImageId("");
|
||||
setIsActive(true);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: MusicSubcategory) {
|
||||
setEditing(row);
|
||||
setCategoryId(row.category_id != null ? String(row.category_id) : "");
|
||||
setName(row.name ?? "");
|
||||
setOrder(row.order != null ? String(row.order) : "");
|
||||
setDescription(row.description ?? "");
|
||||
setImageId("");
|
||||
setIsActive(row.is_active ?? true);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /music-subcategories/:id
|
||||
await apiFetch(`/music-subcategories/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name,
|
||||
order: order === "" ? undefined : Number(order),
|
||||
is_active: isActive,
|
||||
},
|
||||
});
|
||||
toast.success("زیردسته ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /music-subcategories
|
||||
await apiFetch("/music-subcategories", {
|
||||
method: "POST",
|
||||
body: toFormData({
|
||||
category_id: categoryId,
|
||||
name,
|
||||
order,
|
||||
description,
|
||||
image_id: imageId,
|
||||
is_active: isActive,
|
||||
}),
|
||||
});
|
||||
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(`/music-subcategories/${deleting.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
toast.success("زیردسته حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function categoryName(row: MusicSubcategory): string {
|
||||
if (row.category?.name) return row.category.name;
|
||||
const match = categories.find((c) => c.id === row.category_id);
|
||||
return match?.name ?? "—";
|
||||
}
|
||||
|
||||
const columns: Column<MusicSubcategory>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "category",
|
||||
header: "دستهبندی والد",
|
||||
render: (r) => categoryName(r),
|
||||
},
|
||||
{ key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? 0) },
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
render: (r) =>
|
||||
r.is_active ? (
|
||||
<Badge tone="success">فعال</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">غیرفعال</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="music-subcategory-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="music-subcategory-form"
|
||||
onSubmit={save}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{!editing && (
|
||||
<Field label="دستهبندی والد" required>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">انتخاب کنید…</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name ?? `#${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً صدای باران"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
{!editing && (
|
||||
<>
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="شناسه تصویر">
|
||||
<Input
|
||||
value={imageId}
|
||||
onChange={(e) => setImageId(e.target.value)}
|
||||
dir="ltr"
|
||||
placeholder="image_id"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Switch checked={isActive} onChange={setIsActive} label="فعال" />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa, formatDuration } from "@/lib/utils";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, SOUND_KEYS, IMAGE_KEYS } from "@/lib/media";
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
duration?: number | null;
|
||||
type?: string;
|
||||
order?: number;
|
||||
playlist_id?: number;
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function MusicTracksPage() {
|
||||
const { data, loading, error, reload } = useList<Track>("/music");
|
||||
const { data: playlists } = useList<Playlist>("/music-playlists");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Track | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [artist, setArtist] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [duration, setDuration] = useState("");
|
||||
const [playlistId, setPlaylistId] = useState("");
|
||||
const [imageId, setImageId] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const [deleting, setDeleting] = useState<Track | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setTitle("");
|
||||
setArtist("");
|
||||
setType("");
|
||||
setDuration("");
|
||||
setPlaylistId("");
|
||||
setImageId("");
|
||||
setOrder("");
|
||||
setFile(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Track) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setArtist(row.artist ?? "");
|
||||
setType(row.type ?? "");
|
||||
setDuration(row.duration != null ? String(row.duration) : "");
|
||||
setPlaylistId(row.playlist_id != null ? String(row.playlist_id) : "");
|
||||
setImageId("");
|
||||
setOrder(row.order != null ? String(row.order) : "");
|
||||
setFile(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
// Update is JSON on /music/:id
|
||||
await apiFetch(`/music/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
title,
|
||||
artist,
|
||||
order: order === "" ? undefined : Number(order),
|
||||
},
|
||||
});
|
||||
toast.success("آهنگ ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /music
|
||||
await apiFetch("/music", {
|
||||
method: "POST",
|
||||
body: toFormData({
|
||||
title,
|
||||
artist,
|
||||
type,
|
||||
duration,
|
||||
playlist_id: playlistId,
|
||||
image_id: imageId,
|
||||
file,
|
||||
}),
|
||||
});
|
||||
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(`/music/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("آهنگ حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Track>[] = [
|
||||
{
|
||||
key: "play",
|
||||
header: "پخش",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="audio" src={pickUrl(r, SOUND_KEYS)} label={r.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "thumbnail",
|
||||
header: "تصویر",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="image" src={pickUrl(r, IMAGE_KEYS)} label={r.title} />
|
||||
),
|
||||
},
|
||||
{ key: "title", header: "عنوان" },
|
||||
{ key: "artist", header: "هنرمند", render: (r) => r.artist ?? "—" },
|
||||
{
|
||||
key: "duration",
|
||||
header: "مدت زمان",
|
||||
render: (r) => formatDuration(r.duration),
|
||||
},
|
||||
{ key: "type", header: "نوع", render: (r) => r.type ?? "—" },
|
||||
];
|
||||
|
||||
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="track-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="track-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="عنوان" required>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثلاً آرامش شبانه"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="هنرمند">
|
||||
<Input
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(e.target.value)}
|
||||
placeholder="نام هنرمند"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{editing ? (
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="نوع">
|
||||
<Input
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="مثلاً music"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="مدت زمان (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="پلیلیست">
|
||||
<Select
|
||||
value={playlistId}
|
||||
onChange={(e) => setPlaylistId(e.target.value)}
|
||||
>
|
||||
<option value="">انتخاب کنید…</option>
|
||||
{playlists.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name ?? `#${p.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="شناسه تصویر">
|
||||
<Input
|
||||
value={imageId}
|
||||
onChange={(e) => setImageId(e.target.value)}
|
||||
dir="ltr"
|
||||
placeholder="image_id"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="فایل صوتی" required>
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { NAV } from "@/lib/nav";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
|
||||
export default function DashboardHome() {
|
||||
const sections = NAV.filter((s) => s.label !== "میزکار");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="خوش آمدید 👋"
|
||||
subtitle="از طریق بخشهای زیر، محتوای اپلیکیشن مدیتیشن را مدیریت کنید."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.label} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-soft text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<h2 className="font-bold text-foreground">{section.label}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-lg bg-surface-muted px-3 py-1.5 text-xs text-foreground transition hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
title?: string;
|
||||
category?: string;
|
||||
tags?: string[] | string;
|
||||
}
|
||||
|
||||
function asTags(value: Question["tags"]): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t));
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseTagsInput(input: string): string[] {
|
||||
// Accept both Latin "," and Persian "،" separators.
|
||||
return input
|
||||
.split(/[,،]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function QuestionsPage() {
|
||||
const [filterTag, setFilterTag] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
|
||||
const { data, loading, error, reload } = useList<Question>("/questions", {
|
||||
tag: filterTag || undefined,
|
||||
category: filterCategory || undefined,
|
||||
});
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Question | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<Question | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setTagsInput("");
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Question) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setCategory(row.category ?? "");
|
||||
setTagsInput(asTags(row.tags).join("، "));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const tags = parseTagsInput(tagsInput);
|
||||
if (editing) {
|
||||
// Update is JSON on /questions/:id
|
||||
await apiFetch(`/questions/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { title, category, tags },
|
||||
});
|
||||
toast.success("پرسش ویرایش شد.");
|
||||
} else {
|
||||
// Create is multipart on /questions (toFormData expands tags -> tags[])
|
||||
await apiFetch("/questions", {
|
||||
method: "POST",
|
||||
body: toFormData({ title, category, tags }),
|
||||
});
|
||||
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(`/questions/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("پرسش حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Question>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "title", header: "عنوان", render: (r) => r.title ?? "—" },
|
||||
{ key: "category", header: "دسته", render: (r) => r.category ?? "—" },
|
||||
{
|
||||
key: "tags",
|
||||
header: "برچسبها",
|
||||
render: (r) => {
|
||||
const tags = asTags(r.tags);
|
||||
return tags.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((t, i) => (
|
||||
<Badge key={`${t}-${i}`}>{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="بانک پرسشها"
|
||||
subtitle="مدیریت پرسشها و دستهبندی آنها"
|
||||
action={
|
||||
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
|
||||
پرسش جدید
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<Field label="فیلتر برچسب">
|
||||
<Input
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
placeholder="مثلاً انگیزشی"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="فیلتر دسته">
|
||||
<Input
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
placeholder="مثلاً صبحگاهی"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<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="question-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="question-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="عنوان" required>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="متن پرسش"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="دسته">
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="مثلاً صبحگاهی"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="برچسبها"
|
||||
hint="برچسبها را با کاما (،) از هم جدا کنید"
|
||||
>
|
||||
<Input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="انگیزشی، آرامش، تمرکز"
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title ?? deleting?.id}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } 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";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, SOUND_KEYS, VIDEO_KEYS, IMAGE_KEYS } from "@/lib/media";
|
||||
|
||||
interface Scene {
|
||||
id: number;
|
||||
name: string;
|
||||
order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export default function ScenesPage() {
|
||||
const { data, loading, error, reload } = useList<Scene>("/scenes");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Scene | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [video, setVideo] = useState<File | null>(null);
|
||||
const [sound, setSound] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<Scene | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setOrder("");
|
||||
setIsActive(true);
|
||||
setImage(null);
|
||||
setVideo(null);
|
||||
setSound(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Scene) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setOrder(row.order != null ? String(row.order) : "");
|
||||
setIsActive(!!row.is_active);
|
||||
setImage(null);
|
||||
setVideo(null);
|
||||
setSound(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
// Scenes are file-bearing: both create and update use multipart POST.
|
||||
const body = toFormData({
|
||||
name,
|
||||
order,
|
||||
is_active: isActive,
|
||||
image,
|
||||
video,
|
||||
sound,
|
||||
});
|
||||
if (editing) {
|
||||
await apiFetch(`/scenes/${editing.id}`, { method: "POST", body });
|
||||
toast.success("صحنه ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/scenes", { method: "POST", body });
|
||||
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(`/scenes/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("صحنه حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Scene>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام صحنه" },
|
||||
{
|
||||
key: "image",
|
||||
header: "تصویر",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="image" src={pickUrl(r, IMAGE_KEYS)} label={r.name} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "video",
|
||||
header: "ویدیو",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="video" src={pickUrl(r, VIDEO_KEYS)} label={r.name} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sound",
|
||||
header: "صدا",
|
||||
className: "w-20",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="audio" src={pickUrl(r, SOUND_KEYS)} label={r.name} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order",
|
||||
header: "ترتیب",
|
||||
render: (r) => (r.order != null ? toFa(r.order) : "—"),
|
||||
className: "w-24",
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
render: (r) =>
|
||||
r.is_active ? (
|
||||
<Badge tone="success">فعال</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">غیرفعال</Badge>
|
||||
),
|
||||
className: "w-28",
|
||||
},
|
||||
];
|
||||
|
||||
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="scene-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="scene-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="نام صحنه" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً جنگل بارانی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Switch checked={isActive} onChange={setIsActive} label="فعال" />
|
||||
|
||||
<Field
|
||||
label="تصویر"
|
||||
hint={editing ? "در صورت عدم انتخاب، تصویر قبلی حفظ میشود." : undefined}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="ویدیو"
|
||||
hint={editing ? "در صورت عدم انتخاب، ویدیوی قبلی حفظ میشود." : undefined}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(e) => setVideo(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="صدا"
|
||||
hint={editing ? "در صورت عدم انتخاب، صدای قبلی حفظ میشود." : undefined}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setSound(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useItem, useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Field,
|
||||
Input,
|
||||
PageHeader,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from "@/components/ui";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Scene {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SceneSettings {
|
||||
active_scene_id?: number | string | null;
|
||||
scene_volume?: number | string | null;
|
||||
background_play_seconds?: number | string | null;
|
||||
video_enabled?: boolean;
|
||||
}
|
||||
|
||||
export default function SceneSettingsPage() {
|
||||
const { data, loading } = useItem<SceneSettings>("/scene-settings");
|
||||
const { data: scenes } = useList<Scene>("/scenes");
|
||||
const toast = useToast();
|
||||
|
||||
const [activeSceneId, setActiveSceneId] = useState("");
|
||||
const [sceneVolume, setSceneVolume] = useState("");
|
||||
const [backgroundPlaySeconds, setBackgroundPlaySeconds] = useState("");
|
||||
const [videoEnabled, setVideoEnabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setActiveSceneId(
|
||||
data.active_scene_id != null ? String(data.active_scene_id) : "",
|
||||
);
|
||||
setSceneVolume(data.scene_volume != null ? String(data.scene_volume) : "");
|
||||
setBackgroundPlaySeconds(
|
||||
data.background_play_seconds != null
|
||||
? String(data.background_play_seconds)
|
||||
: "",
|
||||
);
|
||||
setVideoEnabled(!!data.video_enabled);
|
||||
}, [data]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/scene-settings", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
active_scene_id: activeSceneId ? Number(activeSceneId) : null,
|
||||
scene_volume: sceneVolume ? Number(sceneVolume) : null,
|
||||
background_play_seconds: backgroundPlaySeconds
|
||||
? Number(backgroundPlaySeconds)
|
||||
: null,
|
||||
video_enabled: videoEnabled,
|
||||
},
|
||||
});
|
||||
toast.success("تنظیمات صحنه ذخیره شد.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در ذخیرهسازی");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="تنظیمات صحنه"
|
||||
subtitle="پیکربندی صحنه فعال، صدا و پخش پسزمینه"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="max-w-xl">
|
||||
<form onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="صحنه فعال">
|
||||
<Select
|
||||
value={activeSceneId}
|
||||
onChange={(e) => setActiveSceneId(e.target.value)}
|
||||
>
|
||||
<option value="">انتخاب صحنه</option>
|
||||
{scenes.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name} ({toFa(s.id)})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="میزان صدای صحنه">
|
||||
<Input
|
||||
type="number"
|
||||
value={sceneVolume}
|
||||
onChange={(e) => setSceneVolume(e.target.value)}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="مدت پخش پسزمینه (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
value={backgroundPlaySeconds}
|
||||
onChange={(e) => setBackgroundPlaySeconds(e.target.value)}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Switch
|
||||
checked={videoEnabled}
|
||||
onChange={setVideoEnabled}
|
||||
label="نمایش ویدیو فعال باشد"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیره تنظیمات
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface SliderAction {
|
||||
path?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface Slider {
|
||||
id: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
action?: SliderAction;
|
||||
}
|
||||
|
||||
const ACTION_TYPES = [
|
||||
{ value: "screen", label: "صفحه (screen)" },
|
||||
{ value: "link", label: "لینک (link)" },
|
||||
];
|
||||
|
||||
export default function SlidersPage() {
|
||||
const { data, loading, error, reload } = useList<Slider>("/slider");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Slider | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [actionPath, setActionPath] = useState("");
|
||||
const [actionType, setActionType] = useState("screen");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<Slider | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setUrl("");
|
||||
setActionPath("");
|
||||
setActionType("screen");
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: Slider) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setDescription(row.description ?? "");
|
||||
setUrl(row.url ?? "");
|
||||
setActionPath(row.action?.path ?? "");
|
||||
setActionType(row.action?.type ?? "screen");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
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 },
|
||||
},
|
||||
});
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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(`/slider/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("اسلایدر حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Slider>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "title", header: "عنوان", render: (r) => r.title ?? "—" },
|
||||
{
|
||||
key: "description",
|
||||
header: "توضیحات",
|
||||
render: (r) => r.description ?? "—",
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
header: "آدرس",
|
||||
render: (r) =>
|
||||
r.url ? (
|
||||
<span dir="ltr" className="block truncate">
|
||||
{r.url}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action_type",
|
||||
header: "نوع اکشن",
|
||||
render: (r) => r.action?.type ?? "—",
|
||||
className: "w-32",
|
||||
},
|
||||
];
|
||||
|
||||
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="slider-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="slider-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="عنوان" required>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثلاً مدیتیشن صبحگاهی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="توضیح کوتاه درباره اسلایدر"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="آدرس (URL)">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="مسیر اکشن">
|
||||
<Input
|
||||
value={actionPath}
|
||||
onChange={(e) => setActionPath(e.target.value)}
|
||||
placeholder="/screen/path"
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="نوع اکشن">
|
||||
<Select
|
||||
value={actionType}
|
||||
onChange={(e) => setActionType(e.target.value)}
|
||||
>
|
||||
{ACTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"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 {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface SurveyOption {
|
||||
id?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: number;
|
||||
question?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
order?: number;
|
||||
is_active?: boolean;
|
||||
options?: SurveyOption[];
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
single: "تکگزینه",
|
||||
multiple: "چندگزینه",
|
||||
};
|
||||
|
||||
export default function SurveysPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<SurveyQuestion>("/survey-questions");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<SurveyQuestion | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [question, setQuestion] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState("single");
|
||||
const [order, setOrder] = useState("0");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [options, setOptions] = useState<string[]>([""]);
|
||||
|
||||
const [deleting, setDeleting] = useState<SurveyQuestion | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setQuestion("");
|
||||
setDescription("");
|
||||
setType("single");
|
||||
setOrder("0");
|
||||
setIsActive(true);
|
||||
setOptions([""]);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: SurveyQuestion) {
|
||||
setEditing(row);
|
||||
setQuestion(row.question ?? "");
|
||||
setDescription(row.description ?? "");
|
||||
setType(row.type ?? "single");
|
||||
setOrder(String(row.order ?? 0));
|
||||
setIsActive(row.is_active ?? true);
|
||||
setOptions(
|
||||
row.options && row.options.length
|
||||
? row.options.map((o) => o.label ?? "")
|
||||
: [""],
|
||||
);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function setOptionAt(index: number, value: string) {
|
||||
setOptions((prev) => prev.map((o, i) => (i === index ? value : o)));
|
||||
}
|
||||
function addOption() {
|
||||
setOptions((prev) => [...prev, ""]);
|
||||
}
|
||||
function removeOption(index: number) {
|
||||
setOptions((prev) =>
|
||||
prev.length > 1 ? prev.filter((_, i) => i !== index) : prev,
|
||||
);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
question,
|
||||
description,
|
||||
type,
|
||||
order: Number(order),
|
||||
is_active: isActive,
|
||||
options: options
|
||||
.map((label) => label.trim())
|
||||
.filter((label) => label !== "")
|
||||
.map((label) => ({ label })),
|
||||
};
|
||||
if (editing) {
|
||||
await apiFetch(`/survey-questions/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
toast.success("پرسش ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/survey-questions", { method: "POST", body });
|
||||
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(`/survey-questions/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("پرسش حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<SurveyQuestion>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "question", header: "پرسش", render: (r) => r.question ?? "—" },
|
||||
{
|
||||
key: "type",
|
||||
header: "نوع",
|
||||
render: (r) => (r.type ? TYPE_LABELS[r.type] ?? r.type : "—"),
|
||||
},
|
||||
{
|
||||
key: "order",
|
||||
header: "ترتیب",
|
||||
render: (r) => (r.order != null ? toFa(r.order) : "—"),
|
||||
className: "w-20",
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
render: (r) => (
|
||||
<Badge tone={r.is_active ? "success" : "neutral"}>
|
||||
{r.is_active ? "فعال" : "غیرفعال"}
|
||||
</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="survey-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="survey-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="پرسش" required>
|
||||
<Input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="متن پرسش"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="توضیحات تکمیلی (اختیاری)"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="نوع" required>
|
||||
<Select value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="single">تکگزینه</option>
|
||||
<option value="multiple">چندگزینه</option>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Switch checked={isActive} onChange={setIsActive} label="فعال" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
گزینهها
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={addOption}
|
||||
icon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
افزودن گزینه
|
||||
</Button>
|
||||
</div>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={opt}
|
||||
onChange={(e) => setOptionAt(i, e.target.value)}
|
||||
placeholder={`گزینه ${toFa(i + 1)}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(i)}
|
||||
aria-label="حذف گزینه"
|
||||
className="text-danger"
|
||||
disabled={options.length <= 1}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.question ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } 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,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, SOUND_KEYS } from "@/lib/media";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface BackgroundSound {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function BackgroundSoundsPage() {
|
||||
const { data, loading, error, reload } =
|
||||
useList<BackgroundSound>("/background-sounds");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<BackgroundSound | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [sound, setSound] = useState<File | null>(null);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<BackgroundSound | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setOrder("");
|
||||
setIsActive(true);
|
||||
setSound(null);
|
||||
setImage(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: BackgroundSound) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setOrder(row.order?.toString() ?? "");
|
||||
setIsActive(!!row.is_active);
|
||||
setSound(null);
|
||||
setImage(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = toFormData({
|
||||
name,
|
||||
order,
|
||||
is_active: isActive,
|
||||
sound,
|
||||
image,
|
||||
});
|
||||
if (editing) {
|
||||
// File-bearing resource: update via POST /background-sounds/:id (multipart)
|
||||
await apiFetch(`/background-sounds/${editing.id}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
toast.success("صدای پسزمینه ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/background-sounds", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
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(`/background-sounds/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("صدای پسزمینه حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<BackgroundSound>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "preview",
|
||||
header: "پخش",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="audio" src={pickUrl(r, SOUND_KEYS)} label={r.name} />
|
||||
),
|
||||
},
|
||||
{ key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? "—") },
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
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="background-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="background-form"
|
||||
onSubmit={save}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً صدای باران"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="وضعیت">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onChange={setIsActive}
|
||||
label={isActive ? "فعال" : "غیرفعال"}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="فایل صوتی"
|
||||
hint={editing ? "در صورت عدم انتخاب، فایل فعلی حفظ میشود." : undefined}
|
||||
required={!editing}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setSound(e.target.files?.[0] ?? null)}
|
||||
required={!editing}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="تصویر"
|
||||
hint={editing ? "در صورت عدم انتخاب، تصویر فعلی حفظ میشود." : undefined}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } 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,
|
||||
Switch,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl, SOUND_KEYS } from "@/lib/media";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface BellSound {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function BellSoundsPage() {
|
||||
const { data, loading, error, reload } = useList<BellSound>("/bell-sounds");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<BellSound | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [order, setOrder] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [sound, setSound] = useState<File | null>(null);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<BellSound | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setOrder("");
|
||||
setIsActive(true);
|
||||
setSound(null);
|
||||
setImage(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: BellSound) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setOrder(row.order?.toString() ?? "");
|
||||
setIsActive(!!row.is_active);
|
||||
setSound(null);
|
||||
setImage(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = toFormData({
|
||||
name,
|
||||
order,
|
||||
is_active: isActive,
|
||||
sound,
|
||||
image,
|
||||
});
|
||||
if (editing) {
|
||||
// File-bearing resource: update via POST /bell-sounds/:id (multipart)
|
||||
await apiFetch(`/bell-sounds/${editing.id}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
toast.success("صدای زنگ ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/bell-sounds", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
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(`/bell-sounds/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("صدای زنگ حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<BellSound>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "preview",
|
||||
header: "پخش",
|
||||
render: (r) => (
|
||||
<MediaPreview kind="audio" src={pickUrl(r, SOUND_KEYS)} label={r.name} />
|
||||
),
|
||||
},
|
||||
{ key: "order", header: "ترتیب", render: (r) => toFa(r.order ?? "—") },
|
||||
{
|
||||
key: "is_active",
|
||||
header: "وضعیت",
|
||||
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="bell-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="bell-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً زنگ تبتی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="ترتیب">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="وضعیت">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onChange={setIsActive}
|
||||
label={isActive ? "فعال" : "غیرفعال"}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="فایل صوتی"
|
||||
hint={editing ? "در صورت عدم انتخاب، فایل فعلی حفظ میشود." : undefined}
|
||||
required={!editing}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setSound(e.target.files?.[0] ?? null)}
|
||||
required={!editing}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="تصویر"
|
||||
hint={editing ? "در صورت عدم انتخاب، تصویر فعلی حفظ میشود." : undefined}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
// Read-only view of GET /timer/options — the option sets the app uses to build
|
||||
// the meditation timer (durations, intervals, default bells, etc.). The exact
|
||||
// shape is backend-defined, so we render it generically: primitive values as a
|
||||
// definition list, arrays of objects as small tables.
|
||||
|
||||
import { useItem } from "@/lib/useResource";
|
||||
import { Button, Card, PageHeader, Spinner, Badge, EmptyState } from "@/components/ui";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
function Primitive({ value }: { value: unknown }) {
|
||||
if (typeof value === "boolean") {
|
||||
return <Badge tone={value ? "success" : "neutral"}>{value ? "بله" : "خیر"}</Badge>;
|
||||
}
|
||||
if (value === null || value === undefined) return <span className="text-muted">—</span>;
|
||||
return <span>{toFa(String(value))}</span>;
|
||||
}
|
||||
|
||||
function ArrayBlock({ items }: { items: unknown[] }) {
|
||||
if (!items.length) return <EmptyState message="موردی وجود ندارد." />;
|
||||
|
||||
// Array of objects -> table of their union of keys.
|
||||
if (items.every((it) => it && typeof it === "object" && !Array.isArray(it))) {
|
||||
const keys = Array.from(
|
||||
new Set(items.flatMap((it) => Object.keys(it as object))),
|
||||
);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full text-right text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-muted text-muted">
|
||||
{keys.map((k) => (
|
||||
<th key={k} className="px-3 py-2 font-medium">
|
||||
{k}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{keys.map((k) => (
|
||||
<td key={k} className="px-3 py-2">
|
||||
<Primitive value={(it as Record<string, unknown>)[k]} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of primitives -> chips.
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((it, i) => (
|
||||
<Badge key={i}>{toFa(String(it))}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimerOptionsPage() {
|
||||
const { data, loading, error, reload } = useItem<Record<string, unknown>>(
|
||||
"/timer/options",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="گزینههای تایمر"
|
||||
subtitle="مقادیر پیشفرض و فهرستهایی که اپلیکیشن برای ساخت تایمر مدیتیشن استفاده میکند."
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-8 w-8" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon="⚠️"
|
||||
title="خطا در دریافت اطلاعات"
|
||||
message={error}
|
||||
action={
|
||||
<Button variant="secondary" onClick={reload}>
|
||||
تلاش دوباره
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : !data || typeof data !== "object" || !Object.keys(data).length ? (
|
||||
<EmptyState
|
||||
title="گزینهای موجود نیست"
|
||||
message="هیچ گزینهای از سرور دریافت نشد."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Card key={key}>
|
||||
<h2 className="mb-3 font-bold text-foreground">{key}</h2>
|
||||
{Array.isArray(value) ? (
|
||||
<ArrayBlock items={value} />
|
||||
) : value && typeof value === "object" ? (
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{Object.entries(value as Record<string, unknown>).map(
|
||||
([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 border-b border-border pb-1.5 last:border-0">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd>
|
||||
<Primitive value={v} />
|
||||
</dd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dl>
|
||||
) : (
|
||||
<Primitive value={value} />
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"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 {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
PageHeader,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa, formatDuration } from "@/lib/utils";
|
||||
|
||||
interface TimerPreset {
|
||||
id: number;
|
||||
name: string;
|
||||
duration_seconds: number;
|
||||
start_bell_id: number | null;
|
||||
end_bell_id: number | null;
|
||||
interval_bell_id: number | null;
|
||||
interval_seconds: number | null;
|
||||
interval_repeat: number | null;
|
||||
background_sound_id: number | null;
|
||||
background_image_id: number | null;
|
||||
volume: number | null;
|
||||
}
|
||||
|
||||
interface BellSound {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BackgroundSound {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
duration_seconds: "",
|
||||
start_bell_id: "",
|
||||
end_bell_id: "",
|
||||
interval_bell_id: "",
|
||||
interval_seconds: "",
|
||||
interval_repeat: "",
|
||||
background_sound_id: "",
|
||||
background_image_id: "",
|
||||
volume: "",
|
||||
};
|
||||
|
||||
export default function TimerPresetsPage() {
|
||||
const { data, loading, error, reload } = useList<TimerPreset>("/timer-presets");
|
||||
const { data: bells } = useList<BellSound>("/bell-sounds");
|
||||
const { data: backgroundSounds } =
|
||||
useList<BackgroundSound>("/background-sounds");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<TimerPreset | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<TimerPreset | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
function set<K extends keyof typeof form>(key: K, value: string) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm({ ...emptyForm });
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(row: TimerPreset) {
|
||||
setEditing(row);
|
||||
setForm({
|
||||
name: row.name ?? "",
|
||||
duration_seconds: row.duration_seconds?.toString() ?? "",
|
||||
start_bell_id: row.start_bell_id?.toString() ?? "",
|
||||
end_bell_id: row.end_bell_id?.toString() ?? "",
|
||||
interval_bell_id: row.interval_bell_id?.toString() ?? "",
|
||||
interval_seconds: row.interval_seconds?.toString() ?? "",
|
||||
interval_repeat: row.interval_repeat?.toString() ?? "",
|
||||
background_sound_id: row.background_sound_id?.toString() ?? "",
|
||||
background_image_id: row.background_image_id?.toString() ?? "",
|
||||
volume: row.volume?.toString() ?? "",
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function toNum(value: string): number | null {
|
||||
if (value === "" || value === null || value === undefined) return null;
|
||||
const n = Number(value);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
duration_seconds: toNum(form.duration_seconds),
|
||||
start_bell_id: toNum(form.start_bell_id),
|
||||
end_bell_id: toNum(form.end_bell_id),
|
||||
interval_bell_id: toNum(form.interval_bell_id),
|
||||
interval_seconds: toNum(form.interval_seconds),
|
||||
interval_repeat: toNum(form.interval_repeat),
|
||||
background_sound_id: toNum(form.background_sound_id),
|
||||
background_image_id: toNum(form.background_image_id),
|
||||
volume: toNum(form.volume),
|
||||
};
|
||||
if (editing) {
|
||||
await apiFetch(`/timer-presets/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
toast.success("پیشتنظیم ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/timer-presets", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
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(`/timer-presets/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("پیشتنظیم حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<TimerPreset>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-24" },
|
||||
{ key: "name", header: "نام" },
|
||||
{
|
||||
key: "duration_seconds",
|
||||
header: "مدت",
|
||||
render: (r) => formatDuration(r.duration_seconds),
|
||||
},
|
||||
{ key: "volume", header: "صدا", render: (r) => toFa(r.volume ?? "—") },
|
||||
];
|
||||
|
||||
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="preset-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="preset-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="نام" required>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="مثلاً مدیتیشن صبحگاهی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="مدت (ثانیه)" required>
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={form.duration_seconds}
|
||||
onChange={(e) => set("duration_seconds", e.target.value)}
|
||||
placeholder="مثلاً ۶۰۰"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="زنگ شروع">
|
||||
<Select
|
||||
value={form.start_bell_id}
|
||||
onChange={(e) => set("start_bell_id", e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{bells.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="زنگ پایان">
|
||||
<Select
|
||||
value={form.end_bell_id}
|
||||
onChange={(e) => set("end_bell_id", e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{bells.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="زنگ میاندورهای">
|
||||
<Select
|
||||
value={form.interval_bell_id}
|
||||
onChange={(e) => set("interval_bell_id", e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{bells.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="فاصله زمانی (ثانیه)">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={form.interval_seconds}
|
||||
onChange={(e) => set("interval_seconds", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="تعداد تکرار فاصله">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={form.interval_repeat}
|
||||
onChange={(e) => set("interval_repeat", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="صدای پسزمینه">
|
||||
<Select
|
||||
value={form.background_sound_id}
|
||||
onChange={(e) => set("background_sound_id", e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{backgroundSounds.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="شناسه تصویر پسزمینه">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={form.background_image_id}
|
||||
onChange={(e) => set("background_image_id", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="میزان صدا">
|
||||
<Input
|
||||
type="number"
|
||||
dir="ltr"
|
||||
value={form.volume}
|
||||
onChange={(e) => set("volume", e.target.value)}
|
||||
placeholder="مثلاً ۸۰"
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.name}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch, ApiError, toFormData } from "@/lib/api";
|
||||
import { useList } from "@/lib/useResource";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { DataTable, type Column } from "@/components/DataTable";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
interface Worry {
|
||||
id: number;
|
||||
title?: string;
|
||||
note?: string;
|
||||
is_done?: boolean;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export default function WorriesPage() {
|
||||
const { data, loading, error, reload } = useList<Worry>("/worries");
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Worry | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const [deleting, setDeleting] = useState<Worry | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setTitle("");
|
||||
setNote("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: Worry) {
|
||||
setEditing(row);
|
||||
setTitle(row.title ?? "");
|
||||
setNote(row.note ?? "");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await apiFetch(`/worries/${editing.id}`, {
|
||||
method: "PUT",
|
||||
body: { title, note },
|
||||
});
|
||||
toast.success("نگرانی ویرایش شد.");
|
||||
} else {
|
||||
await apiFetch("/worries", {
|
||||
method: "POST",
|
||||
body: toFormData({ title, note }),
|
||||
});
|
||||
toast.success("نگرانی افزوده شد.");
|
||||
}
|
||||
setOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در ذخیرهسازی");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row: Worry) {
|
||||
setTogglingId(row.id);
|
||||
try {
|
||||
await apiFetch(`/worries/${row.id}/toggle`, { method: "PATCH" });
|
||||
toast.success("وضعیت تغییر کرد.");
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در تغییر وضعیت");
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await apiFetch(`/worries/${deleting.id}`, { method: "DELETE" });
|
||||
toast.success("نگرانی حذف شد.");
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : "خطا در حذف");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function isResolved(row: Worry): boolean | undefined {
|
||||
if (row.is_done != null) return row.is_done;
|
||||
if (row.resolved != null) return row.resolved;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const columns: Column<Worry>[] = [
|
||||
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
|
||||
{ key: "title", header: "عنوان", render: (r) => r.title ?? "—" },
|
||||
{ key: "note", header: "یادداشت", render: (r) => r.note ?? "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "وضعیت",
|
||||
render: (r) => {
|
||||
const resolved = isResolved(r);
|
||||
if (resolved == null) return "—";
|
||||
return (
|
||||
<Badge tone={resolved ? "success" : "neutral"}>
|
||||
{resolved ? "انجامشده" : "در انتظار"}
|
||||
</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={() => toggle(row)}
|
||||
loading={togglingId === row.id}
|
||||
>
|
||||
تغییر وضعیت
|
||||
</Button>
|
||||
<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="worry-form" type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="worry-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="عنوان" required>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="عنوان نگرانی"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="یادداشت">
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="یادداشت (اختیاری)"
|
||||
/>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
message={`آیا از حذف «${deleting?.title ?? ""}» مطمئن هستید؟`}
|
||||
loading={removing}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user