@windward/core 0.26.0 → 0.28.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/components/Content/Blocks/Accordion.vue +9 -15
  3. package/components/Content/Blocks/BlockQuote.vue +29 -4
  4. package/components/Content/Blocks/ClickableIcons.vue +22 -9
  5. package/components/Content/Blocks/Email.vue +11 -4
  6. package/components/Content/Blocks/Feedback/FeedbackAnalytics.vue +179 -0
  7. package/components/Content/Blocks/Feedback.vue +115 -111
  8. package/components/Content/Blocks/FileDownload.vue +2 -2
  9. package/components/Content/Blocks/Image.vue +144 -0
  10. package/components/Content/Blocks/OpenResponse.vue +419 -5
  11. package/components/Content/Blocks/ScenarioChoice.vue +11 -2
  12. package/components/Content/Blocks/Tab.vue +16 -29
  13. package/components/Content/Blocks/UserUpload.vue +66 -38
  14. package/components/Content/Blocks/Video.vue +377 -28
  15. package/components/Settings/AccordionSettings.vue +3 -15
  16. package/components/Settings/BlockQuoteSettings.vue +6 -4
  17. package/components/Settings/ClickableIconsSettings.vue +24 -10
  18. package/components/Settings/EmailSettings.vue +3 -11
  19. package/components/Settings/FileDownloadSettings.vue +8 -2
  20. package/components/Settings/ImageSettings.vue +26 -0
  21. package/components/Settings/OpenResponseCollateSettings.vue +10 -0
  22. package/components/Settings/OpenResponseSettings.vue +67 -7
  23. package/components/Settings/ScenarioChoiceSettings.vue +11 -5
  24. package/components/Settings/TabSettings.vue +3 -18
  25. package/components/Settings/UserUploadSettings.vue +16 -8
  26. package/components/Settings/VideoSettings/SourcePicker.vue +55 -21
  27. package/components/Settings/VideoSettings.vue +18 -2
  28. package/components/utils/ContentViewer.vue +180 -1
  29. package/components/utils/glossary/GlossaryToolTip.vue +4 -23
  30. package/helpers/GlossaryHelper.ts +4 -7
  31. package/i18n/en-US/components/content/blocks/accordion.ts +3 -0
  32. package/i18n/en-US/components/content/blocks/block_quote.ts +3 -1
  33. package/i18n/en-US/components/content/blocks/feedback.ts +2 -0
  34. package/i18n/en-US/components/content/blocks/file_download.ts +2 -1
  35. package/i18n/en-US/components/content/blocks/index.ts +2 -0
  36. package/i18n/en-US/components/content/blocks/open_response.ts +19 -1
  37. package/i18n/en-US/components/content/blocks/open_response_collate.ts +1 -1
  38. package/i18n/en-US/components/content/blocks/scenario_choice.ts +2 -0
  39. package/i18n/en-US/components/content/blocks/user_upload.ts +2 -1
  40. package/i18n/en-US/components/settings/accordion.ts +2 -1
  41. package/i18n/en-US/components/settings/block_quote.ts +1 -1
  42. package/i18n/en-US/components/settings/clickable_icon.ts +5 -0
  43. package/i18n/en-US/components/settings/email.ts +2 -1
  44. package/i18n/en-US/components/settings/file_download.ts +2 -2
  45. package/i18n/en-US/components/settings/image.ts +1 -0
  46. package/i18n/en-US/components/settings/open_response.ts +8 -0
  47. package/i18n/en-US/components/settings/open_response_collate.ts +3 -0
  48. package/i18n/en-US/components/settings/scenario_choice.ts +3 -1
  49. package/i18n/en-US/components/settings/tab.ts +4 -3
  50. package/i18n/en-US/components/settings/user_upload.ts +1 -0
  51. package/i18n/en-US/components/settings/video.ts +3 -1
  52. package/i18n/en-US/shared/content_blocks.ts +1 -1
  53. package/i18n/es-ES/components/content/blocks/accordion.ts +3 -0
  54. package/i18n/es-ES/components/content/blocks/block_quote.ts +3 -1
  55. package/i18n/es-ES/components/content/blocks/feedback.ts +2 -0
  56. package/i18n/es-ES/components/content/blocks/file_download.ts +2 -1
  57. package/i18n/es-ES/components/content/blocks/index.ts +2 -0
  58. package/i18n/es-ES/components/content/blocks/open_response.ts +19 -2
  59. package/i18n/es-ES/components/content/blocks/open_response_collate.ts +1 -1
  60. package/i18n/es-ES/components/content/blocks/scenario_choice.ts +2 -0
  61. package/i18n/es-ES/components/content/blocks/user_upload.ts +2 -1
  62. package/i18n/es-ES/components/settings/accordion.ts +4 -2
  63. package/i18n/es-ES/components/settings/block_quote.ts +1 -1
  64. package/i18n/es-ES/components/settings/clickable_icon.ts +7 -0
  65. package/i18n/es-ES/components/settings/email.ts +2 -1
  66. package/i18n/es-ES/components/settings/image.ts +1 -0
  67. package/i18n/es-ES/components/settings/open_response.ts +8 -0
  68. package/i18n/es-ES/components/settings/open_response_collate.ts +3 -0
  69. package/i18n/es-ES/components/settings/scenario_choice.ts +3 -1
  70. package/i18n/es-ES/components/settings/tab.ts +3 -2
  71. package/i18n/es-ES/components/settings/user_upload.ts +1 -0
  72. package/i18n/es-ES/components/settings/video.ts +3 -1
  73. package/i18n/es-ES/shared/content_blocks.ts +1 -1
  74. package/i18n/sv-SE/components/content/blocks/accordion.ts +3 -0
  75. package/i18n/sv-SE/components/content/blocks/block_quote.ts +3 -1
  76. package/i18n/sv-SE/components/content/blocks/feedback.ts +2 -0
  77. package/i18n/sv-SE/components/content/blocks/file_download.ts +2 -1
  78. package/i18n/sv-SE/components/content/blocks/index.ts +2 -0
  79. package/i18n/sv-SE/components/content/blocks/open_response.ts +19 -2
  80. package/i18n/sv-SE/components/content/blocks/open_response_collate.ts +1 -1
  81. package/i18n/sv-SE/components/content/blocks/scenario_choice.ts +2 -0
  82. package/i18n/sv-SE/components/content/blocks/user_upload.ts +2 -1
  83. package/i18n/sv-SE/components/settings/accordion.ts +2 -1
  84. package/i18n/sv-SE/components/settings/block_quote.ts +1 -1
  85. package/i18n/sv-SE/components/settings/clickable_icon.ts +6 -0
  86. package/i18n/sv-SE/components/settings/email.ts +2 -1
  87. package/i18n/sv-SE/components/settings/image.ts +1 -0
  88. package/i18n/sv-SE/components/settings/open_response.ts +8 -0
  89. package/i18n/sv-SE/components/settings/open_response_collate.ts +3 -0
  90. package/i18n/sv-SE/components/settings/scenario_choice.ts +3 -1
  91. package/i18n/sv-SE/components/settings/tab.ts +5 -3
  92. package/i18n/sv-SE/components/settings/user_upload.ts +1 -0
  93. package/i18n/sv-SE/components/settings/video.ts +3 -1
  94. package/i18n/sv-SE/shared/content_blocks.ts +1 -1
  95. package/models/SurveyResultMetric.ts +8 -0
  96. package/package.json +2 -2
  97. package/plugin.js +8 -0
  98. package/test/Components/Content/Blocks/Feedback/FeedbackTemplates/FeedbackAnalytics.spec.js +23 -0
  99. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionLikert.spec.js +1 -1
  100. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionOpenResponse.spec.js +1 -1
  101. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionTrueFalse.spec.js +1 -1
  102. package/test/Components/Settings/AccordionSettings.spec.js +0 -13
  103. package/test/Components/Settings/ClickableIconsSettings.spec.js +1 -12
  104. package/test/Components/Settings/EmailSettings.spec.js +0 -9
  105. package/test/Components/Settings/TabSettings.spec.js +0 -13
  106. package/test/helpers/GlossaryHelper.spec.js +8 -8
  107. package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionLikert.vue +1 -1
  108. package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionOpenResponse.vue +1 -1
  109. /package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionTrueFalse.vue +0 -0
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <v-container class="pa-0">
2
+ <v-container :class="['pa-0', blockDirectionClasses]" :dir="blockTextDirection">
3
3
  <div>
