coursecode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- package/template/vite.config.js +339 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stub-player/config-panel.js - Config panel component
|
|
3
|
+
*
|
|
4
|
+
* Generates the config panel HTML for viewing and editing course configuration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate config panel HTML
|
|
9
|
+
* Note: Content is populated dynamically by JS, this creates the container/tabs
|
|
10
|
+
*/
|
|
11
|
+
export function generateConfigPanel() {
|
|
12
|
+
return `
|
|
13
|
+
<div id="stub-player-config-panel">
|
|
14
|
+
<div id="stub-player-config-panel-header">
|
|
15
|
+
<h3>📋 Course Config</h3>
|
|
16
|
+
<button id="stub-player-config-panel-close">×</button>
|
|
17
|
+
</div>
|
|
18
|
+
<div id="stub-player-config-tabs">
|
|
19
|
+
<button class="active" data-tab="course">Course</button>
|
|
20
|
+
<button data-tab="slide">Slide</button>
|
|
21
|
+
<button data-tab="objectives">Objectives</button>
|
|
22
|
+
<button data-tab="engagement">Engagement</button>
|
|
23
|
+
<button data-tab="raw">Raw</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="stub-player-config-body">
|
|
26
|
+
<div class="config-loading">Loading config...</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initialize Client-Side Handlers
|
|
34
|
+
*/
|
|
35
|
+
import { escapeHtml } from './edit-utils.js';
|
|
36
|
+
|
|
37
|
+
export function createConfigPanelHandlers(context) {
|
|
38
|
+
const { getCmiData } = context;
|
|
39
|
+
|
|
40
|
+
let configData = null;
|
|
41
|
+
let currentSlideConfig = null;
|
|
42
|
+
let currentConfigTab = 'course';
|
|
43
|
+
|
|
44
|
+
const configBody = document.getElementById('stub-player-config-body');
|
|
45
|
+
|
|
46
|
+
// Tab switching
|
|
47
|
+
const tabs = document.querySelectorAll('#stub-player-config-tabs button');
|
|
48
|
+
tabs.forEach(tab => {
|
|
49
|
+
tab.addEventListener('click', () => {
|
|
50
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
51
|
+
tab.classList.add('active');
|
|
52
|
+
|
|
53
|
+
currentConfigTab = tab.dataset.tab;
|
|
54
|
+
renderConfigTab();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Close button
|
|
59
|
+
document.getElementById('stub-player-config-panel-close')?.addEventListener('click', () => {
|
|
60
|
+
document.getElementById('stub-player-config-panel').classList.remove('visible');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function loadConfig() {
|
|
64
|
+
if (!configBody) return;
|
|
65
|
+
|
|
66
|
+
configBody.innerHTML = '<div class="config-loading">Loading config...</div>';
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch('/__config');
|
|
70
|
+
if (response.ok) {
|
|
71
|
+
configData = await response.json();
|
|
72
|
+
renderConfigTab();
|
|
73
|
+
} else {
|
|
74
|
+
configBody.innerHTML = '<div class="config-error">Failed to load config</div>';
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
configBody.innerHTML = '<div class="config-error">Error: ' + err.message + '</div>';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderConfigTab() {
|
|
82
|
+
if (!configData || !configBody) return;
|
|
83
|
+
|
|
84
|
+
if (currentConfigTab === 'course') {
|
|
85
|
+
renderCourseTab();
|
|
86
|
+
} else if (currentConfigTab === 'slide') {
|
|
87
|
+
renderSlideTab();
|
|
88
|
+
} else if (currentConfigTab === 'objectives') {
|
|
89
|
+
renderObjectivesTab();
|
|
90
|
+
} else if (currentConfigTab === 'engagement') {
|
|
91
|
+
renderEngagementTab();
|
|
92
|
+
} else if (currentConfigTab === 'raw') {
|
|
93
|
+
renderRawTab();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function renderCourseTab() {
|
|
98
|
+
const layouts = ['article', 'traditional', 'focused', 'presentation', 'canvas'];
|
|
99
|
+
const widths = ['narrow', 'medium', 'wide', 'full'];
|
|
100
|
+
const formats = ['cmi5', 'scorm2004', 'scorm1.2'];
|
|
101
|
+
|
|
102
|
+
// Fetch theme data
|
|
103
|
+
let themeTokens = [];
|
|
104
|
+
try {
|
|
105
|
+
const themeResponse = await fetch('/__theme');
|
|
106
|
+
if (themeResponse.ok) {
|
|
107
|
+
const themeData = await themeResponse.json();
|
|
108
|
+
themeTokens = themeData.tokens || [];
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.warn('Could not load theme data:', e);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
configBody.innerHTML = `
|
|
115
|
+
<div class="config-section">
|
|
116
|
+
<div class="config-section-header">Course Metadata</div>
|
|
117
|
+
<div class="config-row">
|
|
118
|
+
<span class="config-label">Title</span>
|
|
119
|
+
<input type="text" class="config-input" data-path="metadata.title" value="${escapeHtml(configData.metadata?.title || '')}" placeholder="Course Title">
|
|
120
|
+
</div>
|
|
121
|
+
<div class="config-row">
|
|
122
|
+
<span class="config-label">Description</span>
|
|
123
|
+
<input type="text" class="config-input" data-path="metadata.description" value="${escapeHtml(configData.metadata?.description || '')}" placeholder="Course description">
|
|
124
|
+
</div>
|
|
125
|
+
<div class="config-row">
|
|
126
|
+
<span class="config-label">Version</span>
|
|
127
|
+
<input type="text" class="config-input" data-path="metadata.version" value="${escapeHtml(configData.metadata?.version || '')}" placeholder="1.0.0">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="config-row">
|
|
130
|
+
<span class="config-label">Author</span>
|
|
131
|
+
<input type="text" class="config-input" data-path="metadata.author" value="${escapeHtml(configData.metadata?.author || '')}" placeholder="Author name">
|
|
132
|
+
</div>
|
|
133
|
+
<div class="config-row">
|
|
134
|
+
<span class="config-label">Language</span>
|
|
135
|
+
<input type="text" class="config-input" data-path="metadata.language" value="${escapeHtml(configData.metadata?.language || '')}" placeholder="en">
|
|
136
|
+
</div>
|
|
137
|
+
<div class="config-row">
|
|
138
|
+
<span class="config-label">Total Slides</span>
|
|
139
|
+
<span class="config-value">${configData.slideCount || 0}</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="config-row">
|
|
142
|
+
<span class="config-label">Output Format</span>
|
|
143
|
+
<select data-path="format">
|
|
144
|
+
${formats.map(f => `<option value="${f}" ${configData.format === f ? 'selected' : ''}>${f}</option>`).join('')}
|
|
145
|
+
</select>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div class="config-divider"></div>
|
|
150
|
+
|
|
151
|
+
<div class="config-section">
|
|
152
|
+
<div class="config-section-header">Layout</div>
|
|
153
|
+
<div class="config-row">
|
|
154
|
+
<span class="config-label">Course Layout</span>
|
|
155
|
+
<select data-path="layout">
|
|
156
|
+
${layouts.map(l => `<option value="${l}" ${configData.layout === l ? 'selected' : ''}>${l}</option>`).join('')}
|
|
157
|
+
</select>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="config-row">
|
|
160
|
+
<span class="config-label">Content Width</span>
|
|
161
|
+
<select data-path="slideDefaults.contentWidth" ${['focused', 'presentation', 'canvas'].includes(configData.layout) ? 'disabled' : ''}>
|
|
162
|
+
${widths.map(w => `<option value="${w}" ${configData.slideDefaults?.contentWidth === w ? 'selected' : ''}>${w}</option>`).join('')}
|
|
163
|
+
</select>
|
|
164
|
+
${['focused', 'presentation', 'canvas'].includes(configData.layout) ? `<span class="config-override-hint" title="${configData.layout === 'focused' ? 'Focused layout uses 1000px width' : configData.layout === 'canvas' ? 'Canvas layout has no framework chrome' : 'Presentation layout uses full viewport'}">override</span>` : ''}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div class="config-section">
|
|
169
|
+
<div class="config-section-header">Navigation</div>
|
|
170
|
+
<div class="config-row">
|
|
171
|
+
<span class="config-label">Sidebar Enabled</span>
|
|
172
|
+
<div class="config-toggle ${configData.navigation?.sidebar?.enabled ? 'on' : ''}" data-path="navigation.sidebar.enabled"></div>
|
|
173
|
+
</div>
|
|
174
|
+
${configData.navigation?.sidebar?.enabled ? `
|
|
175
|
+
<div class="config-row">
|
|
176
|
+
<span class="config-label">Sidebar Position</span>
|
|
177
|
+
<select data-path="navigation.sidebar.position">
|
|
178
|
+
<option value="left" ${configData.navigation?.sidebar?.position === 'left' ? 'selected' : ''}>left</option>
|
|
179
|
+
<option value="right" ${configData.navigation?.sidebar?.position === 'right' ? 'selected' : ''}>right</option>
|
|
180
|
+
</select>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="config-row">
|
|
183
|
+
<span class="config-label">Sidebar Width</span>
|
|
184
|
+
<input type="text" class="config-input" data-path="navigation.sidebar.width" value="${configData.navigation?.sidebar?.width || '280px'}" placeholder="280px">
|
|
185
|
+
</div>
|
|
186
|
+
<div class="config-row">
|
|
187
|
+
<span class="config-label">Sidebar Collapsible</span>
|
|
188
|
+
<div class="config-toggle ${configData.navigation?.sidebar?.collapsible ? 'on' : ''}" data-path="navigation.sidebar.collapsible"></div>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="config-row">
|
|
191
|
+
<span class="config-label">Sidebar Default Collapsed</span>
|
|
192
|
+
<div class="config-toggle ${configData.navigation?.sidebar?.defaultCollapsed ? 'on' : ''}" data-path="navigation.sidebar.defaultCollapsed"></div>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="config-row">
|
|
195
|
+
<span class="config-label">Sidebar Show Progress</span>
|
|
196
|
+
<div class="config-toggle ${configData.navigation?.sidebar?.showProgress ? 'on' : ''}" data-path="navigation.sidebar.showProgress"></div>
|
|
197
|
+
</div>
|
|
198
|
+
` : ''}
|
|
199
|
+
<div class="config-row">
|
|
200
|
+
<span class="config-label">Breadcrumbs Enabled</span>
|
|
201
|
+
<div class="config-toggle ${configData.navigation?.breadcrumbs?.enabled ? 'on' : ''}" data-path="navigation.breadcrumbs.enabled"></div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="config-divider"></div>
|
|
206
|
+
|
|
207
|
+
<div class="config-section">
|
|
208
|
+
<div class="config-section-header">Accessibility</div>
|
|
209
|
+
<div class="config-row">
|
|
210
|
+
<span class="config-label">Dark Mode</span>
|
|
211
|
+
<div class="config-toggle ${configData.features?.accessibility?.darkMode ? 'on' : ''}" data-path="features.accessibility.darkMode"></div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="config-row">
|
|
214
|
+
<span class="config-label">Font Size Controls</span>
|
|
215
|
+
<div class="config-toggle ${configData.features?.accessibility?.fontSize ? 'on' : ''}" data-path="features.accessibility.fontSize"></div>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="config-row">
|
|
218
|
+
<span class="config-label">High Contrast</span>
|
|
219
|
+
<div class="config-toggle ${configData.features?.accessibility?.highContrast ? 'on' : ''}" data-path="features.accessibility.highContrast"></div>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="config-row">
|
|
222
|
+
<span class="config-label">Reduced Motion</span>
|
|
223
|
+
<div class="config-toggle ${configData.features?.accessibility?.reducedMotion ? 'on' : ''}" data-path="features.accessibility.reducedMotion"></div>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="config-row">
|
|
226
|
+
<span class="config-label">Keyboard Shortcuts</span>
|
|
227
|
+
<div class="config-toggle ${configData.features?.accessibility?.keyboardShortcuts ? 'on' : ''}" data-path="features.accessibility.keyboardShortcuts"></div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="config-section">
|
|
232
|
+
<div class="config-section-header">Completion</div>
|
|
233
|
+
<div class="config-row">
|
|
234
|
+
<span class="config-label">Prompt for Comments</span>
|
|
235
|
+
<div class="config-toggle ${configData.completion?.promptForComments ? 'on' : ''}" data-path="completion.promptForComments"></div>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="config-row">
|
|
238
|
+
<span class="config-label">Prompt for Rating</span>
|
|
239
|
+
<div class="config-toggle ${configData.completion?.promptForRating ? 'on' : ''}" data-path="completion.promptForRating"></div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div class="config-section">
|
|
244
|
+
<div class="config-section-header">Scoring</div>
|
|
245
|
+
<div class="config-row">
|
|
246
|
+
<span class="config-label">Scoring Type</span>
|
|
247
|
+
<select data-path="scoring.type">
|
|
248
|
+
<option value="" ${!configData.scoring?.type ? 'selected' : ''}>(disabled)</option>
|
|
249
|
+
<option value="average" ${configData.scoring?.type === 'average' ? 'selected' : ''}>average</option>
|
|
250
|
+
<option value="weighted" ${configData.scoring?.type === 'weighted' ? 'selected' : ''}>weighted</option>
|
|
251
|
+
<option value="maximum" ${configData.scoring?.type === 'maximum' ? 'selected' : ''}>maximum</option>
|
|
252
|
+
<option value="custom" ${configData.scoring?.type === 'custom' ? 'selected' : ''}>custom</option>
|
|
253
|
+
</select>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div class="config-divider"></div>
|
|
258
|
+
|
|
259
|
+
<div class="config-section">
|
|
260
|
+
<div class="config-section-header">Support</div>
|
|
261
|
+
<div class="config-row">
|
|
262
|
+
<span class="config-label">Email</span>
|
|
263
|
+
<input type="text" class="config-input" data-path="support.email" value="${escapeHtml(configData.support?.email || '')}" placeholder="support@example.com">
|
|
264
|
+
</div>
|
|
265
|
+
<div class="config-row">
|
|
266
|
+
<span class="config-label">Phone</span>
|
|
267
|
+
<input type="text" class="config-input" data-path="support.phone" value="${escapeHtml(configData.support?.phone || '')}" placeholder="+1-800-555-0100">
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="config-divider"></div>
|
|
272
|
+
|
|
273
|
+
<div class="config-section">
|
|
274
|
+
<div class="config-section-header">Development</div>
|
|
275
|
+
<div class="config-row">
|
|
276
|
+
<span class="config-label">Show Slide Indicator</span>
|
|
277
|
+
<div class="config-toggle ${configData.environment?.development?.showSlideIndicator ? 'on' : ''}" data-path="environment.development.showSlideIndicator"></div>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="config-row">
|
|
280
|
+
<span class="config-label">Disable Beforeunload Guard</span>
|
|
281
|
+
<div class="config-toggle ${configData.environment?.disableBeforeUnloadGuard ? 'on' : ''}" data-path="environment.disableBeforeUnloadGuard"></div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div class="config-divider"></div>
|
|
286
|
+
|
|
287
|
+
<div class="config-section">
|
|
288
|
+
<div class="config-section-header">Theme Colors</div>
|
|
289
|
+
<div class="config-hint">Override palette colors in theme.css. Changes apply after reload.</div>
|
|
290
|
+
${themeTokens.map(t => `
|
|
291
|
+
<div class="config-row config-color-row" data-token="${t.name}">
|
|
292
|
+
<span class="config-label">${escapeHtml(t.label)}</span>
|
|
293
|
+
${t.override ? '<span class="config-override-badge">override</span>' : ''}
|
|
294
|
+
<div class="config-color-controls">
|
|
295
|
+
<input type="color" class="config-color-picker" data-token="${t.name}" value="${t.override || t.default || '#808080'}">
|
|
296
|
+
<input type="text" class="config-color-hex" data-token="${t.name}" value="${t.override || t.default || ''}" placeholder="${t.default || ''}">
|
|
297
|
+
${t.override ? `<button class="config-color-reset" data-token="${t.name}" title="Reset to default">×</button>` : ''}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
`).join('')}
|
|
301
|
+
</div>
|
|
302
|
+
`;
|
|
303
|
+
|
|
304
|
+
// Bind color picker changes
|
|
305
|
+
configBody.querySelectorAll('.config-color-picker').forEach(picker => {
|
|
306
|
+
picker.addEventListener('input', function () {
|
|
307
|
+
const token = this.dataset.token;
|
|
308
|
+
const hexInput = configBody.querySelector(`.config-color-hex[data-token="${token}"]`);
|
|
309
|
+
if (hexInput) hexInput.value = this.value;
|
|
310
|
+
});
|
|
311
|
+
picker.addEventListener('change', async function () {
|
|
312
|
+
const token = this.dataset.token;
|
|
313
|
+
await saveThemeValue(token, this.value);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Bind hex input changes
|
|
318
|
+
configBody.querySelectorAll('.config-color-hex').forEach(input => {
|
|
319
|
+
let timeout;
|
|
320
|
+
input.addEventListener('input', function () {
|
|
321
|
+
const token = this.dataset.token;
|
|
322
|
+
const picker = configBody.querySelector(`.config-color-picker[data-token="${token}"]`);
|
|
323
|
+
// Only update picker if valid hex
|
|
324
|
+
if (/^#[0-9A-Fa-f]{6}$/.test(this.value)) {
|
|
325
|
+
if (picker) picker.value = this.value;
|
|
326
|
+
}
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
timeout = setTimeout(async () => {
|
|
329
|
+
if (/^#[0-9A-Fa-f]{6}$/.test(this.value)) {
|
|
330
|
+
await saveThemeValue(token, this.value);
|
|
331
|
+
}
|
|
332
|
+
}, 500);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Bind reset buttons
|
|
337
|
+
configBody.querySelectorAll('.config-color-reset').forEach(btn => {
|
|
338
|
+
btn.addEventListener('click', async function () {
|
|
339
|
+
const token = this.dataset.token;
|
|
340
|
+
await saveThemeValue(token, null);
|
|
341
|
+
// Refresh the tab to show updated state
|
|
342
|
+
await renderCourseTab();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Bind select changes
|
|
347
|
+
configBody.querySelectorAll('select[data-path]').forEach(sel => {
|
|
348
|
+
sel.addEventListener('change', async function () {
|
|
349
|
+
const path = this.dataset.path;
|
|
350
|
+
const value = this.value;
|
|
351
|
+
updateConfigDataLocally(path, value);
|
|
352
|
+
await saveConfigValue(path, value);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Bind toggle clicks
|
|
357
|
+
configBody.querySelectorAll('.config-toggle[data-path]').forEach(toggle => {
|
|
358
|
+
toggle.addEventListener('click', async function () {
|
|
359
|
+
const isOn = this.classList.contains('on');
|
|
360
|
+
const newValue = !isOn;
|
|
361
|
+
const path = this.dataset.path;
|
|
362
|
+
this.classList.toggle('on');
|
|
363
|
+
updateConfigDataLocally(path, newValue);
|
|
364
|
+
await saveConfigValue(path, newValue);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Bind input changes (with debounce)
|
|
369
|
+
configBody.querySelectorAll('.config-input[data-path]').forEach(input => {
|
|
370
|
+
let timeout;
|
|
371
|
+
input.addEventListener('input', function () {
|
|
372
|
+
clearTimeout(timeout);
|
|
373
|
+
const path = this.dataset.path;
|
|
374
|
+
const value = this.value;
|
|
375
|
+
timeout = setTimeout(async () => {
|
|
376
|
+
updateConfigDataLocally(path, value);
|
|
377
|
+
await saveConfigValue(path, value);
|
|
378
|
+
}, 500);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Update configData locally to keep it in sync with form state.
|
|
385
|
+
* This prevents stale values when the panel re-renders.
|
|
386
|
+
*/
|
|
387
|
+
function updateConfigDataLocally(path, value) {
|
|
388
|
+
if (!configData) return;
|
|
389
|
+
|
|
390
|
+
const parts = path.split('.');
|
|
391
|
+
let current = configData;
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
394
|
+
const key = parts[i];
|
|
395
|
+
if (current[key] === undefined) {
|
|
396
|
+
current[key] = {};
|
|
397
|
+
}
|
|
398
|
+
current = current[key];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
current[parts[parts.length - 1]] = value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function renderSlideTab() {
|
|
405
|
+
const cmiData = getCmiData ? getCmiData() : {};
|
|
406
|
+
const currentSlideId = cmiData['cmi.location'];
|
|
407
|
+
|
|
408
|
+
if (!currentSlideId) {
|
|
409
|
+
configBody.innerHTML = `
|
|
410
|
+
<div class="config-slide-info">
|
|
411
|
+
<div class="slide-title">No Slide Selected</div>
|
|
412
|
+
<div class="slide-id">(none)</div>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="config-section">
|
|
415
|
+
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
|
|
416
|
+
Navigate to a slide to see its configuration here.
|
|
417
|
+
</p>
|
|
418
|
+
</div>
|
|
419
|
+
`;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Show loading state
|
|
424
|
+
configBody.innerHTML = '<div class="config-loading">Loading slide config...</div>';
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const response = await fetch('/__slide-config/' + encodeURIComponent(currentSlideId));
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
throw new Error('Slide not found');
|
|
430
|
+
}
|
|
431
|
+
const slideConfig = await response.json();
|
|
432
|
+
currentSlideConfig = slideConfig;
|
|
433
|
+
|
|
434
|
+
// Build the slide tab HTML with editable fields
|
|
435
|
+
let html = '';
|
|
436
|
+
|
|
437
|
+
// === Slide Identity ===
|
|
438
|
+
html += `
|
|
439
|
+
<div class="config-slide-info">
|
|
440
|
+
<div class="slide-title">${escapeHtml(slideConfig.title || slideConfig.id)}</div>
|
|
441
|
+
<div class="slide-id">${escapeHtml(slideConfig.id)}</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div class="config-section">
|
|
445
|
+
<div class="config-section-header">Identity</div>
|
|
446
|
+
<div class="config-row">
|
|
447
|
+
<span class="config-label">Title</span>
|
|
448
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="title" value="${escapeHtml(slideConfig.title || '')}" placeholder="Slide title">
|
|
449
|
+
</div>
|
|
450
|
+
<div class="config-row">
|
|
451
|
+
<span class="config-label">Type</span>
|
|
452
|
+
<span class="config-value config-badge config-badge-${slideConfig.type || 'slide'}">${escapeHtml(slideConfig.type || 'slide')}</span>
|
|
453
|
+
</div>
|
|
454
|
+
<div class="config-row">
|
|
455
|
+
<span class="config-label">Component</span>
|
|
456
|
+
<span class="config-value config-path">${escapeHtml(slideConfig.component || '(none)')}</span>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
// === Menu Configuration ===
|
|
462
|
+
const menu = slideConfig.menu || {};
|
|
463
|
+
html += `
|
|
464
|
+
<div class="config-divider"></div>
|
|
465
|
+
<div class="config-section">
|
|
466
|
+
<div class="config-section-header">Menu</div>
|
|
467
|
+
<div class="config-row">
|
|
468
|
+
<span class="config-label">Label</span>
|
|
469
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="menu.label" value="${escapeHtml(menu.label || slideConfig.title || slideConfig.id)}" placeholder="Menu label">
|
|
470
|
+
</div>
|
|
471
|
+
<div class="config-row">
|
|
472
|
+
<span class="config-label">Icon</span>
|
|
473
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="menu.icon" value="${escapeHtml(menu.icon || '')}" placeholder="Icon name (e.g., book-open)">
|
|
474
|
+
</div>
|
|
475
|
+
<div class="config-row">
|
|
476
|
+
<span class="config-label">Hidden</span>
|
|
477
|
+
<div class="config-toggle slide-config-toggle ${menu.hidden ? 'on' : ''}" data-slide-path="menu.hidden"></div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
`;
|
|
481
|
+
|
|
482
|
+
// === Audio Configuration ===
|
|
483
|
+
const audio = slideConfig.audio || {};
|
|
484
|
+
const hasAudio = !!slideConfig.audio;
|
|
485
|
+
html += `
|
|
486
|
+
<div class="config-divider"></div>
|
|
487
|
+
<div class="config-section">
|
|
488
|
+
<div class="config-section-header">Audio</div>
|
|
489
|
+
${hasAudio ? `
|
|
490
|
+
<div class="config-row">
|
|
491
|
+
<span class="config-label">Source</span>
|
|
492
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="audio.src" value="${escapeHtml(audio.src || '')}" placeholder="Audio source path">
|
|
493
|
+
</div>
|
|
494
|
+
<div class="config-row">
|
|
495
|
+
<span class="config-label">Autoplay</span>
|
|
496
|
+
<div class="config-toggle slide-config-toggle ${audio.autoplay ? 'on' : ''}" data-slide-path="audio.autoplay"></div>
|
|
497
|
+
</div>
|
|
498
|
+
<div class="config-row">
|
|
499
|
+
<span class="config-label">Completion Threshold</span>
|
|
500
|
+
<input type="number" class="config-input slide-config-input" data-slide-path="audio.completionThreshold" value="${audio.completionThreshold !== undefined ? audio.completionThreshold : 0.95}" min="0" max="1" step="0.05" style="width: 80px;">
|
|
501
|
+
</div>
|
|
502
|
+
` : `
|
|
503
|
+
<div class="config-row">
|
|
504
|
+
<span class="config-label" style="color: #6b7280; font-style: italic;">No audio configured</span>
|
|
505
|
+
</div>
|
|
506
|
+
`}
|
|
507
|
+
</div>
|
|
508
|
+
`;
|
|
509
|
+
|
|
510
|
+
// === Engagement Configuration ===
|
|
511
|
+
const engagement = slideConfig.engagement || {};
|
|
512
|
+
html += `
|
|
513
|
+
<div class="config-divider"></div>
|
|
514
|
+
<div class="config-section">
|
|
515
|
+
<div class="config-section-header">Engagement</div>
|
|
516
|
+
<div class="config-row">
|
|
517
|
+
<span class="config-label">Required</span>
|
|
518
|
+
<div class="config-toggle slide-config-toggle ${engagement.required ? 'on' : ''}" data-slide-path="engagement.required"></div>
|
|
519
|
+
</div>
|
|
520
|
+
`;
|
|
521
|
+
|
|
522
|
+
if (engagement.required) {
|
|
523
|
+
html += `
|
|
524
|
+
<div class="config-row">
|
|
525
|
+
<span class="config-label">Mode</span>
|
|
526
|
+
<select class="slide-config-select" data-slide-path="engagement.mode">
|
|
527
|
+
<option value="all" ${(engagement.mode || 'all') === 'all' ? 'selected' : ''}>all</option>
|
|
528
|
+
<option value="any" ${engagement.mode === 'any' ? 'selected' : ''}>any</option>
|
|
529
|
+
</select>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="config-row">
|
|
532
|
+
<span class="config-label">Show Indicator</span>
|
|
533
|
+
<div class="config-toggle slide-config-toggle ${engagement.showIndicator !== false ? 'on' : ''}" data-slide-path="engagement.showIndicator"></div>
|
|
534
|
+
</div>
|
|
535
|
+
`;
|
|
536
|
+
|
|
537
|
+
const reqCount = engagement.requirements?.length || 0;
|
|
538
|
+
if (reqCount > 0) {
|
|
539
|
+
html += `
|
|
540
|
+
<div class="config-row">
|
|
541
|
+
<span class="config-label">Requirements</span>
|
|
542
|
+
<span class="config-value" style="color: #f18701; cursor: pointer;" onclick="document.querySelector('#stub-player-config-tabs button[data-tab=engagement]').click()">${reqCount} configured → Edit</span>
|
|
543
|
+
</div>
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
html += '</div>';
|
|
549
|
+
|
|
550
|
+
// === Navigation Configuration ===
|
|
551
|
+
const nav = slideConfig.navigation || {};
|
|
552
|
+
const controls = nav.controls || {};
|
|
553
|
+
html += `
|
|
554
|
+
<div class="config-divider"></div>
|
|
555
|
+
<div class="config-section">
|
|
556
|
+
<div class="config-section-header">Navigation</div>
|
|
557
|
+
<div class="config-row">
|
|
558
|
+
<span class="config-label">Sequential</span>
|
|
559
|
+
<div class="config-toggle slide-config-toggle ${nav.sequential !== false ? 'on' : ''}" data-slide-path="navigation.sequential"></div>
|
|
560
|
+
</div>
|
|
561
|
+
<div class="config-row">
|
|
562
|
+
<span class="config-label">Show Previous</span>
|
|
563
|
+
<div class="config-toggle slide-config-toggle ${controls.showPrevious !== false ? 'on' : ''}" data-slide-path="navigation.controls.showPrevious"></div>
|
|
564
|
+
</div>
|
|
565
|
+
<div class="config-row">
|
|
566
|
+
<span class="config-label">Show Next</span>
|
|
567
|
+
<div class="config-toggle slide-config-toggle ${controls.showNext !== false ? 'on' : ''}" data-slide-path="navigation.controls.showNext"></div>
|
|
568
|
+
</div>
|
|
569
|
+
`;
|
|
570
|
+
|
|
571
|
+
if (controls.exitTarget) {
|
|
572
|
+
html += `
|
|
573
|
+
<div class="config-row">
|
|
574
|
+
<span class="config-label">Exit Target</span>
|
|
575
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.exitTarget" value="${escapeHtml(controls.exitTarget)}" placeholder="Slide ID">
|
|
576
|
+
</div>
|
|
577
|
+
`;
|
|
578
|
+
}
|
|
579
|
+
if (controls.nextTarget) {
|
|
580
|
+
html += `
|
|
581
|
+
<div class="config-row">
|
|
582
|
+
<span class="config-label">Next Target</span>
|
|
583
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.nextTarget" value="${escapeHtml(controls.nextTarget)}" placeholder="Slide ID">
|
|
584
|
+
</div>
|
|
585
|
+
`;
|
|
586
|
+
}
|
|
587
|
+
if (controls.previousTarget) {
|
|
588
|
+
html += `
|
|
589
|
+
<div class="config-row">
|
|
590
|
+
<span class="config-label">Previous Target</span>
|
|
591
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.previousTarget" value="${escapeHtml(controls.previousTarget)}" placeholder="Slide ID">
|
|
592
|
+
</div>
|
|
593
|
+
`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// === Gating Configuration ===
|
|
597
|
+
const gating = nav.gating || {};
|
|
598
|
+
const gatingConditions = gating.conditions || [];
|
|
599
|
+
const slideIds = configData.slideIds || [];
|
|
600
|
+
const objectiveIds = configData.objectiveIds || [];
|
|
601
|
+
const assessmentIds = slideIds.filter(s => s.type === 'assessment');
|
|
602
|
+
|
|
603
|
+
html += `
|
|
604
|
+
<div class="config-divider"></div>
|
|
605
|
+
<div class="config-section">
|
|
606
|
+
<div class="config-section-header">Gating</div>
|
|
607
|
+
<div class="config-row">
|
|
608
|
+
<span class="config-label">Mode</span>
|
|
609
|
+
<select class="slide-config-select" data-slide-path="navigation.gating.mode">
|
|
610
|
+
<option value="">None (no gating)</option>
|
|
611
|
+
<option value="all" ${gating.mode === 'all' ? 'selected' : ''}>All conditions</option>
|
|
612
|
+
<option value="any" ${gating.mode === 'any' ? 'selected' : ''}>Any condition</option>
|
|
613
|
+
</select>
|
|
614
|
+
</div>
|
|
615
|
+
<div class="config-row">
|
|
616
|
+
<span class="config-label">Message</span>
|
|
617
|
+
<input type="text" class="config-input slide-config-input" data-slide-path="navigation.gating.message" value="${escapeHtml(gating.message || '')}" placeholder="Message when gated" style="max-width: 240px;">
|
|
618
|
+
</div>
|
|
619
|
+
`;
|
|
620
|
+
|
|
621
|
+
// Render existing conditions
|
|
622
|
+
if (gatingConditions.length > 0) {
|
|
623
|
+
html += '<div class="gating-conditions-list">';
|
|
624
|
+
|
|
625
|
+
for (let i = 0; i < gatingConditions.length; i++) {
|
|
626
|
+
const cond = gatingConditions[i];
|
|
627
|
+
html += `
|
|
628
|
+
<div class="gating-condition-item" data-condition-index="${i}">
|
|
629
|
+
<div class="config-row gating-condition-type-row">
|
|
630
|
+
<span class="config-label">Type</span>
|
|
631
|
+
<div class="gating-condition-type-controls">
|
|
632
|
+
<select class="gating-condition-type" data-index="${i}">
|
|
633
|
+
<option value="objectiveStatus" ${cond.type === 'objectiveStatus' ? 'selected' : ''}>Objective Status</option>
|
|
634
|
+
<option value="assessmentStatus" ${cond.type === 'assessmentStatus' ? 'selected' : ''}>Assessment Status</option>
|
|
635
|
+
<option value="stateFlag" ${cond.type === 'stateFlag' ? 'selected' : ''}>State Flag</option>
|
|
636
|
+
<option value="timeOnSlide" ${cond.type === 'timeOnSlide' ? 'selected' : ''}>Time on Slide</option>
|
|
637
|
+
</select>
|
|
638
|
+
<button type="button" class="gating-remove-btn" data-index="${i}" aria-label="Remove condition">✕</button>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
`;
|
|
642
|
+
|
|
643
|
+
// Type-specific fields
|
|
644
|
+
if (cond.type === 'objectiveStatus') {
|
|
645
|
+
html += `
|
|
646
|
+
<div class="config-row" style="margin-bottom: 4px;">
|
|
647
|
+
<span class="config-label" style="font-size: 10px;">Objective</span>
|
|
648
|
+
<select class="gating-condition-field" data-index="${i}" data-field="objectiveId" style="font-size: 11px;">
|
|
649
|
+
<option value="">Select...</option>
|
|
650
|
+
${objectiveIds.map(o => `<option value="${escapeHtml(o.id)}" ${cond.objectiveId === o.id ? 'selected' : ''}>${escapeHtml(o.id)}</option>`).join('')}
|
|
651
|
+
</select>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="config-row">
|
|
654
|
+
<span class="config-label" style="font-size: 10px;">Completion</span>
|
|
655
|
+
<select class="gating-condition-field" data-index="${i}" data-field="completion_status" style="font-size: 11px;">
|
|
656
|
+
<option value="completed" ${cond.completion_status === 'completed' ? 'selected' : ''}>completed</option>
|
|
657
|
+
<option value="incomplete" ${cond.completion_status === 'incomplete' ? 'selected' : ''}>incomplete</option>
|
|
658
|
+
</select>
|
|
659
|
+
</div>
|
|
660
|
+
`;
|
|
661
|
+
} else if (cond.type === 'assessmentStatus') {
|
|
662
|
+
html += `
|
|
663
|
+
<div class="config-row" style="margin-bottom: 4px;">
|
|
664
|
+
<span class="config-label" style="font-size: 10px;">Assessment</span>
|
|
665
|
+
<select class="gating-condition-field" data-index="${i}" data-field="assessmentId" style="font-size: 11px;">
|
|
666
|
+
<option value="">Select...</option>
|
|
667
|
+
${assessmentIds.map(a => `<option value="${escapeHtml(a.id)}" ${cond.assessmentId === a.id ? 'selected' : ''}>${escapeHtml(a.title)}</option>`).join('')}
|
|
668
|
+
</select>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="config-row">
|
|
671
|
+
<span class="config-label" style="font-size: 10px;">Requires</span>
|
|
672
|
+
<select class="gating-condition-field" data-index="${i}" data-field="requires" style="font-size: 11px;">
|
|
673
|
+
<option value="passed" ${cond.requires === 'passed' ? 'selected' : ''}>passed</option>
|
|
674
|
+
<option value="failed" ${cond.requires === 'failed' ? 'selected' : ''}>failed</option>
|
|
675
|
+
<option value="attempted" ${cond.requires === 'attempted' ? 'selected' : ''}>attempted</option>
|
|
676
|
+
</select>
|
|
677
|
+
</div>
|
|
678
|
+
`;
|
|
679
|
+
} else if (cond.type === 'stateFlag') {
|
|
680
|
+
html += `
|
|
681
|
+
<div class="config-row">
|
|
682
|
+
<span class="config-label" style="font-size: 10px;">Flag Key</span>
|
|
683
|
+
<input type="text" class="gating-condition-field config-input" data-index="${i}" data-field="key" value="${escapeHtml(cond.key || '')}" placeholder="flag_key" style="font-size: 11px;">
|
|
684
|
+
</div>
|
|
685
|
+
`;
|
|
686
|
+
} else if (cond.type === 'timeOnSlide') {
|
|
687
|
+
html += `
|
|
688
|
+
<div class="config-row" style="margin-bottom: 4px;">
|
|
689
|
+
<span class="config-label" style="font-size: 10px;">Slide</span>
|
|
690
|
+
<select class="gating-condition-field" data-index="${i}" data-field="slideId" style="font-size: 11px;">
|
|
691
|
+
<option value="">Select...</option>
|
|
692
|
+
${slideIds.map(s => `<option value="${escapeHtml(s.id)}" ${cond.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`).join('')}
|
|
693
|
+
</select>
|
|
694
|
+
</div>
|
|
695
|
+
<div class="config-row">
|
|
696
|
+
<span class="config-label" style="font-size: 10px;">Min Seconds</span>
|
|
697
|
+
<input type="number" class="gating-condition-field config-input" data-index="${i}" data-field="minSeconds" value="${cond.minSeconds || 30}" min="1" style="font-size: 11px; width: 60px;">
|
|
698
|
+
</div>
|
|
699
|
+
`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
html += '</div>';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
html += '</div>';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Add condition button
|
|
709
|
+
html += `
|
|
710
|
+
<div style="margin-top: 8px;">
|
|
711
|
+
<button type="button" class="gating-add-btn" style="background: #2d5a87; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;">+ Add Condition</button>
|
|
712
|
+
</div>
|
|
713
|
+
`;
|
|
714
|
+
|
|
715
|
+
html += '</div>';
|
|
716
|
+
|
|
717
|
+
html += '</div>';
|
|
718
|
+
|
|
719
|
+
configBody.innerHTML = html;
|
|
720
|
+
|
|
721
|
+
// Bind slide config handlers
|
|
722
|
+
bindSlideConfigHandlers(currentSlideId);
|
|
723
|
+
|
|
724
|
+
} catch (err) {
|
|
725
|
+
configBody.innerHTML = `
|
|
726
|
+
<div class="config-slide-info">
|
|
727
|
+
<div class="slide-title">Slide</div>
|
|
728
|
+
<div class="slide-id">${escapeHtml(currentSlideId)}</div>
|
|
729
|
+
</div>
|
|
730
|
+
<div class="config-section">
|
|
731
|
+
<div class="config-error">Error loading slide config: ${escapeHtml(err.message)}</div>
|
|
732
|
+
</div>
|
|
733
|
+
`;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function bindSlideConfigHandlers(slideId) {
|
|
738
|
+
// Bind toggle clicks
|
|
739
|
+
configBody.querySelectorAll('.slide-config-toggle[data-slide-path]').forEach(toggle => {
|
|
740
|
+
toggle.addEventListener('click', async function () {
|
|
741
|
+
const isOn = this.classList.contains('on');
|
|
742
|
+
this.classList.toggle('on');
|
|
743
|
+
await saveSlideConfigValue(slideId, this.dataset.slidePath, !isOn);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Bind select changes
|
|
748
|
+
configBody.querySelectorAll('.slide-config-select[data-slide-path]').forEach(sel => {
|
|
749
|
+
sel.addEventListener('change', async function () {
|
|
750
|
+
await saveSlideConfigValue(slideId, this.dataset.slidePath, this.value);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Bind input changes (with debounce)
|
|
755
|
+
configBody.querySelectorAll('.slide-config-input[data-slide-path]').forEach(input => {
|
|
756
|
+
let timeout;
|
|
757
|
+
input.addEventListener('input', function () {
|
|
758
|
+
clearTimeout(timeout);
|
|
759
|
+
timeout = setTimeout(async () => {
|
|
760
|
+
let value = this.value;
|
|
761
|
+
// Convert numbers
|
|
762
|
+
if (this.type === 'number') {
|
|
763
|
+
value = parseFloat(value);
|
|
764
|
+
}
|
|
765
|
+
await saveSlideConfigValue(slideId, this.dataset.slidePath, value);
|
|
766
|
+
}, 500);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// === Gating condition handlers ===
|
|
771
|
+
|
|
772
|
+
// Add condition button
|
|
773
|
+
configBody.querySelector('.gating-add-btn')?.addEventListener('click', async () => {
|
|
774
|
+
await addGatingCondition(slideId);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Remove condition button
|
|
778
|
+
configBody.querySelectorAll('.gating-remove-btn[data-index]').forEach(btn => {
|
|
779
|
+
btn.addEventListener('click', async () => {
|
|
780
|
+
const index = parseInt(btn.dataset.index, 10);
|
|
781
|
+
await removeGatingCondition(slideId, index);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Condition type change
|
|
786
|
+
configBody.querySelectorAll('.gating-condition-type[data-index]').forEach(sel => {
|
|
787
|
+
sel.addEventListener('change', async () => {
|
|
788
|
+
const index = parseInt(sel.dataset.index, 10);
|
|
789
|
+
await updateGatingConditionType(slideId, index, sel.value);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Condition field change
|
|
794
|
+
configBody.querySelectorAll('.gating-condition-field[data-index]').forEach(field => {
|
|
795
|
+
field.addEventListener('change', async () => {
|
|
796
|
+
const index = parseInt(field.dataset.index, 10);
|
|
797
|
+
const fieldName = field.dataset.field;
|
|
798
|
+
await updateGatingConditionField(slideId, index, fieldName, field.value);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// === Unified write helper ===
|
|
804
|
+
async function writeConfig(target, id, value) {
|
|
805
|
+
try {
|
|
806
|
+
const response = await fetch('/__write', {
|
|
807
|
+
method: 'POST',
|
|
808
|
+
headers: { 'Content-Type': 'application/json' },
|
|
809
|
+
body: JSON.stringify({ target, id, value })
|
|
810
|
+
});
|
|
811
|
+
if (!response.ok) {
|
|
812
|
+
const err = await response.json();
|
|
813
|
+
console.error(`Write error [${target}]:`, err);
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
return true;
|
|
817
|
+
} catch (err) {
|
|
818
|
+
console.error(`Failed to write [${target}]:`, err);
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Gating condition management — mutate locally, write full gating object
|
|
824
|
+
async function addGatingCondition(slideId) {
|
|
825
|
+
const gating = currentSlideConfig?.navigation?.gating;
|
|
826
|
+
if (!gating?.conditions) return;
|
|
827
|
+
gating.conditions.push({ type: 'objectiveStatus', objectiveId: '', completion_status: 'completed' });
|
|
828
|
+
if (await writeConfig('gating', slideId, gating)) renderSlideTab();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function removeGatingCondition(slideId, index) {
|
|
832
|
+
const gating = currentSlideConfig?.navigation?.gating;
|
|
833
|
+
if (!gating?.conditions) return;
|
|
834
|
+
gating.conditions.splice(index, 1);
|
|
835
|
+
if (await writeConfig('gating', slideId, gating)) renderSlideTab();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function updateGatingConditionType(slideId, index, newType) {
|
|
839
|
+
const gating = currentSlideConfig?.navigation?.gating;
|
|
840
|
+
if (!gating?.conditions) return;
|
|
841
|
+
const defaults = {
|
|
842
|
+
objectiveStatus: { type: 'objectiveStatus', objectiveId: '', completion_status: 'completed' },
|
|
843
|
+
assessmentStatus: { type: 'assessmentStatus', assessmentId: '', requires: 'passed' },
|
|
844
|
+
slideVisited: { type: 'slideVisited', slideId: '' },
|
|
845
|
+
timeOnSlide: { type: 'timeOnSlide', slideId: '', minSeconds: 30 },
|
|
846
|
+
stateFlag: { type: 'stateFlag', key: '' }
|
|
847
|
+
};
|
|
848
|
+
gating.conditions[index] = defaults[newType] || { type: newType };
|
|
849
|
+
if (await writeConfig('gating', slideId, gating)) renderSlideTab();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function updateGatingConditionField(slideId, index, fieldName, value) {
|
|
853
|
+
const gating = currentSlideConfig?.navigation?.gating;
|
|
854
|
+
if (!gating?.conditions?.[index]) return;
|
|
855
|
+
gating.conditions[index][fieldName] = value;
|
|
856
|
+
await writeConfig('gating', slideId, gating);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function saveSlideConfigValue(slideId, propPath, value) {
|
|
860
|
+
await writeConfig('slide', slideId, { [propPath]: value });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function renderObjectivesTab() {
|
|
864
|
+
const objectives = configData.objectives || [];
|
|
865
|
+
const slideIds = configData.slideIds || [];
|
|
866
|
+
|
|
867
|
+
if (objectives.length === 0) {
|
|
868
|
+
configBody.innerHTML = `
|
|
869
|
+
<div class="config-section">
|
|
870
|
+
<div class="config-section-header">Learning Objectives</div>
|
|
871
|
+
<p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
|
|
872
|
+
No objectives defined in course-config.js
|
|
873
|
+
</p>
|
|
874
|
+
</div>
|
|
875
|
+
`;
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Slide options available in configData.slideIds
|
|
880
|
+
|
|
881
|
+
let html = `
|
|
882
|
+
<div class="config-section">
|
|
883
|
+
<div class="config-section-header">Learning Objectives (${objectives.length})</div>
|
|
884
|
+
</div>
|
|
885
|
+
`;
|
|
886
|
+
|
|
887
|
+
for (const obj of objectives) {
|
|
888
|
+
const c = obj.criteria || {};
|
|
889
|
+
const criteriaType = c.type || 'none';
|
|
890
|
+
|
|
891
|
+
// Build criteria-specific fields
|
|
892
|
+
let criteriaFieldsHtml = '';
|
|
893
|
+
|
|
894
|
+
switch (criteriaType) {
|
|
895
|
+
case 'slideVisited':
|
|
896
|
+
criteriaFieldsHtml = `
|
|
897
|
+
<div class="config-row objective-criteria-field" data-criteria-type="slideVisited">
|
|
898
|
+
<span class="config-label">Slide</span>
|
|
899
|
+
<select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideId">
|
|
900
|
+
<option value="">Select slide...</option>
|
|
901
|
+
${slideIds.map(s =>
|
|
902
|
+
`<option value="${escapeHtml(s.id)}" ${c.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`
|
|
903
|
+
).join('')}
|
|
904
|
+
</select>
|
|
905
|
+
</div>
|
|
906
|
+
`;
|
|
907
|
+
break;
|
|
908
|
+
case 'allSlidesVisited':
|
|
909
|
+
criteriaFieldsHtml = `
|
|
910
|
+
<div class="config-row objective-criteria-field" data-criteria-type="allSlidesVisited">
|
|
911
|
+
<span class="config-label">Slide IDs</span>
|
|
912
|
+
<input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideIds" value="${escapeHtml((c.slideIds || []).join(', '))}" placeholder="slide1, slide2, slide3">
|
|
913
|
+
</div>
|
|
914
|
+
`;
|
|
915
|
+
break;
|
|
916
|
+
case 'timeOnSlide':
|
|
917
|
+
criteriaFieldsHtml = `
|
|
918
|
+
<div class="config-row objective-criteria-field" data-criteria-type="timeOnSlide">
|
|
919
|
+
<span class="config-label">Slide</span>
|
|
920
|
+
<select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideId">
|
|
921
|
+
<option value="">Select slide...</option>
|
|
922
|
+
${slideIds.map(s =>
|
|
923
|
+
`<option value="${escapeHtml(s.id)}" ${c.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`
|
|
924
|
+
).join('')}
|
|
925
|
+
</select>
|
|
926
|
+
</div>
|
|
927
|
+
<div class="config-row objective-criteria-field" data-criteria-type="timeOnSlide">
|
|
928
|
+
<span class="config-label">Min Seconds</span>
|
|
929
|
+
<input type="number" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.minSeconds" value="${c.minSeconds || 30}" min="1" style="width: 80px;">
|
|
930
|
+
</div>
|
|
931
|
+
`;
|
|
932
|
+
break;
|
|
933
|
+
case 'flag':
|
|
934
|
+
criteriaFieldsHtml = `
|
|
935
|
+
<div class="config-row objective-criteria-field" data-criteria-type="flag">
|
|
936
|
+
<span class="config-label">Flag Key</span>
|
|
937
|
+
<input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.key" value="${escapeHtml(c.key || '')}" placeholder="flag_key">
|
|
938
|
+
</div>
|
|
939
|
+
<div class="config-row objective-criteria-field" data-criteria-type="flag">
|
|
940
|
+
<span class="config-label">Equals Value</span>
|
|
941
|
+
<input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.equals" value="${c.equals !== undefined ? escapeHtml(String(c.equals)) : ''}" placeholder="true">
|
|
942
|
+
</div>
|
|
943
|
+
`;
|
|
944
|
+
break;
|
|
945
|
+
case 'allFlags':
|
|
946
|
+
criteriaFieldsHtml = `
|
|
947
|
+
<div class="config-row objective-criteria-field" data-criteria-type="allFlags">
|
|
948
|
+
<span class="config-label">Flag Keys</span>
|
|
949
|
+
<input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.flags" value="${escapeHtml((c.flags || []).map(f => typeof f === 'string' ? f : f.key).join(', '))}" placeholder="flag1, flag2, flag3">
|
|
950
|
+
</div>
|
|
951
|
+
`;
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
html += `
|
|
956
|
+
<div class="config-objective-card" data-objective-id="${escapeHtml(obj.id)}">
|
|
957
|
+
<div class="config-row" style="margin-bottom: 8px;">
|
|
958
|
+
<span class="config-label">ID</span>
|
|
959
|
+
<input type="text" class="config-input objective-id-input" data-original-id="${escapeHtml(obj.id)}" value="${escapeHtml(obj.id)}" style="font-family: 'Consolas', 'Monaco', monospace; font-weight: 600; color: #f18701;">
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<div class="config-row">
|
|
963
|
+
<span class="config-label">Description</span>
|
|
964
|
+
<input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="description" value="${escapeHtml(obj.description || '')}" placeholder="Objective description" style="flex: 1; max-width: 260px;">
|
|
965
|
+
</div>
|
|
966
|
+
|
|
967
|
+
<div class="config-row">
|
|
968
|
+
<span class="config-label">Initial Completion</span>
|
|
969
|
+
<select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="initialCompletion">
|
|
970
|
+
<option value="incomplete" ${(obj.initialCompletion || 'incomplete') === 'incomplete' ? 'selected' : ''}>incomplete</option>
|
|
971
|
+
<option value="completed" ${obj.initialCompletion === 'completed' ? 'selected' : ''}>completed</option>
|
|
972
|
+
</select>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
<div class="config-row">
|
|
976
|
+
<span class="config-label">Initial Success</span>
|
|
977
|
+
<select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="initialSuccess">
|
|
978
|
+
<option value="unknown" ${(obj.initialSuccess || 'unknown') === 'unknown' ? 'selected' : ''}>unknown</option>
|
|
979
|
+
<option value="passed" ${obj.initialSuccess === 'passed' ? 'selected' : ''}>passed</option>
|
|
980
|
+
<option value="failed" ${obj.initialSuccess === 'failed' ? 'selected' : ''}>failed</option>
|
|
981
|
+
</select>
|
|
982
|
+
</div>
|
|
983
|
+
|
|
984
|
+
<div class="config-row">
|
|
985
|
+
<span class="config-label">Criteria Type</span>
|
|
986
|
+
<select class="objective-config-select objective-criteria-type-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.type">
|
|
987
|
+
<option value="none" ${criteriaType === 'none' ? 'selected' : ''}>Manual (no auto-complete)</option>
|
|
988
|
+
<option value="slideVisited" ${criteriaType === 'slideVisited' ? 'selected' : ''}>slideVisited</option>
|
|
989
|
+
<option value="allSlidesVisited" ${criteriaType === 'allSlidesVisited' ? 'selected' : ''}>allSlidesVisited</option>
|
|
990
|
+
<option value="timeOnSlide" ${criteriaType === 'timeOnSlide' ? 'selected' : ''}>timeOnSlide</option>
|
|
991
|
+
<option value="flag" ${criteriaType === 'flag' ? 'selected' : ''}>flag</option>
|
|
992
|
+
<option value="allFlags" ${criteriaType === 'allFlags' ? 'selected' : ''}>allFlags</option>
|
|
993
|
+
</select>
|
|
994
|
+
</div>
|
|
995
|
+
${criteriaFieldsHtml}
|
|
996
|
+
</div>
|
|
997
|
+
`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
configBody.innerHTML = html;
|
|
1001
|
+
|
|
1002
|
+
// Bind objective handlers
|
|
1003
|
+
bindObjectiveHandlers();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function bindObjectiveHandlers() {
|
|
1007
|
+
// Bind objective ID rename (with debounce on blur)
|
|
1008
|
+
configBody.querySelectorAll('.objective-id-input[data-original-id]').forEach(input => {
|
|
1009
|
+
let _timeout;
|
|
1010
|
+
input.addEventListener('blur', async function () {
|
|
1011
|
+
const oldId = this.dataset.originalId;
|
|
1012
|
+
const newId = this.value.trim();
|
|
1013
|
+
|
|
1014
|
+
if (!newId || oldId === newId) return;
|
|
1015
|
+
|
|
1016
|
+
await renameObjective(oldId, newId);
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Bind select changes
|
|
1021
|
+
configBody.querySelectorAll('.objective-config-select[data-obj-id]').forEach(sel => {
|
|
1022
|
+
sel.addEventListener('change', async function () {
|
|
1023
|
+
const objId = this.dataset.objId;
|
|
1024
|
+
const path = this.dataset.objPath;
|
|
1025
|
+
await saveObjectiveValue(objId, path, this.value);
|
|
1026
|
+
|
|
1027
|
+
// If criteria type changed, re-render to show/hide appropriate fields
|
|
1028
|
+
if (path === 'criteria.type') {
|
|
1029
|
+
// Reload objectives data and re-render
|
|
1030
|
+
await loadConfig();
|
|
1031
|
+
renderObjectivesTab();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// Bind input changes (with debounce)
|
|
1037
|
+
configBody.querySelectorAll('.objective-config-input[data-obj-id]').forEach(input => {
|
|
1038
|
+
let timeout;
|
|
1039
|
+
input.addEventListener('input', function () {
|
|
1040
|
+
clearTimeout(timeout);
|
|
1041
|
+
timeout = setTimeout(async () => {
|
|
1042
|
+
const objId = this.dataset.objId;
|
|
1043
|
+
const path = this.dataset.objPath;
|
|
1044
|
+
let value = this.value;
|
|
1045
|
+
|
|
1046
|
+
if (this.type === 'number') {
|
|
1047
|
+
value = parseFloat(value);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
await saveObjectiveValue(objId, path, value);
|
|
1051
|
+
}, 500);
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function saveObjectiveValue(objectiveId, propPath, value) {
|
|
1057
|
+
await writeConfig('objective', objectiveId, { [propPath]: value });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function renameObjective(oldId, newId) {
|
|
1061
|
+
const ok = await writeConfig('rename-objective', oldId, newId);
|
|
1062
|
+
if (!ok) {
|
|
1063
|
+
alert('Rename failed');
|
|
1064
|
+
}
|
|
1065
|
+
await loadConfig();
|
|
1066
|
+
renderObjectivesTab();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function renderRawTab() {
|
|
1070
|
+
configBody.innerHTML = `
|
|
1071
|
+
<div class="config-section">
|
|
1072
|
+
<div class="config-section-header">Raw Config (Read Only)</div>
|
|
1073
|
+
<pre class="config-readonly">${escapeHtml(JSON.stringify(configData, null, 2))}</pre>
|
|
1074
|
+
</div>
|
|
1075
|
+
`;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// === Engagement Tab ===
|
|
1079
|
+
|
|
1080
|
+
const REQUIREMENT_TYPES = [
|
|
1081
|
+
{ value: 'timeOnSlide', label: 'Time on Slide' },
|
|
1082
|
+
{ value: 'scrollDepth', label: 'Scroll Depth' },
|
|
1083
|
+
{ value: 'interactionComplete', label: 'Interaction Complete' },
|
|
1084
|
+
{ value: 'audioComplete', label: 'Audio Complete' },
|
|
1085
|
+
{ value: 'modalAudioComplete', label: 'Modal Audio Complete' },
|
|
1086
|
+
{ value: 'flag', label: 'Flag' },
|
|
1087
|
+
{ value: 'allFlags', label: 'All Flags' },
|
|
1088
|
+
{ value: 'viewAllTabs', label: 'View All Tabs' }
|
|
1089
|
+
];
|
|
1090
|
+
|
|
1091
|
+
const REQUIREMENT_DEFAULTS = {
|
|
1092
|
+
timeOnSlide: { type: 'timeOnSlide', minSeconds: 30 },
|
|
1093
|
+
scrollDepth: { type: 'scrollDepth', percentage: 80 },
|
|
1094
|
+
interactionComplete: { type: 'interactionComplete', interactionId: '' },
|
|
1095
|
+
audioComplete: { type: 'audioComplete', audioId: '' },
|
|
1096
|
+
modalAudioComplete: { type: 'modalAudioComplete', modalId: '' },
|
|
1097
|
+
flag: { type: 'flag', key: '' },
|
|
1098
|
+
allFlags: { type: 'allFlags', flags: [] },
|
|
1099
|
+
viewAllTabs: { type: 'viewAllTabs', componentId: '' }
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
async function renderEngagementTab() {
|
|
1103
|
+
if (!configData) return;
|
|
1104
|
+
|
|
1105
|
+
const slideIds = configData.slideIds || [];
|
|
1106
|
+
let html = '<div class="config-section"><div class="config-section-header">Engagement Requirements</div>';
|
|
1107
|
+
html += '<p class="config-description">Manage requirements across all slides. Only slides with engagement.required=true use requirements.</p>';
|
|
1108
|
+
|
|
1109
|
+
// Fetch all slide configs in parallel
|
|
1110
|
+
const slideConfigs = await Promise.all(
|
|
1111
|
+
slideIds.map(async (s) => {
|
|
1112
|
+
try {
|
|
1113
|
+
const res = await fetch('/__slide-config/' + encodeURIComponent(s.id));
|
|
1114
|
+
return res.ok ? await res.json() : null;
|
|
1115
|
+
} catch { return null; }
|
|
1116
|
+
})
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
let hasAny = false;
|
|
1120
|
+
for (let si = 0; si < slideIds.length; si++) {
|
|
1121
|
+
const slide = slideConfigs[si];
|
|
1122
|
+
if (!slide) continue;
|
|
1123
|
+
const engagement = slide.engagement || {};
|
|
1124
|
+
const requirements = engagement.requirements || [];
|
|
1125
|
+
if (!engagement.required && requirements.length === 0) continue;
|
|
1126
|
+
|
|
1127
|
+
hasAny = true;
|
|
1128
|
+
const title = slide.title || slide.id;
|
|
1129
|
+
html += `
|
|
1130
|
+
<div class="engagement-slide-card" data-eng-slide="${escapeHtml(slide.id)}">
|
|
1131
|
+
<div class="engagement-slide-header">
|
|
1132
|
+
<span class="engagement-slide-title">${escapeHtml(title)}</span>
|
|
1133
|
+
<span class="engagement-slide-status ${engagement.required ? 'active' : 'inactive'}">${engagement.required ? 'Required' : 'Not required'}</span>
|
|
1134
|
+
</div>
|
|
1135
|
+
`;
|
|
1136
|
+
|
|
1137
|
+
for (let ri = 0; ri < requirements.length; ri++) {
|
|
1138
|
+
const req = requirements[ri];
|
|
1139
|
+
html += `
|
|
1140
|
+
<div class="engagement-req-group" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">
|
|
1141
|
+
<div class="engagement-req-header">
|
|
1142
|
+
<span class="engagement-req-number">Requirement ${ri + 1}</span>
|
|
1143
|
+
<button type="button" class="engagement-req-remove" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">✕</button>
|
|
1144
|
+
</div>
|
|
1145
|
+
<div class="config-row">
|
|
1146
|
+
<span class="config-label">Type</span>
|
|
1147
|
+
<select class="engagement-req-type" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">
|
|
1148
|
+
${REQUIREMENT_TYPES.map(t => `<option value="${t.value}" ${req.type === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
|
|
1149
|
+
</select>
|
|
1150
|
+
</div>
|
|
1151
|
+
${renderRequirementFields(req, slide.id, ri)}
|
|
1152
|
+
</div>
|
|
1153
|
+
`;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
html += `
|
|
1157
|
+
<button type="button" class="engagement-req-add" data-eng-slide="${escapeHtml(slide.id)}">+ Add Requirement</button>
|
|
1158
|
+
</div>
|
|
1159
|
+
`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (!hasAny) {
|
|
1163
|
+
html += '<p class="config-empty">No slides have engagement requirements configured. Enable engagement.required on a slide first.</p>';
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
html += '</div>';
|
|
1167
|
+
configBody.innerHTML = html;
|
|
1168
|
+
bindEngagementHandlers();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function renderRequirementFields(req, slideId, index) {
|
|
1172
|
+
const prefix = `data-eng-slide="${escapeHtml(slideId)}" data-req-index="${index}"`;
|
|
1173
|
+
switch (req.type) {
|
|
1174
|
+
case 'timeOnSlide':
|
|
1175
|
+
return `<div class="config-row"><span class="config-label">Min Seconds</span><input type="number" class="engagement-req-field config-input" ${prefix} data-field="minSeconds" value="${req.minSeconds || 30}" min="1" style="width: 80px;"></div>`;
|
|
1176
|
+
case 'scrollDepth':
|
|
1177
|
+
return `<div class="config-row"><span class="config-label">Percentage</span><input type="number" class="engagement-req-field config-input" ${prefix} data-field="percentage" value="${req.percentage || 80}" min="1" max="100" style="width: 80px;"></div>`;
|
|
1178
|
+
case 'interactionComplete':
|
|
1179
|
+
return `<div class="config-row"><span class="config-label">Interaction ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="interactionId" value="${escapeHtml(req.interactionId || '')}" placeholder="interaction-id"></div>`;
|
|
1180
|
+
case 'audioComplete':
|
|
1181
|
+
return `<div class="config-row"><span class="config-label">Audio ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="audioId" value="${escapeHtml(req.audioId || '')}" placeholder="audio-id"></div>`;
|
|
1182
|
+
case 'modalAudioComplete':
|
|
1183
|
+
return `<div class="config-row"><span class="config-label">Modal ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="modalId" value="${escapeHtml(req.modalId || '')}" placeholder="modal-id"></div>`;
|
|
1184
|
+
case 'flag':
|
|
1185
|
+
return `<div class="config-row"><span class="config-label">Key</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="key" value="${escapeHtml(req.key || '')}" placeholder="flag_key"></div>`;
|
|
1186
|
+
case 'viewAllTabs':
|
|
1187
|
+
return `<div class="config-row"><span class="config-label">Component ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="componentId" value="${escapeHtml(req.componentId || '')}" placeholder="tabs-id"></div>`;
|
|
1188
|
+
default:
|
|
1189
|
+
return '';
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function bindEngagementHandlers() {
|
|
1194
|
+
// Add requirement
|
|
1195
|
+
configBody.querySelectorAll('.engagement-req-add').forEach(btn => {
|
|
1196
|
+
btn.addEventListener('click', async () => {
|
|
1197
|
+
const slideId = btn.dataset.engSlide;
|
|
1198
|
+
const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
|
|
1199
|
+
if (!slideRes.ok) return;
|
|
1200
|
+
const slide = await slideRes.json();
|
|
1201
|
+
const reqs = [...(slide.engagement?.requirements || []), { ...REQUIREMENT_DEFAULTS.timeOnSlide }];
|
|
1202
|
+
await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
|
|
1203
|
+
renderEngagementTab();
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// Remove requirement
|
|
1208
|
+
configBody.querySelectorAll('.engagement-req-remove').forEach(btn => {
|
|
1209
|
+
btn.addEventListener('click', async () => {
|
|
1210
|
+
const slideId = btn.dataset.engSlide;
|
|
1211
|
+
const index = parseInt(btn.dataset.reqIndex, 10);
|
|
1212
|
+
const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
|
|
1213
|
+
if (!slideRes.ok) return;
|
|
1214
|
+
const slide = await slideRes.json();
|
|
1215
|
+
const reqs = [...(slide.engagement?.requirements || [])];
|
|
1216
|
+
reqs.splice(index, 1);
|
|
1217
|
+
await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
|
|
1218
|
+
renderEngagementTab();
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Type change
|
|
1223
|
+
configBody.querySelectorAll('.engagement-req-type').forEach(sel => {
|
|
1224
|
+
sel.addEventListener('change', async () => {
|
|
1225
|
+
const slideId = sel.dataset.engSlide;
|
|
1226
|
+
const index = parseInt(sel.dataset.reqIndex, 10);
|
|
1227
|
+
const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
|
|
1228
|
+
if (!slideRes.ok) return;
|
|
1229
|
+
const slide = await slideRes.json();
|
|
1230
|
+
const reqs = [...(slide.engagement?.requirements || [])];
|
|
1231
|
+
reqs[index] = { ...(REQUIREMENT_DEFAULTS[sel.value] || { type: sel.value }) };
|
|
1232
|
+
await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
|
|
1233
|
+
renderEngagementTab();
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// Field change (debounced)
|
|
1238
|
+
configBody.querySelectorAll('.engagement-req-field').forEach(field => {
|
|
1239
|
+
let timeout;
|
|
1240
|
+
const handler = async () => {
|
|
1241
|
+
const slideId = field.dataset.engSlide;
|
|
1242
|
+
const index = parseInt(field.dataset.reqIndex, 10);
|
|
1243
|
+
const fieldName = field.dataset.field;
|
|
1244
|
+
let value = field.value;
|
|
1245
|
+
if (field.type === 'number') value = parseFloat(value);
|
|
1246
|
+
|
|
1247
|
+
const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
|
|
1248
|
+
if (!slideRes.ok) return;
|
|
1249
|
+
const slide = await slideRes.json();
|
|
1250
|
+
const reqs = [...(slide.engagement?.requirements || [])];
|
|
1251
|
+
if (reqs[index]) {
|
|
1252
|
+
reqs[index] = { ...reqs[index], [fieldName]: value };
|
|
1253
|
+
await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
field.addEventListener('input', () => { clearTimeout(timeout); timeout = setTimeout(handler, 500); });
|
|
1257
|
+
field.addEventListener('change', handler);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async function saveThemeValue(token, value) {
|
|
1262
|
+
try {
|
|
1263
|
+
const response = await fetch('/__theme-edit', {
|
|
1264
|
+
method: 'POST',
|
|
1265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1266
|
+
body: JSON.stringify({ token, value })
|
|
1267
|
+
});
|
|
1268
|
+
if (!response.ok) {
|
|
1269
|
+
console.error('Theme save failed');
|
|
1270
|
+
}
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
console.error('Theme save error:', err);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function saveConfigValue(path, value) {
|
|
1277
|
+
try {
|
|
1278
|
+
const response = await fetch('/__write', {
|
|
1279
|
+
method: 'POST',
|
|
1280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1281
|
+
body: JSON.stringify({ target: 'config', id: path, value })
|
|
1282
|
+
});
|
|
1283
|
+
if (!response.ok) {
|
|
1284
|
+
const err = await response.json();
|
|
1285
|
+
console.error('Config save error:', err);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
console.error('Failed to save config:', err);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Expose for refresh when slide changes
|
|
1293
|
+
window.__refreshSlideTab = () => {
|
|
1294
|
+
if (currentConfigTab === 'slide' && document.getElementById('stub-player-config-panel').classList.contains('visible')) {
|
|
1295
|
+
renderSlideTab();
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
return {
|
|
1300
|
+
loadConfig,
|
|
1301
|
+
render: renderConfigTab
|
|
1302
|
+
};
|
|
1303
|
+
}
|