Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c6d16693c | ||
|
|
3cc1d1385c | ||
|
|
bd71076c72 |
Regular → Executable
@@ -10,6 +10,7 @@ import {
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Textarea,
|
||||
Modal,
|
||||
PageHeader,
|
||||
@@ -19,24 +20,42 @@ import { MediaPreview } from "@/components/MediaPreview";
|
||||
import { pickUrl } from "@/lib/media";
|
||||
import { toFa } from "@/lib/utils";
|
||||
|
||||
type CategoryType = "media" | "playlist" | "breathing_template";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
subcategories_count?: number;
|
||||
}
|
||||
|
||||
// General category types, shared across media, playlists and breathing templates.
|
||||
const TYPE_OPTIONS: { value: CategoryType; label: string }[] = [
|
||||
{ value: "media", label: "رسانه" },
|
||||
{ value: "playlist", label: "پلیلیست" },
|
||||
{ value: "breathing_template", label: "قالب تنفس" },
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<CategoryType, string> = Object.fromEntries(
|
||||
TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||||
) as Record<CategoryType, string>;
|
||||
|
||||
// The icon is a direct image-file field on the category (not an image_id ref).
|
||||
const ICON_KEYS = ["icon", "icon_url"];
|
||||
|
||||
export default function MediaCategoriesPage() {
|
||||
const { data, loading, error, reload } = useList<Category>("/categories");
|
||||
export default function CategoriesPage() {
|
||||
const [typeFilter, setTypeFilter] = useState<CategoryType>("media");
|
||||
const { data, loading, error, reload } = useList<Category>("/categories", {
|
||||
type: typeFilter,
|
||||
});
|
||||
const toast = useToast();
|
||||
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<CategoryType>("media");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -47,6 +66,7 @@ export default function MediaCategoriesPage() {
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
setType(typeFilter);
|
||||
setDescription("");
|
||||
setIcon(null);
|
||||
setOpen(true);
|
||||
@@ -54,6 +74,7 @@ export default function MediaCategoriesPage() {
|
||||
function openEdit(row: Category) {
|
||||
setEditing(row);
|
||||
setName(row.name ?? "");
|
||||
setType(row.type ?? "media");
|
||||
setDescription(row.description ?? "");
|
||||
setIcon(null);
|
||||
setOpen(true);
|
||||
@@ -68,6 +89,7 @@ export default function MediaCategoriesPage() {
|
||||
const body = toFormData({
|
||||
_method: "PUT",
|
||||
name,
|
||||
type,
|
||||
description,
|
||||
icon,
|
||||
});
|
||||
@@ -76,7 +98,7 @@ export default function MediaCategoriesPage() {
|
||||
} else {
|
||||
await apiFetch("/categories", {
|
||||
method: "POST",
|
||||
body: toFormData({ name, description, icon }),
|
||||
body: toFormData({ name, type, description, icon }),
|
||||
});
|
||||
toast.success("دستهبندی افزوده شد.");
|
||||
}
|
||||
@@ -114,6 +136,12 @@ export default function MediaCategoriesPage() {
|
||||
),
|
||||
},
|
||||
{ key: "name", header: "نام دستهبندی" },
|
||||
{
|
||||
key: "type",
|
||||
header: "نوع",
|
||||
render: (r) => TYPE_LABELS[r.type] ?? r.type,
|
||||
className: "w-28",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "توضیحات",
|
||||
@@ -130,12 +158,26 @@ export default function MediaCategoriesPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="دستهبندی رسانهها"
|
||||
subtitle="مدیریت دستهبندیهای اصلی محتوای صوتی و تصویری"
|
||||
title="دستهبندیها"
|
||||
subtitle="مدیریت دستهبندیهای عمومی رسانه، پلیلیست و قالب تنفس"
|
||||
action={
|
||||
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
|
||||
دستهبندی جدید
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as CategoryType)}
|
||||
aria-label="نوع دستهبندی"
|
||||
className="w-40"
|
||||
>
|
||||
{TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
|
||||
دستهبندی جدید
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -184,6 +226,19 @@ export default function MediaCategoriesPage() {
|
||||
}
|
||||
>
|
||||
<form id="category-form" onSubmit={save} className="flex flex-col gap-4">
|
||||
<Field label="نوع" required>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as CategoryType)}
|
||||
>
|
||||
{TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="نام دستهبندی" required>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -85,7 +85,9 @@ function ChipMultiSelect({
|
||||
export default function MediaPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, loading, error, reload } = useList<Media>("/media", { search });
|
||||
const { data: categories } = useList<Category>("/categories");
|
||||
const { data: categories } = useList<Category>("/categories", {
|
||||
type: "media",
|
||||
});
|
||||
const { data: subCategories } = useList<Category>("/sub-categories");
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
+9
-5
@@ -33,14 +33,18 @@ export const NAV: NavSection[] = [
|
||||
icon: HomeIcon,
|
||||
items: [{ href: "/dashboard", label: "خانه" }],
|
||||
},
|
||||
{
|
||||
label: "عمومی",
|
||||
icon: TagIcon,
|
||||
items: [
|
||||
{ href: "/dashboard/categories", label: "دستهبندیها" },
|
||||
{ href: "/dashboard/sub-categories", label: "زیردستهها" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "رسانهها",
|
||||
icon: MediaIcon,
|
||||
items: [
|
||||
{ href: "/dashboard/media", label: "فهرست رسانهها" },
|
||||
{ href: "/dashboard/media/categories", label: "دستهبندیها" },
|
||||
{ href: "/dashboard/media/sub-categories", label: "زیردستهها" },
|
||||
],
|
||||
items: [{ href: "/dashboard/media", label: "فهرست رسانهها" }],
|
||||
},
|
||||
{
|
||||
label: "موسیقی",
|
||||
|
||||
Generated
+1
-24
@@ -277,30 +277,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@@ -3312,6 +3288,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
||||
Regular → Executable
Reference in New Issue
Block a user