feat: initial

This commit is contained in:
2026-06-03 03:08:57 +03:30
parent 3d9585ac5c
commit 6fa30eb29a
45 changed files with 7053 additions and 86 deletions
+303
View File
@@ -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>
);
}
+174
View File
@@ -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>
);
}
+247
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+355
View File
@@ -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>
);
}
+217
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+249
View File
@@ -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>
);
}
+263
View File
@@ -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>
);
}
+287
View File
@@ -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>
);
}
+308
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+285
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+277
View File
@@ -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>
);
}
+317
View File
@@ -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>
);
}
+260
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+354
View File
@@ -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>
);
}
+234
View File
@@ -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>
);
}