import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { ListPaginationBar, DEFAULT_LIST_PER_PAGE } from '../components/shared/list/ListPaginationBar'; import { BulkActionsBar } from '../components/shared/list/BulkActionsBar'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { Modal } from '../components/shared/Modal'; import { SingleCoursePicker } from '../components/shared/SingleCoursePicker'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import { formatPostDate } from '../lib/formatPostDate'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type ThreadStatus = 'pending' | 'approved' | 'spam' | 'trash'; type ThreadType = 'discussion' | 'qa'; /** Learner thread triage hint for admins (moderation vs staff reply vs done). */ type ThreadAttention = 'moderate' | 'reply' | 'answered' | 'spam'; type AuthorRef = { id: number; name: string; email?: string; }; type CourseRef = { id: number; title: string; permalink?: string; }; type ContentRef = { id: number; type: string; title: string; permalink?: string; }; type ThreadRow = { id: number; content: string; excerpt: string; thread_type: ThreadType; status: ThreadStatus; is_pending: boolean; created_at: string; reply_count: number; attention?: ThreadAttention; needs_staff_reply?: boolean; author: AuthorRef; course: CourseRef; content_ref: ContentRef; can_moderate: boolean; can_edit: boolean; can_delete: boolean; }; type ReplyRow = { id: number; parent_id: number; content: string; created_at: string; status: ThreadStatus; is_pending: boolean; author: AuthorRef; can_edit: boolean; can_delete: boolean; }; type ThreadDetail = ThreadRow & { replies: ReplyRow[] }; type ListResponse = { success: boolean; data: { items: ThreadRow[]; total: number; page: number; per_page: number }; }; type DetailResponse = { success: boolean; data: ThreadDetail; }; type SummaryResponse = { success: boolean; data: { total: number; pending: number; discussions: number; qa: number; replies: number; }; }; type DiscussionsBulkResponse = { success: boolean; message?: string; data?: { processed: number; skipped: Array<{ id: number; code?: string; message: string }>; action: string; }; }; type StatusFilter = 'pending' | 'approved' | 'spam' | 'trash' | 'all'; type ContentTypeFilter = '' | 'lesson' | 'quiz'; type ThreadTypeFilter = '' | ThreadType; type AttentionFilter = 'all' | ThreadAttention; const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ { value: 'all', label: 'All statuses' }, { value: 'pending', label: 'Pending' }, { value: 'approved', label: 'Approved' }, { value: 'spam', label: 'Spam' }, { value: 'trash', label: 'Rejected (trash)' }, ]; const THREAD_TYPE_OPTIONS: Array<{ value: ThreadTypeFilter; label: string }> = [ { value: '', label: 'Discussions and Q&A' }, { value: 'discussion', label: 'Discussions only' }, { value: 'qa', label: 'Q&A only' }, ]; const CONTENT_TYPE_OPTIONS: Array<{ value: ContentTypeFilter; label: string }> = [ { value: '', label: 'Lessons and quizzes' }, { value: 'lesson', label: 'Lessons only' }, { value: 'quiz', label: 'Quizzes only' }, ]; /** When the REST payload omits `attention` (older Pro builds), derive a safe fallback. */ function resolveAttention(row: Pick): ThreadAttention { if (row.attention) return row.attention; if (row.status === 'spam' || row.status === 'trash') return 'spam'; if (row.is_pending) return 'moderate'; return 'answered'; } function buildThreadModerationMenuItems( row: ThreadRow, ctx: { rowBusyId: number | null; onApprove: () => void | Promise; onMarkSpam: () => void | Promise; onTrash: () => void | Promise; onEdit: () => void; onDelete: () => void | Promise; } ): RowActionItem[] { const busy = ctx.rowBusyId === row.id; const items: RowActionItem[] = []; if (row.can_moderate && row.is_pending) { items.push({ key: 'approve', label: 'Approve', disabled: busy, onClick: ctx.onApprove }); } if (row.can_moderate && row.status !== 'spam' && row.status !== 'trash') { items.push({ key: 'spam', label: 'Mark as spam', danger: true, disabled: busy, onClick: ctx.onMarkSpam, }); items.push({ key: 'trash', label: 'Reject (move to trash)', danger: true, disabled: busy, onClick: ctx.onTrash, }); } if (row.can_edit) { items.push({ key: 'edit', label: 'Edit', disabled: busy, onClick: ctx.onEdit }); } if (row.can_delete) { items.push({ key: 'delete', label: 'Delete permanently', danger: true, disabled: busy, onClick: ctx.onDelete, }); } return items; } // Both pills delegate to the shared StatusBadge — tone mapping captures the // domain semantics (approved/pending/spam/reject) without per-page color // strings. Tooltips are preserved via a wrapping span with `title`. function StatusPill({ status }: { status: ThreadStatus }) { if (status === 'approved') { return ; } if (status === 'pending') { return ; } if (status === 'spam') { return ; } return ( ); } function AttentionPill({ attention }: { attention: ThreadAttention }) { if (attention === 'moderate') { return ( ); } if (attention === 'reply') { return ( ); } if (attention === 'spam') { return ( ); } return ( ); } const ATTENTION_OPTIONS: Array<{ value: AttentionFilter; label: string }> = [ { value: 'all', label: 'Any attention state' }, { value: 'moderate', label: 'Moderation first' }, { value: 'reply', label: 'Needs instructor reply' }, { value: 'answered', label: 'Staff up to date' }, { value: 'spam', label: 'Rejected only' }, ]; function TypeTag({ type }: { type: ThreadType }) { const isQa = type === 'qa'; return ( ); } function PostTypeLabel({ type }: { type: string }) { const t = type === 'sik_lesson' ? __('Lesson', 'sikshya') : type === 'sik_quiz' ? __('Quiz', 'sikshya') : type || __('Content', 'sikshya'); return ; } const DISCUSSIONS_BULK_OPTIONS = [ { value: 'bulk_approve', label: 'Approve' }, { value: 'bulk_spam', label: 'Mark as spam' }, { value: 'bulk_trash', label: 'Reject (move to trash)' }, { value: 'bulk_delete', label: 'Delete permanently' }, ] as const; export function DiscussionsPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const { confirm, alert } = useSikshyaDialog(); const featureOk = isFeatureEnabled(config, 'community_discussions'); const addon = useAddonEnabled('community_discussions'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const gateOpen = mode === 'full'; const [filterCourseId, setFilterCourseId] = useState(0); const [filterContentType, setFilterContentType] = useState(''); const [filterThreadType, setFilterThreadType] = useState(''); const [filterAttention, setFilterAttention] = useState('all'); const [status, setStatus] = useState('all'); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); const [page, setPage] = useState(1); const perPage = DEFAULT_LIST_PER_PAGE; const [rowBusyId, setRowBusyId] = useState(null); const [drawerThreadId, setDrawerThreadId] = useState(null); const [editingId, setEditingId] = useState(null); const [editingDraft, setEditingDraft] = useState(''); const [editingBusy, setEditingBusy] = useState(false); const [replyDraft, setReplyDraft] = useState(''); const [replyBusy, setReplyBusy] = useState(false); /** Narrow drawer strip (thread stays selected; expands again with ◀). */ const [detailPanelCollapsed, setDetailPanelCollapsed] = useState(false); /** Right drawer slide-in (enter animation). */ const [discussionDrawerEntered, setDiscussionDrawerEntered] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkActionValue, setBulkActionValue] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const bulkSelectAllRef = useRef(null); useEffect(() => { if (drawerThreadId === null) return; setDetailPanelCollapsed(false); }, [drawerThreadId]); useEffect(() => { if (drawerThreadId === null || !gateOpen) { setDiscussionDrawerEntered(false); return undefined; } setDiscussionDrawerEntered(false); const tid = window.setTimeout(() => setDiscussionDrawerEntered(true), 15); return () => window.clearTimeout(tid); }, [drawerThreadId, gateOpen]); useEffect(() => { if (drawerThreadId === null || !gateOpen || typeof document === 'undefined') { return undefined; } const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [drawerThreadId, gateOpen]); useEffect(() => { if (drawerThreadId === null) return undefined; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; e.preventDefault(); setDrawerThreadId(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [drawerThreadId]); const listLoader = useCallback(async () => { if (!gateOpen) { return { success: true, data: { items: [], total: 0, page: 1, per_page: perPage }, } as ListResponse; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.admin.discussions({ course_id: filterCourseId > 0 ? filterCourseId : undefined, content_type: filterContentType || undefined, thread_type: filterThreadType || undefined, status, attention: filterAttention, search: search || undefined, page, per_page: perPage, }) ); }, [gateOpen, filterCourseId, filterContentType, filterThreadType, filterAttention, status, search, page, perPage]); const summaryLoader = useCallback(async () => { if (!gateOpen) { return { success: true, data: { total: 0, pending: 0, discussions: 0, qa: 0, replies: 0 } } as SummaryResponse; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.admin.discussionsSummary); }, [gateOpen]); const list = useAsyncData(listLoader, [ gateOpen, filterCourseId, filterContentType, filterThreadType, filterAttention, status, search, page, perPage, ]); const summary = useAsyncData(summaryLoader, [gateOpen]); const rows = list.data?.data.items ?? []; const selectableOnPage = useMemo(() => rows.filter((r) => r.can_moderate).map((r) => r.id), [rows]); useEffect(() => { setSelectedIds(new Set()); setBulkActionValue(''); }, [page, filterCourseId, filterContentType, filterThreadType, filterAttention, status, search]); useEffect(() => { const el = bulkSelectAllRef.current; if (!el) return; const n = selectableOnPage.filter((id) => selectedIds.has(id)).length; el.indeterminate = n > 0 && n < selectableOnPage.length; }, [selectableOnPage, selectedIds]); const total = list.data?.data.total ?? 0; const totalPages = useMemo(() => { if (!total) return 1; return Math.max(1, Math.ceil(total / perPage)); }, [total, perPage]); const detailLoader = useCallback(async () => { if (drawerThreadId == null) { return null; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.admin.discussion(drawerThreadId)); }, [drawerThreadId]); const detail = useAsyncData(detailLoader, [drawerThreadId]); const detailRowRaw = detail.data?.data ?? null; const detailRow = drawerThreadId !== null && detailRowRaw && detailRowRaw.id === drawerThreadId ? detailRowRaw : null; const detailPanelLoading = drawerThreadId !== null && Boolean(detail.loading) && detailRow === null && detail.error == null; const refreshAll = () => { list.refetch(); summary.refetch(); detail.refetch(); }; const onSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); setPage(1); setSearch(searchInput.trim()); }; const resetFilters = () => { setFilterCourseId(0); setFilterContentType(''); setFilterThreadType(''); setFilterAttention('all'); setStatus('all'); setSearch(''); setSearchInput(''); setPage(1); }; const approve = async (id: number) => { setRowBusyId(id); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.discussionApprove(id), {}); refreshAll(); } finally { setRowBusyId(null); } }; const markSpam = async (id: number) => { const ok = await confirm({ title: __('Mark as spam?', 'sikshya'), message: __('Hides this thread from learners and sends it to the WordPress spam queue. Use this for abusive or junk posts.', 'sikshya'), variant: 'danger', confirmLabel: __('Mark as spam', 'sikshya'), }); if (!ok) return; setRowBusyId(id); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.discussionMarkSpam(id), {}); refreshAll(); } finally { setRowBusyId(null); } }; const toggleSelectThread = useCallback((id: number, on: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; }); }, []); const toggleSelectAllOnPage = useCallback(() => { const allOn = selectableOnPage.length > 0 && selectableOnPage.every((id) => selectedIds.has(id)); setSelectedIds((prev) => { const next = new Set(prev); if (allOn) { selectableOnPage.forEach((id) => next.delete(id)); } else { selectableOnPage.forEach((id) => next.add(id)); } return next; }); }, [selectableOnPage, selectedIds]); const applyBulkThreads = async () => { if (!gateOpen || selectedIds.size === 0 || bulkActionValue === '') return; const actionMap: Record = { bulk_approve: 'approve', bulk_spam: 'spam', bulk_trash: 'trash', bulk_delete: 'delete', }; const action = actionMap[bulkActionValue]; if (!action) return; const ids = [...selectedIds]; const countLabel = ids.length === 1 ? '1 thread' : `${ids.length} threads`; if (action === 'delete') { const ok = await confirm({ title: __('Delete selected threads?', 'sikshya'), message: `This permanently deletes ${countLabel} and their replies.`, variant: 'danger', confirmLabel: __('Delete', 'sikshya'), }); if (!ok) return; } else if (action === 'spam') { const ok = await confirm({ title: __('Mark selected as spam?', 'sikshya'), message: `${countLabel} will be hidden from learners and sent to the spam queue.`, variant: 'danger', confirmLabel: __('Mark as spam', 'sikshya'), }); if (!ok) return; } else if (action === 'trash') { const ok = await confirm({ title: __('Reject selected threads?', 'sikshya'), message: `${countLabel} will move to trash (not spam). Learners will no longer see them.`, variant: 'danger', confirmLabel: __('Move to trash', 'sikshya'), }); if (!ok) return; } setBulkBusy(true); try { const res = await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.discussionsBulk, { action, ids, }); if (!res.success) { await alert({ title: __('Bulk action failed', 'sikshya'), message: typeof res.message === 'string' ? res.message : 'Request was not successful.', }); return; } refreshAll(); setSelectedIds(new Set()); setBulkActionValue(''); const skipped = res.data?.skipped?.length ?? 0; if (skipped > 0) { await alert({ title: __('Some threads were skipped', 'sikshya'), message: skipped === ids.length ? res.message ?? 'No threads were updated. Your selection may include threads you cannot moderate or invalid ids.' : `${res.data?.processed ?? 0} updated. ${skipped} skipped (not found or no permission).`, }); } } finally { setBulkBusy(false); } }; const trashThread = async (id: number) => { const ok = await confirm({ title: __('Reject this thread?', 'sikshya'), message: __('Moves the thread to trash (moderator dismissal) without labeling it as spam. Learners no longer see it.', 'sikshya'), variant: 'danger', confirmLabel: __('Move to trash', 'sikshya'), }); if (!ok) return; setRowBusyId(id); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.discussionTrash(id), {}); refreshAll(); } finally { setRowBusyId(null); } }; const remove = async (id: number, kind: 'thread' | 'reply') => { const ok = await confirm({ title: kind === 'thread' ? __('Delete thread?', 'sikshya') : __('Delete reply?', 'sikshya'), message: kind === 'thread' ? 'This permanently removes the thread and all of its replies.' : 'This permanently removes the reply.', variant: 'danger', confirmLabel: __('Delete', 'sikshya'), }); if (!ok) return; setRowBusyId(id); try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.admin.discussion(id)); if (kind === 'thread' && drawerThreadId === id) { setDrawerThreadId(null); } refreshAll(); } finally { setRowBusyId(null); } }; const startEditing = (id: number, current: string) => { setEditingId(id); setEditingDraft(current); }; const cancelEditing = () => { setEditingId(null); setEditingDraft(''); setEditingBusy(false); }; const saveEditing = async () => { if (editingId == null) return; const text = editingDraft.trim(); if (!text) return; setEditingBusy(true); try { await getSikshyaApi().put(SIKSHYA_ENDPOINTS.admin.discussion(editingId), { content: text }); cancelEditing(); refreshAll(); } finally { setEditingBusy(false); } }; const submitReply = async () => { if (drawerThreadId == null) return; const text = replyDraft.trim(); if (!text) return; setReplyBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.discussionReply(drawerThreadId), { content: text }); setReplyDraft(''); refreshAll(); } finally { setReplyBusy(false); } }; const summaryData = summary.data?.data ?? { total: 0, pending: 0, discussions: 0, qa: 0, replies: 0 }; return ( refreshAll()}> {list.loading ? __('Refreshing…', 'sikshya') : __('Refresh', 'sikshya')} ) : null } > addon.enable()} addonError={addon.error} > {gateOpen ? (
) : null} {list.error ? (
list.refetch()} />
) : null}
{ setFilterCourseId(id); setPage(1); }} placeholder={__('All courses', 'sikshya')} hint="Optional — limit threads to a single course." className="w-full max-w-full" />
{__('Apply', 'sikshya')} resetFilters()}> Reset
{filterAttention !== 'all' ? (

Inbox filters look at up to{' '} 1,500 newest threads matching your filters, then paginate.  Narrow by course if you cannot find an older thread.

) : null}
{list.loading ? (
{__('Loading…', 'sikshya')}
) : rows.length === 0 ? ( ) : ( <> setPage(p)} disabled={list.loading} />
void applyBulkThreads()} applyBusy={bulkBusy} trashMode={false} customOptions={[...DISCUSSIONS_BULK_OPTIONS]} selectId="sikshya-discussions-bulk" /> {selectableOnPage.length === 0 ? ( No threads on this page can be moderated by you. ) : null}
{rows.map((r) => { const attention = resolveAttention(r); const rowTint = attention === 'moderate' ? 'bg-amber-50/95 dark:bg-amber-950/30' : attention === 'reply' ? 'bg-violet-50/95 dark:bg-violet-950/25' : attention === 'spam' ? 'bg-slate-100/90 dark:bg-slate-900/85' : ''; return ( ); })}
{__('Select', 'sikshya')} 0 && selectableOnPage.every((id) => selectedIds.has(id)) } disabled={selectableOnPage.length === 0 || list.loading || bulkBusy} onChange={() => toggleSelectAllOnPage()} aria-label={__('Select all threads you can moderate on this page', 'sikshya')} /> {__('Author', 'sikshya')} {__('Course', 'sikshya')} {__('Content', 'sikshya')} {__('What to do', 'sikshya')} {__('Thread', 'sikshya')} {__('Replies', 'sikshya')} {__('Posted', 'sikshya')} {__('Actions', 'sikshya')}
{r.can_moderate ? ( toggleSelectThread(r.id, e.target.checked)} aria-label={`Select thread #${r.id}`} /> ) : ( )}
{r.author.name || `User #${r.author.id}`}
{r.author.email ? (
{r.author.email}
) : null}
{r.course.id > 0 ? ( r.course.permalink ? ( {r.course.title || `#${r.course.id}`} ) : ( {r.course.title || `#${r.course.id}`} ) ) : ( )}
{r.content_ref.permalink ? ( {r.content_ref.title || `#${r.content_ref.id}`} ) : ( {r.content_ref.title || `#${r.content_ref.id}`} )}
{attention === 'moderate' ? 'Approve or reject first.' : attention === 'reply' ? r.thread_type === 'qa' ? 'Post an instructor/admin answer.' : 'Staff should respond when ready.' : attention === 'spam' ? 'No further moderation needed unless you reopen.' : 'Latest reply is from staff (or no reply expected).'}

{r.excerpt}

{r.reply_count} {formatPostDate(r.created_at)}
void approve(r.id), onMarkSpam: () => void markSpam(r.id), onTrash: () => void trashThread(r.id), onEdit: () => startEditing(r.id, r.content), onDelete: () => void remove(r.id, 'thread'), })} />
setPage(p)} disabled={list.loading} /> )}
{gateOpen && drawerThreadId !== null && typeof document !== 'undefined' ? createPortal(
, document.body ) : null}
{editingId !== null && drawerThreadId === null ? ( Cancel void saveEditing()} disabled={editingBusy || editingDraft.trim() === ''}> {editingBusy ? __('Saving…', 'sikshya') : __('Save', 'sikshya')} } >