chore: ai, public course and other ui improvements
This commit is contained in:
@@ -8,6 +8,18 @@ interface OpenCoursePreviewOptions {
|
||||
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') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmedDomain = currentOrgDomain?.trim();
|
||||
const origin = trimmedDomain || window.location.origin;
|
||||
|
||||
return new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
}
|
||||
|
||||
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
@@ -19,9 +31,32 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain || window.location.origin;
|
||||
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
const link = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
snackbar.success('snackbar.public_course.url_copied');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('copyPublicCoursePageUrl error:', error);
|
||||
snackbar.error('snackbar.public_course.url_copy_failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
|
||||
import {
|
||||
clearQuestionnaireValidation,
|
||||
questionnaire,
|
||||
questionnaireMetaData
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
|
||||
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { UNTITLED_EXERCISE_SECTION_TITLE } from './exercise-section-utils';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
|
||||
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
|
||||
let questions: Question[] = [];
|
||||
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mappedQuestions = exercise.questions.map((question) => {
|
||||
const sections: ExerciseSectionState[] = Array.isArray(exercise.sections)
|
||||
? exercise.sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme as ExerciseSectionColorTheme,
|
||||
afterBehavior: section.afterBehavior as unknown as ExerciseSectionAfterBehavior
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
: [];
|
||||
|
||||
const exerciseQuestions = [...(Array.isArray(exercise.questions) ? exercise.questions : [])];
|
||||
|
||||
if (exerciseQuestions.length > 0) {
|
||||
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
|
||||
return {
|
||||
...question,
|
||||
exerciseSectionId: question.exerciseSectionId ?? null,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
|
||||
if (sections.length > 0) {
|
||||
const unsectionedQuestions = mappedQuestions.filter(
|
||||
(question) => !question.deletedAt && !question.exerciseSectionId
|
||||
);
|
||||
|
||||
if (unsectionedQuestions.length > 0) {
|
||||
const untitledSectionId = crypto.randomUUID();
|
||||
const nextSectionOrder =
|
||||
sections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
|
||||
sections.push({
|
||||
id: untitledSectionId,
|
||||
title: UNTITLED_EXERCISE_SECTION_TITLE,
|
||||
description: null,
|
||||
order: nextSectionOrder,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
});
|
||||
|
||||
for (const question of unsectionedQuestions) {
|
||||
question.exerciseSectionId = untitledSectionId;
|
||||
question.isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
questions = normalizeQuestionOrder(mappedQuestions);
|
||||
}
|
||||
|
||||
@@ -36,8 +82,11 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions,
|
||||
sections,
|
||||
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
|
||||
slug: exercise.slug ?? ''
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const UNTITLED_EXERCISE_SECTION_TITLE = 'Untitled section';
|
||||
|
||||
export function getExerciseSectionDisplayTitle({
|
||||
title,
|
||||
sectionNumber,
|
||||
sectionLabel
|
||||
}: {
|
||||
title: string;
|
||||
sectionNumber: number;
|
||||
sectionLabel: string;
|
||||
}) {
|
||||
if (title.trim() === UNTITLED_EXERCISE_SECTION_TITLE) return `${sectionLabel} ${sectionNumber}`;
|
||||
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
PublicCourseSidebarSection,
|
||||
PublicExerciseViewData,
|
||||
PublicLessonViewData
|
||||
} from '@cio/ui/custom/public-course';
|
||||
import type { ExerciseQuestionModel, ExerciseQuestionTypeKey } from '@cio/question-types';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type GetPublicCourseSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicCourseTree = GetPublicCourseSuccess['data'];
|
||||
|
||||
type GetPublicCourseItemSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicItem = GetPublicCourseItemSuccess['data'];
|
||||
|
||||
/**
|
||||
* Group the flat tree into sidebar sections. Items that aren't assigned to any
|
||||
* section are grouped under a synthetic "Lessons" section so the UI never has
|
||||
* orphaned rows.
|
||||
*/
|
||||
export function toPublicSidebarSections(tree: PublicCourseTree): PublicCourseSidebarSection[] {
|
||||
const sectionLookup = new Map<string, PublicCourseSidebarSection>();
|
||||
for (const section of tree.sections) {
|
||||
sectionLookup.set(section.id, {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
const orphanSection: PublicCourseSidebarSection = {
|
||||
id: '__unsectioned',
|
||||
title: 'Lessons',
|
||||
items: []
|
||||
};
|
||||
|
||||
for (const item of tree.items) {
|
||||
const targetSection =
|
||||
item.sectionId && sectionLookup.has(item.sectionId) ? sectionLookup.get(item.sectionId)! : orphanSection;
|
||||
|
||||
targetSection.items.push({
|
||||
kind: item.kind,
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
isUnlocked: item.isUnlocked
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = Array.from(sectionLookup.values()).filter((section) => section.items.length > 0);
|
||||
|
||||
if (orphanSection.items.length > 0) {
|
||||
ordered.push(orphanSection);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson' }>): PublicLessonViewData {
|
||||
return {
|
||||
kind: 'lesson',
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
type ApiQuestion = Extract<PublicItem, { kind: 'exercise' }>['questions']['unsectioned'][number];
|
||||
|
||||
/**
|
||||
* Convert API question shapes into `ExerciseQuestionModel`s so the public
|
||||
* exercise view can drive the same renderer pipeline as the authenticated
|
||||
* student view (`ExerciseQuestion.QuestionList`). Question types that aren't
|
||||
* auto-gradable are filtered out — the creator UI already blocks them on
|
||||
* PUBLIC courses, but we double-check here so the page never ships answers
|
||||
* for a question we can't grade in the browser.
|
||||
*/
|
||||
export function toPublicExerciseQuestions(questions: {
|
||||
unsectioned: ApiQuestion[];
|
||||
sections: Array<{ questions: ApiQuestion[] }>;
|
||||
}): ExerciseQuestionModel[] {
|
||||
const all = [...questions.unsectioned, ...questions.sections.flatMap((section) => section.questions)];
|
||||
|
||||
return all.map((question) => mapQuestion(question));
|
||||
}
|
||||
|
||||
function mapQuestion(question: ApiQuestion): ExerciseQuestionModel {
|
||||
return {
|
||||
id: question.id,
|
||||
title: question.title,
|
||||
questionType: question.questionTypeKey as ExerciseQuestionTypeKey,
|
||||
points: question.points,
|
||||
options: question.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
isCorrect: option.isCorrect,
|
||||
settings: (option.settings ?? {}) as Record<string, unknown>
|
||||
})),
|
||||
settings: (question.settings ?? {}) as Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicExerciseView(exercise: Extract<PublicItem, { kind: 'exercise' }>): PublicExerciseViewData {
|
||||
return {
|
||||
kind: 'exercise',
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
sectionTitle: exercise.sectionTitle,
|
||||
isUnlocked: exercise.isUnlocked,
|
||||
questions: toPublicExerciseQuestions(exercise.questions)
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import type { TCourseCallout } from '@cio/utils/validation/course';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type CourseSettings = {
|
||||
@@ -6,12 +7,13 @@ type CourseSettings = {
|
||||
courseTitle: string;
|
||||
courseDescription: string;
|
||||
grading: boolean;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
|
||||
type: TCourseType;
|
||||
allowNewStudents: boolean;
|
||||
tabs: { id: number; name: string }[];
|
||||
lessonDownload: boolean;
|
||||
isPublished: boolean;
|
||||
isContentGroupingEnabled: boolean;
|
||||
callout: TCourseCallout | null;
|
||||
};
|
||||
|
||||
export const settings = writable<CourseSettings>({
|
||||
@@ -19,7 +21,7 @@ export const settings = writable<CourseSettings>({
|
||||
courseTitle: '',
|
||||
courseDescription: '',
|
||||
grading: false,
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
allowNewStudents: false,
|
||||
tabs: [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
@@ -29,5 +31,6 @@ export const settings = writable<CourseSettings>({
|
||||
],
|
||||
lessonDownload: false,
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true
|
||||
isContentGroupingEnabled: true,
|
||||
callout: null
|
||||
});
|
||||
|
||||
@@ -261,6 +261,21 @@ export type UpdateSubmissionGradesRequest =
|
||||
export type CreateCourseRequest = typeof classroomio.course.$post;
|
||||
export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get'];
|
||||
export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get'];
|
||||
export type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
export type GetCourseBySlugData = GetCourseBySlugSuccess['data'];
|
||||
|
||||
/** Org snippet returned with course slug payload for org-site flows */
|
||||
export type CourseSlugPayloadOrg = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
theme?: unknown;
|
||||
};
|
||||
|
||||
/** Slug course shape including optional org (RPC unions omit `org` on some branches). */
|
||||
export type CourseBySlugWithOrg = GetCourseBySlugData & {
|
||||
org?: CourseSlugPayloadOrg | null;
|
||||
};
|
||||
export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put'];
|
||||
export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete'];
|
||||
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
|
||||
@@ -444,3 +459,10 @@ export type VideoUploadPresignSuccess = Extract<InferResponseType<VideoUploadPre
|
||||
|
||||
export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post'];
|
||||
export type VideoDownloadPresignSuccess = Extract<InferResponseType<VideoDownloadPresignRequest>, { success: true }>;
|
||||
|
||||
export type VideoRecordingUploadInitRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['init']['$post'];
|
||||
export type VideoRecordingUploadCompleteRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['complete']['$post'];
|
||||
export type VideoRecordingPlaybackRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission'][':submissionId']['question'][':questionId']['video-recording']['playback']['$get'];
|
||||
|
||||
Reference in New Issue
Block a user