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
@@ -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;