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