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
+1 -1
View File
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card"
class={cn(
'ui:bg-card ui:text-card-foreground ui:flex ui:flex-col ui:gap-6 ui:rounded-xl ui:border ui:py-6 ui:shadow-sm',
'ui:bg-card ui:text-card-foreground ui:flex ui:flex-col ui:gap-6 ui:rounded-lg ui:border ui:py-6',
className
)}
{...restProps}
@@ -0,0 +1 @@
export { default as NumberBadge } from './number-badge.svelte';
@@ -0,0 +1,36 @@
<script lang="ts">
import LockIcon from '@lucide/svelte/icons/lock';
import { cn } from '../../tools';
interface Props {
/** 1-indexed value shown when not `locked`. */
number: number;
/** When true, uses primary styling (e.g. active sidebar row or current question). */
active?: boolean;
/** Locked badges show a lock icon and muted treatment. */
locked?: boolean;
class?: string;
}
let { number, active = false, locked = false, class: className }: Props = $props();
const badgeClass = $derived(
cn(
'ui:inline-flex ui:items-center ui:justify-center ui:shrink-0',
'ui:size-7 ui:rounded-md ui:text-xs ui:font-medium ui:tabular-nums ui:tracking-tight',
'ui:transition-colors',
active
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:border ui:bg-muted ui:text-muted-foreground ui:group-hover:bg-muted-foreground/15 ui:group-hover:text-foreground',
className
)
);
</script>
<span class={badgeClass} aria-hidden="true">
{#if locked}
<LockIcon class="ui:size-3.5" />
{:else}
{number}
{/if}
</span>
+1 -1
View File
@@ -18,7 +18,7 @@
data-slot="page-header"
class={cn(
'ui:flex ui:my-4 ui:py-2 ui:flex-col',
isSticky && 'ui:sticky ui:top-13 ui:z-10 ui:bg-background',
isSticky && 'ui:sticky ui:top-14 ui:z-10 ui:bg-background',
className
)}
{...restProps}
@@ -15,7 +15,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'ui:border-input ui:placeholder:text-muted-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:dark:bg-input/30 ui:field-sizing-content ui:shadow-xs ui:flex ui:min-h-16 ui:w-full ui:rounded-md ui:border ui:bg-transparent ui:px-3 ui:py-2 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm',
'ui:border-input ui:placeholder:text-muted-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:dark:bg-input/30 ui:field-sizing-content ui:flex ui:min-h-16 ui:w-full ui:rounded-md ui:border ui:bg-transparent ui:px-3 ui:py-2 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm',
className
)}
bind:value
@@ -2,7 +2,7 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const toggleGroupItemVariants = tv({
base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-accent ui:data-[state=on]:text-accent-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4",
base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-primary ui:data-[state=on]:text-primary-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4",
variants: {
variant: {
default: 'ui:bg-transparent',
@@ -35,7 +35,7 @@
});
</script>
<AnimatePresence let:item list={[{ key: id }]}>
<AnimatePresence let:_item list={[{ key: id }]}>
<Motion
initial="hidden"
animate={isInView}
@@ -0,0 +1,287 @@
<script module lang="ts">
const TWO_PI = Math.PI * 2;
/** Resolves `color-mix(in srgb, var(--primary) …)` for canvas dot fills (inherits `--primary` from this component’s DOM subtree). */
function primaryMixOnRoot(el: HTMLElement, primaryPercent: number): string {
const probe = document.createElement('span');
probe.setAttribute('aria-hidden', 'true');
probe.style.cssText =
'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;' +
`color:var(--primary)`;
el.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved || 'rgba(128, 128, 128, 0.25)';
}
type Dot = {
ax: number;
ay: number;
sx: number;
sy: number;
vx: number;
vy: number;
x: number;
y: number;
};
</script>
<script lang="ts">
import { cn } from '../../../tools';
type Props = {
dotRadius?: number;
dotSpacing?: number;
cursorRadius?: number;
cursorForce?: number;
bulgeOnly?: boolean;
bulgeStrength?: number;
glowRadius?: number;
sparkle?: boolean;
waveAmplitude?: number;
glowColor?: string;
class?: string;
};
let {
dotRadius = 1.5,
dotSpacing = 14,
cursorRadius = 500,
cursorForce = 0.1,
bulgeOnly = true,
bulgeStrength = 67,
glowRadius = 160,
sparkle = false,
waveAmplitude = 0,
glowColor = '#ffffff',
class: className = ''
}: Props = $props();
let root: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let glowEl: SVGCircleElement | undefined;
const glowId = `dot-field-glow-${crypto.randomUUID().slice(0, 10)}`;
let dots: Dot[] = [];
const mouse = { x: -9999, y: -9999, prevX: -9999, prevY: -9999, speed: 0 };
let size = { w: 0, h: 0, offsetX: 0, offsetY: 0 };
let glowOpacity = 0;
let engagement = 0;
$effect(() => {
void dotRadius;
void dotSpacing;
void cursorRadius;
void cursorForce;
void bulgeOnly;
void bulgeStrength;
void sparkle;
void waveAmplitude;
void glowColor;
const currentGlow = glowEl;
if (!canvas || !root) return;
const canvasEl: HTMLCanvasElement = canvas;
const rootEl: HTMLDivElement = root;
const fillGradientFrom = primaryMixOnRoot(rootEl, 40);
const fillGradientTo = primaryMixOnRoot(rootEl, 22);
const ctx = canvasEl.getContext('2d', { alpha: true });
if (!ctx) return;
const context = ctx;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let resizeTimer: ReturnType<typeof setTimeout>;
let raf = 0;
let frameCount = 0;
function buildDots(w: number, h: number) {
const step = dotRadius + dotSpacing;
const cols = Math.floor(w / step);
const rows = Math.floor(h / step);
const padX = (w % step) / 2;
const padY = (h % step) / 2;
const nextDots: Dot[] = new Array(rows * cols);
let idx = 0;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const ax = padX + col * step + step / 2;
const ay = padY + row * step + step / 2;
nextDots[idx++] = { ax, ay, sx: ax, sy: ay, vx: 0, vy: 0, x: ax, y: ay };
}
}
dots = nextDots;
}
function doResize() {
const rect = rootEl.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
canvasEl.width = w * dpr;
canvasEl.height = h * dpr;
canvasEl.style.width = `${w}px`;
canvasEl.style.height = `${h}px`;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
size = {
w,
h,
offsetX: rect.left + window.scrollX,
offsetY: rect.top + window.scrollY
};
buildDots(w, h);
}
function resize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(doResize, 100);
}
function onMouseMove(e: MouseEvent) {
mouse.x = e.pageX - size.offsetX;
mouse.y = e.pageY - size.offsetY;
}
function updateMouseSpeed() {
const dx = mouse.prevX - mouse.x;
const dy = mouse.prevY - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
mouse.speed += (dist - mouse.speed) * 0.5;
if (mouse.speed < 0.001) mouse.speed = 0;
mouse.prevX = mouse.x;
mouse.prevY = mouse.y;
}
const speedInterval = setInterval(updateMouseSpeed, 20);
function tick() {
frameCount++;
const len = dots.length;
const { w, h } = size;
const t = frameCount * 0.02;
const targetEngagement = Math.min(mouse.speed / 5, 1);
engagement += (targetEngagement - engagement) * 0.06;
if (engagement < 0.001) engagement = 0;
glowOpacity += (engagement - glowOpacity) * 0.08;
if (currentGlow) {
currentGlow.setAttribute('cx', String(mouse.x));
currentGlow.setAttribute('cy', String(mouse.y));
currentGlow.style.opacity = String(glowOpacity);
}
context.clearRect(0, 0, w, h);
const grad = context.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, fillGradientFrom);
grad.addColorStop(1, fillGradientTo);
context.fillStyle = grad;
const crSq = cursorRadius * cursorRadius;
const rad = dotRadius / 2;
context.beginPath();
for (let i = 0; i < len; i++) {
const d = dots[i];
const dx = mouse.x - d.ax;
const dy = mouse.y - d.ay;
const distSq = dx * dx + dy * dy;
if (distSq < crSq && engagement > 0.01) {
const dist = Math.sqrt(distSq);
const angle = Math.atan2(dy, dx);
if (bulgeOnly) {
const falloff = 1 - dist / cursorRadius;
const push = falloff * falloff * bulgeStrength * engagement;
d.sx += (d.ax - Math.cos(angle) * push - d.sx) * 0.15;
d.sy += (d.ay - Math.sin(angle) * push - d.sy) * 0.15;
} else {
const safeDist = Math.max(dist, 0.001);
const move = (500 / safeDist) * (mouse.speed * cursorForce);
d.vx += Math.cos(angle) * -move;
d.vy += Math.sin(angle) * -move;
}
} else if (bulgeOnly) {
d.sx += (d.ax - d.sx) * 0.1;
d.sy += (d.ay - d.sy) * 0.1;
}
if (!bulgeOnly) {
d.vx *= 0.9;
d.vy *= 0.9;
d.x = d.ax + d.vx;
d.y = d.ay + d.vy;
d.sx += (d.x - d.sx) * 0.1;
d.sy += (d.y - d.sy) * 0.1;
}
let drawX = d.sx;
let drawY = d.sy;
if (waveAmplitude > 0) {
drawY += Math.sin(d.ax * 0.03 + t) * waveAmplitude;
drawX += Math.cos(d.ay * 0.03 + t * 0.7) * waveAmplitude * 0.5;
}
if (sparkle) {
const hash = ((i * 2654435761) ^ (frameCount >> 3)) >>> 0;
if (hash % 100 < 3) {
context.moveTo(drawX + rad * 1.8, drawY);
context.arc(drawX, drawY, rad * 1.8, 0, TWO_PI);
} else {
context.moveTo(drawX + rad, drawY);
context.arc(drawX, drawY, rad, 0, TWO_PI);
}
} else {
context.moveTo(drawX + rad, drawY);
context.arc(drawX, drawY, rad, 0, TWO_PI);
}
}
context.fill();
raf = requestAnimationFrame(tick);
}
doResize();
window.addEventListener('resize', resize);
window.addEventListener('mousemove', onMouseMove, { passive: true });
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
clearInterval(speedInterval);
clearTimeout(resizeTimer);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMouseMove);
};
});
</script>
<div bind:this={root} class={cn('relative h-full w-full', className)}>
<canvas bind:this={canvas} class="absolute inset-0 h-full w-full"></canvas>
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs>
<radialGradient id={glowId}>
<stop offset="0%" stop-color="#fff" />
<stop offset="100%" stop-color="#fff" />
</radialGradient>
</defs>
<circle
bind:this={glowEl}
cx="-9999"
cy="-9999"
r={glowRadius}
fill="url(#{glowId})"
style:opacity="0"
style:will-change="opacity"
/>
</svg>
</div>
@@ -0,0 +1 @@
export { default as DotField } from './dot-field.svelte';
@@ -31,7 +31,7 @@
</script>
<div class={cn('flex justify-center space-x-1', containerClass)}>
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
<AnimatePresence let:_item list={[{ key: wordsspilit }]}>
{#each wordsspilit as char, i}
<Motion
initial="hidden"
@@ -1,4 +1,5 @@
export * from './border-beam';
export * from './dot-field';
export * from './dot-pattern';
export * from './card';
export * from './blurfade';
@@ -18,6 +18,12 @@
icon: Component;
iconClass?: string;
};
/** e.g. public-course indicator on the banner */
visibilityBadge?: {
label: string;
icon: Component;
iconClass?: string;
};
class?: string;
/** Custom banner area (e.g. optimized `Image`); overrides `bannerImage` */
media?: Snippet;
@@ -34,6 +40,7 @@
bannerImage,
bannerAlt = 'Course banner image',
typeBadge,
visibilityBadge,
class: className = '',
media,
overlay,
@@ -72,6 +79,20 @@
{typeBadge.label}
</Badge>
{/if}
{#if visibilityBadge}
{@const VIcon = visibilityBadge.icon}
<Badge
class={cn(
'ui:absolute ui:bottom-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:gap-1 ui:capitalize',
typeBadge ? 'ui:right-2 ui:left-auto' : 'ui:left-2'
)}
variant="secondary"
>
<VIcon class={visibilityBadge.iconClass} />
{visibilityBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
@@ -118,6 +139,20 @@
{typeBadge.label}
</Badge>
{/if}
{#if visibilityBadge}
{@const VIcon = visibilityBadge.icon}
<Badge
class={cn(
'ui:absolute ui:bottom-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:gap-1 ui:capitalize',
typeBadge ? 'ui:right-2 ui:left-auto' : 'ui:left-2'
)}
variant="secondary"
>
<VIcon class={visibilityBadge.iconClass} />
{visibilityBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
@@ -0,0 +1,126 @@
<script lang="ts">
import * as Select from '../../base/select';
import { Textarea } from '../../base/textarea';
import { Button } from '../../base/button';
import ModelPicker from '../model-picker/model-picker.svelte';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import { DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
interface SelectOption {
value: string;
label: string;
}
interface SubmitPayload {
prompt: string;
level: string;
model?: string;
}
interface Props {
heading: string;
placeholder: string;
loading?: boolean;
levelOptions?: SelectOption[];
model?: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
onModelChange?: (model: AgentModelId) => void;
onLockedModelSelect?: (model: AgentModelId) => void;
onsubmit?: (payload: SubmitPayload) => void;
}
let {
heading,
placeholder,
loading = false,
levelOptions = [],
model = DEFAULT_PICKER_MODEL_ID,
lockedModelIds = [],
onModelChange,
onLockedModelSelect,
onsubmit
}: Props = $props();
let prompt = $state('');
let level = $state('beginner');
let selectedModel = $state<AgentModelId>(model);
const selectedLevelLabel = $derived(
levelOptions.find((o) => o.value === level)?.label ?? levelOptions[0]?.label ?? ''
);
const canSubmit = $derived(!!prompt.trim() && !loading);
$effect(() => {
if (model !== selectedModel) {
selectedModel = model;
}
});
function handleModelChange(id: AgentModelId) {
selectedModel = id;
onModelChange?.(id);
}
function handleSubmit() {
if (!canSubmit) return;
onsubmit?.({ prompt, level, model: selectedModel });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
</script>
<div class="ui:flex ui:w-full ui:flex-col ui:items-center ui:gap-8">
<h1 class="ui:text-center ui:text-3xl ui:font-semibold ui:tracking-tight">{heading}</h1>
<div class="ui:bg-card ui:w-full ui:max-w-3xl ui:rounded-2xl ui:border ui:p-4">
<Textarea
bind:value={prompt}
{placeholder}
rows={4}
disabled={loading}
onkeydown={handleKeydown}
class="ui:w-full ui:resize-none ui:border-0! ui:focus-visible:ring-0! ui:focus-visible:ring-offset-0!"
/>
<div class="ui:mt-3 ui:flex ui:items-center ui:gap-2">
<Select.Root type="single" bind:value={level} disabled={loading}>
<Select.Trigger>
{selectedLevelLabel}
</Select.Trigger>
<Select.Content>
{#each levelOptions as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<ModelPicker
value={selectedModel}
disabled={loading}
variant="select"
{lockedModelIds}
onChange={handleModelChange}
onLockedSelect={onLockedModelSelect}
/>
<div class="ui:ml-auto">
<Button variant="secondary" size="icon" disabled={!canSubmit} onclick={handleSubmit}>
{#if loading}
<LoaderCircleIcon class="ui:h-4 ui:w-4 ui:animate-spin" />
{:else}
<ArrowUpIcon class="ui:h-4 ui:w-4" />
{/if}
</Button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,4 @@
import CourseCreator from './course-creator.svelte';
export { CourseCreator };
export { CourseCreator as default };
@@ -25,7 +25,27 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
declare module '@tiptap/core' {
interface Storage {
searchAndReplace: SearchAndReplaceStorage;
}
interface Commands<ReturnType> {
search: {
/**
@@ -218,22 +238,6 @@ const replaceAll = (
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
name: 'searchAndReplace',
@@ -4,6 +4,10 @@ export { default as QuestionRenderer } from './question-renderer.svelte';
export { default as QuestionList } from './question-list.svelte';
export { default as QuestionSurface } from './question-surface.svelte';
export { default as QuestionTitle } from './question-title.svelte';
export { default as SectionAfterBehavior } from './section-after-behavior.svelte';
export { default as SectionHeader } from './section-header.svelte';
export { default as SectionNavigationSidebar } from './section-navigation-sidebar.svelte';
export { default as SectionOverview } from './section-overview.svelte';
export * from './renderer-contract';
export { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
@@ -17,6 +21,7 @@ export { default as TextareaEdit } from './renderers/textarea/edit.svelte';
export { default as MatchingEdit } from './renderers/matching/edit.svelte';
export { default as OrderingEdit } from './renderers/ordering/edit.svelte';
export { default as HotspotEdit } from './renderers/hotspot/edit.svelte';
export { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
export { default as TrueFalseTake } from './renderers/true-false/take.svelte';
export { default as ShortAnswerTake } from './renderers/short-answer/take.svelte';
@@ -29,6 +34,7 @@ export { default as TextareaTake } from './renderers/textarea/take.svelte';
export { default as MatchingTake } from './renderers/matching/take.svelte';
export { default as OrderingTake } from './renderers/ordering/take.svelte';
export { default as HotspotTake } from './renderers/hotspot/take.svelte';
export { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
export { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
export { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
@@ -42,6 +48,7 @@ export { default as MatchingPreview } from './renderers/matching/preview.svelte'
export { default as OrderingPreview } from './renderers/ordering/preview.svelte';
export { default as OrderingDisplay } from './renderers/ordering/ordering-display.svelte';
export { default as HotspotPreview } from './renderers/hotspot/preview.svelte';
export { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
export { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
@@ -55,3 +62,4 @@ export { default as MatchingSubmission } from './renderers/matching/submission.s
export { default as OrderingSubmission } from './renderers/ordering/submission.svelte';
export { default as HotspotSubmission } from './renderers/hotspot/submission.svelte';
export { default as LinkSubmission } from './renderers/link/submission.svelte';
export { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
@@ -5,7 +5,7 @@
interface Props {
contract: ExerciseQuestionListRenderContract;
onAnswerChange?: (question: ExerciseQuestionModel, answer: AnswerData) => void;
onAnswerChange?: (question: ExerciseQuestionModel, answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionModel, updatedQuestion: ExerciseQuestionModel) => void;
itemClass?: string;
showContainer?: boolean;
@@ -33,10 +33,13 @@
disabled: contract.disabled,
labels: contract.labels,
onImageUpload: contract.onImageUpload,
onFileUpload: contract.onFileUpload
onFileUpload: contract.onFileUpload,
onVideoRecordingUpload: contract.onVideoRecordingUpload
}}
onAnswerChange={(answer) => onAnswerChange(question, answer)}
onQuestionChange={(updatedQuestion) => onQuestionChange(question, updatedQuestion)}
questionNumber={index + 1}
questionNumberActive={false}
{showContainer}
/>
</div>
@@ -14,6 +14,7 @@
import { Textarea } from '../../base/textarea';
import { IconButton } from '../icon-button';
import { MediaPlayer, isYoutubeUrl } from '../media-player';
import NumberBadge from '$src/base/number-badge/number-badge.svelte';
import QuestionTitle from './question-title.svelte';
import { YoutubeLinkForm } from '../youtube-link-form';
import QuestionSurface from './question-surface.svelte';
@@ -22,10 +23,14 @@
interface Props {
contract: ExerciseQuestionRenderContract;
onAnswerChange?: (answer: AnswerData) => void;
onAnswerChange?: (answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionRendererProps['question']) => void;
showContainer?: boolean;
questionTypeSelect?: Snippet;
/** When set, a numbered badge is shown to the left of the title (non-edit modes). */
questionNumber?: number;
/** When false, the badge uses muted styling (e.g. question lists). Defaults to true. */
questionNumberActive?: boolean;
}
let {
@@ -33,7 +38,9 @@
onAnswerChange = () => {},
onQuestionChange = () => {},
showContainer = true,
questionTypeSelect
questionTypeSelect,
questionNumber,
questionNumberActive = true
}: Props = $props();
const Renderer = $derived(getExerciseQuestionRenderer(contract.question.questionType, contract.mode));
@@ -265,8 +272,8 @@
{#snippet content()}
{#if contract.mode === 'edit'}
<div class="ui:space-y-3">
<div class="ui:flex ui:items-end ui:gap-2">
<div class="ui:space-y-1 ui:w-full">
<div class="ui:flex ui:min-w-0 ui:flex-col ui:items-stretch ui:gap-2 ui:sm:flex-row ui:sm:items-end">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-1">
<p class="ui:text-sm ui:font-medium">{label('question.edit.title_label')}</p>
<Input
class="ui:w-full"
@@ -413,7 +420,14 @@
{#if contract.mode !== 'edit'}
<div class="ui:mb-4 ui:space-y-3">
{#if contract.question.title.trim().length > 0}
{#if questionNumber !== undefined}
<div class="ui:flex ui:items-center ui:gap-2">
<NumberBadge number={questionNumber} active={questionNumberActive} />
{#if contract.question.title.trim().length > 0}
<QuestionTitle title={contract.question.title} class="ui:min-w-0 ui:flex-1" />
{/if}
</div>
{:else if contract.question.title.trim().length > 0}
<QuestionTitle title={contract.question.title} />
{/if}
@@ -463,6 +477,7 @@
labels={contract.labels}
onImageUpload={contract.onImageUpload}
onFileUpload={contract.onFileUpload}
onVideoRecordingUpload={contract.onVideoRecordingUpload}
{onAnswerChange}
{onQuestionChange}
/>
@@ -23,6 +23,7 @@ import { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte
import { default as StarEdit } from './renderers/star/edit.svelte';
import { default as TextareaEdit } from './renderers/textarea/edit.svelte';
import { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
import { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
import { default as CheckboxTake } from './renderers/checkbox/take.svelte';
import { default as FileUploadTake } from './renderers/file-upload/take.svelte';
@@ -38,6 +39,7 @@ import { default as ShortAnswerTake } from './renderers/short-answer/take.svelte
import { default as StarTake } from './renderers/star/take.svelte';
import { default as TextareaTake } from './renderers/textarea/take.svelte';
import { default as TrueFalseTake } from './renderers/true-false/take.svelte';
import { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
import { default as CheckboxPreview } from './renderers/checkbox/preview.svelte';
import { default as FileUploadPreview } from './renderers/file-upload/preview.svelte';
@@ -53,6 +55,7 @@ import { default as ShortAnswerPreview } from './renderers/short-answer/preview.
import { default as StarPreview } from './renderers/star/preview.svelte';
import { default as TextareaPreview } from './renderers/textarea/preview.svelte';
import { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
import { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
import { default as CheckboxSubmission } from './renderers/checkbox/submission.svelte';
import { default as FileUploadSubmission } from './renderers/file-upload/submission.svelte';
@@ -68,6 +71,7 @@ import { default as ShortAnswerSubmission } from './renderers/short-answer/submi
import { default as StarSubmission } from './renderers/star/submission.svelte';
import { default as TextareaSubmission } from './renderers/textarea/submission.svelte';
import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
import { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
import { default as RadioReview } from './renderers/radio/review.svelte';
import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
@@ -172,6 +176,13 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
take: StarTake,
preview: StarPreview,
submission: StarSubmission
},
[QUESTION_TYPE_KEY.VIDEO_RECORDING]: {
edit: VideoRecordingEdit,
take: VideoRecordingTake,
preview: VideoRecordingPreview,
submission: VideoRecordingSubmission,
review: VideoRecordingSubmission
}
};
@@ -157,7 +157,7 @@
<div class="ui:space-y-2">
{#each question.options ?? [] as option, index}
{@const optionImageUrl = getOptionImageUrl(option)}
<div class="ui:flex ui:items-start ui:gap-2">
<div class="ui:flex ui:min-w-0 ui:items-start ui:gap-2">
<IconButton
type="button"
{disabled}
@@ -177,7 +177,7 @@
<span class="ui:sr-only">{label('checkbox.edit.toggle_correct_sr')}</span>
</IconButton>
<div class="ui:flex-1 ui:space-y-2">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-2">
<Input
class="ui:w-full"
placeholder={[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ').trim()}
@@ -61,10 +61,12 @@
{@const optionValue = getOptionValue(option, index)}
{@const optionId = `checkbox-${question.id ?? 'question'}-${index}`}
{@const optionImageUrl = getOptionImageUrl(option)}
{@const isSelected = isOptionSelected(option, index)}
<div
class={cn(
'ui:rounded-md ui:p-2 ui:border',
isSelected ? 'ui:border-primary' : 'ui:border-border',
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2'
)}
>
@@ -152,7 +152,7 @@
<div class="ui:space-y-2">
{#each question.options ?? [] as option, index}
{@const optionImageUrl = getOptionImageUrl(option)}
<div class="ui:flex ui:items-start ui:gap-2">
<div class="ui:flex ui:min-w-0 ui:items-start ui:gap-2">
<IconButton
type="button"
{disabled}
@@ -172,7 +172,7 @@
<span class="ui:sr-only">{label('radio.edit.mark_correct_sr')}</span>
</IconButton>
<div class="ui:flex-1 ui:space-y-2">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-2">
<Input
class="ui:w-full"
placeholder={[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ').trim()}
@@ -51,10 +51,12 @@
{@const optionValue = getOptionValue(option, index)}
{@const optionId = `radio-${question.id ?? 'question'}-${index}`}
{@const optionImageUrl = getOptionImageUrl(option)}
{@const isSelected = selectedOptionValue !== '' && selectedOptionValue === optionValue}
<div
class={cn(
'ui:rounded-md ui:p-2 ui:border',
isSelected ? 'ui:border-primary' : 'ui:border-border',
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2'
)}
>
@@ -0,0 +1,68 @@
<script lang="ts">
import {
VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS,
VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS,
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Badge } from '../../../../base/badge';
import { Input } from '../../../../base/input';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationValue = $derived(String(getVideoRecordingMaxDurationSeconds(question.settings)));
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next, allowRetakes: true } });
}
function onDurationChange(value: string) {
const duration = Number(value);
if (!Number.isFinite(duration) || duration <= 0) {
patchSettings({ maxDurationSeconds: VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS });
return;
}
patchSettings({
maxDurationSeconds: Math.min(Math.round(duration), VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS)
});
}
</script>
<div class="ui:space-y-3">
<div class="ui:grid ui:gap-3 ui:md:grid-cols-[minmax(0,220px)_1fr] ui:md:items-start">
<div class="ui:space-y-1">
<p class="ui:text-sm ui:font-medium">
{label('video_recording.edit.max_duration_label', 'Max duration')}
</p>
<Input
type="number"
min="1"
max={VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS}
step="1"
value={maxDurationValue}
placeholder={label('video_recording.edit.max_duration_placeholder', '60')}
{disabled}
onchange={(event) => onDurationChange(event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-2 ui:pt-1">
<Badge variant="secondary">{label('video_recording.edit.manual_grading', 'Requires manual grading')}</Badge>
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.edit.max_duration_helper',
'Students record in the browser and can retake before submitting.'
)}
</p>
</div>
</div>
</div>
@@ -0,0 +1,15 @@
export function formatSeconds(totalSeconds: number): string {
const safeSeconds = Math.max(0, Math.round(totalSeconds));
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
const units = ['Bytes', 'KB', 'MB', 'GB'];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, unitIndex);
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
@@ -0,0 +1,25 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { formatSeconds } from './format';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationSeconds = $derived(getVideoRecordingMaxDurationSeconds(question.settings));
</script>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.preview.max_duration_label', 'Max duration')}: {formatSeconds(maxDurationSeconds)}
</p>
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.preview.manual_grading',
'Learners record a short video response. Manual grading required.'
)}
</p>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import { MediaPlayer } from '../../../media-player';
import LabeledValueRow from '../shared/labeled-value-row.svelte';
import { formatFileSize, formatSeconds } from './format';
let { answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const recording = $derived(answer?.type === 'VIDEO_RECORDING' ? answer : null);
const playbackUrl = $derived.by(() => {
if (!recording) return '';
const enrichedUrl = (recording as { fileUrl?: string }).fileUrl;
return recording.playbackUrl ?? enrichedUrl ?? '';
});
function formatDate(value: string | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString();
}
</script>
{#if !recording}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.submission.no_video', 'No video submitted.')}
</p>
{:else}
<div class="ui:space-y-3">
{#if playbackUrl}
<div class="ui:max-w-xl ui:overflow-hidden ui:rounded-md">
<MediaPlayer
source={{ type: 'upload', url: playbackUrl }}
options={{ controls: true, playsinline: true, width: '100%', maxHeight: '360px' }}
/>
</div>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.submission.unavailable', 'Video unavailable.')}
</p>
{/if}
<div class="ui:grid ui:gap-2 ui:text-sm ui:sm:grid-cols-2">
<LabeledValueRow
label={label('video_recording.submission.duration', 'Duration')}
value={formatSeconds(recording.durationSeconds)}
/>
<LabeledValueRow
label={label('video_recording.submission.file_size', 'File size')}
value={formatFileSize(recording.size)}
/>
<LabeledValueRow
label={label('video_recording.submission.recorded_at', 'Recorded')}
value={formatDate(recording.recordedAt)}
/>
<LabeledValueRow
label={label('video_recording.submission.uploaded_at', 'Uploaded')}
value={formatDate(recording.uploadedAt)}
/>
<LabeledValueRow
label={label('video_recording.submission.retake_count', 'Retakes')}
value={String(recording.retakeCount ?? 0)}
/>
</div>
</div>
{/if}
@@ -0,0 +1,481 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import {
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Button } from '../../../../base/button';
import * as Select from '../../../../base/select';
import { Spinner } from '../../../../base/spinner';
import { formatSeconds } from './format';
type RecordingState =
| 'idle'
| 'permission_denied'
| 'ready'
| 'recording'
| 'recorded'
| 'uploading'
| 'uploaded'
| 'failed';
let {
question,
answer = null,
disabled = false,
labels,
onAnswerChange = () => {},
onVideoRecordingUpload
}: ExerciseQuestionRendererProps = $props();
let recordingState = $state<RecordingState>('idle');
let stream = $state<MediaStream | null>(null);
let mediaRecorder = $state<MediaRecorder | null>(null);
let liveVideo = $state<HTMLVideoElement | null>(null);
let previewVideo = $state<HTMLVideoElement | null>(null);
let elapsedSeconds = $state(0);
let durationSeconds = $state(0);
let recordingBlob = $state<Blob | null>(null);
let previewUrl = $state('');
let uploadError = $state('');
let recordedAt = $state('');
let retakeCount = $state(0);
let isPreviewPlaying = $state(false);
let cameraDevices = $state<MediaDeviceInfo[]>([]);
let selectedCameraDeviceId = $state('');
let timerId: number | null = null;
let recordingStartedAt = 0;
let chunks: Blob[] = [];
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationSeconds = $derived(getVideoRecordingMaxDurationSeconds(question.settings));
const remainingSeconds = $derived(Math.max(0, maxDurationSeconds - elapsedSeconds));
const isFinalWarning = $derived(maxDurationSeconds > 15 && remainingSeconds <= 10 && recordingState === 'recording');
const savedRecording = $derived(answer?.type === 'VIDEO_RECORDING' ? answer : null);
const savedPlaybackUrl = $derived.by(() => {
if (!savedRecording) return '';
return savedRecording.playbackUrl ?? (savedRecording as { fileUrl?: string }).fileUrl ?? '';
});
const canUseRecording = $derived(recordingBlob && durationSeconds >= 1 && !disabled);
const recordingProgressPercent = $derived(
maxDurationSeconds > 0 ? Math.min(100, Math.max(0, (elapsedSeconds / maxDurationSeconds) * 100)) : 0
);
const recordingProgressDegrees = $derived(recordingProgressPercent * 3.6);
const progressRingBackground = $derived(
`conic-gradient(var(--primary) ${recordingProgressDegrees}deg, color-mix(in oklab, var(--primary) 12%, var(--background)) ${recordingProgressDegrees}deg)`
);
const isSupported = $derived.by(
() =>
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia &&
'MediaRecorder' in window
);
$effect(() => {
if (liveVideo && stream) {
liveVideo.srcObject = stream;
}
});
function getSupportedMimeType(): string {
const candidates = ['video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
const supported = candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
return supported ?? '';
}
function normalizeMimeType(mimeType: string): 'video/webm' | 'video/mp4' | 'video/quicktime' {
if (mimeType.includes('mp4')) return 'video/mp4';
if (mimeType.includes('quicktime')) return 'video/quicktime';
return 'video/webm';
}
async function refreshCameraDevices() {
if (!navigator.mediaDevices?.enumerateDevices) return;
const devices = await navigator.mediaDevices.enumerateDevices();
cameraDevices = devices.filter((device) => device.kind === 'videoinput');
if (!selectedCameraDeviceId && cameraDevices[0]) {
selectedCameraDeviceId = cameraDevices[0].deviceId;
}
}
function getVideoConstraints(): MediaTrackConstraints {
if (selectedCameraDeviceId) {
return { deviceId: { exact: selectedCameraDeviceId } };
}
return { facingMode: 'user' };
}
function clearTimer() {
if (timerId === null) return;
window.clearInterval(timerId);
timerId = null;
}
function stopStream() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
if (liveVideo) liveVideo.srcObject = null;
}
function revokePreviewUrl() {
if (!previewUrl) return;
URL.revokeObjectURL(previewUrl);
previewUrl = '';
}
function resetLocalRecording() {
revokePreviewUrl();
recordingBlob = null;
durationSeconds = 0;
elapsedSeconds = 0;
uploadError = '';
isPreviewPlaying = false;
}
async function requestCamera(): Promise<MediaStream | null> {
if (disabled || !isSupported) return null;
try {
stopStream();
const nextStream = await navigator.mediaDevices.getUserMedia({
video: getVideoConstraints(),
audio: true
});
stream = nextStream;
selectedCameraDeviceId = nextStream.getVideoTracks()[0]?.getSettings().deviceId ?? selectedCameraDeviceId;
recordingState = 'ready';
uploadError = '';
await refreshCameraDevices();
return nextStream;
} catch {
recordingState = 'permission_denied';
return null;
}
}
async function handleCameraChange(nextDeviceId: string) {
if (!nextDeviceId || nextDeviceId === selectedCameraDeviceId || recordingState === 'recording') return;
selectedCameraDeviceId = nextDeviceId;
if (recordingState === 'ready') {
await requestCamera();
}
}
async function startRecording() {
if (disabled || recordingState === 'recording') return;
const activeStream = stream ?? (await requestCamera());
if (!activeStream) return;
resetLocalRecording();
chunks = [];
const mimeType = getSupportedMimeType();
const recorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
mediaRecorder = recorder;
recordingStartedAt = performance.now();
recordedAt = new Date().toISOString();
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
clearTimer();
const measuredDuration = Math.max(0, (performance.now() - recordingStartedAt) / 1000);
durationSeconds = Math.round(measuredDuration * 10) / 10;
elapsedSeconds = Math.min(maxDurationSeconds, Math.ceil(durationSeconds));
const blobType = normalizeMimeType(recorder.mimeType || mimeType);
const blob = new Blob(chunks, { type: blobType });
recordingBlob = blob;
previewUrl = URL.createObjectURL(blob);
recordingState = durationSeconds >= 1 ? 'recorded' : 'failed';
};
recorder.start();
recordingState = 'recording';
timerId = window.setInterval(() => {
const elapsed = (performance.now() - recordingStartedAt) / 1000;
elapsedSeconds = Math.min(maxDurationSeconds, elapsed);
if (elapsed >= maxDurationSeconds) {
stopRecording();
}
}, 250);
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
clearTimer();
stopStream();
}
async function togglePreviewPlayback() {
if (!previewVideo) return;
if (previewVideo.paused) {
await previewVideo.play();
isPreviewPlaying = true;
return;
}
previewVideo.pause();
isPreviewPlaying = false;
}
function onPreviewEnded() {
isPreviewPlaying = false;
}
function retakeRecording() {
resetLocalRecording();
retakeCount += 1;
recordingState = stream ? 'ready' : 'idle';
}
function deleteRecording() {
resetLocalRecording();
onAnswerChange(null);
recordingState = stream ? 'ready' : 'idle';
}
async function acceptRecording() {
if (!recordingBlob || !canUseRecording || !onVideoRecordingUpload) return;
recordingState = 'uploading';
uploadError = '';
try {
const mimeType = normalizeMimeType(recordingBlob.type);
const result = await onVideoRecordingUpload({
questionId: question.id ?? question.key ?? '',
blob: recordingBlob,
fileName: `recording-${Date.now()}.${mimeType === 'video/mp4' ? 'mp4' : 'webm'}`,
mimeType,
size: recordingBlob.size,
durationSeconds,
recordedAt,
retakeCount
});
onAnswerChange({ ...result, playbackUrl: previewUrl });
recordingState = 'uploaded';
} catch (error) {
uploadError =
error instanceof Error
? error.message
: label('video_recording.take.upload_failed', 'Upload failed. Try again.');
recordingState = 'failed';
}
}
onDestroy(() => {
clearTimer();
stopStream();
revokePreviewUrl();
});
</script>
<div class="ui:space-y-4" aria-live="polite">
{#if !isSupported}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.take.unsupported', 'Video recording is not supported in this browser.')}
</p>
{:else}
<div class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<div
class={recordingState === 'recording' ? 'ui:rounded-full ui:p-1 ui:shadow-sm' : ''}
style={recordingState === 'recording' ? `background: ${progressRingBackground};` : undefined}
>
<div
class="ui:bg-muted ui:relative ui:grid ui:aspect-square ui:w-52 ui:max-w-full ui:place-items-center ui:overflow-hidden ui:rounded-full ui:sm:w-64"
>
{#if recordingState === 'ready' || recordingState === 'recording'}
<video
bind:this={liveVideo}
autoplay
muted
playsinline
class="ui:h-full ui:w-full ui:object-cover ui:[transform:scaleX(-1)]"
></video>
{#if recordingState === 'ready'}
<button
type="button"
class="ui:absolute ui:inset-0 ui:grid ui:place-items-center ui:bg-black/10 ui:text-white ui:transition ui:hover:bg-black/20 ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring ui:disabled:pointer-events-none ui:disabled:opacity-50"
{disabled}
onclick={startRecording}
>
<span
class="ui:rounded-full ui:bg-background/90 ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-foreground ui:shadow-md"
>
{label('video_recording.take.start_recording', 'Start recording')}
</span>
</button>
{/if}
{:else if previewUrl || savedPlaybackUrl}
<!-- svelte-ignore a11y_media_has_caption - v1 browser recordings do not generate captions. -->
<video
bind:this={previewVideo}
src={previewUrl || savedPlaybackUrl}
playsinline
class="ui:h-full ui:w-full ui:object-cover ui:[transform:scaleX(-1)]"
onpause={() => (isPreviewPlaying = false)}
onplay={() => (isPreviewPlaying = true)}
onended={onPreviewEnded}
></video>
<button
type="button"
class="ui:group ui:absolute ui:inset-0 ui:grid ui:place-items-center ui:bg-black/10 ui:text-white ui:transition ui:hover:bg-black/20 ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring"
aria-label={isPreviewPlaying
? label('video_recording.take.pause_preview', 'Pause recording preview')
: label('video_recording.take.play_preview', 'Play recording preview')}
onclick={togglePreviewPlayback}
>
{#if isPreviewPlaying}
<span
class="ui:grid ui:size-14 ui:grid-cols-[6px_6px] ui:place-content-center ui:gap-2 ui:rounded-full ui:bg-background/90 ui:text-foreground ui:opacity-0 ui:shadow-md ui:transition-opacity ui:group-hover:opacity-100 ui:group-focus-visible:opacity-100"
aria-hidden="true"
>
<span class="ui:h-5 ui:w-1.5 ui:rounded-sm ui:bg-current"></span>
<span class="ui:h-5 ui:w-1.5 ui:rounded-sm ui:bg-current"></span>
</span>
{:else}
<span
class="ui:grid ui:size-14 ui:place-items-center ui:rounded-full ui:bg-background/90 ui:text-foreground ui:shadow-md"
aria-hidden="true"
>
<span
class="ui:ml-1 ui:h-0 ui:w-0 ui:border-y-[10px] ui:border-y-transparent ui:border-l-[16px] ui:border-l-current"
></span>
</span>
{/if}
</button>
{:else if recordingState === 'permission_denied'}
<div class="ui:flex ui:max-w-40 ui:flex-col ui:items-center ui:gap-3 ui:text-center">
<p class="ui:text-sm ui:text-muted-foreground">
{label('video_recording.take.permission_denied', 'Camera or microphone access was blocked.')}
</p>
<Button type="button" size="sm" variant="secondary" {disabled} onclick={requestCamera}>
{label('video_recording.take.start_camera', 'Set up camera')}
</Button>
</div>
{:else}
<button
type="button"
class="ui:grid ui:size-24 ui:place-items-center ui:rounded-full ui:bg-background ui:px-3 ui:text-center ui:text-sm ui:font-medium ui:text-foreground ui:shadow-sm ui:ring-1 ui:ring-border ui:transition ui:hover:bg-accent ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring ui:disabled:pointer-events-none ui:disabled:opacity-50"
{disabled}
onclick={requestCamera}
>
{label('video_recording.take.start_camera', 'Set up camera')}
</button>
{/if}
</div>
</div>
{#if cameraDevices.length > 1 && recordingState !== 'recording'}
<div class="ui:flex ui:w-full ui:max-w-xs ui:flex-col ui:gap-1.5">
<span class="ui:text-xs ui:font-medium ui:text-muted-foreground">
{label('video_recording.take.camera_label', 'Camera')}
</span>
<Select.Root type="single" value={selectedCameraDeviceId} onValueChange={handleCameraChange}>
<Select.Trigger class="ui:w-full">
{cameraDevices.find((device) => device.deviceId === selectedCameraDeviceId)?.label ||
label('video_recording.take.camera_placeholder', 'Select camera')}
</Select.Trigger>
<Select.Content>
{#each cameraDevices as device, cameraIndex (device.deviceId)}
<Select.Item value={device.deviceId} label={device.label || `Camera ${cameraIndex + 1}`}>
{device.label || `${label('video_recording.take.camera_label', 'Camera')} ${cameraIndex + 1}`}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="ui:flex ui:flex-wrap ui:items-center ui:justify-center ui:gap-x-4 ui:gap-y-1 ui:text-sm">
<span class={isFinalWarning ? 'ui:text-destructive ui:font-medium' : 'ui:text-muted-foreground'}>
{label('video_recording.take.elapsed', 'Elapsed')}: {formatSeconds(elapsedSeconds)}
</span>
<span class={isFinalWarning ? 'ui:text-destructive ui:font-medium' : 'ui:text-muted-foreground'}>
{label('video_recording.take.remaining', 'Remaining')}: {formatSeconds(remainingSeconds)}
</span>
<span class="ui:text-muted-foreground">
{label('video_recording.take.max_duration', 'Max')}: {formatSeconds(maxDurationSeconds)}
</span>
</div>
{#if recordingState === 'failed' && durationSeconds < 1 && recordingBlob}
<p class="ui:text-destructive ui:text-sm">
{label('video_recording.take.too_short', 'Record for at least 1 second.')}
</p>
{/if}
{#if uploadError}
<p class="ui:text-destructive ui:text-sm">{uploadError}</p>
{/if}
{#if savedRecording && recordingState !== 'recording' && recordingState !== 'uploading'}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.take.uploaded', 'Recording uploaded')}
</p>
{/if}
{#if savedRecording && recordingState === 'recorded'}
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.take.replaces_previous',
'A new accepted take replaces the previous answer after upload.'
)}
</p>
{/if}
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-2">
{#if savedRecording && (recordingState === 'idle' || recordingState === 'uploaded')}
<Button type="button" variant="outline" {disabled} onclick={startRecording}>
{label('video_recording.take.retake', 'Retake')}
</Button>
<Button type="button" variant="ghost" {disabled} onclick={deleteRecording}>
{label('video_recording.take.delete', 'Delete recording')}
</Button>
{:else if recordingState === 'recording'}
<Button type="button" variant="destructive" {disabled} onclick={stopRecording}>
{label('video_recording.take.stop_recording', 'Stop recording')}
</Button>
{:else if recordingState === 'recorded' || recordingState === 'failed'}
<Button type="button" disabled={!canUseRecording} onclick={acceptRecording}>
{label(
recordingState === 'failed' && uploadError
? 'video_recording.take.retry_upload'
: 'video_recording.take.use_recording',
recordingState === 'failed' && uploadError ? 'Retry upload' : 'Use recording'
)}
</Button>
<Button type="button" variant="ghost" {disabled} onclick={retakeRecording}>
{label('video_recording.take.retake', 'Retake')}
</Button>
{#if savedRecording}
<Button type="button" variant="ghost" {disabled} onclick={deleteRecording}>
{label('video_recording.take.delete', 'Delete recording')}
</Button>
{/if}
{:else if recordingState === 'uploading'}
<Button type="button" disabled>
<Spinner class="ui:size-4" />
{label('video_recording.take.uploading', 'Uploading recording...')}
</Button>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import * as Select from '../../base/select';
import type { ExerciseSectionAfterBehavior } from '@cio/question-types';
interface SectionOption {
id: string;
title: string;
order: number;
}
interface Props {
sectionIndex: number;
currentSectionId: string;
allSections: SectionOption[];
afterBehavior: ExerciseSectionAfterBehavior;
onChange: (behavior: ExerciseSectionAfterBehavior) => void;
labels: {
afterSectionPrefix: string;
continue: string;
goToSection: string;
submit: string;
};
}
let { sectionIndex, currentSectionId, allSections, afterBehavior, onChange, labels }: Props = $props();
const currentValue = $derived(
afterBehavior.action === 'go_to_section' ? `go_to_section:${afterBehavior.exerciseSectionId}` : afterBehavior.action
);
function getOptionLabel(value: string) {
if (value === 'continue') return labels.continue;
if (value === 'submit') return labels.submit;
const targetSectionId = value.replace('go_to_section:', '');
const targetSection = allSections.find((section) => section.id === targetSectionId);
return targetSection ? `${labels.goToSection}: ${targetSection.title}` : labels.goToSection;
}
function handleValueChange(value: string) {
if (!value) return;
if (value === 'continue' || value === 'submit') {
onChange({ action: value });
return;
}
const exerciseSectionId = value.replace('go_to_section:', '');
onChange({ action: 'go_to_section', exerciseSectionId });
}
</script>
<div
class="ui:flex ui:items-center ui:justify-between ui:gap-3 ui:border ui:border-border ui:bg-muted/30 ui:px-3 ui:py-2"
>
<p class="ui:text-sm ui:font-medium ui:text-muted-foreground">{labels.afterSectionPrefix} {sectionIndex + 1}</p>
<Select.Root type="single" value={currentValue} onValueChange={handleValueChange}>
<Select.Trigger class="ui:w-[260px]">{getOptionLabel(currentValue)}</Select.Trigger>
<Select.Content>
<Select.Item value="continue" label={labels.continue}>{labels.continue}</Select.Item>
{#each allSections as section (section.id)}
{#if section.id !== currentSectionId}
<Select.Item value={`go_to_section:${section.id}`} label={`${labels.goToSection}: ${section.title}`}>
{labels.goToSection}: {section.title}
</Select.Item>
{/if}
{/each}
<Select.Item value="submit" label={labels.submit}>{labels.submit}</Select.Item>
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
import Separator from '$src/base/separator/separator.svelte';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
const COLOR_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'ui:bg-blue-600!',
green: 'ui:bg-emerald-500!',
amber: 'ui:bg-amber-500!',
rose: 'ui:bg-rose-500!',
violet: 'ui:bg-violet-500!',
slate: 'ui:bg-slate-500!'
};
interface Props {
title: string;
description?: string | null;
sectionNumber: number;
totalSections: number;
colorTheme?: ExerciseSectionColorTheme;
questionCount?: number;
totalPoints?: number;
labels?: {
section?: string;
questions?: string;
points?: string;
};
}
let {
title,
description = null,
sectionNumber,
totalSections: _totalSections,
colorTheme = 'blue',
questionCount,
totalPoints,
labels = {}
}: Props = $props();
const sectionMetaSummary = $derived.by(() => {
const segments: string[] = [];
if (questionCount !== undefined) {
segments.push(`${questionCount} ${labels.questions ?? 'questions'}`);
}
if (totalPoints !== undefined) {
segments.push(`${totalPoints} ${labels.points ?? 'points'}`);
}
return segments.join(' · ');
});
</script>
<header>
<div class="ui:flex ui:items-center ui:gap-2">
<span class={`ui:h-2 ui:w-2 ui:rounded-full ui:inline-block ${COLOR_CLASSES[colorTheme]}`}> </span>
<p class="ui:text-sm ui:font-medium ui:uppercase">
{title}
</p>
{#if questionCount !== undefined || totalPoints !== undefined}
<p class="ui:text-xs ui:text-muted-foreground">({sectionMetaSummary})</p>
{/if}
<Separator orientation="horizontal" class="ui:flex-1 ui:h-0.5! {COLOR_CLASSES[colorTheme]}" />
</div>
<p class="ui:text-sm ui:text-muted-foreground">
{description}
</p>
</header>
@@ -0,0 +1,48 @@
<script lang="ts">
interface SectionNavQuestion {
key: string;
label: string;
isAnswered: boolean;
isCurrent: boolean;
}
interface SectionNavGroup {
id: string;
title: string;
questions: SectionNavQuestion[];
isCurrent: boolean;
}
interface Props {
sections: SectionNavGroup[];
}
let { sections }: Props = $props();
</script>
<aside class="ui:rounded-md ui:border ui:bg-background ui:p-3">
<div class="ui:flex ui:flex-col ui:gap-3">
{#each sections as section (section.id)}
<div class="ui:space-y-2">
<p class={`ui:text-xs ui:font-semibold ${section.isCurrent ? 'ui:text-primary' : 'ui:text-muted-foreground'}`}>
{section.title}
</p>
<div class="ui:flex ui:flex-wrap ui:gap-1.5">
{#each section.questions as question (question.key)}
<span
class={`ui:inline-flex ui:h-7 ui:min-w-7 ui:items-center ui:justify-center ui:rounded ui:border ui:px-1.5 ui:text-xs ui:font-medium ${
question.isCurrent
? 'ui:border-primary ui:bg-primary ui:text-primary-foreground'
: question.isAnswered
? 'ui:border-primary/40 ui:bg-primary/10 ui:text-primary'
: 'ui:border-border ui:text-muted-foreground'
}`}
>
{question.label}
</span>
{/each}
</div>
</div>
{/each}
</div>
</aside>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Button } from '../../base/button';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
const ACCENT_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'ui:bg-blue-600',
green: 'ui:bg-emerald-500',
amber: 'ui:bg-amber-500',
rose: 'ui:bg-rose-500',
violet: 'ui:bg-violet-500',
slate: 'ui:bg-slate-500'
};
interface Props {
sectionTitle: string;
sectionDescription?: string | null;
sectionNumber: number;
totalSections: number;
questionCount: number;
totalPoints: number;
colorTheme?: ExerciseSectionColorTheme;
onBegin: () => void;
onBack: () => void;
labels: {
beginSection: string;
back: string;
questions: string;
points: string;
section: string;
};
}
let {
sectionTitle,
sectionDescription = null,
sectionNumber,
totalSections,
questionCount,
colorTheme = 'blue',
onBegin,
onBack,
labels
}: Props = $props();
const segmentIndexes = $derived(Array.from({ length: totalSections }, (_, index) => index));
</script>
<section
class="ui:mx-auto ui:flex ui:min-h-[32rem] ui:w-full ui:max-w-5xl ui:flex-col ui:bg-background ui:px-3 ui:py-6"
>
<div class="ui:mb-16 ui:flex ui:items-center ui:gap-5">
<div class="ui:flex ui:flex-1 ui:gap-2">
{#each segmentIndexes as segmentIndex}
<span
class={`ui:h-1 ui:flex-1 ui:rounded-full ${segmentIndex < sectionNumber ? 'ui:bg-muted-foreground/30' : 'ui:bg-muted'}`}
aria-hidden="true"
></span>
{/each}
</div>
<p class="ui:shrink-0 ui:text-sm ui:text-muted-foreground">
{labels.section}
{sectionNumber} of {totalSections}
</p>
</div>
<div class="ui:flex ui:flex-1 ui:flex-col">
<p class="ui:text-sm ui:font-semibold ui:uppercase ui:tracking-[0.18em] ui:text-muted-foreground">
{labels.section}
{sectionNumber} of {totalSections}
</p>
<span class={`ui:mt-8 ui:h-1 ui:w-14 ui:rounded-full ${ACCENT_CLASSES[colorTheme]}`}></span>
<h2 class="ui:mt-10 ui:font-serif ui:text-5xl ui:font-bold ui:tracking-tight ui:text-foreground md:ui:text-6xl">
{sectionTitle}
</h2>
<div class="ui:mt-9 ui:flex ui:items-center ui:gap-3 ui:text-lg ui:text-muted-foreground">
<span class={`ui:h-2.5 ui:w-2.5 ui:rounded-full ${ACCENT_CLASSES[colorTheme]}`} aria-hidden="true"></span>
<span>{questionCount} {labels.questions}</span>
</div>
{#if sectionDescription}
<p class="ui:mt-8 ui:max-w-3xl ui:text-xl ui:leading-8 ui:text-muted-foreground">{sectionDescription}</p>
{/if}
<div class="ui:mt-auto ui:border-t ui:pt-8">
<div class="ui:flex ui:items-center ui:justify-between ui:gap-4">
<Button type="button" variant="outline" onclick={onBack}>{labels.back}</Button>
<Button type="button" onclick={onBegin} size="lg">{labels.beginSection}</Button>
</div>
</div>
</div>
</section>
@@ -0,0 +1,4 @@
import ModelPicker from './model-picker.svelte';
export { ModelPicker };
export { ModelPicker as default };
@@ -0,0 +1,117 @@
<script lang="ts">
import * as Popover from '../../base/popover';
import * as Select from '../../base/select';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import CheckIcon from '@lucide/svelte/icons/check';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
interface Props {
value: AgentModelId;
disabled?: boolean;
variant?: 'compact' | 'select';
lockedModelIds?: readonly AgentModelId[];
onChange: (id: AgentModelId) => void;
onLockedSelect?: (id: AgentModelId) => void;
}
let { value, disabled = false, variant = 'compact', lockedModelIds = [], onChange, onLockedSelect }: Props = $props();
let open = $state(false);
function isLocked(id: AgentModelId) {
return lockedModelIds.includes(id);
}
function handleSelect(id: AgentModelId) {
if (isLocked(id)) {
onLockedSelect?.(id);
open = false;
return;
}
onChange(id);
open = false;
}
</script>
{#if variant === 'select'}
<Select.Root type="single" {value} onValueChange={(v) => v && handleSelect(v as AgentModelId)} {disabled}>
<Select.Trigger>
<span class="ui:flex ui:items-center ui:gap-1">
{AGENT_MODELS[value].label}
{#if AGENT_MODELS[value].costTier === 'high'}
<span class="ui:text-red-500 ui:font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 ui:font-medium">$</span>
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#each UI_PICKER_MODEL_IDS as id (id)}
<Select.Item value={id} label={AGENT_MODELS[id].label}>
<span class="ui:flex ui:w-full ui:items-center ui:justify-between ui:gap-2">
<span class="ui:flex ui:min-w-0 ui:items-center ui:gap-2">
<span>{AGENT_MODELS[id].label}</span>
{#if isLocked(id)}
<RocketIcon size={16} class="ui:text-primary! ui:shrink-0" />
{/if}
</span>
<span
class={AGENT_MODELS[id].costTier === 'high'
? 'ui:text-red-500 ui:font-medium'
: 'ui:text-green-500 ui:font-medium'}
>
{AGENT_MODELS[id].costTier === 'high' ? '$$$' : '$'}
</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="ui:text-muted-foreground hover:ui:bg-muted ui:hover:text-foreground flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<span class="truncate">{AGENT_MODELS[value].label}</span>
{#if AGENT_MODELS[value].costTier === 'high'}
<span class="ui:text-red-500 font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 font-medium">$</span>
{/if}
<ChevronDownIcon size={12} />
</Popover.Trigger>
<Popover.Content class="ui:p-0! ui:z-[110] w-48" align="end" side="top">
<div class="py-1">
{#each UI_PICKER_MODEL_IDS as id (id)}
{@const isSelected = id === value}
<button
type="button"
onclick={() => handleSelect(id)}
class="hover:ui:bg-muted flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors"
>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
{#if isLocked(id)}
<RocketIcon size={16} class="ui:text-primary! ui:shrink-0" />
{/if}
</span>
<span class="flex shrink-0 items-center gap-1">
{#if AGENT_MODELS[id].costTier === 'high'}
<span class="ui:text-red-500 font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 font-medium">$</span>
{/if}
{#if isSelected}
<CheckIcon size={14} class="ui:text-primary" />
{/if}
</span>
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
{/if}
@@ -23,6 +23,7 @@
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
>
@@ -33,7 +33,12 @@
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:px-8 ui:font-semibold">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:px-8 ui:font-semibold"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
@@ -52,7 +52,12 @@
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:rounded-full">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-full"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
@@ -64,7 +64,7 @@ export interface OrgLandingPageProps {
hero: {
heading: string;
subheading: string;
primaryAction: { label: string; href: string };
primaryAction: { label: string; href: string; disabled?: boolean };
secondaryAction?: { label: string; href: string };
image?: string;
};
@@ -7,13 +7,15 @@
import type { ComponentProps } from 'svelte';
import { cn } from '../../tools';
type PasswordInputAutocomplete = ComponentProps<typeof InputGroup.Input>['autocomplete'];
interface Props {
placeholder?: string;
value?: string;
class?: string;
disabled?: boolean;
'aria-invalid'?: 'true' | 'false' | undefined;
autocomplete?: string;
autocomplete?: PasswordInputAutocomplete;
showPasswordTooltip?: string;
hidePasswordTooltip?: string;
showPasswordAriaLabel?: string;
@@ -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;
@@ -104,6 +104,41 @@ export const QUESTION_TYPE_PICKER_EXERCISE_LABELS: ExerciseQuestionLabels = {
'file_upload.take.download': 'Download',
'file_upload.preview.accepted_types_label': 'Accepted types',
'file_upload.preview.max_size_label': 'Max size (MB)',
'video_recording.edit.max_duration_label': 'Max duration',
'video_recording.edit.max_duration_helper': 'Students record in the browser and can retake before submitting.',
'video_recording.edit.max_duration_placeholder': '60',
'video_recording.edit.manual_grading': 'Requires manual grading',
'video_recording.take.unsupported': 'Video recording is not supported in this browser.',
'video_recording.take.permission_prompt': 'Allow camera and microphone access to record your answer.',
'video_recording.take.permission_denied': 'Camera or microphone access was blocked. Check browser permissions.',
'video_recording.take.start_camera': 'Set up camera',
'video_recording.take.camera_label': 'Camera',
'video_recording.take.camera_placeholder': 'Select camera',
'video_recording.take.play_preview': 'Play recording preview',
'video_recording.take.pause_preview': 'Pause recording preview',
'video_recording.take.start_recording': 'Start recording',
'video_recording.take.stop_recording': 'Stop recording',
'video_recording.take.elapsed': 'Elapsed',
'video_recording.take.remaining': 'Remaining',
'video_recording.take.max_duration': 'Max',
'video_recording.take.too_short': 'Record for at least 1 second.',
'video_recording.take.use_recording': 'Use recording',
'video_recording.take.retake': 'Retake',
'video_recording.take.delete': 'Delete recording',
'video_recording.take.retry_upload': 'Retry upload',
'video_recording.take.uploading': 'Uploading recording...',
'video_recording.take.uploaded': 'Recording uploaded',
'video_recording.take.upload_failed': 'Upload failed. Try again.',
'video_recording.take.replaces_previous': 'A new accepted take replaces the previous answer after upload.',
'video_recording.preview.max_duration_label': 'Max duration',
'video_recording.preview.manual_grading': 'Learners record a short video response. Manual grading required.',
'video_recording.submission.no_video': 'No video submitted.',
'video_recording.submission.unavailable': 'Video unavailable.',
'video_recording.submission.duration': 'Duration',
'video_recording.submission.file_size': 'File size',
'video_recording.submission.recorded_at': 'Recorded',
'video_recording.submission.uploaded_at': 'Uploaded',
'video_recording.submission.retake_count': 'Retakes',
'link.edit.instructions_placeholder': 'Optional instructions for learners',
'link.edit.helper': 'Learners can submit one or more valid URLs (http/https).',
'link.take.helper': 'Add one or more links. Only valid http/https URLs are accepted.',
@@ -296,6 +296,22 @@ const QUESTION_TYPE_PICKER_ENTRIES: QuestionTypePickerEntry[] = [
questionType: 'STAR',
settings: { correctValue: 4, maxStars: 5 }
}
},
{
key: QUESTION_TYPE_KEY.VIDEO_RECORDING,
displayLabel: 'Video recording',
description: 'Learners record a short camera response — useful for reflection, speaking, and demos.',
demoQuestion: {
id: 'picker-video-recording',
key: 'picker-video-recording',
title: 'Record a short reflection explaining one idea from this module that you would apply at work.',
questionType: 'VIDEO_RECORDING',
settings: {
description: 'Keep it concise. You can retake before submitting.',
maxDurationSeconds: 120,
allowRetakes: true
}
}
}
];
@@ -1,5 +1,5 @@
<script lang="ts">
import type { AnswerData } from '@cio/question-types';
import type { AnswerData, ExerciseQuestionModel } from '@cio/question-types';
import { QUESTION_TYPE_KEY, type QuestionTypeKey } from '@cio/question-types';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -10,14 +10,14 @@
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
import StarIcon from '@lucide/svelte/icons/star';
import UploadIcon from '@lucide/svelte/icons/upload';
import type { Component } from 'svelte';
import VideoIcon from '@lucide/svelte/icons/video';
import { QuestionRenderer } from '../exercise-question';
import { QUESTION_TYPE_PICKER_EXERCISE_LABELS } from './exercise-labels';
import { QUESTION_TYPE_PICKER_ITEMS, getQuestionTypePickerItem } from './picker-data';
import { cn } from '../../tools';
/** Lucide icons for types without `sidebarGlyph` in picker data. */
function iconForKey(key: QuestionTypeKey): Component<{ class?: string; 'aria-hidden'?: string | boolean }> {
function iconForKey(key: QuestionTypeKey): typeof CircleDotIcon {
switch (key) {
case QUESTION_TYPE_KEY.RADIO:
return CircleDotIcon;
@@ -33,6 +33,8 @@
return Link2Icon;
case QUESTION_TYPE_KEY.STAR:
return StarIcon;
case QUESTION_TYPE_KEY.VIDEO_RECORDING:
return VideoIcon;
default:
return CircleDotIcon;
}
@@ -70,6 +72,34 @@
fileUrl: URL.createObjectURL(file)
};
}
async function demoVideoRecordingUpload(input: {
blob: Blob;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
retakeCount: number;
}) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 350);
});
return {
type: 'VIDEO_RECORDING' as const,
assetId: '00000000-0000-4000-8000-000000000001',
storageKey: `demo/${input.fileName}`,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
durationSeconds: input.durationSeconds,
recordedAt: input.recordedAt,
uploadedAt: new Date().toISOString(),
provider: 'cloudflare' as const,
retakeCount: input.retakeCount
};
}
</script>
<div
@@ -129,10 +159,11 @@
showContainer={false}
contract={{
mode: 'take',
question: activeQuestion,
question: activeQuestion as ExerciseQuestionModel,
answer,
labels: QUESTION_TYPE_PICKER_EXERCISE_LABELS,
onFileUpload: demoFileUpload
onFileUpload: demoFileUpload,
onVideoRecordingUpload: demoVideoRecordingUpload
}}
onAnswerChange={(nextAnswer) => {
answer = nextAnswer;
@@ -14,7 +14,6 @@
const maxIndex = $derived(Math.max(courses.length - visible, 0));
let position = $state(0);
let trackEl = $state<HTMLDivElement | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const cardBasis = $derived(`calc((100% - ${(visible - 1) * 12}px) / ${visible})`);
@@ -68,7 +67,7 @@
aria-roledescription="carousel"
>
<div class="cio-cr-outer">
<div class="cio-cr-track" bind:this={trackEl} style={`transform: translateX(${translatePct})`}>
<div class="cio-cr-track" style={`transform: translateX(${translatePct})`}>
{#each courses as course (course.id)}
<a
class="cio-cr-card"
+2
View File
@@ -26,6 +26,7 @@ export * as Item from './base/item';
export * as Kbd from './base/kbd';
export * as Label from './base/label';
export * as Menubar from './base/menubar';
export * as NumberBadge from './base/number-badge';
export * as Page from './base/page';
export * as Pagination from './base/pagination';
export * as Password from './base/password';
@@ -80,4 +81,5 @@ export * as YoutubeLinkForm from './custom/youtube-link-form';
export { BackButton } from './custom/back-button';
export { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from './custom/course-card';
export { NewsfeedReactions } from './custom/newsfeed-reactions';
export * as PublicCourse from './custom/public-course';
export * from './custom/animation';
File diff suppressed because one or more lines are too long