import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getWpApi } from '../../api'; import { ApiError, getApiErrorToastTitle, getErrorSummary, preferToastForApiError } from '../../api/errors'; import { NavIcon } from '../../components/NavIcon'; import { TopRightToast, useTopRightToast } from '../../components/shared/TopRightToast'; import { appViewHref } from '../../lib/appUrl'; import { useAdminRouting } from '../../lib/adminRouting'; import { ApiErrorPanel } from '../../components/shared/ApiErrorPanel'; import { useSikshyaDialog } from '../../components/shared/SikshyaDialogContext'; import { ButtonPrimary } from '../../components/shared/buttons'; import { getSikshyaApi } from '../../api'; import { HorizontalEditorTabs } from '../../components/shared/HorizontalEditorTabs'; import { WPMediaPickerField } from '../../components/shared/WPMediaPickerField'; import { DateTimePickerField } from '../../components/shared/DateTimePickerField'; import { QuillField } from '../../components/shared/QuillField'; import { NumberWithUnitField } from '../../components/shared/NumberWithUnitField'; import type { SikshyaReactConfig } from '../../types'; import { GatedFeatureWorkspace } from '../../components/GatedFeatureWorkspace'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../../lib/licensing'; import { CertificateVisualBuilder } from './CertificateVisualBuilder'; import { certificatePagePatternStoredValue, DEFAULT_CERTIFICATE_PAGE_FINISH, type CertificatePageFinish, defaultCertificateLayout, getPageAspectCss, layoutToHtml, layoutToStorage, parseCertificatePageFinish, parseLayoutFromMeta, type CertLayoutFile, } from './certificateLayout'; import { contentFromPost, excerptFromPost, readMeta, titleFromPost, useWpContentPost, wpRestExcerptPayload, } from './useWpContentPost'; import { PRO_ASSIGNMENT_DEFAULTS, PRO_LESSON_DEFAULTS, PRO_QUESTION_DEFAULTS, PRO_QUIZ_DEFAULTS, ProAssignmentFields, ProGradebookAssignmentWeightFields, ProGradebookQuizWeightFields, ProLessonH5pBlock, ProLessonLiveBlock, ProLessonScormBlock, ProQuestionFields, ProQuizFields, buildProAssignmentMeta, buildProLessonMetaForKind, buildProQuestionMeta, QUIZ_ADVANCED_BANK_DRAW_HARD_MAX, buildProQuizMeta, readProAssignmentFromMeta, readProLessonFromMeta, readProQuestionFromMeta, readProQuizFromMeta, type ProAssignmentValues, type ProLessonValues, type ProQuestionValues, type ProQuizValues, } from './ProIntegrationFields'; import { useAddonEnabled } from '../../hooks/useAddons'; import { AddQuestionAuthoringModal, QUESTION_PICKER_TYPES, type QuestionType, } from '../../components/shared/AddQuestionAuthoringModal'; import { __ } from '../../lib/i18n'; const FIELD = '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 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'; const LABEL = 'block text-sm font-medium text-slate-800 dark:text-slate-200'; const HINT = 'mt-1 text-xs text-slate-500 dark:text-slate-400'; function FormSection(props: { title: string; description?: string; children: React.ReactNode }) { const { title, description, children } = props; return (

{title}

