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>
);
}
+49 -9
View File
@@ -1,26 +1,66 @@
@import "tailwindcss";
/* Persian admin theme — calm meditation palette, RTL-first. */
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f4f6fb;
--surface: #ffffff;
--surface-muted: #eef1f8;
--foreground: #1f2433;
--muted: #6b7390;
--border: #e1e5f0;
--primary: #5b6ee1;
--primary-hover: #4a5cd0;
--primary-soft: #eaedfb;
--danger: #e25b6e;
--success: #2fb583;
--ring: rgba(91, 110, 225, 0.35);
}
@theme inline {
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-muted: var(--surface-muted);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-muted: var(--muted);
--color-border: var(--border);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-primary-soft: var(--primary-soft);
--color-danger: var(--danger);
--color-success: var(--success);
--font-sans: var(--font-vazir), system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
* {
border-color: var(--border);
}
html,
body {
height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-vazir), system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Persian digits feel native when the font handles them; keep tabular for tables. */
table {
font-variant-numeric: tabular-nums;
}
/* Thin custom scrollbars to match the calm theme. */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #cdd3e6;
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
+12 -16
View File
@@ -1,20 +1,17 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Vazirmatn } from "next/font/google";
import { Providers } from "@/components/Providers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
const vazir = Vazirmatn({
variable: "--font-vazir",
subsets: ["arabic", "latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "پنل مدیریت مدیتیشن",
description: "پنل مدیریت محتوای اپلیکیشن مدیتیشن",
};
export default function RootLayout({
@@ -23,11 +20,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="fa" dir="rtl" className={`${vazir.variable} h-full`}>
<body className="min-h-full">
<Providers>{children}</Providers>
</body>
</html>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/components/toast";
import { ApiError } from "@/lib/api";
import { Button, Field, Input } from "@/components/ui";
export default function LoginPage() {
const { login, token, ready } = useAuth();
const router = useRouter();
const toast = useToast();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
// Already logged in? skip the form.
useEffect(() => {
if (ready && token) router.replace("/dashboard");
}, [ready, token, router]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await login(identifier.trim(), password);
toast.success("خوش آمدید!");
router.replace("/dashboard");
} catch (err) {
toast.error(
err instanceof ApiError ? err.message : "ورود ناموفق بود. دوباره تلاش کنید.",
);
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-bl from-[#eef1fb] to-[#f6f3fb] p-4">
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-8 shadow-lg">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-soft text-3xl">
🧘
</div>
<h1 className="text-2xl font-bold text-foreground">پنل مدیریت مدیتیشن</h1>
<p className="mt-2 text-sm text-muted">
برای ورود، نام کاربری و رمز عبور خود را وارد کنید.
</p>
</div>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Field label="ایمیل یا شماره موبایل" required>
<Input
type="text"
dir="ltr"
autoComplete="username"
placeholder="admin@gmail.com یا 09001234567"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</Field>
<Field label="رمز عبور" required>
<Input
type="password"
dir="ltr"
autoComplete="current-password"
placeholder="••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Button type="submit" loading={loading} className="mt-2 w-full py-2.5">
ورود
</Button>
</form>
</div>
</div>
);
}
+17 -59
View File
@@ -1,65 +1,23 @@
import Image from "next/image";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { Spinner } from "@/components/ui";
// Entry point: bounce to the dashboard or the login page based on auth state.
export default function Home() {
const { token, ready } = useAuth();
const router = useRouter();
useEffect(() => {
if (!ready) return;
router.replace(token ? "/dashboard" : "/login");
}, [ready, token, router]);
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<div className="flex min-h-screen items-center justify-center">
<Spinner className="h-10 w-10" />
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button, EmptyState, Spinner } from "./ui";
export interface Column<T> {
key: string;
header: string;
render?: (row: T) => ReactNode;
className?: string;
}
export function DataTable<T extends { id?: number | string }>({
columns,
rows,
loading,
error,
emptyTitle = "موردی ثبت نشده است",
emptyMessage = "هنوز داده‌ای برای نمایش وجود ندارد.",
emptyIcon,
emptyAction,
onRetry,
actions,
}: {
columns: Column<T>[];
rows: T[];
loading?: boolean;
error?: string | null;
emptyTitle?: string;
emptyMessage?: string;
emptyIcon?: ReactNode;
emptyAction?: ReactNode;
onRetry?: () => void;
actions?: (row: T) => ReactNode;
}) {
if (loading) {
return (
<div className="flex justify-center py-16">
<Spinner className="h-8 w-8" />
</div>
);
}
if (error) {
return (
<EmptyState
icon="⚠️"
title="خطا در دریافت اطلاعات"
message={error}
action={
onRetry ? (
<Button variant="secondary" onClick={onRetry}>
تلاش دوباره
</Button>
) : undefined
}
/>
);
}
if (!rows.length)
return (
<EmptyState
title={emptyTitle}
message={emptyMessage}
icon={emptyIcon}
action={emptyAction}
/>
);
return (
<div className="overflow-x-auto rounded-2xl border border-border bg-surface">
<table className="w-full text-right text-sm">
<thead>
<tr className="border-b border-border bg-surface-muted text-muted">
{columns.map((c) => (
<th key={c.key} className={cn("px-4 py-3 font-medium", c.className)}>
{c.header}
</th>
))}
{actions && <th className="px-4 py-3 font-medium">عملیات</th>}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={row.id ?? i}
className="border-b border-border last:border-0 hover:bg-surface-muted/50"
>
{columns.map((c) => (
<td key={c.key} className={cn("px-4 py-3", c.className)}>
{c.render
? c.render(row)
: ((row as Record<string, unknown>)[c.key] as ReactNode) ??
"—"}
</td>
))}
{actions && (
<td className="px-4 py-3">
<div className="flex items-center gap-1">{actions(row)}</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
// Press-to-play / press-to-view preview for uploaded assets.
// - audio: a play button that toggles a compact inline <audio> player.
// - video: a button that opens the video in a modal player.
// - image: a thumbnail that opens the full image in a modal.
// Renders a muted dash when no source is available.
import { useState } from "react";
import { Modal } from "./ui";
import { PlayIcon, PauseIcon, EyeIcon } from "./icons";
type Kind = "audio" | "video" | "image";
export function MediaPreview({
src,
kind,
label,
}: {
src?: string | null;
kind: Kind;
label?: string;
}) {
const [audioOpen, setAudioOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
if (!src) return <span className="text-muted"></span>;
if (kind === "audio") {
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAudioOpen((v) => !v)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary-soft text-primary transition hover:bg-primary hover:text-white"
aria-label={audioOpen ? "توقف" : "پخش"}
>
{audioOpen ? (
<PauseIcon className="h-4 w-4" />
) : (
<PlayIcon className="h-4 w-4" />
)}
</button>
{audioOpen && (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio
src={src}
controls
autoPlay
className="h-8 max-w-[220px]"
onEnded={() => setAudioOpen(false)}
/>
)}
</div>
);
}
if (kind === "image") {
return (
<>
<button
type="button"
onClick={() => setModalOpen(true)}
className="block overflow-hidden rounded-lg border border-border transition hover:opacity-80"
aria-label="نمایش تصویر"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={label ?? ""} className="h-12 w-12 object-cover" />
</button>
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={label ?? "تصویر"}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={label ?? ""} className="mx-auto max-h-[70vh] rounded-lg" />
</Modal>
</>
);
}
// video
return (
<>
<button
type="button"
onClick={() => setModalOpen(true)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary-soft text-primary transition hover:bg-primary hover:text-white"
aria-label="نمایش ویدیو"
>
<EyeIcon className="h-4 w-4" />
</button>
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={label ?? "ویدیو"}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video src={src} controls autoPlay className="w-full rounded-lg" />
</Modal>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "./toast";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<AuthProvider>{children}</AuthProvider>
</ToastProvider>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { NAV } from "@/lib/nav";
import { cn } from "@/lib/utils";
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<nav className="flex h-full flex-col gap-1 overflow-y-auto p-4">
<div className="mb-4 flex items-center gap-2 px-2">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-soft text-xl">
🧘
</span>
<span className="font-bold text-foreground">مدیریت مدیتیشن</span>
</div>
{NAV.map((section) => {
const Icon = section.icon;
return (
<div key={section.label} className="mb-1">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted">
<Icon className="h-4 w-4" />
{section.label}
</div>
<div className="flex flex-col">
{section.items.map((item) => {
const active =
pathname === item.href ||
(item.href !== "/dashboard" &&
pathname?.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"mr-6 rounded-lg px-3 py-2 text-sm transition",
active
? "bg-primary text-white"
: "text-foreground hover:bg-surface-muted",
)}
>
{item.label}
</Link>
);
})}
</div>
</div>
);
})}
</nav>
);
}
+178
View File
@@ -0,0 +1,178 @@
// Minimal inline icon set (no external dependency). Each icon inherits color
// via `currentColor` and accepts standard svg props.
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function base(props: IconProps) {
return {
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
...props,
};
}
export const PlusIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const EditIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
export const TrashIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
export const CloseIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
export const PlayIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 4.5v15l12-7.5-12-7.5Z" />
</svg>
);
export const PauseIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M8 5v14M16 5v14" />
</svg>
);
export const EyeIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
export const SearchIcon = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
export const LogoutIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</svg>
);
export const MenuIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
);
export const SpinnerIcon = (p: IconProps) => (
<svg {...base(p)} className={`animate-spin ${p.className ?? ""}`}>
<path d="M21 12a9 9 0 1 1-6.2-8.6" />
</svg>
);
// Section icons (single-path, decorative).
export const HomeIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
</svg>
);
export const MediaIcon = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="m10 8 6 4-6 4V8Z" />
</svg>
);
export const MusicIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
export const TimerIcon = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="13" r="8" />
<path d="M12 9v4l2 2M9 2h6" />
</svg>
);
export const ImageIcon = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-5-5L5 21" />
</svg>
);
export const SliderIcon = (p: IconProps) => (
<svg {...base(p)}>
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 2v2M18 2v2M6 20v2M18 20v2" />
</svg>
);
export const QuestionIcon = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .9-1 1.7M12 17h.01" />
</svg>
);
export const SurveyIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 11l3 3 8-8" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
);
export const BreathIcon = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="9" />
</svg>
);
export const MoodIcon = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01" />
</svg>
);
export const WorryIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 7h18l-2 13H5L3 7Z" />
<path d="M8 7V5a4 4 0 0 1 8 0v2" />
</svg>
);
export const SceneIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="m3 17 5-6 4 4 3-4 6 6" />
<circle cx="8" cy="7" r="2" />
</svg>
);
export const TrophyIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 0 1-10 0V4Z" />
<path d="M17 5h3v2a3 3 0 0 1-3 3M7 5H4v2a3 3 0 0 0 3 3" />
</svg>
);
export const TagIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9-9-9Z" />
<circle cx="7.5" cy="7.5" r="1.2" />
</svg>
);
+71
View File
@@ -0,0 +1,71 @@
"use client";
import {
createContext,
useCallback,
useContext,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastApi {
push: (message: string, kind?: ToastKind) => void;
success: (message: string) => void;
error: (message: string) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const push = useCallback((message: string, kind: ToastKind = "info") => {
const id = ++idRef.current;
setToasts((prev) => [...prev, { id, kind, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const api: ToastApi = {
push,
success: (m) => push(m, "success"),
error: (m) => push(m, "error"),
};
return (
<ToastContext.Provider value={api}>
{children}
<div className="fixed bottom-4 left-4 z-[100] flex flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
"min-w-64 rounded-xl px-4 py-3 text-sm text-white shadow-lg",
t.kind === "success" && "bg-success",
t.kind === "error" && "bg-danger",
t.kind === "info" && "bg-foreground",
)}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
return ctx;
}
+308
View File
@@ -0,0 +1,308 @@
"use client";
import {
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
useEffect,
} from "react";
import { cn } from "@/lib/utils";
import { CloseIcon, SpinnerIcon } from "./icons";
/* ------------------------------- Button -------------------------------- */
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
loading?: boolean;
icon?: ReactNode;
}
const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary-hover",
secondary:
"bg-surface-muted text-foreground hover:bg-[#e2e6f2] border border-border",
danger: "bg-danger text-white hover:opacity-90",
ghost: "text-muted hover:bg-surface-muted",
};
export function Button({
variant = "primary",
loading,
icon,
className,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60",
buttonVariants[variant],
className,
)}
disabled={disabled || loading}
{...props}
>
{loading ? <SpinnerIcon className="h-4 w-4" /> : icon}
{children}
</button>
);
}
/* ------------------------------- Fields -------------------------------- */
export function Field({
label,
hint,
required,
children,
}: {
label: string;
hint?: string;
required?: boolean;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium text-foreground">
{label}
{required && <span className="text-danger"> *</span>}
</span>
{children}
{hint && <span className="text-xs text-muted">{hint}</span>}
</label>
);
}
const fieldClass =
"w-full rounded-xl border border-border bg-surface px-3 py-2 text-sm outline-none transition focus:border-primary focus:ring-4 focus:ring-[var(--ring)] disabled:opacity-60";
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn(fieldClass, props.className)} />;
}
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
rows={3}
{...props}
className={cn(fieldClass, "resize-y", props.className)}
/>
);
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={cn(fieldClass, props.className)} />;
}
export function Switch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label?: string;
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-2 text-sm"
>
<span
className={cn(
"relative h-6 w-11 rounded-full transition",
checked ? "bg-primary" : "bg-[#cdd3e6]",
)}
>
<span
className={cn(
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
checked ? "left-0.5" : "right-0.5",
)}
/>
</span>
{label}
</button>
);
}
/* ----------------------------- Containers ------------------------------ */
export function Card({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-surface p-5 shadow-sm",
className,
)}
>
{children}
</div>
);
}
export function PageHeader({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{action}
</div>
);
}
export function Badge({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "success" | "danger" | "primary";
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
tone === "neutral" && "bg-surface-muted text-muted",
tone === "success" && "bg-[#e3f6ee] text-success",
tone === "danger" && "bg-[#fbe7ea] text-danger",
tone === "primary" && "bg-primary-soft text-primary",
)}
>
{children}
</span>
);
}
export function EmptyState({
title,
message,
icon,
action,
}: {
title?: string;
message: string;
icon?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-muted text-3xl">
{icon ?? "🌙"}
</span>
{title && <p className="font-semibold text-foreground">{title}</p>}
<p className="max-w-sm text-sm text-muted">{message}</p>
{action && <div className="mt-1">{action}</div>}
</div>
);
}
export function Spinner({ className }: { className?: string }) {
return <SpinnerIcon className={cn("h-6 w-6 text-primary", className)} />;
}
/* ------------------------------- Modal --------------------------------- */
export function Modal({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-surface shadow-xl">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="font-bold text-foreground">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted hover:bg-surface-muted"
aria-label="بستن"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
{footer}
</div>
)}
</div>
</div>
);
}
export function ConfirmDialog({
open,
title = "حذف مورد",
message,
confirmText = "حذف",
loading,
onConfirm,
onClose,
}: {
open: boolean;
title?: string;
message: string;
confirmText?: string;
loading?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<Modal
open={open}
onClose={onClose}
title={title}
footer={
<>
<Button variant="secondary" onClick={onClose}>
انصراف
</Button>
<Button variant="danger" loading={loading} onClick={onConfirm}>
{confirmText}
</Button>
</>
}
>
<p className="text-sm text-foreground">{message}</p>
</Modal>
);
}
+82
View File
@@ -0,0 +1,82 @@
# Meditation Admin — API reference
Two base URLs (see `lib/config.ts`):
- **approagency** (account login): `https://api.approagency.ir/api`
- **meditation** (everything else): `https://meditation.approagency.ir/api`
- `package_name`: `com.approagency.meditation`
## Auth flow (two steps)
1. `POST {approagency}/auth/login` — multipart: `auth` (email or mobile), `password`, `package_name`
`{ token }` (the "approo" token).
2. `POST {meditation}/auth/login-with-approo-v2?token=<approoToken>&package_name=...` — multipart: `token`, `package_name`
`{ token }` (the meditation bearer token used for all calls below).
All meditation calls send `Authorization: Bearer <meditationToken>` and `Accept: application/json`.
## Endpoints (path is after the meditation base `/api`)
### Media
- GET `/media` — list. query: `categories`, `tags`, `search`
- GET `/media/filters`
- GET `/media/:id`
- POST `/media` — multipart: title, caption, category_id, subcategory_id, file, duration, visibility, type
- POST `/media/:id` — update (multipart: is_premium; json: subcategory_ids[])
- GET `/media/popular`, `/media/recently-played`, `/media/saved`
- POST `/media/:id/play`, `/media/:id/save`, `/media/:id/note`, `/media/:id/feedback` (stars, content)
- Categories: GET/POST/PUT/DELETE `/categories` (multipart name)
- Sub-categories: GET/POST `/sub-categories`, PUT/DELETE `/sub-categories/:id` (category_id, name)
### Track (music)
- Categories: GET `/music-categories/:id`, POST `/music-categories`, PUT/DELETE `/music-categories/:id` (name, description, order, image_id, is_active)
- Sub-categories: GET `/music-subcategories`, POST `/music-subcategories`, PUT/DELETE `/music-subcategories/:id`
- Music: GET `/music/:id`, POST `/music` (title, artist, file, playlist_id, type, duration, image_id), PUT/DELETE `/music/:id`
- POST `/music/update-order`, GET `/music/playlist/:id`, POST `/music/:musicId/add-to-playlist`, DELETE `/music/:musicId/remove-from-playlist`
- Playlists: GET `/music-playlists/:id?`, POST `/music-playlists`, PUT/DELETE `/music-playlists/:id` (name, description, category_ids[], subcategory_ids[])
### Insight timer
- Timer options: GET `/timer/options`
- Presets: GET/POST `/timer-presets`, GET/PUT/DELETE `/timer-presets/:id` (name, duration_seconds, start/end/interval_bell_id, interval_seconds, interval_repeat, background_sound_id, background_image_id, volume)
- Bell sounds: GET/POST `/bell-sounds`, GET `/bell-sounds/:id`, POST `/bell-sounds/:id` (update), DELETE `/bell-sounds/:id` (multipart: name, order, is_active, sound, image)
- Background sounds: GET/POST `/background-sounds`, GET `/background-sounds/:id`, POST `/background-sounds/:id`, DELETE `/background-sounds/:id`
### Settings / scenes
- GET/POST `/scenes`, POST `/scenes/:id` (update), DELETE `/scenes/:id` (multipart: name, order, is_active, image, video, sound)
- GET `/scene-settings`, PUT `/scene-settings` (active_scene_id, scene_volume, background_play_seconds, video_enabled)
### Slider
- GET `/slider`, POST `/slider` (multipart: title, description, action[path], action[type], url), PUT/DELETE `/slider/:id`
### Images
- GET `/images/all`, GET `/images/public`, POST `/images` (multipart: image, title, type, description), PUT/DELETE `/images/:id`
### Questions
- GET `/questions?tag=&category=`, GET `/questions/:id`, POST `/questions`, PUT/DELETE `/questions/:id` (title, category, tags[])
### Survey questions
- GET `/survey-questions/:id`, POST `/survey-questions`, PUT/DELETE `/survey-questions/:id` (question, description, type, order, is_active, options[].label)
- POST `/survey-questions/:id/answer` (option_ids[])
- GET `/admin/survey-questions/:id`, GET `/admin/survey-questions/:id/analytics`
- GET `/survey-questions/suggested-media`
### Breathing exercise
- GET `/breathing-templates`, POST `/breathing-templates`, PUT/DELETE `/breathing-templates/:id` (name, inhale, exhale, breath_hold, duration, description, image_id)
- GET `/user-templates`, GET `/breathing-sessions`, POST `/breathing-complete?template_id=&duration=`
### Mood
- GET `/moods`, GET `/moods/history`, POST `/moods/today` (mood_id)
### Worry box
- GET `/worries`, POST `/worries` (title, note), PUT/DELETE `/worries/:id`, PATCH `/worries/:id/toggle`
### Comments / Likes / Saves / Ratings (by type+id, e.g. type=media|music|playlist)
- Comments: GET/POST/DELETE `/comments/:type/:id` (content)
- Likes: POST `/likes/toggle`, `/likes/like` (type, id), GET `/likes/my-liked?type=`
- Saves: POST `/saves/toggle`, `/saves/save`, `/saves/unsave`, `/saves/check` (type, id), GET `/saves/my-saved?type=`
- Ratings: GET/POST/DELETE `/ratings/:type/:id` (stars), GET `/ratings/:type/:id/user`
### Misc
- GET `/leader-board`
- GET `/profile`
- POST `/zarinpal/gateway` (description, product_id, platform)
+65
View File
@@ -0,0 +1,65 @@
# Feature page conventions (read before writing any page)
This is a **static-export** Next.js 16 SPA. Every page is a Client Component
(`"use client"` at the top). All data fetching happens in the browser via the
helpers below. Persian (Farsi), RTL. Look at the reference page
`app/dashboard/media/categories/page.tsx` and copy its structure.
## Imports & contracts
```ts
import { apiFetch, ApiError, toFormData, unwrap } from "@/lib/api";
import { useList, useItem } 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, Card, Badge, EmptyState,
} from "@/components/ui";
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { toFa, formatDuration } from "@/lib/utils";
```
### `apiFetch<T>(path, { method, body, query, baseUrl, auth })`
- `path` is appended to the meditation base (`/api` already implied — pass e.g. `"/media"`).
- `body`: pass a **plain object** for JSON, or a **FormData** for multipart. Build multipart with `toFormData({...})` (handles File, arrays as `key[]`, booleans as 1/0, skips empty).
- Bearer token is attached automatically. Throws `ApiError` (has `.message`, `.status`).
### `useList<T>(path, query?)``{ data: T[], loading, error, reload }`
Use for GET list endpoints. Already unwraps `{ data: [...] }`.
### `useItem<T>(path)``{ data, loading, error, reload }`
Use for a single GET record (e.g. settings).
### `DataTable<T>` props: `columns`, `rows`, `loading`, `error`, `emptyMessage?`, `actions?(row)`
`Column<T> = { key, header, render?(row), className? }`.
### Form components
- `<Field label required hint>{children}</Field>` wraps an input with a label.
- `<Input/>`, `<Textarea/>`, `<Select/>` are styled native elements (pass value/onChange).
- `<Switch checked onChange={(v)=>...} label/>` for booleans.
- `<Modal open onClose title footer>` — put the form inside; trigger submit via a
`<Button form="my-form" type="submit" loading={saving}>` in the footer and give the
`<form id="my-form" onSubmit={save}>`.
- `<ConfirmDialog open message loading onConfirm onClose/>` for deletes.
- `<Button variant="primary|secondary|danger|ghost" loading icon>`.
### Helpers
- `toFa(value)` → Persian digits for display (use for ids/numbers/durations in tables).
- `formatDuration(seconds)``م:ث`.
## Page skeleton
Every CRUD page: `PageHeader` (title + subtitle + "new" Button) → `DataTable` with
edit/delete `actions` → a `Modal` create/edit form → a `ConfirmDialog` for delete.
On success call `reload()` and `toast.success(...)`; on error `toast.error(err instanceof ApiError ? err.message : "...")`.
Use real Persian labels everywhere. Keep numeric inputs `type="number"`, file inputs
`type="file"` (read `e.target.files?.[0]`). For ltr-ish fields (urls, ids) add `dir="ltr"`.
## REST conventions in this API (important quirks)
- **Create**: usually `POST /resource` (multipart unless noted JSON).
- **Update**: RESTful resources use `PUT /resource/:id` (JSON). BUT file-bearing
resources (**scenes, bell-sounds, background-sounds**) update via `POST /resource/:id` (multipart).
- **Delete**: `DELETE /resource/:id`.
- Lists: `GET /resource`.
+133
View File
@@ -0,0 +1,133 @@
// Thin fetch wrapper for the meditation API. The app is a static SPA, so every
// call runs in the browser and carries the bearer token from localStorage.
import { MEDITATION_BASE_URL, TOKEN_STORAGE_KEY } from "./config";
export class ApiError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function setToken(token: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearToken() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}
type Query = Record<string, string | number | boolean | undefined | null>;
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
// Plain object -> JSON body. FormData -> multipart (browser sets boundary).
body?: unknown;
query?: Query;
// Override/extend the base url (e.g. the approagency host for login).
baseUrl?: string;
// Skip attaching the bearer token (used by the login calls).
auth?: boolean;
}
function buildUrl(path: string, query?: Query, baseUrl = MEDITATION_BASE_URL) {
const url = new URL(
`${baseUrl}${path.startsWith("/") ? path : `/${path}`}`,
);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, query, baseUrl, auth = true } = options;
const headers: Record<string, string> = { Accept: "application/json" };
if (auth) {
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
}
let payload: BodyInit | undefined;
if (body instanceof FormData) {
payload = body; // browser sets multipart Content-Type + boundary
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
const res = await fetch(buildUrl(path, query, baseUrl), {
method,
headers,
body: payload,
});
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const message =
(data && typeof data === "object" && "message" in data
? String((data as { message: unknown }).message)
: null) ?? `خطای ارتباط با سرور (${res.status})`;
throw new ApiError(message, res.status, data);
}
return data as T;
}
// Many Laravel resource endpoints wrap the payload as { data: ... }. Unwrap it
// when present so callers always get the raw value.
export function unwrap<T = unknown>(res: unknown): T {
if (res && typeof res === "object" && "data" in res) {
return (res as { data: T }).data;
}
return res as T;
}
// Convenience builder for multipart bodies. Skips undefined/null and expands
// arrays to repeated `key[]` entries the way Laravel expects.
export function toFormData(fields: Record<string, unknown>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (value instanceof File || value instanceof Blob) {
fd.append(key, value);
} else if (Array.isArray(value)) {
for (const item of value) fd.append(`${key}[]`, String(item));
} else if (typeof value === "boolean") {
fd.append(key, value ? "1" : "0");
} else {
fd.append(key, String(value));
}
}
return fd;
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
// Two-step login flow + auth state, exposed through React context.
//
// Step 1: POST {approagency}/auth/login (auth, password, package_name) -> approoToken
// Step 2: POST {meditation}/auth/login-with-approo-v2?token=approoToken&package_name=...
// (multipart: token, package_name) -> meditation bearer token
//
// The meditation token is persisted in localStorage and attached to every
// subsequent request by lib/api.ts.
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { APPRO_BASE_URL, MEDITATION_BASE_URL, PACKAGE_NAME } from "./config";
import {
ApiError,
apiFetch,
clearToken,
getToken,
setToken,
toFormData,
} from "./api";
interface AuthState {
token: string | null;
ready: boolean; // hydrated from localStorage yet?
login: (identifier: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
async function approAgencyLogin(
identifier: string,
password: string,
): Promise<string> {
const res = await apiFetch<{ token?: string }>("/auth/login", {
method: "POST",
baseUrl: APPRO_BASE_URL,
auth: false,
body: toFormData({
auth: identifier,
password,
package_name: PACKAGE_NAME,
}),
});
if (!res?.token) {
throw new ApiError("نام کاربری یا رمز عبور نادرست است.", 401, res);
}
return res.token;
}
async function meditationLogin(approoToken: string): Promise<string> {
const res = await apiFetch<{ token?: string }>(
"/auth/login-with-approo-v2",
{
method: "POST",
baseUrl: MEDITATION_BASE_URL,
auth: false,
query: { token: approoToken, package_name: PACKAGE_NAME },
body: toFormData({ token: approoToken, package_name: PACKAGE_NAME }),
},
);
if (!res?.token) {
throw new ApiError("ورود به سرویس مدیتیشن ناموفق بود.", 401, res);
}
return res.token;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setTokenState] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setTokenState(getToken());
setReady(true);
}, []);
const login = useCallback(async (identifier: string, password: string) => {
const approoToken = await approAgencyLogin(identifier, password);
const medToken = await meditationLogin(approoToken);
setToken(medToken);
setTokenState(medToken);
}, []);
const logout = useCallback(() => {
clearToken();
setTokenState(null);
}, []);
return (
<AuthContext.Provider value={{ token, ready, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
// Central runtime configuration. Both URLs are public (the app is a static SPA
// that talks to these APIs directly from the browser), so NEXT_PUBLIC_ envs are
// used as optional overrides with sensible defaults baked in.
export const APPRO_BASE_URL =
process.env.NEXT_PUBLIC_APPRO_BASE_URL ?? "https://api.approagency.ir/api";
export const MEDITATION_BASE_URL =
process.env.NEXT_PUBLIC_MEDITATION_BASE_URL ??
"https://meditation.approagency.ir/api";
export const PACKAGE_NAME =
process.env.NEXT_PUBLIC_PACKAGE_NAME ?? "com.approagency.meditation";
// Origin that serves uploaded files (audio/video/images). Defaults to the
// meditation host without the trailing `/api`, so relative storage paths like
// `storage/sounds/x.mp3` resolve to a full URL.
export const ASSET_BASE_URL =
process.env.NEXT_PUBLIC_ASSET_BASE_URL ??
MEDITATION_BASE_URL.replace(/\/api\/?$/, "");
// localStorage key for the meditation bearer token.
export const TOKEN_STORAGE_KEY = "meditation_admin_token";
+76
View File
@@ -0,0 +1,76 @@
// Helpers for resolving uploaded-asset URLs out of loosely-typed API records.
// Response field names vary across endpoints, so we probe a list of common keys
// and turn relative storage paths into absolute URLs.
import { ASSET_BASE_URL } from "./config";
// Turn a possibly-relative path into an absolute URL. The API serves uploads
// from `/storage/...`, while DB fields like `file_path` hold paths relative to
// that (e.g. "music/x.mp3"), so we add the `storage/` prefix when missing.
export function assetUrl(path?: string | null): string | null {
if (!path) return null;
if (/^(https?:|data:|blob:)/i.test(path)) return path;
let p = String(path).replace(/^\/+/, "");
if (!p.startsWith("storage/")) p = `storage/${p}`;
return `${ASSET_BASE_URL}/${p}`;
}
// A record may store a media reference directly (string) or nested in an object
// (e.g. { sound: { url } }). Read the first key that yields a usable value.
function readField(row: Record<string, unknown>, key: string): string | null {
const v = row[key];
if (typeof v === "string" && v) return v;
if (v && typeof v === "object") {
const obj = v as Record<string, unknown>;
for (const k of ["url", "path", "src", "file"]) {
if (typeof obj[k] === "string" && obj[k]) return obj[k] as string;
}
}
return null;
}
// Find the first present URL among candidate keys, resolved to absolute.
export function pickUrl(
row: unknown,
keys: string[],
): string | null {
if (!row || typeof row !== "object") return null;
const record = row as Record<string, unknown>;
for (const key of keys) {
const found = readField(record, key);
if (found) return assetUrl(found);
}
return null;
}
// Common key sets per media kind. Ordered most-specific / full-URL first.
// (`/media` returns the playable file in `external_url`; `/music` uses `url`.)
export const SOUND_KEYS = [
"url",
"external_url",
"sound_url",
"sound",
"audio_url",
"audio",
"file_url",
"file",
"file_path",
];
export const VIDEO_KEYS = [
"external_url",
"video_url",
"video",
"url",
"file_url",
"file",
"file_path",
];
export const IMAGE_KEYS = [
"image_url",
"image",
"thumbnail",
"thumbnail_url",
"cover",
"url",
"path",
];
+117
View File
@@ -0,0 +1,117 @@
import type { ComponentType, SVGProps } from "react";
import {
BreathIcon,
HomeIcon,
ImageIcon,
MediaIcon,
MoodIcon,
MusicIcon,
QuestionIcon,
SceneIcon,
SliderIcon,
SurveyIcon,
TagIcon,
TimerIcon,
TrophyIcon,
WorryIcon,
} from "@/components/icons";
export interface NavItem {
href: string;
label: string;
}
export interface NavSection {
label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
items: NavItem[];
}
export const NAV: NavSection[] = [
{
label: "میزکار",
icon: HomeIcon,
items: [{ href: "/dashboard", label: "خانه" }],
},
{
label: "رسانه‌ها",
icon: MediaIcon,
items: [
{ href: "/dashboard/media", label: "فهرست رسانه‌ها" },
{ href: "/dashboard/media/categories", label: "دسته‌بندی‌ها" },
{ href: "/dashboard/media/sub-categories", label: "زیر‌دسته‌ها" },
],
},
{
label: "موسیقی",
icon: MusicIcon,
items: [
{ href: "/dashboard/music/tracks", label: "آهنگ‌ها" },
{ href: "/dashboard/music/playlists", label: "پلی‌لیست‌ها" },
{ href: "/dashboard/music/categories", label: "دسته‌بندی‌ها" },
{ href: "/dashboard/music/sub-categories", label: "زیر‌دسته‌ها" },
],
},
{
label: "تایمر مدیتیشن",
icon: TimerIcon,
items: [
{ href: "/dashboard/timer/presets", label: "پیش‌تنظیم‌ها" },
{ href: "/dashboard/timer/bell-sounds", label: "صدای زنگ" },
{ href: "/dashboard/timer/background-sounds", label: "صدای پس‌زمینه" },
{ href: "/dashboard/timer/options", label: "گزینه‌های تایمر" },
],
},
{
label: "صحنه‌ها",
icon: SceneIcon,
items: [
{ href: "/dashboard/scenes", label: "صحنه‌ها" },
{ href: "/dashboard/scenes/settings", label: "تنظیمات صحنه" },
],
},
{
label: "اسلایدر",
icon: SliderIcon,
items: [{ href: "/dashboard/sliders", label: "اسلایدرها" }],
},
{
label: "تصاویر",
icon: ImageIcon,
items: [{ href: "/dashboard/images", label: "کتابخانه تصاویر" }],
},
{
label: "تمرین تنفس",
icon: BreathIcon,
items: [{ href: "/dashboard/breathing", label: "قالب‌های تنفس" }],
},
{
label: "پرسش‌ها",
icon: QuestionIcon,
items: [{ href: "/dashboard/questions", label: "بانک پرسش‌ها" }],
},
{
label: "نظرسنجی",
icon: SurveyIcon,
items: [{ href: "/dashboard/surveys", label: "پرسش‌های نظرسنجی" }],
},
{
label: "حال‌وهوا",
icon: MoodIcon,
items: [{ href: "/dashboard/moods", label: "حالت‌ها" }],
},
{
label: "جعبه نگرانی",
icon: WorryIcon,
items: [{ href: "/dashboard/worries", label: "نگرانی‌ها" }],
},
{
label: "جدول امتیازات",
icon: TrophyIcon,
items: [{ href: "/dashboard/leaderboard", label: "رتبه‌بندی" }],
},
{
label: "محتوای کاربران",
icon: TagIcon,
items: [{ href: "/dashboard/comments", label: "نظرات" }],
},
];
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ApiError, apiFetch, unwrap } from "./api";
type Query = Record<string, string | number | boolean | undefined | null>;
// Generic GET-list hook. Unwraps `{ data: [...] }` and tolerates either a bare
// array or a paginated object with a `data` field.
export function useList<T = unknown>(
path: string | null,
query?: Query,
): {
data: T[];
loading: boolean;
error: string | null;
reload: () => void;
} {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryKey = JSON.stringify(query ?? {});
const reload = useCallback(() => {
if (!path) {
// No path yet (e.g. a filter not chosen): show an empty, non-loading state.
setData([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch(path, { query })
.then((res) => {
const value = unwrap<unknown>(res);
setData(Array.isArray(value) ? (value as T[]) : []);
})
.catch((e: unknown) => {
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات");
setData([]);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, queryKey]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload };
}
// Single-record GET hook.
export function useItem<T = unknown>(path: string | null): {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(() => {
if (!path) {
setData(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch(path)
.then((res) => setData(unwrap<T>(res)))
.catch((e: unknown) =>
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات"),
)
.finally(() => setLoading(false));
}, [path]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload };
}
+19
View File
@@ -0,0 +1,19 @@
// Tiny className combiner (avoids an extra dependency).
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
// Convert ASCII digits to Persian digits for display.
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function toFa(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
// Seconds -> "م:ث" style label (e.g. 90 -> ۱:۳۰).
export function formatDuration(seconds?: number | null): string {
if (!seconds && seconds !== 0) return "—";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return toFa(`${m}:${String(s).padStart(2, "0")}`);
}
+8 -1
View File
@@ -1,7 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Export the whole app as static HTML/JS into `out/` (no Node server).
output: "export",
// Emit `/route/index.html` so any static host serves clean URLs.
trailingSlash: true,
images: {
// Required for `output: export` — no on-demand optimization server exists.
unoptimized: true,
},
};
export default nextConfig;