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
@@ -12,10 +12,12 @@
NUMERIC_FIXTURE,
ORDERING_FIXTURE,
RADIO_FIXTURE,
SECTIONED_EXERCISE_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE,
VIDEO_RECORDING_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
@@ -27,6 +29,16 @@
},
tags: ['autodocs']
});
const sectionLabels = { section: 'Section', questions: 'questions', points: 'points' };
const sectionOverviewLabels = { ...sectionLabels, back: 'Back', beginSection: 'Start section' };
const autoGroupedSection = {
...SECTIONED_EXERCISE_FIXTURE[0],
id: 'section-auto-grouped',
title: 'Section 1',
description: null,
colorTheme: 'blue' as const
};
</script>
<Story name="Checkbox">
@@ -39,6 +51,129 @@
{/snippet}
</Story>
<Story name="Exercise Sections - Section Intro">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={SECTIONED_EXERCISE_FIXTURE[0].title}
sectionDescription={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Auto Grouped Existing Questions">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={autoGroupedSection.title}
sectionDescription={autoGroupedSection.description}
sectionNumber={1}
totalSections={2}
questionCount={autoGroupedSection.questions.length}
totalPoints={10}
colorTheme={autoGroupedSection.colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Question In Progress">
{#snippet template()}
<div class="ui:grid ui:max-w-5xl ui:gap-6 ui:lg:grid-cols-[190px_minmax(0,1fr)]">
<ExerciseQuestion.SectionNavigationSidebar
sections={SECTIONED_EXERCISE_FIXTURE.map((section, sectionIndex) => ({
id: section.id,
title: section.title,
isCurrent: sectionIndex === 0,
questions: section.questions.map((question, questionIndex) => ({
key: String(question.id),
label: `Q${questionIndex + 1}`,
isAnswered: questionIndex === 0,
isCurrent: sectionIndex === 0 && questionIndex === 1
}))
}))}
/>
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={SECTIONED_EXERCISE_FIXTURE[0].title}
description={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: SECTIONED_EXERCISE_FIXTURE[0].questions[1],
answer: TRUE_FALSE_FIXTURE.answer,
disabled: false
}}
/>
</section>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Grading View">
{#snippet template()}
<div class="ui:max-w-4xl ui:space-y-8">
{#each SECTIONED_EXERCISE_FIXTURE as section, sectionIndex (section.id)}
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={section.colorTheme}
questionCount={section.questions.length}
totalPoints={section.questions.length * 5}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: 'review',
questions: section.questions.map((question, questionIndex) => ({
...question,
title: `${questionIndex + 1}. ${question.title}`
})),
answersByKey: {
'q-radio': RADIO_FIXTURE.answer,
'q-true-false': TRUE_FALSE_FIXTURE.answer,
'q-short-answer': SHORT_ANSWER_FIXTURE.answer,
'q-ordering': ORDERING_FIXTURE.answer
},
disabled: true
}}
itemClass="ui:mb-4"
/>
<div class="ui:flex ui:justify-end ui:border-t ui:pt-3 ui:text-sm ui:font-medium">
Section subtotal: {section.questions.length * 4}/{section.questions.length * 5}
</div>
</section>
{/each}
</div>
{/snippet}
</Story>
<Story name="FileUpload">
{#snippet template()}
<QuestionTypeModes
@@ -49,6 +184,26 @@
{/snippet}
</Story>
<Story
name="VideoRecording"
parameters={{
docs: {
description: {
story:
'Browser-recorded video answer with a circular student recording UI, upload states, retakes, and manual grading metadata.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={VIDEO_RECORDING_FIXTURE.question}
answer={VIDEO_RECORDING_FIXTURE.answer}
viewAnswer={null}
/>
{/snippet}
</Story>
<Story name="FillBlank">
{#snippet template()}
<QuestionTypeModes
@@ -6,6 +6,15 @@ export interface QuestionStoryFixture {
wrongAnswer?: unknown;
}
export interface ExerciseSectionStoryFixture {
id: string;
title: string;
description?: string;
order: number;
colorTheme: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
questions: Record<string, unknown>[];
}
const OPTION_IMAGE_A = 'https://brand.cdn.clsrio.com/classroomio-lms-courses.png';
const OPTION_IMAGE_B = 'https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg';
@@ -196,6 +205,34 @@ export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
answer: 'lesson-plan-week-03.pdf'
};
export const VIDEO_RECORDING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-video-recording',
key: 'q-video-recording',
title: 'Record a short reflection on what you learned. [VIDEO_RECORDING]',
questionType: 'VIDEO_RECORDING',
settings: {
description: 'Use the browser camera and microphone. You can retake before submitting.',
maxDurationSeconds: 120,
allowRetakes: true
}
},
answer: {
type: 'VIDEO_RECORDING',
assetId: '00000000-0000-4000-8000-000000000001',
storageKey: 'exercise-recordings/story/video-recording-demo.webm',
fileName: 'video-recording-demo.webm',
mimeType: 'video/webm',
size: 1_204_224,
durationSeconds: 42,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: 2,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
}
};
export const MATCHING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-matching',
@@ -256,3 +293,22 @@ export const LINK_FIXTURE: QuestionStoryFixture = {
},
answer: ['https://classroomio.com/docs', 'https://www.svelte.dev']
};
export const SECTIONED_EXERCISE_FIXTURE: ExerciseSectionStoryFixture[] = [
{
id: 'section-foundations',
title: 'Foundations',
description: 'A short warm-up section before the applied scenario.',
order: 0,
colorTheme: 'blue',
questions: [RADIO_FIXTURE.question, TRUE_FALSE_FIXTURE.question]
},
{
id: 'section-application',
title: 'Applied Practice',
description: 'Students solve open-response and ordering tasks.',
order: 1,
colorTheme: 'green',
questions: [SHORT_ANSWER_FIXTURE.question, ORDERING_FIXTURE.question]
}
];
@@ -105,6 +105,41 @@ export const QUESTION_LABELS = {
'file_upload.take.download': 'Download',
'file_upload.preview.accepted_types_label': 'Accepted types',
'file_upload.preview.max_size_label': 'Max size (MB)',
'video_recording.edit.max_duration_label': 'Max duration',
'video_recording.edit.max_duration_helper': 'Students record in the browser and can retake before submitting.',
'video_recording.edit.max_duration_placeholder': '60',
'video_recording.edit.manual_grading': 'Requires manual grading',
'video_recording.take.unsupported': 'Video recording is not supported in this browser.',
'video_recording.take.permission_prompt': 'Allow camera and microphone access to record your answer.',
'video_recording.take.permission_denied': 'Camera or microphone access was blocked. Check browser permissions.',
'video_recording.take.start_camera': 'Set up camera',
'video_recording.take.camera_label': 'Camera',
'video_recording.take.camera_placeholder': 'Select camera',
'video_recording.take.play_preview': 'Play recording preview',
'video_recording.take.pause_preview': 'Pause recording preview',
'video_recording.take.start_recording': 'Start recording',
'video_recording.take.stop_recording': 'Stop recording',
'video_recording.take.elapsed': 'Elapsed',
'video_recording.take.remaining': 'Remaining',
'video_recording.take.max_duration': 'Max',
'video_recording.take.too_short': 'Record for at least 1 second.',
'video_recording.take.use_recording': 'Use recording',
'video_recording.take.retake': 'Retake',
'video_recording.take.delete': 'Delete recording',
'video_recording.take.retry_upload': 'Retry upload',
'video_recording.take.uploading': 'Uploading recording...',
'video_recording.take.uploaded': 'Recording uploaded',
'video_recording.take.upload_failed': 'Upload failed. Try again.',
'video_recording.take.replaces_previous': 'A new accepted take replaces the previous answer after upload.',
'video_recording.preview.max_duration_label': 'Max duration',
'video_recording.preview.manual_grading': 'Learners record a short video response. Manual grading required.',
'video_recording.submission.no_video': 'No video submitted.',
'video_recording.submission.unavailable': 'Video unavailable.',
'video_recording.submission.duration': 'Duration',
'video_recording.submission.file_size': 'File size',
'video_recording.submission.recorded_at': 'Recorded',
'video_recording.submission.uploaded_at': 'Uploaded',
'video_recording.submission.retake_count': 'Retakes',
'link.edit.instructions_placeholder': 'Optional instructions for learners',
'link.edit.helper': 'Learners can submit one or more valid URLs (http/https).',
'link.take.helper': 'Add one or more links. Only valid http/https URLs are accepted.',
@@ -5,6 +5,7 @@
interface Props {
question: Record;
answer?: unknown;
viewAnswer?: unknown;
/** When set, renders an additional Review block with this incorrect `AnswerData`. */
wrongAnswer?: unknown;
}
@@ -17,7 +18,7 @@
settings?: Record<string, unknown>;
};
let { question, answer = null, wrongAnswer = undefined }: Props = $props();
let { question, answer = null, viewAnswer: initialViewAnswer = answer, wrongAnswer = undefined }: Props = $props();
function cloneQuestion(source: Record) {
return JSON.parse(JSON.stringify(source));
@@ -109,6 +110,21 @@
fileName: `lesson-plan-${index + 1}.pdf`,
mimeType: 'application/pdf'
};
case 'VIDEO_RECORDING':
return {
type: 'VIDEO_RECORDING',
assetId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
storageKey: `exercise-recordings/story/submission-${index + 1}.webm`,
fileName: `reflection-${index + 1}.webm`,
mimeType: 'video/webm',
size: 900_000 + index * 25_000,
durationSeconds: 30 + (index % 6) * 5,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: index % 4,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
};
case 'MATCHING': {
const pairs = Array.isArray(storyQuestion.settings?.pairs)
? (storyQuestion.settings?.pairs as Array<{ left: string; right: string }>)
@@ -168,7 +184,7 @@
$effect(() => {
editQuestion = cloneQuestion(question);
viewAnswer = answer;
viewAnswer = initialViewAnswer;
});
</script>
@@ -224,7 +240,7 @@
contract={{
mode: 'review',
question,
answer: viewAnswer,
answer,
labels: QUESTION_LABELS,
disabled: true
}}