chore: ai, public course and other ui improvements
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Current position — e.g. "3 of 12" — plus a subline — e.g. "Hallucination & limitations". */
|
||||
positionLabel: string;
|
||||
sublineLabel?: string;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onOpenSheet: () => void;
|
||||
/** a11y label for the center button (defaults to "Open course outline"). */
|
||||
openSheetLabel?: string;
|
||||
prevLabel?: string;
|
||||
nextLabel?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
positionLabel,
|
||||
sublineLabel,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
onPrev,
|
||||
onNext,
|
||||
onOpenSheet,
|
||||
openSheetLabel = 'Open course outline',
|
||||
prevLabel = 'Previous lesson',
|
||||
nextLabel = 'Next lesson',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class={cn(
|
||||
'ui:fixed ui:inset-x-0 ui:bottom-0 ui:z-40 ui:flex ui:items-stretch ui:gap-1 ui:border-t ui:border-border ui:bg-background/95 ui:p-1.5 ui:backdrop-blur ui:lg:hidden',
|
||||
className
|
||||
)}
|
||||
aria-label="Lesson navigation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
|
||||
aria-label={prevLabel}
|
||||
disabled={!hasPrev}
|
||||
onclick={onPrev}
|
||||
>
|
||||
<ChevronLeftIcon class="ui:size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:flex-1 ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:px-3 ui:py-2 ui:text-center ui:transition-colors ui:hover:bg-muted"
|
||||
aria-label={openSheetLabel}
|
||||
onclick={onOpenSheet}
|
||||
>
|
||||
<ListIcon class="ui:size-4 ui:text-primary" />
|
||||
<span class="ui:flex ui:min-w-0 ui:flex-col ui:items-start ui:text-left ui:leading-tight">
|
||||
<span class="ui:text-[0.7rem] ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
{positionLabel}
|
||||
</span>
|
||||
{#if sublineLabel}
|
||||
<span class="ui:max-w-[55vw] ui:truncate ui:text-sm ui:font-medium ui:text-foreground">{sublineLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
|
||||
aria-label={nextLabel}
|
||||
disabled={!hasNext}
|
||||
onclick={onNext}
|
||||
>
|
||||
<ChevronRightIcon class="ui:size-5" />
|
||||
</button>
|
||||
</nav>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseCalloutData } from './types';
|
||||
|
||||
interface Props {
|
||||
callout: PublicCourseCalloutData | null;
|
||||
/** "inline" sits below the lesson body; "full" replaces it for locked items. */
|
||||
variant?: 'inline' | 'full';
|
||||
/** Shown when callout is null and variant === 'full'. */
|
||||
lockedFallbackTitle?: string;
|
||||
lockedFallbackDescription?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
callout,
|
||||
variant = 'inline',
|
||||
lockedFallbackTitle = 'This lesson is locked',
|
||||
lockedFallbackDescription = 'The creator hasn’t shared a link for this lesson yet. Check back later.',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const cardBase = 'ui:rounded-xl ui:border ui:border-border ui:bg-card ui:text-card-foreground ui:transition-colors';
|
||||
|
||||
const rootClass = $derived(
|
||||
variant === 'full'
|
||||
? cn(
|
||||
cardBase,
|
||||
'ui:my-10 ui:mx-auto ui:w-full ui:max-w-xl ui:p-8 ui:text-center ui:border-dashed ui:bg-muted/30',
|
||||
className
|
||||
)
|
||||
: cn(cardBase, 'ui:mt-10 ui:p-6', className)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if callout}
|
||||
<aside class={rootClass}>
|
||||
<h3 class={cn('ui:font-semibold ui:text-foreground', variant === 'full' ? 'ui:text-xl' : 'ui:text-base')}>
|
||||
{callout.title}
|
||||
</h3>
|
||||
<p class={cn('ui:mt-2 ui:text-sm ui:text-muted-foreground', variant === 'full' && 'ui:mx-auto ui:max-w-md')}>
|
||||
{callout.description}
|
||||
</p>
|
||||
<a
|
||||
href={callout.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class={cn(
|
||||
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{callout.buttonLabel}
|
||||
<ArrowUpRightIcon class="ui:size-4" />
|
||||
</a>
|
||||
</aside>
|
||||
{:else if variant === 'full'}
|
||||
<aside class={rootClass}>
|
||||
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
|
||||
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -0,0 +1,422 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '../../base/select';
|
||||
import {
|
||||
getExerciseQuestionContractKey,
|
||||
isAutoGradableQuestionTypeId,
|
||||
QUESTION_TYPE_KEY,
|
||||
type AnswerData,
|
||||
type ExerciseQuestionLabels,
|
||||
type ExerciseQuestionModel
|
||||
} from '@cio/question-types';
|
||||
import { untrack } from 'svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { QuestionList } from '../exercise-question';
|
||||
import { cn } from '../../tools';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
||||
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
|
||||
|
||||
interface Props {
|
||||
exercise: PublicExerciseViewData;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
labels?: ExerciseQuestionLabels;
|
||||
/** When set, completed attempts persist and hydrate from `localStorage`. */
|
||||
attemptsPersistenceKey?: string | null;
|
||||
formatAttemptOption?: (args: { attemptNumber: number; correct: number; total: number }) => string;
|
||||
newAttemptOptionLabel?: string;
|
||||
attemptsSelectAriaLabel?: string;
|
||||
submitLabel?: string;
|
||||
tryAgainLabel?: string;
|
||||
privacyHint?: string;
|
||||
summaryTemplate?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
exercise,
|
||||
callout = null,
|
||||
labels,
|
||||
attemptsPersistenceKey = null,
|
||||
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
|
||||
newAttemptOptionLabel = 'Practice again',
|
||||
attemptsSelectAriaLabel = 'Your attempts',
|
||||
submitLabel = 'Check my answers',
|
||||
tryAgainLabel = 'Practice again',
|
||||
privacyHint = 'Graded instantly in your browser — nothing is submitted.',
|
||||
summaryTemplate = 'You got [[correct]] / [[total]] correct.',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
|
||||
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
|
||||
let selectValue = $state<string>('live');
|
||||
let answers = $state<Record<string, AnswerData>>({});
|
||||
let submitted = $state(false);
|
||||
|
||||
const mode = $derived<'take' | 'review'>(submitted ? 'review' : 'take');
|
||||
|
||||
$effect(() => {
|
||||
attemptsPersistenceKey;
|
||||
untrack(() => {
|
||||
persistedAttempts = readPublicExerciseAttempts(attemptsPersistenceKey);
|
||||
const rows = persistedAttempts;
|
||||
if (rows.length === 0) {
|
||||
selectValue = 'live';
|
||||
answers = {};
|
||||
submitted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultIndex = rows.length - 1;
|
||||
selectValue = String(defaultIndex);
|
||||
const row = rows[defaultIndex];
|
||||
answers = structuredClone(row.answersByKey);
|
||||
submitted = true;
|
||||
});
|
||||
});
|
||||
|
||||
function handleAttemptSelect(selected: string) {
|
||||
if (selected === 'live') {
|
||||
selectValue = 'live';
|
||||
answers = {};
|
||||
submitted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = Number.parseInt(selected, 10);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= persistedAttempts.length) return;
|
||||
|
||||
selectValue = selected;
|
||||
submitted = true;
|
||||
answers = structuredClone(persistedAttempts[idx].answersByKey);
|
||||
}
|
||||
|
||||
function handleAnswerChange(question: ExerciseQuestionModel, answer: AnswerData | null) {
|
||||
if (submitted) return;
|
||||
|
||||
const key = getExerciseQuestionContractKey(question);
|
||||
if (answer === null) {
|
||||
const next = { ...answers };
|
||||
delete next[key];
|
||||
answers = next;
|
||||
return;
|
||||
}
|
||||
|
||||
answers = { ...answers, [key]: answer };
|
||||
}
|
||||
|
||||
function hasProvidedAnswer(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean {
|
||||
if (!answer) return false;
|
||||
|
||||
switch (question.questionType) {
|
||||
case QUESTION_TYPE_KEY.RADIO:
|
||||
return answer.type === 'RADIO' && answer.optionId != null;
|
||||
case QUESTION_TYPE_KEY.CHECKBOX:
|
||||
return answer.type === 'CHECKBOX' && answer.optionIds.length > 0;
|
||||
case QUESTION_TYPE_KEY.TRUE_FALSE:
|
||||
return answer.type === 'TRUE_FALSE';
|
||||
case QUESTION_TYPE_KEY.TEXTAREA:
|
||||
return answer.type === 'TEXTAREA' && answer.text.trim().length > 0;
|
||||
case QUESTION_TYPE_KEY.SHORT_ANSWER:
|
||||
return answer.type === 'SHORT_ANSWER' && answer.text.trim().length > 0;
|
||||
case QUESTION_TYPE_KEY.NUMERIC:
|
||||
return answer.type === 'NUMERIC' && Number.isFinite(answer.value);
|
||||
case QUESTION_TYPE_KEY.FILL_BLANK: {
|
||||
if (answer.type !== 'FILL_BLANK') return false;
|
||||
const blankSlots = (question.options ?? []).filter((option) => option.isCorrect !== false).length;
|
||||
if (blankSlots === 0) return false;
|
||||
|
||||
return (
|
||||
answer.values.length >= blankSlots &&
|
||||
answer.values.slice(0, blankSlots).every((value) => typeof value === 'string' && value.trim().length > 0)
|
||||
);
|
||||
}
|
||||
case QUESTION_TYPE_KEY.ORDERING: {
|
||||
if (answer.type !== 'ORDERING') return false;
|
||||
const expectedLength = (question.options ?? [])
|
||||
.map((option) => String(option.value ?? option.label ?? ''))
|
||||
.filter(Boolean).length;
|
||||
|
||||
return (
|
||||
expectedLength > 0 &&
|
||||
answer.orderedValues.length === expectedLength &&
|
||||
answer.orderedValues.every((value) => typeof value === 'string' && value.length > 0)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitAnswers = $derived(
|
||||
exercise.questions.length > 0 &&
|
||||
exercise.questions.every((question, index) =>
|
||||
hasProvidedAnswer(question, answers[getExerciseQuestionContractKey(question, index)])
|
||||
)
|
||||
);
|
||||
|
||||
function isAnswerCorrect(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean | null {
|
||||
if (!answer) return false;
|
||||
|
||||
switch (question.questionType) {
|
||||
case QUESTION_TYPE_KEY.RADIO: {
|
||||
if (answer.type !== 'RADIO') return false;
|
||||
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
||||
if (!correctOption?.id) return false;
|
||||
return String(correctOption.id) === String(answer.optionId);
|
||||
}
|
||||
case QUESTION_TYPE_KEY.CHECKBOX: {
|
||||
if (answer.type !== 'CHECKBOX') return false;
|
||||
const correctIds = new Set(
|
||||
(question.options ?? [])
|
||||
.filter((option) => option.isCorrect && option.id !== undefined)
|
||||
.map((option) => String(option.id))
|
||||
);
|
||||
const selected = new Set(answer.optionIds.map(String));
|
||||
if (selected.size !== correctIds.size) return false;
|
||||
for (const id of correctIds) {
|
||||
if (!selected.has(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case QUESTION_TYPE_KEY.TRUE_FALSE: {
|
||||
if (answer.type !== 'TRUE_FALSE') return false;
|
||||
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
||||
const correctValue = /true/i.test(String(correctOption?.label ?? correctOption?.value ?? ''));
|
||||
return answer.value === correctValue;
|
||||
}
|
||||
case QUESTION_TYPE_KEY.SHORT_ANSWER: {
|
||||
if (answer.type !== 'SHORT_ANSWER') return false;
|
||||
const caseSensitive = Boolean((question.settings as { caseSensitive?: boolean } | undefined)?.caseSensitive);
|
||||
const accepted = (question.options ?? [])
|
||||
.filter((option) => option.isCorrect !== false)
|
||||
.map((option) => String(option.label ?? option.value ?? '').trim());
|
||||
const submittedValue = answer.text.trim();
|
||||
if (!submittedValue || accepted.length === 0) return false;
|
||||
return accepted.some((expected) => {
|
||||
if (caseSensitive) return expected === submittedValue;
|
||||
return expected.toLowerCase() === submittedValue.toLowerCase();
|
||||
});
|
||||
}
|
||||
case QUESTION_TYPE_KEY.NUMERIC: {
|
||||
if (answer.type !== 'NUMERIC') return false;
|
||||
const settings = (question.settings ?? {}) as { correctValue?: unknown; tolerance?: unknown };
|
||||
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
||||
const correctValue = Number(correctOption?.label ?? correctOption?.value ?? settings.correctValue ?? NaN);
|
||||
const tolerance = Number(settings.tolerance ?? 0);
|
||||
if (!Number.isFinite(correctValue)) return null;
|
||||
return Math.abs(answer.value - correctValue) <= (Number.isFinite(tolerance) ? tolerance : 0);
|
||||
}
|
||||
case QUESTION_TYPE_KEY.FILL_BLANK: {
|
||||
if (answer.type !== 'FILL_BLANK') return false;
|
||||
const accepted = (question.options ?? [])
|
||||
.filter((option) => option.isCorrect !== false)
|
||||
.map((option) =>
|
||||
String(option.label ?? option.value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
if (accepted.length === 0) return null;
|
||||
return answer.values.every((value, index) => {
|
||||
const expected = accepted[index];
|
||||
if (expected === undefined) return false;
|
||||
return value.trim().toLowerCase() === expected;
|
||||
});
|
||||
}
|
||||
case QUESTION_TYPE_KEY.ORDERING: {
|
||||
if (answer.type !== 'ORDERING') return false;
|
||||
const expected = (question.options ?? [])
|
||||
.map((option) => String(option.value ?? option.label ?? ''))
|
||||
.filter(Boolean);
|
||||
if (expected.length === 0) return null;
|
||||
return (
|
||||
answer.orderedValues.length === expected.length &&
|
||||
answer.orderedValues.every((value, index) => value === expected[index])
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const totalGradable = $derived(
|
||||
exercise.questions.reduce((count, question) => {
|
||||
const typeId = (question as ExerciseQuestionModel & { questionTypeId?: number }).questionTypeId;
|
||||
const isAutoGradable = typeof typeId === 'number' ? isAutoGradableQuestionTypeId(typeId) : true;
|
||||
return isAutoGradable ? count + 1 : count;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
const correctCountComputed = $derived(
|
||||
submitted
|
||||
? exercise.questions.reduce((count, question) => {
|
||||
const key = getExerciseQuestionContractKey(question);
|
||||
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
|
||||
}, 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
/** When reviewing a stored snapshot, scores are pinned to saved numbers (handles changed rubrics later). */
|
||||
const displayCorrectCount = $derived.by(() => {
|
||||
if (!submitted) return correctCountComputed;
|
||||
const idx =
|
||||
selectValue !== 'live'
|
||||
? Number.parseInt(selectValue, 10)
|
||||
: persistedAttempts.length > 0
|
||||
? persistedAttempts.length - 1
|
||||
: -1;
|
||||
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
|
||||
return persistedAttempts[idx].correctCount;
|
||||
}
|
||||
return correctCountComputed;
|
||||
});
|
||||
|
||||
const displayTotalGradable = $derived.by(() => {
|
||||
if (!submitted) return totalGradable;
|
||||
const idx =
|
||||
selectValue !== 'live'
|
||||
? Number.parseInt(selectValue, 10)
|
||||
: persistedAttempts.length > 0
|
||||
? persistedAttempts.length - 1
|
||||
: -1;
|
||||
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
|
||||
return persistedAttempts[idx].totalGradable;
|
||||
}
|
||||
return totalGradable;
|
||||
});
|
||||
|
||||
const summaryText = $derived(
|
||||
summaryTemplate
|
||||
.replace('[[correct]]', String(displayCorrectCount))
|
||||
.replace('[[total]]', String(displayTotalGradable))
|
||||
);
|
||||
|
||||
const showAttemptsPicker = $derived(
|
||||
!!attemptsPersistenceKey && persistedAttempts.length > 0 && exercise.questions.length > 0 && exercise.isUnlocked
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmitAnswers) return;
|
||||
|
||||
submitted = true;
|
||||
|
||||
if (!attemptsPersistenceKey) return;
|
||||
|
||||
const correct = exercise.questions.reduce((count, question) => {
|
||||
const key = getExerciseQuestionContractKey(question);
|
||||
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
|
||||
}, 0);
|
||||
const tot = totalGradable;
|
||||
|
||||
const snapshot: PublicExerciseStoredAttempt = {
|
||||
savedAt: new Date().toISOString(),
|
||||
answersByKey: structuredClone(answers),
|
||||
correctCount: correct,
|
||||
totalGradable: tot
|
||||
};
|
||||
|
||||
persistedAttempts = [...persistedAttempts, snapshot];
|
||||
writePublicExerciseAttempts(attemptsPersistenceKey, persistedAttempts);
|
||||
selectValue = String(persistedAttempts.length - 1);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
selectValue = 'live';
|
||||
answers = {};
|
||||
submitted = false;
|
||||
}
|
||||
|
||||
/** Trigger text when user is reviewing a frozen attempt vs starting fresh. */
|
||||
const tryAgainButtonLabel = $derived(
|
||||
showAttemptsPicker && selectValue !== 'live' ? newAttemptOptionLabel : tryAgainLabel
|
||||
);
|
||||
</script>
|
||||
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !exercise.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
{:else}
|
||||
{#if exercise.sectionTitle}
|
||||
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
{exercise.sectionTitle}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAttemptsPicker}
|
||||
<div class="ui:mt-4 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
||||
<span class="ui:sr-only" id="public-exercise-attempts-select-label">{attemptsSelectAriaLabel}</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectValue}
|
||||
onValueChange={(nextValue) => nextValue && handleAttemptSelect(nextValue)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="ui:min-w-[min(24rem,calc(100vw-5rem))] ui:max-w-full"
|
||||
aria-labelledby="public-exercise-attempts-select-label"
|
||||
>
|
||||
<span class="ui:truncate">
|
||||
{selectValue === 'live'
|
||||
? newAttemptOptionLabel
|
||||
: formatAttemptOption({
|
||||
attemptNumber: Number(selectValue) + 1,
|
||||
correct: persistedAttempts[Number(selectValue)]?.correctCount ?? 0,
|
||||
total: persistedAttempts[Number(selectValue)]?.totalGradable ?? totalGradable
|
||||
})}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each persistedAttempts as _, attemptIdx (attemptIdx)}
|
||||
{@const stored = persistedAttempts[attemptIdx]}
|
||||
{@const attemptLabel = formatAttemptOption({
|
||||
attemptNumber: attemptIdx + 1,
|
||||
correct: stored.correctCount,
|
||||
total: stored.totalGradable
|
||||
})}
|
||||
<Select.Item value={String(attemptIdx)} label={attemptLabel}>{attemptLabel}</Select.Item>
|
||||
{/each}
|
||||
<Select.Item value="live" label={newAttemptOptionLabel}>{newAttemptOptionLabel}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1
|
||||
class={cn(
|
||||
'ui:text-2xl ui:tracking-tight ui:text-foreground ui:sm:text-3xl',
|
||||
showAttemptsPicker ? 'ui:mt-4' : 'ui:mt-2'
|
||||
)}
|
||||
>
|
||||
{exercise.title}
|
||||
</h1>
|
||||
|
||||
{#if exercise.description}
|
||||
<p class="ui:mt-3 ui:text-muted-foreground">{exercise.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="ui:mt-8 ui:space-y-6">
|
||||
<QuestionList
|
||||
contract={{
|
||||
mode,
|
||||
questions: exercise.questions,
|
||||
answersByKey: answers,
|
||||
disabled: submitted,
|
||||
labels
|
||||
}}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui:mt-10 ui:flex ui:items-center ui:justify-between ui:gap-4">
|
||||
{#if submitted}
|
||||
<div class="ui:text-sm ui:text-muted-foreground">{summaryText}</div>
|
||||
<Button variant="outline" onclick={handleReset}>{tryAgainButtonLabel}</Button>
|
||||
{:else}
|
||||
<span class="ui:text-sm ui:text-muted-foreground">{privacyHint}</span>
|
||||
<Button disabled={!canSubmitAnswers} onclick={handleSubmit}>{submitLabel}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseSidebarItem } from './types';
|
||||
|
||||
interface Props {
|
||||
/** Previous item (or `null` if on the first item). */
|
||||
prev?: PublicCourseSidebarItem | null;
|
||||
/** Next item (or `null` if on the last item). */
|
||||
next?: PublicCourseSidebarItem | null;
|
||||
/** Builds the href for a target item. Falls back to plain buttons if omitted. */
|
||||
hrefFor?: (item: PublicCourseSidebarItem) => string;
|
||||
/** Click handler — used when `hrefFor` is omitted, or for SPA navigation alongside `href`. */
|
||||
onNavigate?: (item: PublicCourseSidebarItem) => void;
|
||||
/** Localized labels for the prev / next captions. */
|
||||
prevLabel?: string;
|
||||
nextLabel?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
prev = null,
|
||||
next = null,
|
||||
hrefFor,
|
||||
onNavigate,
|
||||
prevLabel = 'Previous',
|
||||
nextLabel = 'Next',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const cardClass =
|
||||
'ui:group ui:flex ui:flex-1 ui:items-center ui:gap-3 ui:rounded-lg ui:border ui:border-border ui:bg-background ui:px-4 ui:py-3 ui:text-left ui:transition-colors ui:hover:border-primary/40 ui:hover:bg-muted/50';
|
||||
|
||||
const prevHref = $derived(prev ? hrefFor?.(prev) : undefined);
|
||||
const nextHref = $derived(next ? hrefFor?.(next) : undefined);
|
||||
</script>
|
||||
|
||||
{#snippet prevBody(item: PublicCourseSidebarItem, label: string)}
|
||||
<ArrowLeftIcon class="ui:size-4 ui:text-muted-foreground ui:group-hover:text-primary" />
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-col">
|
||||
<span class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span class="ui:truncate ui:text-sm ui:font-medium ui:text-foreground ui:group-hover:text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet nextBody(item: PublicCourseSidebarItem, label: string)}
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-col ui:items-end">
|
||||
<span class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span class="ui:truncate ui:text-sm ui:font-medium ui:text-foreground ui:group-hover:text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRightIcon class="ui:size-4 ui:text-muted-foreground ui:group-hover:text-primary" />
|
||||
{/snippet}
|
||||
|
||||
{#if prev || next}
|
||||
<nav
|
||||
class={cn('ui:mt-12 ui:flex ui:flex-col ui:gap-3 ui:border-t ui:border-border ui:pt-6 ui:sm:flex-row', className)}
|
||||
aria-label="Lesson navigation"
|
||||
>
|
||||
{#if prev}
|
||||
{#if prevHref}
|
||||
<a href={prevHref} class={cardClass} onclick={() => onNavigate?.(prev)}>
|
||||
{@render prevBody(prev, prevLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" class={cardClass} onclick={() => onNavigate?.(prev)}>
|
||||
{@render prevBody(prev, prevLabel)}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ui:flex-1"></div>
|
||||
{/if}
|
||||
|
||||
{#if next}
|
||||
{#if nextHref}
|
||||
<a href={nextHref} class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
|
||||
{@render nextBody(next, nextLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
|
||||
{@render nextBody(next, nextLabel)}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ui:flex-1"></div>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { default as PublicCourseShell } from './shell.svelte';
|
||||
export { default as PublicCourseSidebar } from './sidebar.svelte';
|
||||
export { default as PublicCourseSidebarRow } from './sidebar-row.svelte';
|
||||
export { default as PublicCourseBottomNav } from './bottom-nav.svelte';
|
||||
export { default as PublicCourseFooterNav } from './footer-nav.svelte';
|
||||
export { default as PublicCoursePoweredBy } from './powered-by.svelte';
|
||||
export { default as PublicCourseMobileSheet } from './mobile-sheet.svelte';
|
||||
export { default as PublicLessonView } from './lesson-view.svelte';
|
||||
export { default as PublicExerciseView } from './exercise-view.svelte';
|
||||
export { default as PublicCourseCallout } from './callout.svelte';
|
||||
|
||||
export {
|
||||
PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION,
|
||||
publicExerciseAttemptsStorageKey,
|
||||
readPublicExerciseAttempts,
|
||||
writePublicExerciseAttempts
|
||||
} from './public-exercise-attempts-storage';
|
||||
export type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
|
||||
export type {
|
||||
PublicCourseCalloutData,
|
||||
PublicCourseOrgData,
|
||||
PublicCourseSidebarItem,
|
||||
PublicCourseSidebarSection,
|
||||
PublicCourseItemVariant,
|
||||
PublicLessonViewData,
|
||||
PublicExerciseViewData,
|
||||
PublicItemViewData
|
||||
} from './types';
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import { SafeHtmlContent } from '../safe-html-content';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicLessonViewData } from './types';
|
||||
|
||||
interface Props {
|
||||
lesson: PublicLessonViewData;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { lesson, callout = null, class: className }: Props = $props();
|
||||
|
||||
const isYoutube = $derived(lesson.video?.type === 'youtube');
|
||||
|
||||
function toEmbedUrl(link: string): string {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
if (url.hostname.includes('youtu.be')) {
|
||||
const videoId = url.pathname.replace(/^\//, '');
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
if (url.hostname.includes('youtube.com')) {
|
||||
const videoId = url.searchParams.get('v');
|
||||
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return link;
|
||||
} catch {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Public lesson page. Width / spacing intentionally mirrors the authenticated
|
||||
lesson view (see `apps/dashboard/src/lib/features/course/pages/lesson.svelte`)
|
||||
so creators see the same content layout whether they're logged in or sharing
|
||||
a public link. The `prose` class (no `ui:` prefix) hooks into the dashboard's
|
||||
app.css typography rules.
|
||||
-->
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !lesson.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
{:else}
|
||||
{#if lesson.video}
|
||||
<div class="ui:mb-6 ui:overflow-hidden ui:rounded-xl ui:bg-black">
|
||||
{#if isYoutube}
|
||||
<iframe
|
||||
class="ui:aspect-video ui:w-full"
|
||||
src={toEmbedUrl(lesson.video.link)}
|
||||
title={lesson.title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{:else}
|
||||
<video class="ui:aspect-video ui:w-full" controls playsinline src={lesson.video.link}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lesson.sectionTitle}
|
||||
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
{lesson.sectionTitle}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="ui:mt-2 ui:text-2xl ui:tracking-tight ui:text-foreground ui:sm:text-3xl">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
|
||||
<div class="prose sm:prose-sm ui:mt-8 ui:max-w-none ui:dark:text-white">
|
||||
{#if lesson.body}
|
||||
<SafeHtmlContent content={lesson.body} />
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground">No content yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as Drawer from '../../base/drawer';
|
||||
import Sidebar from './sidebar.svelte';
|
||||
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange?: (next: boolean) => void;
|
||||
sections: PublicCourseSidebarSection[];
|
||||
activeSlug?: string | null;
|
||||
onItemClick?: (item: PublicCourseSidebarItem) => void;
|
||||
hrefFor?: (item: PublicCourseSidebarItem) => string;
|
||||
title?: string;
|
||||
/** Forwarded to Sidebar's powered-by footer. */
|
||||
showPoweredBy?: boolean;
|
||||
courseSlug?: string | null;
|
||||
orgSlug?: string | null;
|
||||
poweredByLabel?: string;
|
||||
poweredByBrand?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onOpenChange,
|
||||
sections,
|
||||
activeSlug = null,
|
||||
onItemClick,
|
||||
hrefFor,
|
||||
title = 'Course outline',
|
||||
showPoweredBy = true,
|
||||
courseSlug = null,
|
||||
orgSlug = null,
|
||||
poweredByLabel = 'Powered by',
|
||||
poweredByBrand = 'ClassroomIO'
|
||||
}: Props = $props();
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
open = next;
|
||||
onOpenChange?.(next);
|
||||
}
|
||||
|
||||
function handleItemClick(item: PublicCourseSidebarItem) {
|
||||
onItemClick?.(item);
|
||||
handleOpenChange(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay class="ui:fixed ui:inset-0 ui:z-50 ui:bg-black/40" />
|
||||
<Drawer.Content
|
||||
class="ui:fixed ui:inset-x-0 ui:bottom-0 ui:z-50 ui:flex ui:max-h-[85vh] ui:flex-col ui:rounded-t-xl ui:border-t ui:border-border ui:bg-background"
|
||||
>
|
||||
<div class="ui:mx-auto ui:mt-2 ui:h-1 ui:w-10 ui:rounded-full ui:bg-muted" aria-hidden="true"></div>
|
||||
<Drawer.Header class="ui:px-5 ui:pt-3 ui:pb-2">
|
||||
<Drawer.Title class="ui:text-sm ui:font-semibold ui:text-foreground">{title}</Drawer.Title>
|
||||
</Drawer.Header>
|
||||
<div class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]">
|
||||
<Sidebar
|
||||
{sections}
|
||||
{activeSlug}
|
||||
onItemClick={handleItemClick}
|
||||
{hrefFor}
|
||||
{showPoweredBy}
|
||||
{courseSlug}
|
||||
{orgSlug}
|
||||
{poweredByLabel}
|
||||
{poweredByBrand}
|
||||
/>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Course slug for attribution (passed as ?utm_source=...&utm_content=...). */
|
||||
courseSlug?: string | null;
|
||||
/** Org siteName (or org name) for attribution. */
|
||||
orgSlug?: string | null;
|
||||
/** Localized label. */
|
||||
label?: string;
|
||||
/** Localized "ClassroomIO" brand label. */
|
||||
brand?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
courseSlug = null,
|
||||
orgSlug = null,
|
||||
label = 'Powered by',
|
||||
brand = 'ClassroomIO',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const href = $derived.by(() => {
|
||||
const url = new URL('https://classroomio.com');
|
||||
|
||||
url.searchParams.set('utm_source', 'public-course');
|
||||
url.searchParams.set('utm_medium', 'powered-by');
|
||||
|
||||
if (orgSlug) url.searchParams.set('utm_campaign', orgSlug);
|
||||
if (courseSlug) url.searchParams.set('utm_content', courseSlug);
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:px-3 ui:flex ui:items-center ui:justify-center ui:gap-1 ui:py-3 ui:text-xs ui:text-muted-foreground ui:w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
|
||||
{label}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
|
||||
>
|
||||
{brand}
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
|
||||
/** Bump when persisted shape changes. */
|
||||
export const PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION = 1;
|
||||
|
||||
export interface PublicExerciseStoredAttempt {
|
||||
/** ISO timestamp when the learner submitted answers. */
|
||||
savedAt: string;
|
||||
answersByKey: Record<string, AnswerData>;
|
||||
correctCount: number;
|
||||
totalGradable: number;
|
||||
}
|
||||
|
||||
interface PersistedEnvelope {
|
||||
v: number;
|
||||
attempts: PublicExerciseStoredAttempt[];
|
||||
}
|
||||
|
||||
/** Namespace localStorage keys — built by the consuming route (`courseSlug` + exercise `itemSlug`). */
|
||||
export function publicExerciseAttemptsStorageKey(courseSlug: string, exerciseSlug: string): string {
|
||||
return `cio-public-exercise-attempts::v${PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION}::${courseSlug}::${exerciseSlug}`;
|
||||
}
|
||||
|
||||
function isStoredAttempt(value: unknown): value is PublicExerciseStoredAttempt {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof row.savedAt === 'string' &&
|
||||
typeof row.answersByKey === 'object' &&
|
||||
row.answersByKey !== null &&
|
||||
typeof row.correctCount === 'number' &&
|
||||
Number.isFinite(row.correctCount) &&
|
||||
typeof row.totalGradable === 'number' &&
|
||||
Number.isFinite(row.totalGradable)
|
||||
);
|
||||
}
|
||||
|
||||
/** Read persisted attempts — returns newest-last order. Empty if missing or malformed. */
|
||||
export function readPublicExerciseAttempts(storageKey: string | null): PublicExerciseStoredAttempt[] {
|
||||
if (!storageKey || typeof localStorage === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const envelope =
|
||||
parsed && typeof parsed === 'object' && 'attempts' in parsed ? (parsed as PersistedEnvelope) : null;
|
||||
|
||||
if (!envelope || !Array.isArray(envelope.attempts)) return [];
|
||||
|
||||
return envelope.attempts.filter(isStoredAttempt);
|
||||
} catch (error) {
|
||||
console.warn('readPublicExerciseAttempts: failed', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writePublicExerciseAttempts(storageKey: string | null, attempts: PublicExerciseStoredAttempt[]): void {
|
||||
if (!storageKey || typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
const envelope: PersistedEnvelope = {
|
||||
v: PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION,
|
||||
attempts
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(envelope));
|
||||
} catch (error) {
|
||||
console.warn('writePublicExerciseAttempts: failed', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import BottomNav from './bottom-nav.svelte';
|
||||
import FooterNav from './footer-nav.svelte';
|
||||
import MobileSheet from './mobile-sheet.svelte';
|
||||
import Sidebar from './sidebar.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseOrgData, PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
|
||||
interface Props {
|
||||
sections: PublicCourseSidebarSection[];
|
||||
activeSlug?: string | null;
|
||||
hrefFor?: (item: PublicCourseSidebarItem) => string;
|
||||
onItemClick?: (item: PublicCourseSidebarItem) => void;
|
||||
/** Current item for the bottom nav display. If null, bottom nav is hidden. */
|
||||
activeItem?: PublicCourseSidebarItem | null;
|
||||
/** Index (0-based) of the active item in the flattened sequence. */
|
||||
activeFlatIndex?: number | null;
|
||||
/** Total number of items in the flattened sequence. */
|
||||
totalItems?: number;
|
||||
/** Prev / next item handlers for the bottom nav AND the in-page footer nav. */
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
/** Previous / next sidebar items, used to render the in-page footer nav after the lesson body. */
|
||||
prevItem?: PublicCourseSidebarItem | null;
|
||||
nextItem?: PublicCourseSidebarItem | null;
|
||||
/** Course title shown after the org name in the top header. */
|
||||
courseTitle: string;
|
||||
/** Owning org / creator for the top-header logo + name. */
|
||||
org?: PublicCourseOrgData | null;
|
||||
/** Where clicking the org logo / name should go. Defaults to the org home (`/`). */
|
||||
homeHref?: string;
|
||||
/** Where the "Explore courses" button links to. */
|
||||
exploreHref?: string;
|
||||
/** Where the "Sign in" button links to. */
|
||||
signInHref?: string;
|
||||
/** Localized labels for the top-right header actions. */
|
||||
exploreLabel?: string;
|
||||
signInLabel?: string;
|
||||
/** Localized labels for the in-page footer nav. */
|
||||
footerPrevLabel?: string;
|
||||
footerNextLabel?: string;
|
||||
/** Course slug (used for the sidebar's powered-by attribution link). */
|
||||
courseSlug?: string | null;
|
||||
/** Localized strings for the sidebar's powered-by footer. */
|
||||
poweredByLabel?: string;
|
||||
poweredByBrand?: string;
|
||||
/** Hide the powered-by footer entirely (e.g. enterprise white-label). */
|
||||
showPoweredBy?: boolean;
|
||||
/** Main content. Typically a PublicLessonView or PublicExerciseView. */
|
||||
children: Snippet;
|
||||
/**
|
||||
* Optional override for the top-right header slot. When provided,
|
||||
* replaces the default Sign in / Explore courses buttons entirely.
|
||||
*/
|
||||
topRight?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
sections,
|
||||
activeSlug = null,
|
||||
hrefFor,
|
||||
onItemClick,
|
||||
activeItem = null,
|
||||
activeFlatIndex = null,
|
||||
totalItems = 0,
|
||||
onPrev,
|
||||
onNext,
|
||||
hasPrev = false,
|
||||
hasNext = false,
|
||||
prevItem = null,
|
||||
nextItem = null,
|
||||
courseTitle,
|
||||
org = null,
|
||||
homeHref = '/',
|
||||
exploreHref = '/courses',
|
||||
signInHref = '/login',
|
||||
exploreLabel = 'Explore courses',
|
||||
signInLabel = 'Sign in',
|
||||
footerPrevLabel = 'Previous',
|
||||
footerNextLabel = 'Next',
|
||||
courseSlug = null,
|
||||
poweredByLabel = 'Powered by',
|
||||
poweredByBrand = 'ClassroomIO',
|
||||
showPoweredBy = true,
|
||||
children,
|
||||
topRight,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
const positionLabel = $derived(
|
||||
activeFlatIndex !== null && totalItems > 0 ? `${activeFlatIndex + 1} / ${totalItems}` : ''
|
||||
);
|
||||
|
||||
const orgInitial = $derived((org?.name?.trim()?.charAt(0) ?? 'C').toUpperCase());
|
||||
|
||||
/** Left/right arrow keys navigate between items when focus is not in an editable element. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
||||
|
||||
if (event.key === 'ArrowLeft' && hasPrev) {
|
||||
event.preventDefault();
|
||||
onPrev?.();
|
||||
} else if (event.key === 'ArrowRight' && hasNext) {
|
||||
event.preventDefault();
|
||||
onNext?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class={cn('ui:min-h-dvh ui:bg-background ui:text-foreground', className)}>
|
||||
<!--
|
||||
Top header. Sticky so it stays visible as guests scroll long lessons.
|
||||
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in]
|
||||
Height intentionally compact (h-12) to maximize content vertical space.
|
||||
-->
|
||||
<header
|
||||
class="ui:sticky ui:top-0 ui:z-30 ui:border-b ui:border-border ui:bg-background/95 ui:backdrop-blur ui:supports-[backdrop-filter]:bg-background/80"
|
||||
>
|
||||
<div class="ui:flex ui:h-12 ui:items-center ui:gap-3 ui:px-4 ui:lg:px-6">
|
||||
<a
|
||||
href={homeHref}
|
||||
class="ui:flex ui:shrink-0 ui:items-center ui:gap-2 ui:rounded-md ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring"
|
||||
>
|
||||
{#if org?.avatarUrl}
|
||||
<img
|
||||
src={org.avatarUrl}
|
||||
alt=""
|
||||
class="ui:size-6 ui:rounded ui:object-cover"
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="ui:flex ui:size-6 ui:items-center ui:justify-center ui:rounded ui:bg-primary/10 ui:text-[11px] ui:font-semibold ui:text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{orgInitial}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ui:truncate ui:text-sm ui:font-medium ui:text-foreground">
|
||||
{org?.name ?? ''}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<span aria-hidden="true" class="ui:text-muted-foreground/60">|</span>
|
||||
|
||||
<span class="ui:min-w-0 ui:flex-1 ui:truncate ui:text-sm ui:text-muted-foreground" title={courseTitle}>
|
||||
{courseTitle}
|
||||
</span>
|
||||
|
||||
<div class="ui:ml-auto ui:flex ui:shrink-0 ui:items-center ui:gap-2">
|
||||
{#if topRight}
|
||||
{@render topRight()}
|
||||
{:else}
|
||||
<Button href={exploreHref} variant="secondary" size="sm" class="ui:hidden ui:sm:inline-flex">
|
||||
{exploreLabel}
|
||||
</Button>
|
||||
<Button href={signInHref} variant="default" size="sm">{signInLabel}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
Layout: sidebar pinned to the far left of the viewport (no centered max-width
|
||||
container around it), main content centered inside its own column. The header
|
||||
above is 3rem (h-12), so sticky offsets are computed against that.
|
||||
-->
|
||||
<div class="ui:flex ui:min-h-[calc(100dvh-3rem)] ui:w-full">
|
||||
<aside
|
||||
class="ui:sticky ui:top-12 ui:hidden ui:h-[calc(100dvh-3rem)] ui:w-72 ui:shrink-0 ui:overflow-y-auto ui:border-r ui:border-border ui:lg:block"
|
||||
aria-label="Course outline sidebar"
|
||||
>
|
||||
<Sidebar
|
||||
{sections}
|
||||
{activeSlug}
|
||||
{hrefFor}
|
||||
{onItemClick}
|
||||
{showPoweredBy}
|
||||
{courseSlug}
|
||||
orgSlug={org?.siteName ?? org?.name ?? null}
|
||||
{poweredByLabel}
|
||||
{poweredByBrand}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="ui:flex ui:min-w-0 ui:flex-1 ui:flex-col ui:pb-24 ui:lg:pb-12">
|
||||
{@render children()}
|
||||
|
||||
{#if prevItem || nextItem}
|
||||
<div class="ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:pb-10 ui:sm:px-6">
|
||||
<FooterNav
|
||||
prev={prevItem}
|
||||
next={nextItem}
|
||||
{hrefFor}
|
||||
onNavigate={(item) => onItemClick?.(item)}
|
||||
prevLabel={footerPrevLabel}
|
||||
nextLabel={footerNextLabel}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if activeItem}
|
||||
<BottomNav
|
||||
positionLabel={positionLabel || 'Outline'}
|
||||
sublineLabel={activeItem.title}
|
||||
{hasPrev}
|
||||
{hasNext}
|
||||
onPrev={() => onPrev?.()}
|
||||
onNext={() => onNext?.()}
|
||||
onOpenSheet={() => (sheetOpen = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<MobileSheet
|
||||
bind:open={sheetOpen}
|
||||
{sections}
|
||||
{activeSlug}
|
||||
{hrefFor}
|
||||
{showPoweredBy}
|
||||
{courseSlug}
|
||||
orgSlug={org?.siteName ?? org?.name ?? null}
|
||||
{poweredByLabel}
|
||||
{poweredByBrand}
|
||||
onItemClick={(item) => {
|
||||
onItemClick?.(item);
|
||||
sheetOpen = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import NumberBadge from '$src/base/number-badge/number-badge.svelte';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
/** 1-indexed number shown in the badge. Resets per section. */
|
||||
number: number;
|
||||
title: string;
|
||||
/** Is this the active (currently-viewed) row? */
|
||||
active?: boolean;
|
||||
/** Locked rows show a lock icon and a muted treatment. */
|
||||
locked?: boolean;
|
||||
/** Link href for the row. If omitted, the row renders as a button (story/testing). */
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { number, title, active = false, locked = false, href, onClick, class: className }: Props = $props();
|
||||
|
||||
const rowClass = $derived(
|
||||
cn(
|
||||
'ui:group ui:flex ui:items-center ui:gap-1.5 ui:py-1 ui:pl-3 ui:pr-4 ui:rounded-sm ui:text-sm ui:leading-snug ui:w-full ui:text-left ui:transition-colors',
|
||||
active && 'ui:bg-transparent',
|
||||
locked && !active && 'ui:text-muted-foreground',
|
||||
className
|
||||
)
|
||||
);
|
||||
|
||||
const titleClass = $derived(
|
||||
cn(
|
||||
'ui:text-wrap ui:transition-colors',
|
||||
'ui:group-hover:text-primary',
|
||||
active ? 'ui:text-primary' : 'ui:text-foreground/70 ui:group-hover:text-foreground',
|
||||
locked && !active && 'ui:text-muted-foreground'
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
<NumberBadge {number} {active} {locked} />
|
||||
<span class={titleClass}>{title}</span>
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} onclick={onClick}>
|
||||
{@render body()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class={rowClass}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-disabled={locked || undefined}
|
||||
onclick={onClick}
|
||||
>
|
||||
{@render body()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import PoweredBy from './powered-by.svelte';
|
||||
import SidebarRow from './sidebar-row.svelte';
|
||||
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
|
||||
interface Props {
|
||||
sections: PublicCourseSidebarSection[];
|
||||
/** Slug of the active item. Matches against `item.slug`. */
|
||||
activeSlug?: string | null;
|
||||
/** Receives the target item on click; components using `hrefFor` should also provide this. */
|
||||
onItemClick?: (item: PublicCourseSidebarItem) => void;
|
||||
/** Builds the `href` for a given item. Omit to render rows as buttons. */
|
||||
hrefFor?: (item: PublicCourseSidebarItem) => string;
|
||||
/**
|
||||
* Show the "Powered by ClassroomIO" footer pinned to the bottom of the sidebar.
|
||||
* Defaults to `true` when paired with `courseSlug` / `orgSlug`.
|
||||
*/
|
||||
showPoweredBy?: boolean;
|
||||
/** Course slug used in the powered-by attribution link. */
|
||||
courseSlug?: string | null;
|
||||
/** Org slug used in the powered-by attribution link. */
|
||||
orgSlug?: string | null;
|
||||
/** Localized labels for the powered-by footer. */
|
||||
poweredByLabel?: string;
|
||||
poweredByBrand?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
sections,
|
||||
activeSlug = null,
|
||||
onItemClick,
|
||||
hrefFor,
|
||||
showPoweredBy = true,
|
||||
courseSlug = null,
|
||||
orgSlug = null,
|
||||
poweredByLabel = 'Powered by',
|
||||
poweredByBrand = 'ClassroomIO',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
|
||||
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
|
||||
{#each sections as section (section.id)}
|
||||
<div class="ui:flex ui:flex-col ui:gap-2">
|
||||
<h2
|
||||
class="ui:px-3 ui:text-xs ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:tracking-wider ui:transition-colors ui:whitespace-nowrap"
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
<div class="ui:flex ui:flex-col">
|
||||
{#each section.items as item, index (item.id)}
|
||||
<SidebarRow
|
||||
number={index + 1}
|
||||
title={item.title}
|
||||
active={item.slug === activeSlug}
|
||||
locked={!item.isUnlocked}
|
||||
href={hrefFor?.(item)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if showPoweredBy}
|
||||
<PoweredBy {courseSlug} {orgSlug} label={poweredByLabel} brand={poweredByBrand} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared types for the public course UI (sidebar, shell, lesson view, exercise view).
|
||||
* Keep these intentionally narrow — the API and the consuming routes normalize into
|
||||
* these shapes before rendering.
|
||||
*
|
||||
* For exercise questions we deliberately reuse `ExerciseQuestionModel` from
|
||||
* `@cio/question-types` so the public exercise view drives the same renderer
|
||||
* pipeline as the authenticated student view (`ExerciseQuestion.QuestionList`).
|
||||
* That way, every supported question type — RADIO, CHECKBOX, TRUE_FALSE,
|
||||
* SHORT_ANSWER, NUMERIC, FILL_BLANK, ORDERING, MATCHING, etc. — picks up the
|
||||
* standard `take` and `review` renderers without us re-implementing them.
|
||||
*/
|
||||
|
||||
import type { ExerciseQuestionLabels, ExerciseQuestionModel } from '@cio/question-types';
|
||||
|
||||
export interface PublicCourseCalloutData {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
}
|
||||
|
||||
/** Org / creator info shown in the top header (logo, name) of public course pages. */
|
||||
export interface PublicCourseOrgData {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface PublicCourseSidebarItem {
|
||||
kind: 'lesson' | 'exercise';
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export interface PublicCourseSidebarSection {
|
||||
id: string;
|
||||
title: string;
|
||||
items: PublicCourseSidebarItem[];
|
||||
}
|
||||
|
||||
export type PublicCourseItemVariant = 'lesson' | 'exercise';
|
||||
|
||||
export interface PublicLessonViewData {
|
||||
kind: 'lesson';
|
||||
title: string;
|
||||
sectionTitle: string | null;
|
||||
body: string;
|
||||
video: {
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
} | null;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export interface PublicExerciseViewData {
|
||||
kind: 'exercise';
|
||||
title: string;
|
||||
description: string | null;
|
||||
sectionTitle: string | null;
|
||||
isUnlocked: boolean;
|
||||
/**
|
||||
* Questions in the standard `ExerciseQuestionModel` shape. Each question's
|
||||
* `options[].isCorrect` and `settings` carry the answer keys used for
|
||||
* client-side grading in PUBLIC courses.
|
||||
*/
|
||||
questions: ExerciseQuestionModel[];
|
||||
}
|
||||
|
||||
export type { ExerciseQuestionLabels };
|
||||
|
||||
export type PublicItemViewData = PublicLessonViewData | PublicExerciseViewData;
|
||||
Reference in New Issue
Block a user