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:
@@ -1,6 +1,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { accountApi } from '$features/account/api/account.svelte';
|
||||
|
||||
interface OpenCoursePreviewOptions {
|
||||
courseId: string;
|
||||
@@ -8,6 +9,11 @@ interface OpenCoursePreviewOptions {
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
interface ViewAsStudentOptions {
|
||||
courseSlug?: string | null;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
|
||||
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -41,6 +47,45 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "View as student" handoff. Mints a short-lived login-link token for the current
|
||||
* user and navigates to the org domain's `/api/auth/login-link`, which signs them in
|
||||
* there (host-only session) and redirects to the course's student view.
|
||||
*
|
||||
* One flow for cloud and self-hosted: on cloud the tenant-router worker fronts
|
||||
* `/api/auth/*`→API and `/course/*`→dashboard under the tenant host; on self-hosted the
|
||||
* dashboard's own `hooks.server.ts` proxies `/api/auth/*` to the API. Either way the
|
||||
* cookie lands on `currentOrgDomain` and the relative `redirect` resolves there.
|
||||
*/
|
||||
export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewAsStudentOptions) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = await accountApi.createViewAsStudentToken();
|
||||
if (!token) {
|
||||
snackbar.error('snackbar.view_as_student.failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain?.trim() || window.location.origin;
|
||||
const loginLinkUrl = new URL('/api/auth/login-link', origin);
|
||||
loginLinkUrl.searchParams.set('token', token);
|
||||
loginLinkUrl.searchParams.set('redirect', `/course/${courseSlug}`);
|
||||
|
||||
// Cross-origin handoff — open in a new tab so the teacher keeps their dashboard.
|
||||
window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { CourseContentItem, CourseMembers } from '$features/course/utils/ty
|
||||
import type { Marks } from './types';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import Papa from 'papaparse';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
/** Exercise information for display in marks table (linear, no lesson grouping) */
|
||||
export interface ExerciseInfo {
|
||||
@@ -188,13 +186,18 @@ export function generateMarksCSV(
|
||||
* @param studentMarksByExerciseId Student marks by exercise
|
||||
* @param courseTitle Course title for filename and header
|
||||
*/
|
||||
export function generateMarksPDF(
|
||||
export async function generateMarksPDF(
|
||||
students: CourseMembers,
|
||||
exercises: ExerciseInfo[],
|
||||
studentMarksByExerciseId: StudentMarksByExercise,
|
||||
courseTitle: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable')
|
||||
]);
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape' });
|
||||
const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']];
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson'
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
// `lesson.video.hls` may be present on the wire from the public-course
|
||||
// API but isn't in the older inferred type; spread through verbatim.
|
||||
video: lesson.video as PublicLessonViewData['video'],
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +301,8 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
|
||||
? NonNullable<ApiCourse['metadata']>
|
||||
: Record<string, unknown>) & {
|
||||
isContentGroupingEnabled?: boolean;
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
};
|
||||
export type CourseCertificate =
|
||||
NonNullable<ApiCourse['certificate']> extends object
|
||||
|
||||
Reference in New Issue
Block a user