import { useCallback, useMemo, useRef, useState, type ChangeEvent } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { useAddonEnabled } from '../hooks/useAddons'; import { useAsyncData } from '../hooks/useAsyncData'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import { appViewHref } from '../lib/appUrl'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { DataTable, type Column } from '../components/shared/DataTable'; import { SkeletonCard } from '../components/shared/Skeleton'; import { ListEmptyState } from '../components/shared/list'; import { ConfirmDialog } from '../components/shared/ConfirmDialog'; import { HorizontalEditorTabs } from '../components/shared/HorizontalEditorTabs'; import { AddonSettingsPage } from './AddonSettingsPage'; import type { SikshyaReactConfig } from '../types'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { __ } from '../lib/i18n'; type ScormPackage = { id: number; uuid: string; title: string; description?: string | null; scorm_version?: string; launch_path?: string; manifest_identifier?: string; mastery_score?: number | null; file_size_bytes?: number; asset_count?: number; status?: string; uploaded_by?: number; created_at?: string; updated_at?: string; lesson_reference_count?: number; launch_url?: string; }; type PackagesResp = { ok?: boolean; rows?: ScormPackage[]; total?: number; page?: number; per_page?: number; }; type UploadResp = { ok?: boolean; warnings?: string[]; package?: ScormPackage; }; type PanelTab = 'packages' | 'reports' | 'settings'; const TAB_LIST: { id: PanelTab; label: string; icon?: string }[] = [ { id: 'packages', label: 'Package library', icon: 'layers' }, { id: 'reports', label: 'Reports', icon: 'chart' }, { id: 'settings', label: 'Add-on defaults', icon: 'settings' }, ]; function formatBytes(n: number | undefined | null): string { if (!n || n <= 0) return '—'; const units = ['B', 'KB', 'MB', 'GB']; let v = n; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; } function formatDate(s: string | undefined | null): string { if (!s) return '—'; const d = new Date(s.replace(' ', 'T')); if (Number.isNaN(d.getTime())) return s; return d.toLocaleString(); } export function ScormH5pWorkspacePage(props: { config: SikshyaReactConfig; title: string; embedded?: boolean; }) { const { config, title, embedded } = props; const featureOk = isFeatureEnabled(config, 'scorm_h5p_pro'); const addon = useAddonEnabled('scorm_h5p_pro'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const [tab, setTab] = useState('packages'); const inner = ( addon.enable()} addonError={addon.error} > {enabled ? (
setTab(id as PanelTab)} />
{tab === 'packages' ? : null} {tab === 'reports' ? : null} {tab === 'settings' ? : null}
) : null}
); return ( {inner} ); } // ============================================================================ // PACKAGE LIBRARY (upload, manage, attach to lessons) // ============================================================================ function PackageLibrary({ config }: { config: SikshyaReactConfig }) { void config; const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [reloadTick, setReloadTick] = useState(0); const [uploading, setUploading] = useState(false); const [uploadErr, setUploadErr] = useState(null); const [uploadWarnings, setUploadWarnings] = useState([]); const [confirmDelete, setConfirmDelete] = useState(null); const [busyId, setBusyId] = useState(null); const toast = useTopRightToast(2600); const fileInputRef = useRef(null); const loader = useCallback(async () => { return getSikshyaApi().get( SIKSHYA_ENDPOINTS.pro.scormPackages({ page, per_page: 20, search: search.trim() }), ); }, [page, search, reloadTick]); const { loading, data, error, refetch } = useAsyncData(loader, [page, search, reloadTick]); const rows = data?.rows ?? []; const total = data?.total ?? 0; const onUpload = async (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; setUploading(true); setUploadErr(null); setUploadWarnings([]); try { const fd = new FormData(); fd.append('file', file); const resp = await getSikshyaApi().request( SIKSHYA_ENDPOINTS.pro.scormPackages(), { method: 'POST', body: fd }, ); setUploadWarnings(resp.warnings ?? []); toast.success(__('Uploaded', 'sikshya'), 'Package uploaded.'); setReloadTick((n) => n + 1); } catch (err) { setUploadErr(err); } finally { setUploading(false); } }; const onDelete = async (pkg: ScormPackage, force: boolean) => { setBusyId(pkg.id); try { await getSikshyaApi().delete( `${SIKSHYA_ENDPOINTS.pro.scormPackage(pkg.id)}${force ? '?force=1' : ''}`, ); toast.success(__('Deleted', 'sikshya'), 'Package deleted.'); setConfirmDelete(null); setReloadTick((n) => n + 1); } catch (err) { toast.error(__('Delete failed', 'sikshya'), err instanceof Error ? err.message : 'Delete failed'); } finally { setBusyId(null); } }; const columns: Column[] = useMemo( () => [ { id: 'title', header: 'Package', render: (p) => (
{p.title || `Package #${p.id}`} SCORM {p.scorm_version || '1.2'} · {p.asset_count ?? 0} files · {formatBytes(p.file_size_bytes)} {p.description ? ( {p.description} ) : null}
), }, { id: 'launch', header: 'Launch entry', render: (p) => {p.launch_path || '—'}, }, { id: 'usage', header: 'Used by', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (p) => `${p.lesson_reference_count ?? 0} lesson${(p.lesson_reference_count ?? 0) === 1 ? '' : 's'}`, }, { id: 'created', header: 'Uploaded', cellClassName: 'text-xs text-slate-500', render: (p) => formatDate(p.created_at), }, { id: 'actions', header: '', headerClassName: 'text-right', cellClassName: 'text-right', render: (p) => (
{ if (p.launch_url) window.open(p.launch_url, '_blank', 'noopener,noreferrer'); }} > Preview setConfirmDelete(p)} disabled={busyId === p.id}> {busyId === p.id ? __('Working…', 'sikshya') : __('Delete', 'sikshya')}
), }, ], [busyId], ); return (
{/* Upload card */}

{__('Upload a SCORM package', 'sikshya')}

Drop a SCORM 1.2 or 2004 zip. Sikshya validates the manifest, extracts files into a sandboxed folder, and keeps every launch behind an auth-gated proxy.

void onUpload(e)} /> fileInputRef.current?.click()} disabled={uploading}> {uploading ? __('Uploading…', 'sikshya') : __('Upload .zip', 'sikshya')}
{uploadWarnings.length > 0 ? (
    {uploadWarnings.map((w, idx) => (
  • {w}
  • ))}
) : null} {uploadErr ? (
) : null}
{/* Search + table */}
{ setSearch(e.target.value); setPage(1); }} placeholder={__('Search packages…', 'sikshya')} className="w-64 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" /> {total} package{total === 1 ? '' : 's'}
{loading ? ( ) : error ? ( refetch()} /> ) : rows.length === 0 ? (
No packages yet. Upload a zip to get started.
) : ( columns={columns} rows={rows} rowKey={(r) => r.id} wrapInCard={false} /> )}
0 ? __('Force delete', 'sikshya') : __('Delete', 'sikshya') } variant="danger" busy={busyId !== null} onClose={() => setConfirmDelete(null)} onConfirm={async () => { if (confirmDelete) { await onDelete(confirmDelete, (confirmDelete.lesson_reference_count ?? 0) > 0); } }} > {confirmDelete && (confirmDelete.lesson_reference_count ?? 0) > 0 ? `This package is attached to ${confirmDelete.lesson_reference_count} lesson(s). Force delete will detach it from every lesson.` : 'This will permanently remove the package files and database row. This cannot be undone.'}
); } // ============================================================================ // REPORTS PANEL (per-course rollup, lesson drilldown, CSV export) // ============================================================================ type CourseSummary = { course_id: number; totals: { attempts: number; learners: number; completed: number; passed: number }; lessons: Array<{ lesson_id: number; lesson_title: string; package_id: number; package_title: string; attempts: number; learners: number; completed: number; passed: number; avg_score: number | null; avg_time_seconds: number; }>; }; function ReportsPanel({ config }: { config: SikshyaReactConfig }) { const [courseId, setCourseId] = useState(''); const courseIdNum = Number(courseId.trim()) || 0; const loader = useCallback(async () => { if (courseIdNum <= 0) return null; const resp = await getSikshyaApi().get<{ ok?: boolean; data?: CourseSummary }>( SIKSHYA_ENDPOINTS.pro.scormCourseSummary(courseIdNum), ); return resp.data ?? null; }, [courseIdNum]); const { loading, data, error, refetch } = useAsyncData(loader, [courseIdNum]); const [exportBusy, setExportBusy] = useState(false); const handleExport = async () => { if (courseIdNum <= 0) return; setExportBusy(true); try { const url = `${config.restUrl.replace(/\/$/, '')}${SIKSHYA_ENDPOINTS.pro.scormCourseExport(courseIdNum)}`; const res = await fetch(url, { credentials: 'same-origin', headers: { 'X-WP-Nonce': config.restNonce, Accept: 'text/csv' }, }); if (!res.ok) throw new Error(`Export failed (${res.status})`); const blob = await res.blob(); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `sikshya-scorm-course-${courseIdNum}.csv`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); } catch (e) { console.error('SCORM CSV export failed', e); } finally { setExportBusy(false); } }; const courseLessonColumns: Column[] = useMemo( () => [ { id: 'title', header: 'Lesson', render: (l) => (
{l.lesson_title || `Lesson #${l.lesson_id}`}
{l.package_title ?
{l.package_title}
: null}
), }, { id: 'attempts', header: 'Attempts', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => l.attempts.toString(), }, { id: 'learners', header: 'Learners', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => l.learners.toString(), }, { id: 'completed', header: 'Completed', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => l.completed.toString(), }, { id: 'passed', header: 'Passed', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => l.passed.toString(), }, { id: 'avg_score', header: 'Avg score', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => (l.avg_score === null ? '—' : `${l.avg_score.toFixed(1)}%`), }, { id: 'avg_time', header: 'Avg time', headerClassName: 'text-right', cellClassName: 'text-right tabular-nums', render: (l) => (l.avg_time_seconds > 0 ? `${Math.round(l.avg_time_seconds / 60)}m` : '—'), }, ], [], ); return (

{__('Course report', 'sikshya')}

Pull a SCORM/H5P attempt summary for a single course. Use the CSV export to share with stakeholders.

setCourseId(e.target.value.replace(/[^0-9]/g, ''))} placeholder={__('e.g. 142', 'sikshya')} />
void refetch()} disabled={courseIdNum <= 0}> Load report void handleExport()} disabled={courseIdNum <= 0 || exportBusy}> {exportBusy ? __('Preparing…', 'sikshya') : __('Export CSV', 'sikshya')}
{courseIdNum <= 0 ? (

{__('Enter a course ID above to see attempt rollups.', 'sikshya')}

) : loading ? ( ) : error ? ( refetch()} /> ) : !data || data.lessons.length === 0 ? ( ) : ( <>
r.lesson_id} wrapInCard={false} /> )}
); } function SummaryStat({ label, value }: { label: string; value: number }) { return (
  • {label}
    {value}
  • ); } // ============================================================================ // SETTINGS PANEL — re-uses the schema-driven form for now // ============================================================================ function SettingsPanel({ config }: { config: SikshyaReactConfig }) { return ( ); }