chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+61 -2
View File
@@ -15,7 +15,8 @@ import type {
OrderingAnswerData,
LinkAnswerData,
HotspotAnswerData,
StarAnswerData
StarAnswerData,
VideoRecordingAnswerData
} from './answer-data';
import { QUESTION_TYPE_KEY } from './question-type-keys';
@@ -247,6 +248,62 @@ const STAR_CODEC: AnswerCodec<StarAnswerData> = {
}
};
function isVideoRecordingAnswer(value: unknown): value is Omit<VideoRecordingAnswerData, 'type'> & { type?: string } {
if (!value || typeof value !== 'object') return false;
const data = value as Partial<VideoRecordingAnswerData>;
return (
typeof data.assetId === 'string' &&
data.assetId.length > 0 &&
typeof data.storageKey === 'string' &&
data.storageKey.length > 0 &&
typeof data.fileName === 'string' &&
typeof data.mimeType === 'string' &&
data.mimeType.startsWith('video/') &&
typeof data.size === 'number' &&
Number.isFinite(data.size) &&
data.size > 0 &&
typeof data.durationSeconds === 'number' &&
Number.isFinite(data.durationSeconds) &&
data.durationSeconds > 0 &&
typeof data.recordedAt === 'string' &&
typeof data.uploadedAt === 'string' &&
data.provider === 'cloudflare'
);
}
const VIDEO_RECORDING_CODEC: AnswerCodec<VideoRecordingAnswerData> = {
type: 'VIDEO_RECORDING',
toApiPayload(data, questionId) {
const { playbackUrl: _playbackUrl, ...answerData } = data;
return { questionId, answer: JSON.stringify(answerData) };
},
fromApiPayload(payload) {
if (!payload.answer) return null;
try {
const parsed = JSON.parse(payload.answer) as Partial<VideoRecordingAnswerData>;
if (parsed?.type !== 'VIDEO_RECORDING' || !isVideoRecordingAnswer(parsed)) return null;
return {
type: 'VIDEO_RECORDING',
assetId: parsed.assetId,
storageKey: parsed.storageKey,
fileName: parsed.fileName,
mimeType: parsed.mimeType,
size: parsed.size,
durationSeconds: parsed.durationSeconds,
recordedAt: parsed.recordedAt,
uploadedAt: parsed.uploadedAt,
provider: 'cloudflare',
retakeCount: typeof parsed.retakeCount === 'number' ? parsed.retakeCount : undefined
};
} catch {
return null;
}
}
};
export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
@@ -261,7 +318,8 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.ORDERING]: ORDERING_CODEC,
[QUESTION_TYPE_KEY.LINK]: LINK_CODEC,
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC,
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC,
[QUESTION_TYPE_KEY.VIDEO_RECORDING]: VIDEO_RECORDING_CODEC
};
/** Convert any answer to API submission payload */
@@ -320,6 +378,7 @@ export function extractAnswerDisplayValues(data: AnswerData): {
case 'ORDERING':
case 'HOTSPOT':
case 'FILE_UPLOAD':
case 'VIDEO_RECORDING':
return { selectedIds: [], selectedValues: [] };
default:
return { selectedIds: [], selectedValues: [] };
+16 -1
View File
@@ -18,6 +18,20 @@ export type OrderingAnswerData = { type: 'ORDERING'; orderedValues: string[] };
export type LinkAnswerData = { type: 'LINK'; urls: string[] };
export type HotspotAnswerData = { type: 'HOTSPOT'; coordinates: Array<{ x: number; y: number }> };
export type StarAnswerData = { type: 'STAR'; value: number };
export type VideoRecordingAnswerData = {
type: 'VIDEO_RECORDING';
assetId: string;
storageKey: string;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
uploadedAt: string;
provider: 'cloudflare';
retakeCount?: number;
playbackUrl?: string;
};
export type AnswerData =
| RadioAnswerData
@@ -33,4 +47,5 @@ export type AnswerData =
| OrderingAnswerData
| LinkAnswerData
| HotspotAnswerData
| StarAnswerData;
| StarAnswerData
| VideoRecordingAnswerData;
@@ -0,0 +1,21 @@
import type { ExerciseQuestionModel } from './exercise-types';
export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit';
export interface ExerciseSectionAfterBehavior {
action: ExerciseSectionAfterAction;
exerciseSectionId?: string;
}
export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions';
export type ExerciseSectionColorTheme = 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
export interface ExerciseSectionModel {
id?: string;
title: string;
description?: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
questions: ExerciseQuestionModel[];
}
+49 -2
View File
@@ -1,5 +1,5 @@
import type { QuestionTypeKey } from './question-type-keys';
import type { AnswerData } from './answer-data';
import type { AnswerData, VideoRecordingAnswerData } from './answer-data';
export type ExerciseQuestionTypeKey = QuestionTypeKey;
@@ -105,6 +105,41 @@ export type ExerciseQuestionLabelKey =
| 'file_upload.take.view'
| 'file_upload.preview.accepted_types_label'
| 'file_upload.preview.max_size_label'
| 'video_recording.edit.max_duration_label'
| 'video_recording.edit.max_duration_helper'
| 'video_recording.edit.max_duration_placeholder'
| 'video_recording.edit.manual_grading'
| 'video_recording.take.unsupported'
| 'video_recording.take.permission_prompt'
| 'video_recording.take.permission_denied'
| 'video_recording.take.start_camera'
| 'video_recording.take.camera_label'
| 'video_recording.take.camera_placeholder'
| 'video_recording.take.play_preview'
| 'video_recording.take.pause_preview'
| 'video_recording.take.start_recording'
| 'video_recording.take.stop_recording'
| 'video_recording.take.elapsed'
| 'video_recording.take.remaining'
| 'video_recording.take.max_duration'
| 'video_recording.take.too_short'
| 'video_recording.take.use_recording'
| 'video_recording.take.retake'
| 'video_recording.take.delete'
| 'video_recording.take.retry_upload'
| 'video_recording.take.uploading'
| 'video_recording.take.uploaded'
| 'video_recording.take.upload_failed'
| 'video_recording.take.replaces_previous'
| 'video_recording.preview.max_duration_label'
| 'video_recording.preview.manual_grading'
| 'video_recording.submission.no_video'
| 'video_recording.submission.unavailable'
| 'video_recording.submission.duration'
| 'video_recording.submission.file_size'
| 'video_recording.submission.recorded_at'
| 'video_recording.submission.uploaded_at'
| 'video_recording.submission.retake_count'
| 'link.edit.instructions_placeholder'
| 'link.edit.helper'
| 'link.take.helper'
@@ -195,6 +230,17 @@ export type ExerciseQuestionFileUploader = (
file: File
) => Promise<{ fileKey: string; fileName: string; fileUrl?: string }>;
export type ExerciseQuestionVideoRecordingUploader = (input: {
questionId: number | string;
blob: Blob;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
retakeCount: number;
}) => Promise<VideoRecordingAnswerData>;
export interface ExerciseRendererDefinition<TRenderer = unknown> {
edit: TRenderer;
take: TRenderer;
@@ -211,8 +257,9 @@ export interface ExerciseQuestionRendererProps {
maxSubmissionItems?: number;
disabled?: boolean;
labels?: ExerciseQuestionLabels;
onAnswerChange?: (answer: AnswerData) => void;
onAnswerChange?: (answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionModel) => void;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
+2
View File
@@ -8,6 +8,7 @@ export * from './question-scoring-rules';
export * from './question-scoring';
export * from './exercise-types';
export * from './exercise-section-types';
export * from './render-contract';
export * from './form-state';
export * from './answer-data';
@@ -15,3 +16,4 @@ export * from './answer-codecs';
export * from './renderer-registry';
export * from './textarea-validation';
export * from './star-rating-settings';
export * from './video-recording-settings';
@@ -14,7 +14,8 @@ export type QuestionAnswerPayload =
| OrderingAnswerPayload
| HotspotAnswerPayload
| LinkAnswerPayload
| StarAnswerPayload;
| StarAnswerPayload
| VideoRecordingAnswerPayload;
interface BaseAnswerPayload {
type: QuestionTypeKey;
@@ -92,3 +93,17 @@ export interface StarAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.STAR;
value: number;
}
export interface VideoRecordingAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.VIDEO_RECORDING;
assetId: string;
storageKey: string;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
uploadedAt: string;
provider: 'cloudflare';
retakeCount?: number;
}
@@ -1,5 +1,6 @@
import type { QuestionTypeKey } from './question-type-keys';
import { getQuestionTypeByKey } from './question-type-lookup';
import { QUESTION_TYPE_REGISTRY } from './question-type-registry';
export function isAutoGradableQuestionType(key: QuestionTypeKey): boolean {
return getQuestionTypeByKey(key).autoGradable;
@@ -12,3 +13,13 @@ export function supportsPartialCredit(key: QuestionTypeKey): boolean {
export function requiresManualGrading(key: QuestionTypeKey): boolean {
return getQuestionTypeByKey(key).manualGradingRequired;
}
/** Numeric ids of every auto-gradable question type, pulled from the registry. */
export const AUTO_GRADABLE_QUESTION_TYPE_IDS: readonly number[] = QUESTION_TYPE_REGISTRY.filter(
(entry) => entry.autoGradable
).map((entry) => entry.id);
/** True when the numeric `questionTypeId` refers to an auto-gradable type. */
export function isAutoGradableQuestionTypeId(questionTypeId: number): boolean {
return AUTO_GRADABLE_QUESTION_TYPE_IDS.includes(questionTypeId);
}
@@ -12,7 +12,8 @@ export const QUESTION_TYPE_KEY = {
HOTSPOT: 'HOTSPOT',
LINK: 'LINK',
WORD_BANK: 'WORD_BANK',
STAR: 'STAR'
STAR: 'STAR',
VIDEO_RECORDING: 'VIDEO_RECORDING'
} as const;
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
@@ -127,6 +127,15 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
autoGradable: true,
supportsPartialCredit: false,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.VIDEO_RECORDING,
typename: 'VIDEO_RECORDING',
label: 'Video recording',
id: 15,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: true
}
] as const;
@@ -1,6 +1,7 @@
import type {
ExerciseQuestionFileUploader,
ExerciseQuestionImageUploader,
ExerciseQuestionVideoRecordingUploader,
ExerciseQuestionLabelKey,
ExerciseQuestionLabels,
ExerciseQuestionModel,
@@ -19,6 +20,7 @@ export interface ExerciseQuestionRenderContract {
labels?: ExerciseQuestionLabels;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
export interface ExerciseQuestionListRenderContract {
@@ -31,6 +33,7 @@ export interface ExerciseQuestionListRenderContract {
labels?: ExerciseQuestionLabels;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
export function getExerciseQuestionLabel(
@@ -0,0 +1,14 @@
export const VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS = 60;
export const VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS = 120;
export const VIDEO_RECORDING_MIN_DURATION_SECONDS = 1;
export function getVideoRecordingMaxDurationSeconds(settings?: Record<string, unknown> | null): number {
const rawDuration = settings?.maxDurationSeconds;
const duration = typeof rawDuration === 'number' ? rawDuration : rawDuration != null ? Number(rawDuration) : NaN;
if (!Number.isFinite(duration) || duration <= 0) {
return VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS;
}
return Math.min(Math.round(duration), VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS);
}