import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { OVERLAY_Z_FOCUS_SURFACE } from '../lib/overlayLayers'; import { AddonEnablePanel } from '../components/AddonEnablePanel'; import { NavIcon } from '../components/NavIcon'; import { getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../api'; import { getApiErrorToastTitle, getErrorSummary, getToastMessageForApiFailure, preferToastForApiError, } from '../api/errors'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonPrimary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { CreateCourseModal } from '../components/shared/CreateCourseModal'; import { Modal } from '../components/shared/Modal'; import { WPMediaPickerField } from '../components/shared/WPMediaPickerField'; import { CourseBuilderSkeleton, SkeletonLine } from '../components/shared/Skeleton'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { renderContentEditor } from './content-editors/editors'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { AddContentTypePickerModal, defaultTitleFor, type ContentPickerType, } from '../components/shared/AddContentTypePickerModal'; import { useAsyncData } from '../hooks/useAsyncData'; import { appViewHref } from '../lib/appUrl'; import { getCatalogEntry, getLicensing, isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import { useAddonEnabled } from '../hooks/useAddons'; import { useAdminRouting } from '../lib/adminRouting'; import type { FieldConfig, SikshyaReactConfig, TabFieldsMap } from '../types'; import { DateTimePickerField } from '../components/shared/DateTimePickerField'; import { MultiCoursePicker } from '../components/shared/MultiCoursePicker'; import { QuillField } from '../components/shared/QuillField'; import { term, termLower } from '../lib/terminology'; import { navIconForCurriculumRow } from '../lib/curriculumIcons'; import { __ } from '../lib/i18n'; /** Shared field chrome — one place for focus rings and dark mode. */ const FIELD_INPUT = 'mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm transition placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus-visible:ring-brand-500/40 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500'; const FIELD_LABEL = 'block text-sm font-medium text-slate-800 dark:text-slate-200'; const FIELD_HINT = 'mt-1.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400'; /** Same active styling as `Sidebar` top-level links (bluish brand tint + inset ring). */ const BUILDER_NAV_ACTIVE = 'bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100 dark:bg-brand-950/40 dark:text-brand-300 dark:ring-brand-900/40'; const BUILDER_NAV_INACTIVE = 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70'; /** * Curriculum chapter row — top-level "card" surface. Solid white at rest, * darker slate hover, saturated brand-200 ground when selected. The chapter * row is the surface that holds the chapter's lessons (rendered below with a * different — slate-50 — tray background) so the two layers never look the same. */ const CURRICULUM_CHAPTER_ACTIVE = 'border-l-[4px] border-brand-500 bg-brand-100 text-brand-900 font-semibold dark:border-brand-400 dark:bg-brand-900/60 dark:text-brand-100'; const CURRICULUM_CHAPTER_INACTIVE = 'border-l-[4px] border-transparent bg-white text-slate-800 hover:bg-slate-100 hover:text-slate-900 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white'; /** * Curriculum content row — nested item inside the chapter's slate tray. * Transparent at rest, slate-200 hover, brand-100 ground when selected with a * thinner brand-400 left stripe. Different brand shade from the chapter so * the two read as parent and child even when both happen to be selected. */ const CURRICULUM_CONTENT_ACTIVE = 'border-l-[3px] border-brand-500 bg-brand-200 text-brand-900 font-semibold dark:border-brand-400 dark:bg-brand-900/70 dark:text-brand-100'; const CURRICULUM_CONTENT_INACTIVE = 'border-l-[3px] border-transparent text-slate-700 hover:bg-slate-200 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-700/60 dark:hover:text-white'; type TabSummary = { id: string; title: string; description: string }; type UserOpt = { id: number; name: string }; type BootstrapData = { course_id: number; tabs: TabSummary[]; tabFields: TabFieldsMap; values: Record; users: UserOpt[]; preview_url?: string; /** Server: course post is a bundle (`_sikshya_course_type` = bundle). Builder hides non-essential tabs. */ is_bundle?: boolean; }; type BuilderHeaderMeta = { title: string; subtitleBits: string[]; status?: string; }; export type CurriculumContentItem = { id: number; title: string; /** `question` may still appear from legacy chapter links; outline hides these (questions belong under quizzes). */ type: 'lesson' | 'quiz' | 'assignment' | 'question'; status?: string; meta?: { lesson_type?: string; duration?: string; time_limit?: number; points?: number; }; }; /** * Types available when adding from a chapter (questions are created inside / * for a quiz, not here). Aliased to the shared `ContentPickerType` so the * curriculum picker and the standalone Lessons-list picker share one vocabulary. */ export type CurriculumAddableType = ContentPickerType; export type CurriculumChapterTree = { id: number; title: string; contents: CurriculumContentItem[]; }; type CurriculumSelection = | null | { kind: 'chapter'; chapterId: number } | { kind: 'content'; chapterId: number; item: CurriculumContentItem }; const CURRICULUM_DRAG_MIME = 'application/x-sikshya-curriculum-v1'; /** Persisted UI: curriculum outline fills page vs. fixed panel with its own scrollbar. */ const CURRICULUM_OUTLINE_FULL_HEIGHT_KEY = 'sikshya_course_builder_curriculum_outline_full_height'; function readCurriculumOutlineFullHeightPref(): boolean { if (typeof window === 'undefined') { return false; } try { return window.localStorage.getItem(CURRICULUM_OUTLINE_FULL_HEIGHT_KEY) === '1'; } catch { return false; } } /** Persisted UI: curriculum tab takes over the viewport for distraction-free editing. */ const CURRICULUM_FOCUS_MODE_KEY = 'sikshya_course_builder_curriculum_focus_mode'; function readCurriculumFocusModePref(): boolean { if (typeof window === 'undefined') { return false; } try { return window.localStorage.getItem(CURRICULUM_FOCUS_MODE_KEY) === '1'; } catch { return false; } } /** * Per-course dismissal flag for the first-run "Quick start" banner. Stored * locally so the user only sees the welcome guide while a course is fresh — * once dismissed it stays gone even across page reloads. */ const QUICK_START_DISMISSED_PREFIX = 'sikshya_course_builder_quick_start_dismissed_'; function readQuickStartDismissed(courseId: number): boolean { if (typeof window === 'undefined' || !courseId) { return false; } try { return window.localStorage.getItem(`${QUICK_START_DISMISSED_PREFIX}${courseId}`) === '1'; } catch { return false; } } type CurriculumDragPayload = | { t: 'chapter'; chapterId: number } | { t: 'content'; contentId: number; fromChapterId: number }; function parseCurriculumDrag(dt: DataTransfer): CurriculumDragPayload | null { try { const raw = dt.getData(CURRICULUM_DRAG_MIME); if (!raw) { return null; } const o = JSON.parse(raw) as CurriculumDragPayload; if (o.t === 'chapter' && typeof o.chapterId === 'number') { return o; } if (o.t === 'content' && typeof o.contentId === 'number' && typeof o.fromChapterId === 'number') { return o; } return null; } catch { return null; } } function cloneCurriculumTree(tree: CurriculumChapterTree[]): CurriculumChapterTree[] { return tree.map((c) => ({ ...c, contents: c.contents.map((x) => ({ ...x })), })); } function MultiUserPickerField(props: { id: string; users: UserOpt[]; value: number[]; onChange: (next: number[]) => void; placeholder?: string; hint?: string; }) { const { id, users, value, onChange, placeholder, hint } = props; const [q, setQ] = useState(''); const selectedIds = useMemo(() => Array.from(new Set(value.filter((n) => Number.isFinite(n) && n > 0))), [value]); const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedUsers = useMemo( () => selectedIds.map((sid) => users.find((u) => u.id === sid)).filter(Boolean) as UserOpt[], [selectedIds, users] ); const filtered = useMemo(() => { const query = q.trim().toLowerCase(); const base = users.filter((u) => !selectedSet.has(u.id)); if (!query) return base.slice(0, 25); return base .filter((u) => u.name.toLowerCase().includes(query) || String(u.id).includes(query)) .slice(0, 25); }, [q, users, selectedSet]); return (
{selectedUsers.length ? (
{selectedUsers.map((u) => ( {u.name} ))}
) : (
{__('No instructors selected.', 'sikshya')}
)} setQ(e.target.value)} autoComplete="off" spellCheck={false} />
{filtered.length ? (
    {filtered.map((u) => (
  • ))}
) : (
{users.length === selectedIds.length ? __('All users selected.', 'sikshya') : __('No matches.', 'sikshya')}
)}
{hint ?

{hint}

: null}
); } /** Reorder top-level chapters by drag-and-drop. */ function reorderChaptersAtIndex( tree: CurriculumChapterTree[], fromIndex: number, toIndex: number ): CurriculumChapterTree[] { if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= tree.length || toIndex >= tree.length) { return tree; } const next = cloneCurriculumTree(tree); const [ch] = next.splice(fromIndex, 1); let insertAt = toIndex; if (fromIndex < toIndex) { insertAt = toIndex - 1; } next.splice(insertAt, 0, ch); return next; } /** * Move a curriculum item before `beforeItemId`, or append when `beforeItemId` is null. * `beforeItemId` is resolved after the item is removed from its source chapter. */ function moveContentBeforeItem( tree: CurriculumChapterTree[], contentId: number, fromChapterId: number, toChapterId: number, beforeItemId: number | null ): CurriculumChapterTree[] { const next = cloneCurriculumTree(tree); const fromCh = next.find((c) => c.id === fromChapterId); const toCh = next.find((c) => c.id === toChapterId); if (!fromCh || !toCh) { return tree; } const fromIdx = fromCh.contents.findIndex((x) => x.id === contentId); if (fromIdx < 0) { return tree; } if (beforeItemId === contentId) { return tree; } const [item] = fromCh.contents.splice(fromIdx, 1); let insertAt = toCh.contents.length; if (beforeItemId != null) { let i = toCh.contents.findIndex((x) => x.id === beforeItemId); if (i >= 0) { if (fromChapterId === toChapterId && fromIdx < i) { i -= 1; } insertAt = i; } } insertAt = Math.max(0, Math.min(insertAt, toCh.contents.length)); toCh.contents.splice(insertAt, 0, item); return next; } function removeContentFromChapter( tree: CurriculumChapterTree[], chapterId: number, contentId: number ): CurriculumChapterTree[] { const next = cloneCurriculumTree(tree); const ch = next.find((c) => c.id === chapterId); if (!ch) { return tree; } const i = ch.contents.findIndex((x) => x.id === contentId); if (i < 0) { return tree; } ch.contents.splice(i, 1); return next; } function slugify(s: string): string { return s .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function fieldIsVisible(cfg: FieldConfig, values: Record): boolean { const rules = cfg.depends_all; if (Array.isArray(rules) && rules.length > 0) { for (const rule of rules) { const cur = values[rule.on]; if (rule.value !== undefined) { if (String(cur ?? '') !== String(rule.value)) { return false; } } else if (!cur || cur === '0' || cur === false) { return false; } } return true; } if (cfg.depends_on) { const p = values[cfg.depends_on]; if (Array.isArray(cfg.depends_in) && cfg.depends_in.length > 0) { const cur = String(p ?? ''); return cfg.depends_in.some((v) => String(v) === cur); } if (cfg.depends_value !== undefined) { return String(p ?? '') === String(cfg.depends_value); } return Boolean(p); } return true; } type LayoutRow = | { kind: 'full'; fields: [string, FieldConfig][] } | { kind: 'grid'; cols: 2 | 3; fields: [string, FieldConfig][] }; function buildFieldLayoutRows(entries: [string, FieldConfig][]): LayoutRow[] { const rows: LayoutRow[] = []; let i = 0; while (i < entries.length) { const [id, cfg] = entries[i]; const layout = cfg.layout || ''; if (layout === 'two_column') { const chunk: [string, FieldConfig][] = [[id, cfg]]; i++; while (chunk.length < 2 && i < entries.length) { const next = entries[i]; if ((next[1].layout || '') === 'two_column') { chunk.push(next); i++; } else { break; } } rows.push({ kind: 'grid', cols: 2, fields: chunk }); continue; } if (layout === 'three_column') { const chunk: [string, FieldConfig][] = [[id, cfg]]; i++; while (chunk.length < 3 && i < entries.length) { const next = entries[i]; if ((next[1].layout || '') === 'three_column') { chunk.push(next); i++; } else { break; } } rows.push({ kind: 'grid', cols: 3, fields: chunk }); continue; } rows.push({ kind: 'full', fields: [[id, cfg]] }); i++; } return rows; } /** Pricing-tab surface for Content drip (Sikshya Pro); rules live under Learning rules → Scheduled access. */ function ContentDripCourseBuilderGateInput(props: { config: SikshyaReactConfig; courseId: number }) { const { config, courseId } = props; const featureOk = isFeatureEnabled(config, 'content_drip'); const addon = useAddonEnabled('content_drip'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const lic = getLicensing(config); const upgradeUrl = lic?.upgradeUrl || config.brandLinks?.upgradeUrl || 'https://mantrabrain.com/plugins/sikshya-lms/pricing/'; const catalog = getCatalogEntry(config, 'content_drip'); const featureTitle = catalog?.label || 'Content drip & scheduled unlock'; const [enableBusy, setEnableBusy] = useState(false); const rulesHref = courseId > 0 ? appViewHref(config, 'learning-rules', { tab: 'drip', course_id: String(courseId) }) : appViewHref(config, 'learning-rules', { tab: 'drip' }); if (mode === 'pending-addon') { return (
Checking add-on status…
); } if (mode === 'locked-plan') { return (

{featureTitle}

Your plan does not include this module yet. Upgrade to unlock scheduled lesson releases and the Learning rules workspace.

View plans
); } if (mode === 'addon-off') { return (
{ setEnableBusy(true); try { await addon.enable(); } finally { setEnableBusy(false); } }} upgradeUrl={upgradeUrl} error={addon.error} />
); } return (

{__('Content drip is ready', 'sikshya')}

Configure per-lesson delays and fixed unlock dates in Learning rules. Use the button below to jump straight to schedules for this course.

Open scheduled access Add-ons
{courseId <= 0 ? (

{__('Save the course first to pre-select it on the drip screen.', 'sikshya')}

) : null}
); } function FieldInput(props: { id: string; fieldKey?: string; cfg: FieldConfig; value: unknown; onChange: (v: unknown) => void; users: UserOpt[]; config?: SikshyaReactConfig; /** Site root URL for permalink preview */ siteUrl?: string; /** Update another builder field (e.g. attachment ID when picking featured image). */ onSiblingFieldChange?: (key: string, v: unknown) => void; }) { const { id, fieldKey, cfg, value, onChange, users, config, siteUrl, onSiblingFieldChange } = props; const t = cfg.type || 'text'; const base = (siteUrl || '').replace(/\/?$/, '/'); const scalePickerEnabled = cfg.widget === 'grade_scale_picker'; const gradeScales = useAsyncData(async () => { if (!scalePickerEnabled) { return { ok: true, scales: [] as Array<{ id: number; name: string }> }; } try { const r = await getSikshyaApi().get<{ ok?: boolean; scales?: Array<{ id: number; name: string }> }>( SIKSHYA_ENDPOINTS.pro.gradeScales ); return { ok: true, scales: Array.isArray(r.scales) ? r.scales : [] }; } catch { return { ok: true, scales: [] as Array<{ id: number; name: string }> }; } }, [scalePickerEnabled]); const subscriptionPlanPicker = cfg.widget === 'subscription_plan_picker'; const subscriptionPlans = useAsyncData(async () => { if (!subscriptionPlanPicker) { return { ok: true, plans: [] as Array<{ id: number; name?: string; amount?: number; currency?: string; interval_unit?: string }> }; } try { const r = await getSikshyaApi().get<{ ok?: boolean; plans?: Array<{ id: number; name?: string; amount?: number; currency?: string; interval_unit?: string }>; }>(SIKSHYA_ENDPOINTS.pro.plans); return { ok: true, plans: Array.isArray(r.plans) ? r.plans : [] }; } catch { return { ok: true, plans: [] as Array<{ id: number; name?: string; amount?: number; currency?: string; interval_unit?: string }> }; } }, [subscriptionPlanPicker]); if (t === 'textarea') { // Meta description should remain plain-text-ish (SEO snippet), not rich text. if (fieldKey === 'meta_description') { return (