@@ -10,6 +10,7 @@ import {
ConfirmDialog ,
ConfirmDialog ,
Field ,
Field ,
Input ,
Input ,
Select ,
Textarea ,
Textarea ,
Modal ,
Modal ,
PageHeader ,
PageHeader ,
@@ -19,24 +20,42 @@ import { MediaPreview } from "@/components/MediaPreview";
import { pickUrl } from "@/lib/media" ;
import { pickUrl } from "@/lib/media" ;
import { toFa } from "@/lib/utils" ;
import { toFa } from "@/lib/utils" ;
type CategoryType = "media" | "playlist" | "breathing_template" ;
interface Category {
interface Category {
id : number ;
id : number ;
name : string ;
name : string ;
type : CategoryType ;
description? : string | null ;
description? : string | null ;
icon? : string | null ;
icon? : string | null ;
subcategories_count? : number ;
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).
// The icon is a direct image-file field on the category (not an image_id ref).
const ICON_KEYS = [ "icon" , "icon_url" ] ;
const ICON_KEYS = [ "icon" , "icon_url" ] ;
export default function MediaCategoriesPage() {
export default function MediaCategoriesPage() {
const { data , loading , error , reload } = useList < Category > ( "/categories " ) ;
const [ typeFilter , setTypeFilter ] = useState < CategoryType > ( "media " ) ;
const { data , loading , error , reload } = useList < Category > ( "/categories" , {
type : typeFilter ,
} ) ;
const toast = useToast ( ) ;
const toast = useToast ( ) ;
const [ editing , setEditing ] = useState < Category | null > ( null ) ;
const [ editing , setEditing ] = useState < Category | null > ( null ) ;
const [ open , setOpen ] = useState ( false ) ;
const [ open , setOpen ] = useState ( false ) ;
const [ name , setName ] = useState ( "" ) ;
const [ name , setName ] = useState ( "" ) ;
const [ type , setType ] = useState < CategoryType > ( "media" ) ;
const [ description , setDescription ] = useState ( "" ) ;
const [ description , setDescription ] = useState ( "" ) ;
const [ icon , setIcon ] = useState < File | null > ( null ) ;
const [ icon , setIcon ] = useState < File | null > ( null ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ saving , setSaving ] = useState ( false ) ;
@@ -47,6 +66,7 @@ export default function MediaCategoriesPage() {
function openCreate() {
function openCreate() {
setEditing ( null ) ;
setEditing ( null ) ;
setName ( "" ) ;
setName ( "" ) ;
setType ( typeFilter ) ;
setDescription ( "" ) ;
setDescription ( "" ) ;
setIcon ( null ) ;
setIcon ( null ) ;
setOpen ( true ) ;
setOpen ( true ) ;
@@ -54,6 +74,7 @@ export default function MediaCategoriesPage() {
function openEdit ( row : Category ) {
function openEdit ( row : Category ) {
setEditing ( row ) ;
setEditing ( row ) ;
setName ( row . name ? ? "" ) ;
setName ( row . name ? ? "" ) ;
setType ( row . type ? ? "media" ) ;
setDescription ( row . description ? ? "" ) ;
setDescription ( row . description ? ? "" ) ;
setIcon ( null ) ;
setIcon ( null ) ;
setOpen ( true ) ;
setOpen ( true ) ;
@@ -68,6 +89,7 @@ export default function MediaCategoriesPage() {
const body = toFormData ( {
const body = toFormData ( {
_method : "PUT" ,
_method : "PUT" ,
name ,
name ,
type ,
description ,
description ,
icon ,
icon ,
} ) ;
} ) ;
@@ -76,7 +98,7 @@ export default function MediaCategoriesPage() {
} else {
} else {
await apiFetch ( "/categories" , {
await apiFetch ( "/categories" , {
method : "POST" ,
method : "POST" ,
body : toFormData ( { name , description , icon } ) ,
body : toFormData ( { name , type , description , icon } ) ,
} ) ;
} ) ;
toast . success ( "دستهبندی افزوده شد." ) ;
toast . success ( "دستهبندی افزوده شد." ) ;
}
}
@@ -114,6 +136,12 @@ export default function MediaCategoriesPage() {
) ,
) ,
} ,
} ,
{ key : "name" , header : "نام دستهبندی" } ,
{ key : "name" , header : "نام دستهبندی" } ,
{
key : "type" ,
header : "نوع" ,
render : ( r ) = > TYPE_LABELS [ r . type ] ? ? r . type ,
className : "w-28" ,
} ,
{
{
key : "description" ,
key : "description" ,
header : "توضیحات" ,
header : "توضیحات" ,
@@ -130,12 +158,26 @@ export default function MediaCategoriesPage() {
return (
return (
< div >
< div >
< PageHeader
< PageHeader
title = "دستهبندی رسانه ها"
title = "دستهبندیها"
subtitle = "مدیریت دستهبندیهای اصلی محتوای صوتی و تصویری "
subtitle = "مدیریت دستهبندیهای عمومی رسانه، پلیلیست و قالب تنفس "
action = {
action = {
< 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 icon = { < PlusIcon className = "h-4 w-4" / > } onClick = { openCreate } >
د س ت ه ب ن د ی ج د ی د
د س ت ه ب ن د ی ج د ی د
< / Button >
< / Button >
< / div >
}
}
/ >
/ >
@@ -184,6 +226,19 @@ export default function MediaCategoriesPage() {
}
}
>
>
< form id = "category-form" onSubmit = { save } className = "flex flex-col gap-4" >
< 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 >
< Field label = "نام دستهبندی" required >
< Input
< Input
value = { name }
value = { name }