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