4
4
  <h2
5
5
  v-if="
@@ -17,7 +17,7 @@
17
17
  </p>
18
18
  </v-col>
19
19
  <v-col v-if="!blockExists" cols="12">
20
- <v-alert color="warning">
20
+ <v-alert type="warning">
21
21
  <p>
22
22
  {{
23
23
  $t(
@@ -28,44 +28,47 @@
28
28
  </v-alert>
29
29
  </v-col>
30
30
  </v-row>
31
- <v-row>
31
+ <v-row v-if="render">
32
32
  <v-col cols="12">
33
- <DisplayUserFilesTable
34
- v-model="userFileAsset"
35
- :enrollment="enrollment"
36
- ></DisplayUserFilesTable>
37
- </v-col>
38
- <v-col v-if="showUpload" cols="12">
39
- <div v-if="blockExists">
40
- <v-form
41
- ref="form"
42
- v-model="valid"
43
- lazy-validation
44
- class="pb-0"
45
- >
46
- <FileDropZone
47
- v-model="uploadFiles"
48
- :accept="
49
- block.metadata.config.uploadSettings.accept
50
- "
51
- :multiple="
52
- block.metadata.config.uploadSettings
53
- .multiple
54
- "
55
- ></FileDropZone>
56
-
57
- <v-container class="text-center">
58
- <v-btn
59
- :disabled="!canUpload || loading"
60
- color="primary"
61
- elevation="0"
62
- class="text-center"
63
- @click="handleUpload"
33
+ <div class="upload-container" :class="uploadContainerClass">
34
+ <DisplayUserFilesTable
35
+ v-model="userFileAsset"
36
+ :enrollment="enrollment"
37
+ ></DisplayUserFilesTable>
38
+ <div v-if="showUpload">
39
+ <div v-if="blockExists">
40
+ <v-form
41
+ ref="form"
42
+ v-model="valid"
43
+ lazy-validation
44
+ class="pb-0"
64
45
  >
65
- {{ $t('shared.forms.upload') }}
66
- </v-btn>
67
- </v-container>
68
- </v-form>
46
+ <FileDropZone
47
+ v-model="uploadFiles"
48
+ :accept="
49
+ block.metadata.config.uploadSettings
50
+ .accept
51
+ "
52
+ :multiple="
53
+ block.metadata.config.uploadSettings
54
+ .multiple
55
+ "
56
+ ></FileDropZone>
57
+
58
+ <v-container class="text-center">
59
+ <v-btn
60
+ :disabled="!canUpload || loading"
61
+ color="primary"
62
+ elevation="0"
63
+ class="text-center"
64
+ @click="handleUpload"
65
+ >
66
+ {{ $t('shared.forms.upload') }}
67
+ </v-btn>
68
+ </v-container>
69
+ </v-form>
70
+ </div>
71
+ </div>
69
72
  </div>
70
73
  </v-col>
71
74
  </v-row>
@@ -109,6 +112,11 @@ export default {
109
112
  ...mapGetters({
110
113
  enrollment: 'enrollment/get',
111
114
  }),
115
+ uploadContainerClass() {
116
+ return this.$vuetify.theme.dark
117
+ ? 'upload-container--dark'
118
+ : 'upload-container--light'
119
+ },
112
120
  blockExists() {
113
121
  return Uuid.test(this.block.id)
114
122
  },
@@ -255,3 +263,23 @@ export default {
255
263
  },
256
264
  }
257
265
  </script>
266
+
267
+ <style scoped>
268
+ .upload-container {
269
+ padding: 16px;
270
+ border-radius: 4px;
271
+ border: 1px solid;
272
+ }
273
+
274
+ .upload-container--light {
275
+ background-color: #ffffff;
276
+ border-color: #e0e0e0;
277
+ color: #000000;
278
+ }
279
+
280
+ .upload-container--dark {
281
+ background-color: #424242;
282
+ border-color: #616161;
283
+ color: #ffffff;
284
+ }
285
+ </style>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :class="blockDirectionClasses" :dir="blockTextDirection">
3
3
  <h2
4
4
  v-if="
5
5
  block.metadata.config.title &&
@@ -13,7 +13,7 @@
13
13
  {{ block.metadata.config.instructions }}
14
14
  </p>
15
15
 
16
- <div class="video-wrapper">
16
+ <div v-if="hasSource" class="video-wrapper" dir="ltr">
17
17
  <VuetifyPlayer
18
18
  class="no-border-player"
19
19
  :language="$i18n && $i18n.locale ? $i18n.locale : 'en-US'"
@@ -74,6 +74,9 @@
74
74
  </template>
75
75
  </VuetifyPlayer>
76
76
  </div>
77
+ <v-alert v-else type="warning">
78
+ {{ $t('windward.core.components.settings.video.no_video_alert') }}
79
+ </v-alert>
77
80
  <!-- display first note in the playlist for now -->
78
81
  <v-alert
79
82
  v-if="notes.length > 0"
@@ -143,6 +146,124 @@ export default {
143
146
  }
144
147
  },
145
148
  computed: {
149
+ courseSourceLocale() {
150
+ // Get the source language from translation metadata
151
+ // This is the original language the course was translated FROM
152
+ // For translated courses: metadata.translation.source_language (e.g., "EN")
153
+ // For non-translated courses: falls back to locale code
154
+
155
+ // First check translation metadata for source_language (e.g., "EN", "ES")
156
+ const translationSourceLang = _.get(this.block, 'course.metadata.translation.source_language') ||
157
+ this.$store?.getters?.['course/get']?.metadata?.translation?.source_language
158
+ if (translationSourceLang) {
159
+ return translationSourceLang.toLowerCase()
160
+ }
161
+
162
+ // Try source_locale_id lookup
163
+ const translationSourceLocale = _.get(this.block, 'course.metadata.translation.source_locale_id')
164
+ if (translationSourceLocale) {
165
+ const sourceLocale = this.$store?.getters?.['locales/getById']?.(translationSourceLocale)
166
+ if (sourceLocale?.code) {
167
+ return sourceLocale.code.toLowerCase()
168
+ }
169
+ }
170
+
171
+ return (
172
+ _.get(this.block, 'course.locale.code')?.toLowerCase() ||
173
+ this.$store?.getters?.['course/sourceLocale']?.code?.toLowerCase() ||
174
+ 'en-us'
175
+ )
176
+ },
177
+ courseTargetLocale() {
178
+ // Get the target language from translation metadata
179
+ // This is the language the course was translated INTO
180
+ // For translated courses: metadata.translation.target_language (e.g., "PT-BR", "ES")
181
+
182
+ const translationTargetLang = _.get(this.block, 'course.metadata.translation.target_language') ||
183
+ this.$store?.getters?.['course/get']?.metadata?.translation?.target_language
184
+ if (translationTargetLang) {
185
+ return translationTargetLang.toLowerCase()
186
+ }
187
+
188
+ // Try target_locale_id lookup
189
+ const translationTargetLocale = _.get(this.block, 'course.metadata.translation.target_locale_id')
190
+ if (translationTargetLocale) {
191
+ const targetLocale = this.$store?.getters?.['locales/getById']?.(translationTargetLocale)
192
+ if (targetLocale?.code) {
193
+ return targetLocale.code.toLowerCase()
194
+ }
195
+ }
196
+
197
+ // No target language - course is not translated
198
+ return null
199
+ },
200
+ courseCurrentLocale() {
201
+ // Get the current course's locale code (the language this course is in)
202
+ // For a translated course, this is the target language
203
+ // Priority: translation target_language > block.course.locale > store getters > fallback
204
+
205
+ // For translated courses, use target_language directly
206
+ if (this.courseTargetLocale) {
207
+ return this.courseTargetLocale
208
+ }
209
+
210
+ const localeCode =
211
+ _.get(this.block, 'course.locale.code')?.toLowerCase() ||
212
+ _.get(this.block, 'course.locale.name')?.toLowerCase() ||
213
+ this.$store?.getters?.['course/localeCode']?.toLowerCase() ||
214
+ this.$store?.getters?.['course/locale']?.code?.toLowerCase() ||
215
+ this.$store?.getters?.['course/locale']?.name?.toLowerCase() ||
216
+ this.$store?.state?.course?.course?.locale?.code?.toLowerCase() ||
217
+ this.$store?.state?.course?.course?.locale?.name?.toLowerCase()
218
+
219
+ return localeCode || this.courseSourceLocale
220
+ },
221
+ isTranslatedCourse() {
222
+ // Check if this course is a translated course (has translation metadata)
223
+ return !!(
224
+ _.get(this.block, 'course.metadata.translation') ||
225
+ this.$store?.getters?.['course/get']?.metadata?.translation
226
+ )
227
+ },
228
+ courseTargetLocaleId() {
229
+ // Get the target_locale_id from translation metadata (UUID)
230
+ // This is used to match VTT files which are linked by locale_id
231
+ return _.get(this.block, 'course.metadata.translation.target_locale_id') ||
232
+ this.$store?.getters?.['course/get']?.metadata?.translation?.target_locale_id ||
233
+ _.get(this.block, 'course.locale_id') ||
234
+ this.$store?.getters?.['course/get']?.locale_id ||
235
+ null
236
+ },
237
+ allowedCaptionLocales() {
238
+ // Only show captions for the source language and the current course's language
239
+ // This prevents showing all translated captions (FR, ES, DE, etc.) when viewing a specific course
240
+ const locales = new Set()
241
+
242
+ // Helper to add both full locale and base language code
243
+ const addLocale = (locale) => {
244
+ if (!locale) return
245
+ locales.add(locale)
246
+ locales.add(locale.toLowerCase())
247
+ // Also add base language code (e.g., "en" from "en-US", "fr" from "fr-FR")
248
+ const base = locale.split('-')[0]?.toLowerCase()
249
+ if (base) {
250
+ locales.add(base)
251
+ }
252
+ }
253
+
254
+ // Always include the source language (original English captions)
255
+ addLocale(this.courseSourceLocale)
256
+
257
+ // Include the current course's language
258
+ addLocale(this.courseCurrentLocale)
259
+
260
+ // Also include tracks with no locale (legacy/default tracks)
261
+ locales.add(null)
262
+ locales.add(undefined)
263
+ locales.add('')
264
+
265
+ return locales
266
+ },
146
267
  hasSource() {
147
268
  // __isDirty is used to communicate from the settings panel that a source / caption
148
269
  // has changed and to reload the video player
@@ -172,12 +293,38 @@ export default {
172
293
  let file = this.resolveAsset(
173
294
  playlist[index].sources[sourceIndex]
174
295
  )
175
- if (
176
- !_.isEmpty(_.get(file, 'asset.metadata.notes')) &&
177
- //check the file notes are actually text and not an empty div
178
- this.hasWords(_.get(file, 'asset.metadata.notes'))
179
- ) {
180
- result.push(_.get(file, 'asset.metadata.notes'))
296
+
297
+ // Try to get translated notes from VTT file (for translated courses)
298
+ let notes = null
299
+ if (this.isTranslatedCourse) {
300
+ const linkedCaptions = this.getAllLinkedCaptions(file)
301
+ // Find VTT matching by locale_id (UUID) or locale.code (language code)
302
+ const currentVtt = _.find(linkedCaptions, vtt => {
303
+ // First try matching by locale_id (most reliable)
304
+ if (this.courseTargetLocaleId && vtt.locale_id === this.courseTargetLocaleId) {
305
+ return true
306
+ }
307
+ // Also try matching by locale.code
308
+ const vttLocale = _.get(vtt, 'locale.code', '').toLowerCase()
309
+ return vttLocale && this.isAllowedCaptionLanguage(vttLocale) &&
310
+ vttLocale === this.courseCurrentLocale
311
+ })
312
+ // Notes can be in different locations:
313
+ // - vtt.metadata.notes (FileAssetMap metadata)
314
+ // - vtt.notes (FileAssetMapResource exposes this)
315
+ // - vtt.asset.metadata.notes (FileAsset metadata - where translation stores them)
316
+ notes = _.get(currentVtt, 'metadata.notes') ||
317
+ _.get(currentVtt, 'notes') ||
318
+ _.get(currentVtt, 'asset.metadata.notes')
319
+ }
320
+
321
+ // Fallback to video asset notes
322
+ if (!notes || !this.hasWords(notes)) {
323
+ notes = _.get(file, 'asset.metadata.notes')
324
+ }
325
+
326
+ if (notes && this.hasWords(notes)) {
327
+ result.push(notes)
181
328
  }
182
329
  }
183
330
  }
@@ -189,36 +336,123 @@ export default {
189
336
  linkedPlaylist() {
190
337
  const playlist = _.cloneDeep(this.block.metadata.config.playlist)
191
338
  for (const index in playlist) {
339
+ // Build comprehensive track list from multiple sources
340
+ const allTracks = []
341
+ const addedSrcs = new Set() // Track added URLs to avoid duplicates
342
+ const addedLangs = new Set() // Track added languages to avoid duplicate srclang keys
343
+
344
+ // 1. FIRST: Resolve video files and get linked captions BEFORE transforming sources
345
+ const resolvedVideoFiles = []
192
346
  for (const sourceIndex in playlist[index].sources) {
193
- let file = this.resolveAsset(
347
+ let videoFile = this.resolveAsset(
194
348
  playlist[index].sources[sourceIndex]
195
349
  )
350
+ resolvedVideoFiles.push(videoFile)
196
351
 
197
- playlist[index].sources[sourceIndex] = {
198
- src: _.get(file, 'asset.public_url', ''),
199
- type: _.get(file, 'asset.metadata.mime', ''),
200
- }
352
+ // Get ALL linked captions from this video file (adds to block.assets)
353
+ const linkedCaptions = this.getAllLinkedCaptions(videoFile)
201
354
 
202
- // If there's linked captions and there's no hard-set captions
203
- // Fallback to the linked captions
204
- const linkedCaptions = this.getLinkedCaptions(file)
205
- if (playlist[index].tracks.length === 0 && linkedCaptions) {
206
- playlist[index].tracks.push(linkedCaptions)
355
+ for (const vtt of linkedCaptions) {
356
+ // Get the src - may be directly available or need to extract from asset
357
+ const src = _.get(vtt, 'asset.public_url', '')
358
+
359
+ // Skip if no src or already added
360
+ if (!src || addedSrcs.has(src)) {
361
+ continue
362
+ }
363
+
364
+ const srclang = _.get(vtt, 'locale.code') || this.courseSourceLocale
365
+
366
+ // Skip if this language was already added (avoid duplicate keys)
367
+ if (addedLangs.has(srclang)) {
368
+ continue
369
+ }
370
+
371
+ // FILTER: Only include tracks that match the course's language(s)
372
+ // - Original courses: show only the course's own language
373
+ // - Translated courses: show source language + target language
374
+ if (!this.isAllowedCaptionLanguage(srclang)) {
375
+ continue
376
+ }
377
+
378
+ const label = this.getLabelForLanguage(srclang) || _.get(vtt, 'locale.name') || srclang
379
+
380
+ allTracks.push({
381
+ src: src,
382
+ kind: 'captions',
383
+ srclang: srclang,
384
+ label: label,
385
+ default: false,
386
+ })
387
+ addedSrcs.add(src)
388
+ addedLangs.add(srclang)
207
389
  }
208
390
  }
209
391
 
392
+ // 2. Process manually-added tracks from playlist[x].tracks[]
210
393
  for (const trackIndex in playlist[index].tracks) {
211
394
  let file = this.resolveAsset(
212
395
  playlist[index].tracks[trackIndex]
213
396
  )
214
397
 
215
- playlist[index].tracks[trackIndex] = {
216
- src: _.get(file, 'asset.public_url', ''),
398
+ const trackObj = playlist[index].tracks[trackIndex]
399
+ const src = _.get(file, 'asset.public_url', '')
400
+
401
+ // Skip if no src or already added
402
+ if (!src || addedSrcs.has(src)) {
403
+ continue
404
+ }
405
+
406
+ const srclang = trackObj.srclang || this.courseSourceLocale
407
+
408
+ // Skip if this language was already added (avoid duplicate keys)
409
+ if (addedLangs.has(srclang)) {
410
+ continue
411
+ }
412
+
413
+ // FILTER: Only include tracks that match source or target language
414
+ // For translated courses, only show source language and target language tracks
415
+ // For non-translated courses, this filter is skipped and all tracks are shown
416
+ if (this.isTranslatedCourse && !this.isAllowedCaptionLanguage(srclang)) {
417
+ continue
418
+ }
419
+
420
+ const label = trackObj.label || this.getLabelForLanguage(srclang)
421
+
422
+ allTracks.push({
423
+ src: src,
217
424
  kind: 'captions',
218
- srclang: 'en-US',
219
- default: true,
425
+ srclang: srclang,
426
+ label: label,
427
+ default: false,
428
+ })
429
+ addedSrcs.add(src)
430
+ addedLangs.add(srclang)
431
+ }
432
+
433
+ // 3. NOW transform video sources to player format
434
+ for (const sourceIndex in playlist[index].sources) {
435
+ const file = resolvedVideoFiles[sourceIndex]
436
+ playlist[index].sources[sourceIndex] = {
437
+ src: _.get(file, 'asset.public_url', ''),
438
+ type: _.get(file, 'asset.metadata.mime', ''),
220
439
  }
221
440
  }
441
+
442
+ // 4. Sort tracks: course source language first
443
+ allTracks.sort((a, b) => {
444
+ if (a.srclang === this.courseSourceLocale) return -1
445
+ if (b.srclang === this.courseSourceLocale) return 1
446
+ return 0
447
+ })
448
+
449
+ // 5. Set default on first track (should be source language)
450
+ if (allTracks.length > 0) {
451
+ allTracks[0].default = true
452
+ }
453
+
454
+ // 6. Replace tracks array with processed tracks
455
+ playlist[index].tracks = allTracks
222
456
  for (const adIndex in playlist[index].ads) {
223
457
  for (const adSourceIndex in playlist[index].ads[adIndex]
224
458
  .sources) {
@@ -351,6 +585,9 @@ export default {
351
585
  },
352
586
  },
353
587
  },
588
+ mounted() {
589
+ // Video block mounted - locale filtering applied via isTranslatedCourse check
590
+ },
354
591
  beforeMount() {
355
592
  // Apply the default config
356
593
  if (_.isEmpty(this.block.metadata.config)) {
@@ -397,15 +634,64 @@ export default {
397
634
  async onBeforeSave() {
398
635
  this.block.body = 'video'
399
636
  },
400
- getLinkedCaptions(file) {
401
- // Check to see if the video source has a linked asset and it's a vtt file
402
- const linkedCaption = _.find(
637
+ /**
638
+ * Get ALL linked VTT caption files (supports multiple languages)
639
+ * @param {Object} file - The video file object with linked_assets
640
+ * @returns {Array} Array of VTT files with locale information
641
+ */
642
+ getAllLinkedCaptions(file) {
643
+ // Get ALL VTT files from linked_assets (not just first one)
644
+ const linkedCaptions = _.filter(
403
645
  _.get(file, 'asset.linked_assets', []),
404
- function (f) {
405
- return _.get(f, 'asset.metadata.extension', '') === 'vtt'
406
- }
646
+ (f) => _.get(f, 'asset.metadata.extension', '') === 'vtt'
407
647
  )
408
648
 
649
+ // Add locale information to each VTT AND add to block.assets
650
+ return linkedCaptions.map(vtt => {
651
+ // CRITICAL: Add to block.assets so resolveAsset can find it
652
+ const foundAsset = this.block.assets.find((a) => {
653
+ return a.file_asset_id === vtt.file_asset_id
654
+ })
655
+ if (!foundAsset) {
656
+ this.block.assets.push(_.cloneDeep(vtt))
657
+ }
658
+
659
+ // Try to get locale - priority: API response > store lookup > null
660
+ let locale = vtt.locale || null
661
+
662
+ // If no locale from API but we have locale_id, try store lookup
663
+ if (!locale && vtt.locale_id && this.$store?.getters?.['locales/getById']) {
664
+ locale = this.$store.getters['locales/getById'](vtt.locale_id)
665
+ }
666
+
667
+ return {
668
+ ...vtt,
669
+ locale: locale
670
+ }
671
+ })
672
+ },
673
+
674
+ /**
675
+ * Get single linked caption (backward compatibility)
676
+ * @param {Object} file - The video file object
677
+ * @returns {Object|null} First VTT file found
678
+ */
679
+ getLinkedCaptions(file) {
680
+ // Check to see if the video source has a linked asset and it's a vtt file
681
+ // Prefer the most recently linked VTT (last match), not the oldest.
682
+ const linkedAssets = _.get(file, 'asset.linked_assets', [])
683
+ const linkedCaption = _.findLast(linkedAssets, function (f) {
684
+ const ext = _.get(f, 'asset.metadata.extension', '').toLowerCase()
685
+ const mime = _.get(f, 'asset.metadata.mime', '').toLowerCase()
686
+ const name = _.get(f, 'asset.name', '').toLowerCase()
687
+
688
+ return (
689
+ ext === 'vtt' ||
690
+ mime === 'text/vtt' ||
691
+ name.endsWith('.vtt')
692
+ )
693
+ })
694
+
409
695
  if (linkedCaption) {
410
696
  const foundAsset = this.block.assets.find((a) => {
411
697
  return a.file_asset_id === linkedCaption.file_asset_id
@@ -421,6 +707,69 @@ export default {
421
707
  return linkedCaption || null
422
708
  },
423
709
 
710
+ /**
711
+ * TODO:find a better place for this to live
712
+ * Get human-readable language label for a language code
713
+ * @param {string} langCode - ISO language code (e.g., 'en-US')
714
+ * @returns {string} Language name
715
+ */
716
+ getLabelForLanguage(langCode) {
717
+ // Map common language codes to display names
718
+ const labels = {
719
+ 'en': 'English',
720
+ 'en-US': 'English',
721
+ 'en-GB': 'English (UK)',
722
+ 'es': 'Spanish',
723
+ 'es-ES': 'Spanish',
724
+ 'es-MX': 'Spanish (Mexico)',
725
+ 'fr': 'French',
726
+ 'fr-FR': 'French',
727
+ 'fr-CA': 'French (Canada)',
728
+ 'de': 'German',
729
+ 'de-DE': 'German',
730
+ 'it': 'Italian',
731
+ 'it-IT': 'Italian',
732
+ 'pt': 'Portuguese',
733
+ 'pt-BR': 'Portuguese (Brazil)',
734
+ 'pt-PT': 'Portuguese (Portugal)',
735
+ 'zh': 'Chinese',
736
+ 'zh-CN': 'Chinese (Simplified)',
737
+ 'zh-TW': 'Chinese (Traditional)',
738
+ 'ja': 'Japanese',
739
+ 'ja-JP': 'Japanese',
740
+ 'ko': 'Korean',
741
+ 'ko-KR': 'Korean',
742
+ 'ru': 'Russian',
743
+ 'ru-RU': 'Russian',
744
+ 'ar': 'Arabic',
745
+ 'ar-SA': 'Arabic',
746
+ 'hi': 'Hindi',
747
+ 'hi-IN': 'Hindi',
748
+ }
749
+ return labels[langCode] || langCode
750
+ },
751
+
752
+ /**
753
+ * Check if a caption language is allowed based on course source/target languages
754
+ * Since allowedCaptionLocales already contains both full codes (en-us) and base codes (en),
755
+ * we just need to check if the language or its base is in the Set.
756
+ * @param {string} lang - The language code to check (e.g., 'en-US', 'PT-BR')
757
+ * @returns {boolean} - True if the language is allowed
758
+ */
759
+ isAllowedCaptionLanguage(lang) {
760
+ if (!lang) {
761
+ // Allow tracks with no language set (legacy/default tracks)
762
+ return true
763
+ }
764
+
765
+ const langLower = lang.toLowerCase()
766
+ const langBase = langLower.split('-')[0]
767
+
768
+ // Check if the full language code or its base is in allowedCaptionLocales
769
+ return this.allowedCaptionLocales.has(langLower) ||
770
+ this.allowedCaptionLocales.has(langBase)
771
+ },
772
+
424
773
  /**
425
774
  * Check if the given text has words, omitting HTML tags and HTML entities
426
775
  * @param {string} text - The text to check
@@ -165,7 +165,9 @@ export default {
165
165
  this.block.metadata.config = {}
166
166
  }
167
167
  if (_.isEmpty(this.block.metadata.config.title)) {
168
- this.block.metadata.config.title = ''
168
+ this.block.metadata.config.title = this.$t(
169
+ 'windward.core.components.settings.accordion.accordion'
170
+ )
169
171
  }
170
172
  if (!_.isBoolean(this.block.metadata.config.display_title)) {
171
173
  this.$set(this.block.metadata.config, 'display_title', true)
@@ -183,21 +185,7 @@ export default {
183
185
  this.block.metadata.config.editOnContentItem = false
184
186
  }
185
187
  if (_.isEmpty(this.block.metadata.config.items)) {
186
- const defaultObject = {
187
- header: '',
188
- expand: false,
189
- content: '',
190
- fileConfig: {
191
- display: {
192
- width: 100,
193
- margin: '',
194
- padding: '',
195
- },
196
- hideBackground: true,
197
- },
198
- }
199
188
  this.block.metadata.config.items = []
200
- this.block.metadata.config.items.push(defaultObject)
201
189
  }
202
190
  this.block.body = this.$t(
203
191
  'windward.core.shared.content_blocks.title.accordion'