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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file document-gallery.js
|
|
3
|
+
* @description Sidebar document gallery with auto-discovered thumbnails.
|
|
4
|
+
* Renders a collapsible gallery section below the navigation menu.
|
|
5
|
+
* Documents open in the existing lightbox on click.
|
|
6
|
+
*
|
|
7
|
+
* Config (in course-config.js → navigation.documentGallery):
|
|
8
|
+
* enabled: boolean — Master toggle
|
|
9
|
+
* directory: string — Path relative to course/ for auto-discovery
|
|
10
|
+
* label: string — Gallery section header label
|
|
11
|
+
* icon: string — Lucide icon name for header
|
|
12
|
+
* allowDownloads: boolean — Show download button in lightbox
|
|
13
|
+
* fileTypes: string[] — File extensions to include
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { iconManager } from '../utilities/icons.js';
|
|
17
|
+
import { logger } from '../utilities/logger.js';
|
|
18
|
+
import { open as openLightbox } from '../components/ui-components/lightbox.js';
|
|
19
|
+
|
|
20
|
+
/** @type {HTMLElement|null} */
|
|
21
|
+
let galleryContainer = null;
|
|
22
|
+
|
|
23
|
+
/** @type {boolean} */
|
|
24
|
+
let isExpanded = false;
|
|
25
|
+
|
|
26
|
+
/** @type {object|null} */
|
|
27
|
+
let galleryConfig = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the document gallery.
|
|
31
|
+
* @param {object} courseConfig - Full course configuration object
|
|
32
|
+
*/
|
|
33
|
+
export async function init(courseConfig) {
|
|
34
|
+
galleryConfig = courseConfig.navigation?.documentGallery;
|
|
35
|
+
|
|
36
|
+
if (!galleryConfig?.enabled) {
|
|
37
|
+
logger.debug('[DocumentGallery] Disabled or not configured');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
galleryContainer = document.getElementById('sidebar-gallery');
|
|
42
|
+
if (!galleryContainer) {
|
|
43
|
+
logger.warn('[DocumentGallery] #sidebar-gallery element not found');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fetch the gallery manifest
|
|
48
|
+
const items = await _fetchManifest();
|
|
49
|
+
if (!items || items.length === 0) {
|
|
50
|
+
logger.debug('[DocumentGallery] No documents found');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Render the gallery
|
|
55
|
+
_render(items);
|
|
56
|
+
|
|
57
|
+
// Show the gallery container
|
|
58
|
+
galleryContainer.removeAttribute('hidden');
|
|
59
|
+
|
|
60
|
+
// Listen for sidebar close to reset gallery state
|
|
61
|
+
_setupSidebarResetListener();
|
|
62
|
+
|
|
63
|
+
logger.debug(`[DocumentGallery] Initialized with ${items.length} items`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch the gallery manifest from the build output.
|
|
68
|
+
* @returns {Promise<object[]|null>} Array of document items or null
|
|
69
|
+
*/
|
|
70
|
+
async function _fetchManifest() {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch('./_gallery-manifest.json');
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
// Manifest doesn't exist yet (dev mode or no docs) — not an error
|
|
75
|
+
logger.debug('[DocumentGallery] No gallery manifest found (this is normal in dev mode)');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const manifest = await response.json();
|
|
79
|
+
return manifest.items || [];
|
|
80
|
+
} catch (_error) {
|
|
81
|
+
logger.debug('[DocumentGallery] Could not load gallery manifest');
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the gallery header and thumbnail grid.
|
|
88
|
+
* @param {object[]} items - Array of document items from manifest
|
|
89
|
+
*/
|
|
90
|
+
function _render(items) {
|
|
91
|
+
const label = galleryConfig.label || 'Resources';
|
|
92
|
+
const iconName = galleryConfig.icon || 'file-text';
|
|
93
|
+
|
|
94
|
+
const headerIcon = iconManager.getIcon(iconName, { size: 'sm' });
|
|
95
|
+
const chevronIcon = iconManager.getIcon('chevron-down', { size: 'sm' });
|
|
96
|
+
|
|
97
|
+
galleryContainer.innerHTML = `
|
|
98
|
+
<button class="sidebar-gallery-header"
|
|
99
|
+
aria-expanded="false"
|
|
100
|
+
aria-controls="sidebar-gallery-content"
|
|
101
|
+
data-testid="gallery-toggle">
|
|
102
|
+
<span class="sidebar-gallery-header-icon" aria-hidden="true">${headerIcon}</span>
|
|
103
|
+
<span class="sidebar-gallery-header-label">${label}</span>
|
|
104
|
+
<span class="sidebar-gallery-header-count">(${items.length})</span>
|
|
105
|
+
<span class="sidebar-gallery-header-chevron" aria-hidden="true">${chevronIcon}</span>
|
|
106
|
+
</button>
|
|
107
|
+
<div id="sidebar-gallery-content" class="sidebar-gallery-content" role="region" aria-label="${label}">
|
|
108
|
+
<div class="sidebar-gallery-grid">
|
|
109
|
+
${items.map(item => _renderItem(item)).join('')}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// Wire up toggle
|
|
115
|
+
const header = galleryContainer.querySelector('.sidebar-gallery-header');
|
|
116
|
+
header.addEventListener('click', _toggleGallery);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render a single gallery item thumbnail.
|
|
121
|
+
* @param {object} item - Document item { src, type, label, thumbnail? }
|
|
122
|
+
* @returns {string} HTML string for the item
|
|
123
|
+
*/
|
|
124
|
+
function _renderItem(item) {
|
|
125
|
+
const thumbHtml = _renderThumbnail(item);
|
|
126
|
+
const displayLabel = item.label || _formatFilename(item.src);
|
|
127
|
+
const downloadHtml = galleryConfig.allowDownloads
|
|
128
|
+
? `<a class="sidebar-gallery-download" href="${item.src}" download title="Download" aria-label="Download ${displayLabel}">${iconManager.getIcon('download', { size: 'xs' })}</a>`
|
|
129
|
+
: '';
|
|
130
|
+
|
|
131
|
+
// Use a button for accessibility — lightbox opens on click
|
|
132
|
+
return `
|
|
133
|
+
<button class="sidebar-gallery-item"
|
|
134
|
+
data-action="gallery-open"
|
|
135
|
+
data-gallery-src="${item.src}"
|
|
136
|
+
data-gallery-type="${item.type}"
|
|
137
|
+
data-testid="gallery-item-${_slugify(item.src)}"
|
|
138
|
+
title="${displayLabel}"
|
|
139
|
+
type="button">
|
|
140
|
+
<div class="sidebar-gallery-thumb">
|
|
141
|
+
${thumbHtml}
|
|
142
|
+
${downloadHtml}
|
|
143
|
+
</div>
|
|
144
|
+
<span class="sidebar-gallery-label">${displayLabel}</span>
|
|
145
|
+
</button>
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render the thumbnail content based on document type.
|
|
151
|
+
* @param {object} item - Document item
|
|
152
|
+
* @returns {string} HTML for the thumbnail interior
|
|
153
|
+
*/
|
|
154
|
+
function _renderThumbnail(item) {
|
|
155
|
+
switch (item.type) {
|
|
156
|
+
case 'image':
|
|
157
|
+
return `<img class="sidebar-gallery-thumb-img" src="${item.src}" alt="${item.label || ''}" loading="lazy">`;
|
|
158
|
+
|
|
159
|
+
case 'pdf':
|
|
160
|
+
if (item.thumbnail) {
|
|
161
|
+
return `<img class="sidebar-gallery-thumb-img" src="${item.thumbnail}" alt="${item.label || 'PDF document'}" loading="lazy">`;
|
|
162
|
+
}
|
|
163
|
+
return `
|
|
164
|
+
<div class="sidebar-gallery-thumb-pdf">
|
|
165
|
+
<span class="sidebar-gallery-thumb-pdf-icon" aria-hidden="true">
|
|
166
|
+
${iconManager.getIcon('file-text', { size: 'lg' })}
|
|
167
|
+
</span>
|
|
168
|
+
<span class="sidebar-gallery-thumb-pdf-badge">PDF</span>
|
|
169
|
+
</div>
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
case 'markdown':
|
|
173
|
+
// Markdown thumbnails are rendered async after init
|
|
174
|
+
return `
|
|
175
|
+
<div class="sidebar-gallery-thumb-md" data-md-src="${item.src}">
|
|
176
|
+
<div class="sidebar-gallery-thumb-md-content">
|
|
177
|
+
<p style="opacity: 0.5; font-style: italic;">Loading...</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
default:
|
|
183
|
+
return `
|
|
184
|
+
<div class="sidebar-gallery-thumb-pdf">
|
|
185
|
+
<span class="sidebar-gallery-thumb-pdf-icon" aria-hidden="true">
|
|
186
|
+
${iconManager.getIcon('file', { size: 'lg' })}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Toggle gallery expanded/collapsed state.
|
|
195
|
+
*/
|
|
196
|
+
function _toggleGallery() {
|
|
197
|
+
isExpanded = !isExpanded;
|
|
198
|
+
|
|
199
|
+
const sidebar = galleryContainer.closest('.sidebar');
|
|
200
|
+
|
|
201
|
+
galleryContainer.classList.toggle('expanded', isExpanded);
|
|
202
|
+
|
|
203
|
+
// Update ARIA
|
|
204
|
+
const header = galleryContainer.querySelector('.sidebar-gallery-header');
|
|
205
|
+
header.setAttribute('aria-expanded', String(isExpanded));
|
|
206
|
+
|
|
207
|
+
// Inverse collapse: toggle nav visibility
|
|
208
|
+
if (sidebar) {
|
|
209
|
+
sidebar.classList.toggle('gallery-expanded', isExpanded);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Load markdown thumbnails on first expand
|
|
213
|
+
if (isExpanded) {
|
|
214
|
+
_loadMarkdownThumbnails();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reset gallery to collapsed state.
|
|
220
|
+
* Called when sidebar is closed.
|
|
221
|
+
*/
|
|
222
|
+
function _resetGallery() {
|
|
223
|
+
if (!isExpanded || !galleryContainer) return;
|
|
224
|
+
|
|
225
|
+
isExpanded = false;
|
|
226
|
+
galleryContainer.classList.remove('expanded');
|
|
227
|
+
|
|
228
|
+
const header = galleryContainer.querySelector('.sidebar-gallery-header');
|
|
229
|
+
if (header) {
|
|
230
|
+
header.setAttribute('aria-expanded', 'false');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sidebar = galleryContainer.closest('.sidebar');
|
|
234
|
+
if (sidebar) {
|
|
235
|
+
sidebar.classList.remove('gallery-expanded');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Set up listener for sidebar close to reset gallery state.
|
|
241
|
+
* Uses transitionend on the sidebar to detect when it finishes collapsing.
|
|
242
|
+
*/
|
|
243
|
+
function _setupSidebarResetListener() {
|
|
244
|
+
const sidebar = document.getElementById('sidebar');
|
|
245
|
+
if (!sidebar) return;
|
|
246
|
+
|
|
247
|
+
sidebar.addEventListener('transitionend', (event) => {
|
|
248
|
+
// Only respond to the sidebar's own transition (not children)
|
|
249
|
+
if (event.target !== sidebar) return;
|
|
250
|
+
|
|
251
|
+
// Check if sidebar is now collapsed
|
|
252
|
+
if (sidebar.classList.contains('collapsed')) {
|
|
253
|
+
_resetGallery();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Also handle click-based open/close for gallery items
|
|
258
|
+
galleryContainer.addEventListener('click', (event) => {
|
|
259
|
+
// Ignore clicks on download links
|
|
260
|
+
if (event.target.closest('.sidebar-gallery-download')) return;
|
|
261
|
+
|
|
262
|
+
const item = event.target.closest('[data-action="gallery-open"]');
|
|
263
|
+
if (!item) return;
|
|
264
|
+
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
const src = item.dataset.gallerySrc;
|
|
267
|
+
const type = item.dataset.galleryType;
|
|
268
|
+
_openInLightbox(src, type);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Open a document in the lightbox.
|
|
274
|
+
* @param {string} src - Document source path
|
|
275
|
+
* @param {string} type - Document type (pdf, markdown, image)
|
|
276
|
+
*/
|
|
277
|
+
function _openInLightbox(src) {
|
|
278
|
+
openLightbox(src, '');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Load and render markdown thumbnails (deferred until gallery is expanded).
|
|
283
|
+
*/
|
|
284
|
+
async function _loadMarkdownThumbnails() {
|
|
285
|
+
const mdThumbs = galleryContainer.querySelectorAll('[data-md-src]');
|
|
286
|
+
for (const thumb of mdThumbs) {
|
|
287
|
+
const src = thumb.dataset.mdSrc;
|
|
288
|
+
if (thumb.dataset.loaded) continue;
|
|
289
|
+
thumb.dataset.loaded = 'true';
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const response = await fetch(src);
|
|
293
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
|
|
296
|
+
// Simple markdown-to-HTML for thumbnail preview (first ~500 chars)
|
|
297
|
+
const preview = _simpleMarkdownToHtml(text.slice(0, 500));
|
|
298
|
+
const content = thumb.querySelector('.sidebar-gallery-thumb-md-content');
|
|
299
|
+
if (content) {
|
|
300
|
+
content.innerHTML = preview;
|
|
301
|
+
}
|
|
302
|
+
} catch (_error) {
|
|
303
|
+
const content = thumb.querySelector('.sidebar-gallery-thumb-md-content');
|
|
304
|
+
if (content) {
|
|
305
|
+
content.innerHTML = '<p style=\'opacity: 0.5; font-style: italic;\'>Preview unavailable</p>';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Simple markdown to HTML converter for thumbnail previews.
|
|
313
|
+
* Only handles basic elements (headings, paragraphs, lists).
|
|
314
|
+
* @param {string} md - Raw markdown text
|
|
315
|
+
* @returns {string} HTML string
|
|
316
|
+
*/
|
|
317
|
+
function _simpleMarkdownToHtml(md) {
|
|
318
|
+
return md
|
|
319
|
+
.split('\n')
|
|
320
|
+
.map(line => {
|
|
321
|
+
const trimmed = line.trim();
|
|
322
|
+
if (!trimmed) return '';
|
|
323
|
+
if (trimmed.startsWith('# ')) return `<h1>${trimmed.slice(2)}</h1>`;
|
|
324
|
+
if (trimmed.startsWith('## ')) return `<h2>${trimmed.slice(3)}</h2>`;
|
|
325
|
+
if (trimmed.startsWith('### ')) return `<h2>${trimmed.slice(4)}</h2>`;
|
|
326
|
+
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) return `<li>${trimmed.slice(2)}</li>`;
|
|
327
|
+
if (/^\d+\.\s/.test(trimmed)) return `<li>${trimmed.replace(/^\d+\.\s/, '')}</li>`;
|
|
328
|
+
return `<p>${trimmed}</p>`;
|
|
329
|
+
})
|
|
330
|
+
.join('');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Format a filename for display (remove extension, replace separators).
|
|
335
|
+
* @param {string} src - File path
|
|
336
|
+
* @returns {string} Formatted display name
|
|
337
|
+
*/
|
|
338
|
+
function _formatFilename(src) {
|
|
339
|
+
const name = src.split('/').pop();
|
|
340
|
+
return name
|
|
341
|
+
.replace(/\.[^.]+$/, '') // Remove extension
|
|
342
|
+
.replace(/[_-]/g, ' ') // Replace separators with spaces
|
|
343
|
+
.replace(/\b\w/g, c => c.toUpperCase()); // Title case
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create a URL-safe slug from a file path.
|
|
348
|
+
* @param {string} src - File path
|
|
349
|
+
* @returns {string} Slugified string
|
|
350
|
+
*/
|
|
351
|
+
function _slugify(src) {
|
|
352
|
+
return src
|
|
353
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
354
|
+
.replace(/-+/g, '-')
|
|
355
|
+
.replace(/^-|-$/g, '')
|
|
356
|
+
.toLowerCase();
|
|
357
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file navigation-helpers.js
|
|
3
|
+
* @description Pure utility functions for navigation logic.
|
|
4
|
+
* These functions have no side effects and don't depend on module state.
|
|
5
|
+
* @author Seth
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { courseConfig } from '../../../course/course-config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks if gating should be bypassed.
|
|
13
|
+
*
|
|
14
|
+
* Bypass conditions (any of):
|
|
15
|
+
* 1. Development mode + disableGating config = true
|
|
16
|
+
* 2. URL parameter ?skipGating=true (for static preview exports)
|
|
17
|
+
* 3. Global flag window.__SCORM_PREVIEW_SKIP_GATING (set by stub player)
|
|
18
|
+
*
|
|
19
|
+
* @returns {boolean} True if gating should be bypassed
|
|
20
|
+
*/
|
|
21
|
+
export function shouldBypassGating() {
|
|
22
|
+
// Test override: force gating on regardless of other flags
|
|
23
|
+
if (window.__FORCE_GATING === true) return false;
|
|
24
|
+
|
|
25
|
+
// Check URL parameter (works in any mode - for preview exports)
|
|
26
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
27
|
+
if (urlParams.get('skipGating') === 'true') {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check global flag (set by stub LMS player)
|
|
32
|
+
if (window.__SCORM_PREVIEW_SKIP_GATING === true) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check dev mode config
|
|
37
|
+
const buildMode = import.meta?.env?.MODE;
|
|
38
|
+
const isProductionBuild = buildMode === 'production';
|
|
39
|
+
const devGatingDisabled = courseConfig.environment?.development?.disableGating === true;
|
|
40
|
+
return !isProductionBuild && devGatingDisabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks if engagement requirements should be bypassed in development mode.
|
|
45
|
+
* Semantically clearer alias for shouldBypassGating.
|
|
46
|
+
* @returns {boolean} True if engagement checks should be bypassed
|
|
47
|
+
*/
|
|
48
|
+
export function shouldBypassEngagement() {
|
|
49
|
+
return shouldBypassGating();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Evaluates a single gating condition against current course state.
|
|
54
|
+
* This is a pure function that takes all dependencies as parameters.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} condition - The condition to evaluate
|
|
57
|
+
* @param {object} stateManager - StateManager instance for reading course state
|
|
58
|
+
* @param {Map} assessmentConfigs - Map of assessment configurations
|
|
59
|
+
* @returns {boolean} True if the condition is met
|
|
60
|
+
* @throws {Error} If condition type is unknown
|
|
61
|
+
*/
|
|
62
|
+
export function evaluateGatingCondition(condition, stateManager, assessmentConfigs) {
|
|
63
|
+
if (!condition || typeof condition !== 'object') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
switch (condition.type) {
|
|
68
|
+
case 'objectiveStatus': {
|
|
69
|
+
const objective = stateManager.getDomainState('objectives')?.[condition.objectiveId];
|
|
70
|
+
if (!objective) return false;
|
|
71
|
+
|
|
72
|
+
// Check completion_status if specified
|
|
73
|
+
if (condition.completion_status !== undefined) {
|
|
74
|
+
return objective.completion_status === condition.completion_status;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check success_status if specified
|
|
78
|
+
if (condition.success_status !== undefined) {
|
|
79
|
+
return objective.success_status === condition.success_status;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// No valid field specified
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'assessmentStatus': {
|
|
87
|
+
// Read from the per-assessment domain (e.g., 'assessment_final-exam')
|
|
88
|
+
const assessmentDomain = stateManager.getDomainState(`assessment_${condition.assessmentId}`);
|
|
89
|
+
if (!assessmentDomain) return false;
|
|
90
|
+
|
|
91
|
+
const summary = assessmentDomain.summary;
|
|
92
|
+
if (!summary || !summary.lastResults) return false;
|
|
93
|
+
|
|
94
|
+
const passed = summary.lastResults.passed;
|
|
95
|
+
|
|
96
|
+
switch (condition.requires) {
|
|
97
|
+
case 'completed':
|
|
98
|
+
return summary.submitted === true;
|
|
99
|
+
case 'passed':
|
|
100
|
+
return passed === true;
|
|
101
|
+
case 'failed':
|
|
102
|
+
return summary.submitted === true && passed === false;
|
|
103
|
+
default:
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'assessmentAttempts': {
|
|
109
|
+
// Read from the per-assessment domain
|
|
110
|
+
const assessmentDomain = stateManager.getDomainState(`assessment_${condition.assessmentId}`);
|
|
111
|
+
if (!assessmentDomain) return false;
|
|
112
|
+
|
|
113
|
+
const summary = assessmentDomain.summary;
|
|
114
|
+
const attempts = summary?.attempts || 0;
|
|
115
|
+
|
|
116
|
+
if (condition.min !== undefined && attempts < condition.min) return false;
|
|
117
|
+
if (condition.max !== undefined && attempts > condition.max) return false;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'assessmentConfig': {
|
|
122
|
+
const config = assessmentConfigs.get(condition.assessmentId);
|
|
123
|
+
if (!config) return false;
|
|
124
|
+
|
|
125
|
+
// Helper to get nested property value
|
|
126
|
+
const getPropertyValue = (obj, path) => path.split('.').reduce((o, k) => (o || {})[k], obj);
|
|
127
|
+
const value = getPropertyValue(config, condition.property);
|
|
128
|
+
|
|
129
|
+
if (value === undefined) return false;
|
|
130
|
+
|
|
131
|
+
if (condition.equals !== undefined) {
|
|
132
|
+
return value === condition.equals;
|
|
133
|
+
}
|
|
134
|
+
if (condition.greaterThan !== undefined) {
|
|
135
|
+
return typeof value === 'number' && value > condition.greaterThan;
|
|
136
|
+
}
|
|
137
|
+
if (condition.lessThan !== undefined) {
|
|
138
|
+
return typeof value === 'number' && value < condition.lessThan;
|
|
139
|
+
}
|
|
140
|
+
// If just checking for property existence
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'stateFlag': {
|
|
145
|
+
const flags = stateManager.getDomainState('flags');
|
|
146
|
+
const value = flags?.[condition.key];
|
|
147
|
+
if (condition.equals !== undefined) {
|
|
148
|
+
return value === condition.equals;
|
|
149
|
+
}
|
|
150
|
+
if (condition.exists !== undefined) {
|
|
151
|
+
return condition.exists ? value !== undefined : value === undefined;
|
|
152
|
+
}
|
|
153
|
+
return !!value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'timeOnSlide': {
|
|
157
|
+
const sessionData = stateManager.getDomainState('sessionData');
|
|
158
|
+
const slideDurations = sessionData?.slideDurations || {};
|
|
159
|
+
const slideDurationMs = slideDurations[condition.slideId] || 0;
|
|
160
|
+
const totalSeconds = slideDurationMs / 1000;
|
|
161
|
+
return totalSeconds >= (condition.minSeconds || 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'custom': {
|
|
165
|
+
// Custom conditions can use a function or reference custom state
|
|
166
|
+
if (typeof condition.evaluate === 'function') {
|
|
167
|
+
return condition.evaluate(stateManager);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
throw new Error(`Unknown gating condition type: ${condition.type}. Valid types: objectiveStatus, assessmentStatus, assessmentAttempts, assessmentConfig, stateFlag, timeOnSlide, custom.`);
|
|
174
|
+
}
|
|
175
|
+
}
|