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