chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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: [] };
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user