import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../api'; import { getErrorSummary } from '../api/errors'; import { DataTable } from '../components/shared/DataTable'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListSearchToolbar, type SortFieldOption } from '../components/shared/list/ListSearchToolbar'; import { InlineRowActions } from '../components/shared/list/InlineRowActions'; import type { RowActionItem } from '../components/shared/list/RowActionsMenu'; import { DEFAULT_LIST_PER_PAGE, ListPaginationBar } from '../components/shared/list/ListPaginationBar'; import { ButtonPrimary } from '../components/shared/buttons'; import { DataTableSkeleton } from '../components/shared/Skeleton'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { QuillField } from '../components/shared/QuillField'; import type { Column } from '../components/shared/DataTable'; import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useWpTermCollection } from '../hooks/useWpTermCollection'; import { useAdminRouting } from '../lib/adminRouting'; import { courseCategoryViewHref } from '../lib/courseCategoryViewHref'; import type { SikshyaReactConfig, WpTerm } from '../types'; import { __ } from '../lib/i18n'; const TAXONOMY = 'sikshya_course_category'; const FIELD = 'mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-white'; const LABEL = 'block text-sm font-medium text-slate-800 dark:text-slate-200'; type CategoryPayload = { id: number; name: string; slug: string; description: string; parent: number; image_id: number; }; type WpMediaFrame = { open: () => void; on: (event: 'select', cb: () => void) => void; state: () => { get: (k: 'selection') => { first: () => { toJSON: () => { id?: number; url?: string } } } }; }; export function CourseCategoriesPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string; subtitle: string }) { const { config, title, subtitle } = props; const { route } = useAdminRouting(); const { confirm, alert: alertDialog } = useSikshyaDialog(); const toast = useTopRightToast(4200); const categoryIdFromUrl = useMemo(() => { const raw = route.query.category_id || route.query.term_id || ''; const n = Number(raw); return Number.isFinite(n) && n > 0 ? n : 0; }, [route.query.category_id, route.query.term_id]); const [selectedId, setSelectedId] = useState(null); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [slug, setSlug] = useState(''); const [parent, setParent] = useState(0); const [imageId, setImageId] = useState(0); const [imagePreview, setImagePreview] = useState(null); const mediaFrameRef = useRef(null); const [loadingCategory, setLoadingCategory] = useState(false); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 320); const [orderby, setOrderby] = useState<'name' | 'count'>('name'); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [listNonce, setListNonce] = useState(0); const [page, setPage] = useState(1); const bumpList = useCallback(() => setListNonce((n) => n + 1), []); useEffect(() => { setPage(1); }, [debouncedSearch, orderby, order, listNonce]); const listQuery = useWpTermCollection({ taxonomyRestBase: TAXONOMY, search: debouncedSearch, orderby, order, page, perPage: DEFAULT_LIST_PER_PAGE, refreshNonce: listNonce, }); const rows = Array.isArray(listQuery.data?.data) ? listQuery.data.data : []; const startNew = useCallback(() => { setSelectedId(null); setName(''); setDescription(''); setSlug(''); setParent(0); setImageId(0); setImagePreview(null); setFormError(null); }, []); useEffect(() => { if (categoryIdFromUrl <= 0) { return; } setSelectedId(categoryIdFromUrl); }, [categoryIdFromUrl]); useEffect(() => { if (selectedId === null) { return; } let cancelled = false; setLoadingCategory(true); setFormError(null); void getSikshyaApi() .get<{ success: boolean; data?: { category?: CategoryPayload } }>(SIKSHYA_ENDPOINTS.admin.courseCategory(selectedId)) .then((res) => { if (cancelled) { return; } const c = res.success && res.data?.category ? res.data.category : null; if (!c) { setFormError('Could not load category.'); return; } setName(c.name); setDescription(c.description || ''); setSlug(c.slug || ''); setParent(typeof c.parent === 'number' ? c.parent : 0); setImageId(typeof c.image_id === 'number' ? c.image_id : 0); }) .catch((e) => { if (!cancelled) { setFormError(getErrorSummary(e)); } }) .finally(() => { if (!cancelled) { setLoadingCategory(false); } }); return () => { cancelled = true; }; }, [selectedId]); useEffect(() => { if (!imageId || imageId <= 0) { setImagePreview(null); return; } let cancelled = false; void getWpApi() .get<{ source_url?: string }>(`/media/${imageId}`) .then((m) => { if (!cancelled && m?.source_url) { setImagePreview(m.source_url); } }) .catch(() => { if (!cancelled) { setImagePreview(null); } }); return () => { cancelled = true; }; }, [imageId]); const parentOptions = useMemo(() => rows.filter((t) => t.id !== selectedId), [rows, selectedId]); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); setFormError(null); try { const body: Record = { name: name.trim(), description: description.trim(), slug: slug.trim(), parent, image: imageId > 0 ? imageId : 0, }; if (selectedId !== null) { body.term_id = selectedId; } const res = await getSikshyaApi().post<{ success: boolean; message?: string }>( SIKSHYA_ENDPOINTS.admin.courseCategorySave, body ); if (!res.success) { throw new Error(res.message || 'Save failed'); } const notice = (res.message || '').trim(); if (selectedId === null) { toast.success(__('Category created', 'sikshya'), notice || 'The category was saved.'); bumpList(); startNew(); } else { toast.success(__('Category updated', 'sikshya'), notice || 'Your changes were saved.'); bumpList(); } } catch (err) { const msg = getErrorSummary(err); setFormError(msg); toast.error(__('Could not save category', 'sikshya'), msg); } finally { setSaving(false); } }; const sortFieldOptions: SortFieldOption[] = useMemo( () => [ { value: 'name', label: 'Name' }, { value: 'count', label: 'Course count' }, ], [] ); const columns: Column[] = useMemo( () => [ { id: 'id', header: 'ID', alwaysVisible: true, cellClassName: 'whitespace-nowrap tabular-nums text-slate-600 dark:text-slate-400', render: (t) => t.id, }, { id: 'image', header: '', columnPickerLabel: 'Image', alwaysVisible: true, headerClassName: 'w-16', cellClassName: 'w-16', render: (t) => { const src = typeof t.sikshya_category_image_url === 'string' ? t.sikshya_category_image_url : ''; return (
{src ? ( ) : ( )}
); }, }, { id: 'name', header: 'Category', sortKey: 'name', render: (t) => { const rowActions: RowActionItem[] = []; const viewHref = courseCategoryViewHref(t, config); if (viewHref) { rowActions.push({ key: 'view', label: 'View category', href: viewHref, external: true, }); } rowActions.push( { key: 'edit', label: 'Edit in form', onClick: () => { setSelectedId(t.id); }, }, { key: 'delete', label: 'Delete', danger: true, onClick: () => void (async () => { const ok = await confirm({ title: __('Delete category?', 'sikshya'), message: `Delete category “${t.name}”?`, variant: 'danger', confirmLabel: __('Delete', 'sikshya'), }); if (!ok) { return; } try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.admin.courseCategory(t.id)); if (selectedId === t.id) { startNew(); } bumpList(); toast.success(__('Category deleted', 'sikshya'), 'The category was removed.'); } catch (e) { await alertDialog({ title: __('Could not delete category', 'sikshya'), message: getErrorSummary(e), }); } })(), } ); return (
{t.slug}
); }, }, { id: 'count', header: 'Courses', sortKey: 'count', cellClassName: 'tabular-nums text-slate-600 dark:text-slate-400', render: (t) => (typeof t.count === 'number' ? t.count : '—'), }, ], [selectedId, bumpList, startNew, confirm, alertDialog, config] ); const emptyContent = ( ); const onSortOrderToggle = () => setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); const onSortColumn = useCallback( (key: string) => { if (key === orderby) { setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); } else if (key === 'name' || key === 'count') { setOrderby(key); setOrder('asc'); } }, [orderby] ); return (
setOrderby(v as 'name' | 'count')} sortOrder={order} onSortOrderToggle={onSortOrderToggle} />
{listQuery.data?.total != null ? ( Showing {rows.length} of {listQuery.data.total} ) : ( {__('Course categories for your catalog', 'sikshya')} )}
{listQuery.error ? (
) : listQuery.loading ? ( ) : ( <> r.id} emptyContent={emptyContent} wrapInCard={false} sortState={{ orderby, order }} onSortColumn={onSortColumn} /> )}
); }