feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -3,13 +3,15 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import * as ButtonGroup from '@cio/ui/base/button-group';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import EyeIcon from '@lucide/svelte/icons/eye';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import * as Page from '@cio/ui/base/page';
import { ContentType } from '@cio/utils/constants/content';
@@ -37,7 +39,6 @@
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import {
@@ -373,6 +374,11 @@
const sectionId = handleAddSection();
await scrollToExerciseElement(`exercise-section-${sectionId}`);
}
function togglePreviewMode() {
preview = !preview;
reorderQuestions = false;
}
</script>
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
@@ -401,71 +407,87 @@
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex items-center gap-2">
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => {
preview = !preview;
reorderQuestions = false;
}}
tooltip={preview
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
tooltipSide="bottom"
>
{#if preview}
<PencilIcon size={20} />
{:else}
<EyeIcon size={20} />
{/if}
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
<CirclePlusIcon size={20} />
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="center" class="z-201!">
<DropdownMenu.Item onclick={addQuestionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
<div class="flex flex-wrap items-center justify-end gap-2">
<ButtonGroup.Root>
<Button variant="outline" size="sm" onclick={addQuestionFromHeader}>
<CirclePlusIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="outline"
size="icon-sm"
aria-label={$t('course.navItem.lessons.exercises.all_exercises.add')}
>
<ChevronDownIcon size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="z-201!">
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline" size="sm" onclick={togglePreviewMode}>
{#if preview}
<PencilIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<EyeIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.preview')}
{/if}
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button size="sm" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<ButtonGroup.Separator />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="default"
size="icon-sm"
aria-label={$t('course.navItem.lessons.exercises.all_exercises.more')}
>
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="z-201!">
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</div>
</div>
{/if}
@@ -478,7 +500,7 @@
<Page.Body>
{#snippet child()}
<div class="overflow-x-hidden">
<div class="overflow-x-hidden pb-20">
{#if $isOrgStudent}
{#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
@@ -10,7 +10,7 @@
import { fade } from 'svelte/transition';
import Save from '@lucide/svelte/icons/save';
import Pencil from '@lucide/svelte/icons/pencil';
import HistoryIcon from '@lucide/svelte/icons/history';
// import HistoryIcon from '@lucide/svelte/icons/history';
import VideoIcon from '@lucide/svelte/icons/video';
import MODES from '$lib/utils/constants/mode';
@@ -381,13 +381,15 @@
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
<HistoryIcon size={20} />
</IconButton>
{/if}
<div class="flex flex-row items-center gap-2 lg:flex">
<!--
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
<HistoryIcon size={20} />
</IconButton>
{/if}
-->
{#if mode === MODES.edit}
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
{#if lessonApi.isSaving}
@@ -416,7 +418,9 @@
<Page.Body>
{#snippet child()}
<div class={`overflow-x-hidden py-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
<div
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
>
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -367,10 +367,10 @@
<Field.Field>
<div class="relative w-fit">
<img
style="min-width:280px; min-height:200px"
style="min-width:320px; min-height:200px"
alt="Course cover"
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
class="relative mt-2 h-[200px] w-[320px] rounded-md md:mt-0"
/>
</div>
</Field.Field>