{description ?

{description}

: null}
{children}
); } /** Resolve attachment URL for the WordPress media picker preview. */ function useAttachmentPreviewUrl(attachmentId: number): string { const [url, setUrl] = useState(''); useEffect(() => { if (!attachmentId || attachmentId <= 0) { setUrl(''); return; } let cancelled = false; void getWpApi() .get<{ source_url?: string }>(`/media/${attachmentId}`) .then((media) => { if (!cancelled && media?.source_url) { setUrl(media.source_url); } }) .catch(() => { if (!cancelled) { setUrl(''); } }); return () => { cancelled = true; }; }, [attachmentId]); return url; } function EditorFeaturedImageField(props: { fieldId: string; attachmentId: number; onAttachmentIdChange: (id: number) => void; description?: string; }) { const previewUrl = useAttachmentPreviewUrl(props.attachmentId); return (

{props.description ?? 'Optional. Uses the WordPress featured image for this item when your theme or lists show thumbnails.'}

{}} onAttachmentIdChange={props.onAttachmentIdChange} className={FIELD} placeholder={__('Opens the media library — upload a new image or choose an existing file.', 'sikshya')} />
); } function statusPillClass(status: string): string { const s = String(status || '').toLowerCase(); if (s === 'publish' || s === 'published') { return 'bg-emerald-50 text-emerald-800 ring-1 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-200 dark:ring-emerald-900/40'; } if (s === 'pending') { return 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-200 dark:ring-amber-900/40'; } return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-700'; } const DEFAULT_MCQ_OPTIONS = ['', '', '', '']; function metaStringArray(raw: unknown): string[] { if (!Array.isArray(raw)) { return []; } return raw.map((x) => String(x)); } function parseQuestionCorrectJson(raw: string): unknown { try { return JSON.parse(raw) as unknown; } catch { return null; } } /** Full-width editor surface (matches dashboard main column usage). */ const EDITOR_SURFACE = 'overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900'; type EditorShellProps = { loading: boolean; saving: boolean; error: unknown; onRetry: () => void; saveMsg: string | null; /** * When true, skip the default `space-y-8` wrapper so full-height workspaces * (e.g. certificate builder) receive a bounded flex column from the parent. */ flush?: boolean; children: React.ReactNode; }; function EditorFormShell({ loading, saving: _saving, error, onRetry, saveMsg, flush, children }: EditorShellProps) { const toast = useTopRightToast(3800); const toastErrorSigRef = useRef(null); useEffect(() => { if (!saveMsg) return; toast.success(__('Saved', 'sikshya'), saveMsg); }, [saveMsg, toast]); useEffect(() => { if (!error) { toastErrorSigRef.current = null; return; } if (!preferToastForApiError(error)) { return; } const summary = getErrorSummary(error); const sig = error instanceof ApiError ? `${error.method}:${error.status}:${error.url}:${summary}` : `e:${summary}`; if (toastErrorSigRef.current === sig) { return; } toastErrorSigRef.current = sig; toast.show({ open: true, kind: 'error', title: getApiErrorToastTitle(error), message: summary, ttlMs: 10000, }); }, [error, toast]); const showErrorPanel = Boolean(error) && !preferToastForApiError(error); return ( <> {showErrorPanel ? (
) : null} {loading ? (
Loading…
) : flush ? (
{children}
) : (
{children}
)} ); } export type ContentEditorProps = { config: SikshyaReactConfig; postType: string; postId: number; backHref: string; entityLabel: string; onSavedNewId?: (newId: number) => void; /** Hide “Back to list” for embedded panels (e.g. course builder curriculum). */ embedded?: boolean; /** When set, chapter editor locks parent course to this ID. */ forcedCourseId?: number; /** * When embedded, allows the parent (Course Builder) to flush pending edits * before saving or publishing the course. Pass null on editor unmount. */ exposeSave?: (saveFn: (() => Promise) | null) => void; }; function useMoveToTrash( editor: ReturnType, backHref: string, entityLabel: string ) { const { confirm } = useSikshyaDialog(); const { navigateHref } = useAdminRouting(); return useCallback(() => { void (async () => { if (editor.isNew) { return; } const ok = await confirm({ title: __('Move to trash?', 'sikshya'), message: `Move this ${entityLabel.toLowerCase()} to the trash? You can restore it later from the Trash tab.`, variant: 'danger', confirmLabel: __('Move to trash', 'sikshya'), }); if (!ok) { return; } try { await editor.remove(); navigateHref(backHref); } catch { /* handled by editor error state */ } })(); }, [editor, backHref, entityLabel, confirm, navigateHref]); } export function LessonEditor(props: ContentEditorProps) { const { postId, backHref, entityLabel, onSavedNewId, embedded } = props; const editor = useWpContentPost('sik_lesson', postId); const moveToTrash = useMoveToTrash(editor, backHref, entityLabel); const [title, setTitle] = useState(''); const [excerpt, setExcerpt] = useState(''); const [content, setContent] = useState(''); const [status, setStatus] = useState('draft'); const [featured, setFeatured] = useState(0); const [durationValue, setDurationValue] = useState(''); const [durationUnit, setDurationUnit] = useState<'min' | 'hr'>('min'); const [lessonType, setLessonType] = useState('text'); const [videoUrl, setVideoUrl] = useState(''); // Optional transcript shown beside the player on the Learn page — keep // both an external file URL (downloadable) and a paste-in text (inline // disclosure) for flexibility. Either, both, or neither may be set. const [transcriptUrl, setTranscriptUrl] = useState(''); const [transcriptText, setTranscriptText] = useState(''); const [isFreePreview, setIsFreePreview] = useState(false); const [saveMsg, setSaveMsg] = useState(null); const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content'); const [proValues, setProValues] = useState(PRO_LESSON_DEFAULTS); const liveAddon = useAddonEnabled('live_classes'); const scormAddon = useAddonEnabled('scorm_h5p_pro'); const liveReady = Boolean(liveAddon.enabled && liveAddon.licenseOk); const scormReady = Boolean(scormAddon.enabled && scormAddon.licenseOk); const liveOffered = Boolean(liveAddon.loading) || liveAddon.licenseOk === true; // While the Addons catalog is still loading, licenseOk is null — don't hide Pro lesson kinds (users thought SCORM/H5P were "missing"). const scormOffered = Boolean(scormAddon.loading) || scormAddon.licenseOk === true; useEffect(() => { if (editor.isNew) { setTitle(''); setExcerpt(''); setContent(''); setStatus('draft'); setFeatured(0); setDurationValue(''); setDurationUnit('min'); setLessonType('text'); setVideoUrl(''); setTranscriptUrl(''); setTranscriptText(''); setProValues(PRO_LESSON_DEFAULTS); return; } if (!editor.post) { return; } const p = editor.post; setTitle(titleFromPost(p)); setExcerpt(excerptFromPost(p)); setContent(contentFromPost(p)); setStatus(p.status || 'draft'); setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0); const m = p.meta as Record | undefined; const rawDur = String(readMeta(m, '_sikshya_lesson_duration') ?? '').trim(); if (!rawDur) { setDurationValue(''); setDurationUnit('min'); } else { const lower = rawDur.toLowerCase(); const unit: 'min' | 'hr' = lower.includes('h') ? 'hr' : 'min'; const num = lower .replace(/hours?|hrs?|hr|minutes?|mins?|min/g, '') .replace(/[^\d.]/g, '') .trim(); setDurationValue(num || rawDur); setDurationUnit(unit); } setLessonType(String(readMeta(m, '_sikshya_lesson_type') ?? 'text') || 'text'); setVideoUrl(String(readMeta(m, '_sikshya_lesson_video_url') ?? '')); setTranscriptUrl(String(readMeta(m, '_sikshya_lesson_transcript_url') ?? '')); setTranscriptText(String(readMeta(m, '_sikshya_lesson_transcript_text') ?? '')); setIsFreePreview(Boolean(readMeta(m, '_sikshya_is_free') === true || String(readMeta(m, '_sikshya_is_free') ?? '') === '1')); setProValues(readProLessonFromMeta(m)); }, [editor.post, editor.isNew]); const onSave = async (): Promise => { setSaveMsg(null); editor.setError(null); const kind = (lessonType.trim() || 'text'); const durNum = parseFloat(durationValue); const duration = durationValue.trim() && Number.isFinite(durNum) && durNum > 0 ? `${durNum}${Number.isInteger(durNum) ? '' : ''} ${durationUnit === 'hr' ? __('hr', 'sikshya') : __('min', 'sikshya')}` : ''; const body: Record = { title, content, status, excerpt: wpRestExcerptPayload(excerpt), featured_media: featured > 0 ? featured : 0, meta: { _sikshya_lesson_duration: duration, _sikshya_lesson_type: kind, _sikshya_lesson_video_url: kind === 'video' ? videoUrl.trim() : '', _sikshya_lesson_transcript_url: transcriptUrl.trim(), _sikshya_lesson_transcript_text: transcriptText, _sikshya_is_free: isFreePreview ? '1' : '0', ...buildProLessonMetaForKind(kind, proValues), }, }; try { const res = await editor.save(body); if (editor.isNew && res && typeof res === 'object' && 'id' in res) { const id = (res as { id: number }).id; if (typeof id === 'number' && id > 0) { onSavedNewId?.(id); return true; } } setSaveMsg(__('Lesson saved.', 'sikshya')); await editor.load(); return true; } catch { /* error in hook */ return false; } }; useEffect(() => { if (!embedded || !props.exposeSave) { return; } props.exposeSave(onSave); return () => props.exposeSave?.(null); }, [ embedded, props.exposeSave, title, content, excerpt, status, featured, durationValue, durationUnit, lessonType, videoUrl, transcriptUrl, transcriptText, isFreePreview, proValues, ]); return ( void editor.load()} saveMsg={saveMsg} >
setEditorTab(id as 'content' | 'settings')} />

{title?.trim() ? title : 'Lesson'}

Lessons are the ordered curriculum steps. SCORM packages and H5P content attach here as one playable activity per lesson (course settings only tune defaults across those lessons).

{status || 'draft'} {durationValue?.trim() ? ( {`${durationValue.trim()} ${durationUnit === 'hr' ? __('hr', 'sikshya') : __('min', 'sikshya')}`} ) : null} {lessonType === 'video' ? 'Video' : lessonType === 'live' ? 'Live class' : lessonType === 'scorm' ? 'SCORM' : lessonType === 'h5p' ? 'H5P' : 'Text'}
{editorTab === 'content' ? (
setTitle(e.target.value)} placeholder={__('e.g. Installing WordPress', 'sikshya')} />

A short blurb for lesson lists; optional but helps learners scan the outline.