feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,62 @@
<script lang="ts">
import { Motion, AnimatePresence } from 'svelte-motion';
import { inview } from 'svelte-inview';
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
duration?: number;
delay?: number;
yOffset?: number;
inViewMargin?: string;
blur?: string;
once?: boolean;
class?: string;
children?: Snippet;
}
let {
duration = 0.4,
delay = 0,
yOffset = 6,
inViewMargin = '-50px',
blur = '6px',
once = false,
class: className = '',
children
}: Props = $props();
const id = crypto.randomUUID().slice(0, 8);
let isInView = $state<'hidden' | 'visible'>('hidden');
const defaultVariants = $derived({
hidden: { opacity: 0, y: yOffset, filter: `blur(${blur})` },
visible: { opacity: 1, y: 0, filter: `blur(0px)` }
});
</script>
<AnimatePresence let:item list={[{ key: id }]}>
<Motion
initial="hidden"
animate={isInView}
exit="hidden"
variants={defaultVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: 'easeOut'
}}
let:motion
>
<div
use:inview={{ rootMargin: inViewMargin, unobserveOnEnter: once }}
use:motion
oninview_change={({ detail }) => {
isInView = detail.inView ? 'visible' : 'hidden';
}}
class={cn(className)}
>
{@render children?.()}
</div>
</Motion>
</AnimatePresence>
@@ -0,0 +1 @@
export { default as BlurFade } from './blurfade.svelte';
@@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '../../../tools';
import { Motion } from 'svelte-motion';
import type { Snippet } from 'svelte';
interface Props {
variant?: {
hidden: { filter: string; opacity: number };
visible: { filter: string; opacity: number };
};
duration?: number;
class?: string;
children?: Snippet;
}
let {
variant,
duration = 1,
class: className = 'text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-7xl md:leading-20',
children
}: Props = $props();
const defaultVariants = {
hidden: { filter: 'blur(10px)', opacity: 0 },
visible: { filter: 'blur(0px)', opacity: 1 }
};
const combinedVariants = $derived(variant ?? defaultVariants);
</script>
<Motion initial="hidden" animate="visible" transition={{ duration }} variants={combinedVariants} let:motion>
<h1 class={cn(className)} use:motion>
{@render children?.()}
</h1>
</Motion>
@@ -0,0 +1 @@
export { default as BlurIn } from './blurin.svelte';
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '../../../tools';
interface Props {
size?: number;
duration?: number;
anchor?: number;
borderWidth?: number;
colorFrom?: string;
colorTo?: string;
delay?: number;
class?: string;
}
let {
size = 200,
duration = 15,
anchor = 90,
borderWidth = 1.5,
colorFrom = '#ffaa40',
colorTo = '#9c40ff',
delay = 0,
class: className = ''
}: Props = $props();
</script>
<div
style:--border-width={borderWidth}
style:--size={size}
style:--color-from={colorFrom}
style:--color-to={colorTo}
style:--delay="{delay}s"
style:--anchor={anchor}
style:--duration={duration}
class={cn('cio-marketing-border-beam', className)}
></div>
@@ -0,0 +1 @@
export { default as BorderBeam } from './border-beam.svelte';
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children: Snippet;
}
let { children, className }: Props = $props();
</script>
<div class={cn('p-4 text-left', className)}>
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '../../../tools';
import Ellipses from './ellipses.svelte';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children?: Snippet;
}
let { className, children }: Props = $props();
</script>
<div
class={cn(
'relative mx-auto w-full border border-dashed border-zinc-300 px-8 shadow-sm sm:px-6 md:px-8 dark:border-zinc-800 dark:bg-[#09090B]',
className
)}
>
<div class="absolute top-4 left-0 z-0 h-px w-full bg-zinc-400 sm:top-6 md:top-8 dark:bg-zinc-700"></div>
<div class="absolute bottom-4 left-0 z-0 h-px w-full bg-zinc-400 sm:bottom-6 md:bottom-8 dark:bg-zinc-700"></div>
<div class="relative w-full border-x border-zinc-400 dark:border-zinc-700">
<Ellipses />
<div class="relative z-20 mx-auto py-8">
{@render children?.()}
</div>
</div>
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
const sharedClass =
'rounded-full outline outline-8 dark:outline-gray-950 sm:my-6 md:my-8 size-1 my-4 outline-gray-50 bg-green-400';
</script>
<div class="absolute z-0 grid h-full w-full items-center gap-8 lg:grid-cols-2">
<section class="absolute z-0 grid h-full w-full grid-cols-2 place-content-between">
<div class={`${sharedClass} -mx-[2.5px]`}></div>
<div class={`${sharedClass} -mx-[2px] place-self-end`}></div>
<div class={`${sharedClass} -mx-[2.5px]`}></div>
<div class={`${sharedClass} -mx-[2px] place-self-end`}></div>
</section>
</div>
@@ -0,0 +1,2 @@
export { default as CardContainer } from './container.svelte';
export { default as CardBody } from './body.svelte';
@@ -0,0 +1,45 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { SVGAttributes } from 'svelte/elements';
interface Props extends SVGAttributes<SVGSVGElement> {
width?: string | number;
height?: string | number;
x?: number;
y?: number;
cx?: number;
cy?: number;
cr?: number;
fillColor?: string;
class?: string;
}
let {
width = 16,
height = 16,
x = 0,
y = 0,
cx = 1,
cy = 1,
cr = 1,
fillColor = 'rgb(163 163 163 / 0.8)',
class: className = '',
...restProps
}: Props = $props();
const patternId = crypto.randomUUID().toString().slice(0, 10);
</script>
<svg
aria-hidden="true"
class={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
{...restProps}
fill={fillColor}
>
<defs>
<pattern id={patternId} {width} {height} patternUnits="userSpaceOnUse" patternContentUnits="userSpaceOnUse" {x} {y}>
<circle id="pattern-circle" {cx} {cy} r={cr} />
</pattern>
</defs>
<rect width="100%" height="100%" stroke-width={0} fill={`url(#${patternId})`} />
</svg>
@@ -0,0 +1 @@
export { default as DotPattern } from './dot-pattern.svelte';
@@ -0,0 +1,57 @@
<script lang="ts">
import { cn } from '../../../tools';
import { AnimatePresence, Motion } from 'svelte-motion';
interface Props {
class?: string;
/** Applied to the flex row wrapper (e.g. alignment). */
containerClass?: string;
words?: string;
duration?: number;
delayMultiple?: number;
framerProps?: {
hidden: { opacity: number; x: number };
visible: { opacity: number; x: number };
};
}
let {
class: className = 'drop-shadow-sm',
containerClass = '',
words = 'Gradual Spacing',
duration = 0.5,
delayMultiple = 0.04,
framerProps = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 }
}
}: Props = $props();
const wordsspilit = $derived(words.split(''));
</script>
<div class={cn('flex justify-center space-x-1', containerClass)}>
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
{#each wordsspilit as char, i}
<Motion
initial="hidden"
animate="visible"
exit="hidden"
variants={framerProps}
transition={{
duration,
delay: i * delayMultiple
}}
let:motion
>
<span use:motion class={cn(className)}>
{#if char === ' '}
<span>&nbsp;</span>
{:else}
{char}
{/if}
</span>
</Motion>
{/each}
</AnimatePresence>
</div>
@@ -0,0 +1 @@
export { default as GradualSpacing } from './gradual-spacing.svelte';
@@ -0,0 +1,171 @@
<script lang="ts">
import { AnimatePresence, Motion } from 'svelte-motion';
import { Play, X } from '@lucide/svelte';
type AnimationStyle =
| 'from-bottom'
| 'from-center'
| 'from-top'
| 'from-left'
| 'from-right'
| 'fade'
| 'top-in-bottom-out'
| 'left-in-right-out';
interface Props {
animationStyle?: AnimationStyle;
videoSrc: string;
thumbnailSrc: string;
thumbnailAlt?: string;
iconColor?: string;
}
let {
animationStyle = 'from-center',
videoSrc,
thumbnailSrc,
thumbnailAlt = 'Video thumbnail',
iconColor = 'white'
}: Props = $props();
let isVideoOpen = $state(false);
let isCloseHovered = $state(false);
let isPlayHovered = $state(false);
function openVideo() {
isVideoOpen = true;
}
function closeVideo() {
isVideoOpen = false;
}
const animationVariants: Record<
AnimationStyle,
{ initial: Record<string, number>; animate: Record<string, number>; exit: Record<string, number> }
> = {
'from-bottom': {
initial: { y: 100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 100, opacity: 0 }
},
'from-center': {
initial: { scale: 0.5, opacity: 0 },
animate: { scale: 1, opacity: 1 },
exit: { scale: 0.5, opacity: 0 }
},
'from-top': {
initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -100, opacity: 0 }
},
'from-left': {
initial: { x: -100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -100, opacity: 0 }
},
'from-right': {
initial: { x: 100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 100, opacity: 0 }
},
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 }
},
'top-in-bottom-out': {
initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 200, opacity: 0 }
},
'left-in-right-out': {
initial: { x: -100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 200, opacity: 0 }
}
};
const selectedAnimation = $derived(animationVariants[animationStyle]);
</script>
<div class="relative">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="relative cursor-pointer" onclick={openVideo} role="presentation">
<img
src={thumbnailSrc}
alt={thumbnailAlt}
width={1920}
height={1080}
class="w-full rounded-2xl transition-all duration-200"
/>
<div class="absolute inset-0 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex size-24 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-md transition-transform duration-300 ease-out"
onmouseenter={() => (isPlayHovered = true)}
onmouseleave={() => (isPlayHovered = false)}
role="presentation"
>
<div
class="relative flex size-20 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-2xl transition-all duration-300 ease-out"
>
<span
class="inline-flex size-8 items-center justify-center transition-transform duration-300 ease-out"
style:transform={`scale(${isPlayHovered ? 1.1 : 1})`}
style:color={iconColor}
>
<Play strokeWidth={1.7} class="size-full" />
</span>
</div>
</div>
</div>
</div>
<AnimatePresence let:item list={[{ key: isVideoOpen }]}>
{#if item.key}
<Motion initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} let:motion>
<div use:motion class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md">
<Motion
initial={selectedAnimation.initial}
animate={selectedAnimation.animate}
exit={selectedAnimation.exit}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
let:motion
>
<div use:motion class="relative mx-4 aspect-video w-full max-w-4xl md:mx-0">
<Motion let:motion whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<button
type="button"
use:motion
class="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 backdrop-blur-md"
onclick={closeVideo}
onmouseenter={() => (isCloseHovered = true)}
onmouseleave={() => (isCloseHovered = false)}
>
<X class="size-5 text-white" />
</button>
</Motion>
<Motion animate={{ scale: isCloseHovered ? 0.98 : 1 }} transition={{ duration: 0.2 }} let:motion>
<div
use:motion
class="relative isolate z-1 size-full overflow-hidden rounded-2xl border-2 border-white"
>
<!-- svelte-ignore a11y_missing_attribute -->
<iframe
src={videoSrc}
class="size-full rounded-2xl"
allowfullscreen
title="Video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
></iframe>
</div>
</Motion>
</div>
</Motion>
</div>
</Motion>
{/if}
</AnimatePresence>
</div>
@@ -0,0 +1 @@
export { default as HeroVideoDialog } from './hero-video-dialog.svelte';
+13
View File
@@ -0,0 +1,13 @@
export * from './border-beam';
export * from './dot-pattern';
export * from './card';
export * from './blurfade';
export * from './blurin';
export * from './meteors';
export * from './shimmerbutton';
export * from './sparkle';
export * from './hero-video-dialog';
export * from './shinny-text';
export * from './magic-card';
export * from './gradual-spacing';
export * from './square-card';
@@ -0,0 +1 @@
export { default as MagicCard } from './magic-card.svelte';
@@ -0,0 +1,78 @@
<script lang="ts">
import { Motion, useMotionValue, useMotionTemplate } from 'svelte-motion';
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
gradientSize?: number;
gradientColor?: string;
gradientOpacity?: number;
class?: string;
children?: Snippet;
}
let {
gradientSize = 200,
gradientColor = '#262626',
gradientOpacity = 0.8,
class: className = '',
children
}: Props = $props();
const gradSize = useMotionValue(200);
const gradColor = useMotionValue('#262626');
const mouseX = useMotionValue(-200);
const mouseY = useMotionValue(-200);
$effect(() => {
gradSize.set(gradientSize);
gradColor.set(gradientColor);
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
});
function handleMouseMove(e: MouseEvent) {
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
}
function handleMouseLeave() {
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}
const bg = useMotionTemplate`radial-gradient(${gradSize}px circle at ${mouseX}px ${mouseY}px, ${gradColor}, transparent 100%)`;
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
class={cn(
'group relative flex size-full justify-center overflow-hidden rounded-xl border bg-neutral-100 py-4 text-black dark:bg-neutral-900 dark:text-white',
className
)}
>
<div class="relative z-10">
{#if children}
{@render children()}
{:else}
<div class="flex h-full items-center justify-center text-center">
<p class="text-2xl">Magic Card</p>
</div>
{/if}
</div>
<Motion
style={{
background: bg,
opacity: gradientOpacity
}}
let:motion
>
<div
use:motion
class="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
</Motion>
</div>
@@ -0,0 +1,81 @@
/* Shared by @cio/ui index.css and course-app (no Tailwind prefix; no global theme reset). */
@keyframes border-beam {
100% {
offset-distance: 100%;
}
}
@keyframes meteor {
0% {
transform: rotate(215deg) translateX(0);
opacity: 1;
}
70% {
opacity: 1;
}
100% {
transform: rotate(215deg) translateX(-500px);
opacity: 0;
}
}
@keyframes shimmer-marketing {
0%,
90%,
100% {
background-position: calc(-100% - var(--shimmer-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shimmer-width)) 0;
}
}
@keyframes spin-around {
0% {
transform: translateZ(0) rotate(0);
}
15%,
35% {
transform: translateZ(0) rotate(90deg);
}
65%,
85% {
transform: translateZ(0) rotate(270deg);
}
100% {
transform: translateZ(0) rotate(360deg);
}
}
@keyframes magicslide {
to {
transform: translate(calc(100cqw - 100%), 0);
}
}
@layer components {
.cio-marketing-border-beam {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
border: calc(var(--border-width) * 1px) solid transparent;
mask: linear-gradient(transparent, transparent), linear-gradient(white, white);
mask-clip: padding-box, border-box;
mask-composite: intersect;
}
.cio-marketing-border-beam::after {
content: '';
position: absolute;
aspect-ratio: 1;
width: calc(var(--size) * 1px);
animation-delay: var(--delay);
background: linear-gradient(to left, var(--color-from), var(--color-to), transparent);
offset-anchor: calc(var(--anchor) * 1%) 50%;
offset-path: rect(0 auto auto 0 round calc(var(--size) * 1px));
animation: border-beam calc(var(--duration) * 1s) infinite linear;
}
}
@@ -0,0 +1 @@
export { default as Meteors } from './meteors.svelte';
@@ -0,0 +1,48 @@
<script lang="ts">
import { cn } from '../../../tools';
interface Props {
number?: number;
}
let { number = 10 }: Props = $props();
type MeteorStyle = {
top: number;
left: string;
animationDelay: string;
animationDuration: string;
};
let meteorStyles = $state<MeteorStyle[]>([]);
function changeMeteors(num: number) {
meteorStyles = [...Array(num)].map(() => ({
// Keep within typical card heights so parents with overflow-hidden still show meteors
top: Math.floor(Math.random() * 160) + 8,
left: Math.floor(Math.random() * 100) + '%',
animationDelay: Math.random() * 1 + 0.2 + 's',
animationDuration: Math.floor(Math.random() * 8 + 2.9) + 's'
}));
}
$effect(() => {
const count = number;
if (typeof window === 'undefined') return;
changeMeteors(count);
});
</script>
{#each meteorStyles as style, idx}
<span
id="meteor-{idx + 1}"
class={cn(
'ui:animate-meteor ui:pointer-events-none ui:absolute ui:size-[2.4px] ui:origin-center ui:rounded-full ui:bg-slate-500 ui:shadow-[0_0_0_1px_#ffffff10]'
)}
style="top: {style.top}px; left: {style.left}; animation-delay: {style.animationDelay}; animation-duration: {style.animationDuration};"
>
<div
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:-z-10 ui:h-px ui:w-[50px] ui:-translate-y-1/2 ui:bg-linear-to-r ui:from-slate-500 ui:via-blue-600/30 ui:to-transparent"
></div>
</span>
{/each}
@@ -0,0 +1 @@
export { default as ShimmerButton } from './shimmerbutton.svelte';
@@ -0,0 +1,67 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
href: string;
target?: string;
rel?: string;
shimmerColor?: string;
shimmerSize?: string;
shimmerDuration?: string;
borderRadius?: string;
background?: string;
class?: string;
children?: Snippet;
}
let {
href,
target = '',
rel = '',
shimmerColor = '#ffffff',
shimmerSize = '0.05em',
shimmerDuration = '3s',
borderRadius = '100px',
background = 'rgba(0, 0, 0, 1)',
class: className = '',
children
}: Props = $props();
</script>
<a
style:--spread="90deg"
style:--shimmer-color={shimmerColor}
style:--radius={borderRadius}
style:--speed={shimmerDuration}
style:--cut={shimmerSize}
style:--bg={background}
class={cn(
'group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden rounded-(--radius) border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)] dark:text-white',
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px',
className
)}
{href}
{target}
{rel}
data-rewardful
>
<div class={cn('-z-30 blur-[2px]', '@container-[size] absolute inset-0 overflow-visible')}>
<div class="animate-magicslide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
<div
class="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
></div>
</div>
</div>
{@render children?.()}
<div
class={cn(
'absolute inset-0 h-full w-full',
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
'transform-gpu transition-all duration-300 ease-in-out',
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
)}
></div>
<div class={cn('absolute inset-(--cut) -z-20 rounded-(--radius) [background:var(--bg)]')}></div>
</a>
@@ -0,0 +1 @@
export { default as ShinnyText } from './shinny-text.svelte';
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
shimmerWidth?: number;
class?: string;
children?: Snippet;
}
let { shimmerWidth = 100, class: className, children }: Props = $props();
</script>
<p
style:--shimmer-width="{shimmerWidth}px"
class={cn(
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50',
'animate-shimmer bg-size-[var(--shimmer-width)_100%] bg-clip-text bg-position-[0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
'bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
className
)}
>
{@render children?.()}
</p>
@@ -0,0 +1 @@
export { default as Sparkle } from './sparkle-text.svelte';
@@ -0,0 +1,34 @@
<script lang="ts">
import { Motion } from 'svelte-motion';
interface Props {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
}
let { id, x, y, color, delay, scale }: Props = $props();
</script>
<Motion
initial={{ opacity: 0, left: x, top: y }}
animate={{
opacity: [0, 1, 0],
scale: [0, scale, 0],
rotate: [75, 120, 150]
}}
transition={{ duration: 0.8, repeat: Infinity, delay }}
let:motion
>
<div {id} use:motion class="pointer-events-none absolute z-20" style:left={x} style:top={y}>
<svg width="21" height="21" viewBox="0 0 21 21" class="block">
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={color}
/>
</svg>
</div>
</Motion>
@@ -0,0 +1,72 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import SparkleParticle from './sparkle-particle.svelte';
interface TSparkle {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
lifespan: number;
}
interface Props {
text?: string;
colors?: { first: string; second: string };
sparklesCount?: number;
class?: string;
}
let {
text = 'Hello World',
colors = { first: '#9E7AFF', second: '#FE8BBB' },
sparklesCount = 5,
class: className = ''
}: Props = $props();
let sparkles = $state<TSparkle[]>([]);
function generateStar(): TSparkle {
const starX = `${Math.random() * 100}%`;
const starY = `${Math.random() * 100}%`;
const color = Math.random() > 0.5 ? colors.first : colors.second;
const delay = Math.random() * 4;
const scale = Math.random() * 1 + 0.3;
const lifespan = Math.random() * 10 + 5;
const id = `${starX}-${starY}-${Date.now()}`;
return { id, x: starX, y: starY, color, delay, scale, lifespan };
}
function initializeStars() {
sparkles = Array.from({ length: sparklesCount }, generateStar);
}
function updateStars() {
sparkles = sparkles.map((star) => {
if (star.lifespan <= 0) {
return generateStar();
}
return { ...star, lifespan: star.lifespan - 0.1 };
});
}
let interval: ReturnType<typeof setInterval>;
onMount(() => {
initializeStars();
interval = setInterval(updateStars, 100);
});
onDestroy(() => clearInterval(interval));
</script>
<div style:--sparkles-first-color="{colors.first};" style:--sparkles-second-color="{colors.second};" class={className}>
<span class="relative inline-block">
{#each sparkles as item}
<SparkleParticle {...item} />
{/each}
<strong>{text}</strong>
</span>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children: Snippet;
}
let { children, className }: Props = $props();
</script>
<div class={cn('text-left', className)}>
{@render children()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<SVGElement> {
class?: string;
}
let { class: className = '', ...rest }: Props = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={24}
height={24}
stroke-width="1"
stroke-linecap="round"
stroke="currentColor"
class={cn('absolute text-black dark:text-white', className)}
{...rest}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
@@ -0,0 +1,2 @@
export { default as SquareCardBody } from './body.svelte';
export { default as SquareCardIcon } from './icon.svelte';
@@ -0,0 +1 @@
export const DEFAULT_COURSE_BANNER_IMAGE = '/images/classroomio-course-img-template.jpg';
@@ -0,0 +1,143 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '../../tools';
import { Badge } from '../../base/badge';
import { Separator } from '../../base/separator';
import * as Item from '../../base/item';
import { DEFAULT_COURSE_BANNER_IMAGE } from './constants';
interface Props {
href?: string;
title: string;
description: string;
/** Used when `media` snippet is not provided */
bannerImage?: string;
bannerAlt?: string;
typeBadge?: {
label: string;
icon: Component;
iconClass?: string;
};
class?: string;
/** Custom banner area (e.g. optimized `Image`); overrides `bannerImage` */
media?: Snippet;
/** Optional overlay inside the link (e.g. dropdown), typically absolutely positioned */
overlay?: Snippet;
tags?: Snippet;
footer?: Snippet;
}
let {
href,
title,
description,
bannerImage,
bannerAlt = 'Course banner image',
typeBadge,
class: className = '',
media,
overlay,
tags,
footer
}: Props = $props();
const resolvedBannerImage = $derived(bannerImage?.trim() ? bannerImage : DEFAULT_COURSE_BANNER_IMAGE);
</script>
<Item.Root variant="outline" class={cn('ui:group ui:relative ui:max-w-[320px] ui:p-3!', className)}>
{#snippet child({ props })}
{#if href}
<a {href} {...props} class={cn('ui:block', props.class as string)}>
{@render overlay?.()}
<Item.Header>
<Item.Media variant="image" class="ui:relative ui:h-50! ui:w-full!">
{#if media}
{@render media()}
{:else}
<img
src={resolvedBannerImage}
alt={bannerAlt}
class="ui:w-full ui:h-full ui:rounded-sm ui:object-cover"
/>
{/if}
{#if typeBadge}
{@const Icon = typeBadge.icon}
<Badge
class="ui:absolute ui:bottom-2 ui:left-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:capitalize"
variant="secondary"
>
<Icon class={typeBadge.iconClass} />
{typeBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="ui:flex ui:w-full ui:min-h-14 ui:items-center ui:text-base!">
<span class="ui:line-clamp-2">{title}</span>
</Item.Title>
<Item.Description class="ui:min-h-[63px]">{description}</Item.Description>
{#if tags}
{@render tags()}
{/if}
{#if footer}
<Separator class="ui:my-3" />
{@render footer()}
{/if}
</Item.Content>
</a>
{:else}
<div {...props} class={cn('ui:block', props.class as string)}>
{@render overlay?.()}
<Item.Header>
<Item.Media variant="image" class="ui:relative ui:h-50! ui:w-full!">
{#if media}
{@render media()}
{:else}
<img
src={resolvedBannerImage}
alt={bannerAlt}
class="ui:w-full ui:h-full ui:rounded-sm ui:object-cover"
/>
{/if}
{#if typeBadge}
{@const Icon = typeBadge.icon}
<Badge
class="ui:absolute ui:bottom-2 ui:left-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:capitalize"
variant="secondary"
>
<Icon class={typeBadge.iconClass} />
{typeBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="ui:flex ui:w-full ui:min-h-14 ui:items-center ui:text-base!">
<span class="ui:line-clamp-2">{title}</span>
</Item.Title>
<Item.Description class="ui:min-h-[63px]">{description}</Item.Description>
{#if tags}
{@render tags()}
{/if}
{#if footer}
<Separator class="ui:my-3" />
{@render footer()}
{/if}
</Item.Content>
</div>
{/if}
{/snippet}
</Item.Root>
@@ -0,0 +1,2 @@
export { default as CourseCard } from './course-card.svelte';
export { DEFAULT_COURSE_BANNER_IMAGE } from './constants';
@@ -18,7 +18,9 @@ import { default as NumericEdit } from './renderers/numeric/edit.svelte';
import { default as OrderingEdit } from './renderers/ordering/edit.svelte';
import { default as RadioEdit } from './renderers/radio/edit.svelte';
import { default as LinkEdit } from './renderers/link/edit.svelte';
import { default as WordBankEdit } from './renderers/word-bank/edit.svelte';
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';
@@ -31,7 +33,9 @@ import { default as NumericTake } from './renderers/numeric/take.svelte';
import { default as OrderingTake } from './renderers/ordering/take.svelte';
import { default as RadioTake } from './renderers/radio/take.svelte';
import { default as LinkTake } from './renderers/link/take.svelte';
import { default as WordBankTake } from './renderers/word-bank/take.svelte';
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';
@@ -44,7 +48,9 @@ import { default as NumericPreview } from './renderers/numeric/preview.svelte';
import { default as OrderingPreview } from './renderers/ordering/preview.svelte';
import { default as RadioPreview } from './renderers/radio/preview.svelte';
import { default as LinkPreview } from './renderers/link/preview.svelte';
import { default as WordBankPreview } from './renderers/word-bank/preview.svelte';
import { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
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';
@@ -57,7 +63,9 @@ import { default as NumericSubmission } from './renderers/numeric/submission.sve
import { default as OrderingSubmission } from './renderers/ordering/submission.svelte';
import { default as RadioSubmission } from './renderers/radio/submission.svelte';
import { default as LinkSubmission } from './renderers/link/submission.svelte';
import { default as WordBankSubmission } from './renderers/word-bank/submission.svelte';
import { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
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';
@@ -66,6 +74,7 @@ import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
import { default as NumericReview } from './renderers/numeric/review.svelte';
import { default as ShortAnswerReview } from './renderers/short-answer/review.svelte';
import { default as FillBlankReview } from './renderers/fill-blank/review.svelte';
import { default as WordBankReview } from './renderers/word-bank/review.svelte';
import { default as OrderingReview } from './renderers/ordering/review.svelte';
import { default as TrueFalseReview } from './renderers/true-false/review.svelte';
@@ -150,6 +159,19 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
take: LinkTake,
preview: LinkPreview,
submission: LinkSubmission
},
[QUESTION_TYPE_KEY.WORD_BANK]: {
edit: WordBankEdit,
take: WordBankTake,
preview: WordBankPreview,
submission: WordBankSubmission,
review: WordBankReview
},
[QUESTION_TYPE_KEY.STAR]: {
edit: StarEdit,
take: StarTake,
preview: StarPreview,
submission: StarSubmission
}
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { ExternalLinkIcon, HoverableItem } from '../../../moving-icons';
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
@@ -35,15 +35,19 @@
{#each links as link, index}
<div class="ui:bg-background ui:rounded-md ui:border ui:p-2">
{#if isValidLink(link)}
<a
href={link}
target="_blank"
rel="noreferrer"
class="ui:text-primary ui:inline-flex ui:items-center ui:gap-2 ui:text-sm ui:underline"
>
<span>{formatLinkLabel(index)}</span>
<ExternalLinkIcon class="ui:size-3.5" />
</a>
<HoverableItem>
{#snippet children(isHovered)}
<a
href={link}
target="_blank"
rel="noreferrer"
class="ui:text-primary ui:inline-flex ui:items-center ui:gap-2 ui:text-sm ui:underline"
>
<span>{formatLinkLabel(index)}</span>
<ExternalLinkIcon {isHovered} size={14} class="ui:shrink-0" ariaHidden={true} />
</a>
{/snippet}
</HoverableItem>
<p class="ui:text-muted-foreground ui:mt-1 ui:text-xs">{link}</p>
{:else}
<p class="ui:text-sm">{link}</p>
@@ -0,0 +1,101 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import CircleQuestionMarkIcon from '@lucide/svelte/icons/circle-question-mark';
import { Input } from '../../../../base/input';
import { IconButton } from '../../../icon-button';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
}
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
function onCorrectValueChange(raw: string) {
const parsed = Number(raw);
if (raw.trim() === '' || Number.isNaN(parsed)) {
patchSettings({ correctValue: undefined });
return;
}
const clamped = Math.min(maxStars, Math.max(1, Math.floor(parsed)));
patchSettings({ correctValue: clamped });
}
function onMaxStarsChange(raw: string) {
const parsed = Number(raw);
if (raw.trim() === '' || Number.isNaN(parsed)) {
patchSettings({ maxStars: undefined });
return;
}
const nextMax = Math.min(10, Math.max(1, Math.floor(parsed)));
const rawCorrect = question.settings?.correctValue;
const currentCorrect = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
const nextCorrect = Number.isFinite(currentCorrect)
? Math.min(nextMax, Math.max(1, Math.floor(currentCorrect)))
: undefined;
if (nextCorrect !== undefined) {
patchSettings({ maxStars: nextMax, correctValue: nextCorrect });
return;
}
patchSettings({ maxStars: nextMax });
}
</script>
<div class="ui:space-y-3">
<div class="ui:grid ui:gap-3 ui:md:grid-cols-2">
<div class="ui:space-y-1">
<div class="ui:flex ui:items-center ui:gap-1">
<p class="ui:text-sm ui:font-medium">{label('star.edit.correct_value_label')}</p>
<IconButton type="button" class="ui:h-6 ui:w-6" {disabled} tooltip={label('star.edit.correct_value_info')}>
<CircleQuestionMarkIcon class="ui:size-3.5" />
<span class="ui:sr-only">{label('star.edit.correct_value_info')}</span>
</IconButton>
</div>
<Input
type="number"
min="1"
max={maxStars}
placeholder={label('star.edit.correct_value_placeholder')}
value={String((question.settings?.correctValue as number | undefined) ?? '')}
{disabled}
onchange={(event) => onCorrectValueChange(event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-1">
<div class="ui:flex ui:items-center ui:gap-1">
<p class="ui:text-sm ui:font-medium">{label('star.edit.max_stars_label')}</p>
<IconButton type="button" class="ui:h-6 ui:w-6" {disabled} tooltip={label('star.edit.max_stars_info')}>
<CircleQuestionMarkIcon class="ui:size-3.5" />
<span class="ui:sr-only">{label('star.edit.max_stars_info')}</span>
</IconButton>
</div>
<Input
type="number"
min="1"
max="10"
placeholder={label('star.edit.max_stars_placeholder')}
value={String((question.settings?.maxStars as number | undefined) ?? '')}
{disabled}
onchange={(event) => onMaxStarsChange(event.currentTarget.value)}
/>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
isValidStarRatingValue,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import StarRow from './star-row.svelte';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
const correctValue = $derived.by(() => {
const rawCorrect = question.settings?.correctValue;
const parsed = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
if (!isValidStarRatingValue(parsed, maxStars)) return 0;
return parsed;
});
</script>
<div class="ui:space-y-2">
<p class="ui:text-sm ui:font-medium">{label('star.preview.correct_label')}</p>
<StarRow
{maxStars}
value={correctValue}
disabled={true}
starLabel={(starIndex) =>
label('star.take.star_sr', 'Star {n} of {max}')
.replace('{n}', String(starIndex))
.replace('{max}', String(maxStars))}
/>
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import Star from '@lucide/svelte/icons/star';
let {
maxStars,
value,
disabled = false,
starLabel,
onPick
}: {
maxStars: number;
value: number;
disabled?: boolean;
starLabel: (starIndex: number) => string;
onPick?: (starIndex: number) => void;
} = $props();
const starIndices = $derived(Array.from({ length: maxStars }, (_, index) => index + 1));
</script>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1" role="group">
{#each starIndices as starIndex (starIndex)}
{#if onPick && !disabled}
<button
type="button"
class="ui:rounded-md ui:p-1 ui:text-amber-600 ui:transition-colors ui:hover:bg-amber-500/10 focus-visible:ui:ring-ring ui:outline-none focus-visible:ui:ring-2 ui:dark:text-amber-400"
aria-label={starLabel(starIndex)}
aria-pressed={value >= starIndex}
{disabled}
onclick={() => onPick(starIndex)}
>
<Star
class="ui:size-8 {starIndex <= value
? 'ui:fill-amber-400 ui:text-amber-500 ui:dark:fill-amber-400 ui:dark:text-amber-300'
: 'ui:fill-none ui:text-amber-200 ui:dark:text-amber-500/40'}"
/>
</button>
{:else}
<span class="ui:inline-flex ui:p-1" aria-hidden="true">
<Star
class="ui:size-8 {starIndex <= value
? 'ui:fill-amber-400 ui:text-amber-500 ui:dark:fill-amber-400 ui:dark:text-amber-300'
: 'ui:fill-none ui:text-amber-200 ui:dark:text-amber-500/40'}"
/>
</span>
{/if}
{/each}
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import GenericSubmission from '../submission-generic.svelte';
let {
question,
submissions = [],
labels,
maxSubmissionItems
}: Pick<ExerciseQuestionRendererProps, 'question' | 'submissions' | 'labels' | 'maxSubmissionItems'> = $props();
</script>
<GenericSubmission {question} {submissions} {labels} {maxSubmissionItems} />
@@ -0,0 +1,46 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import StarRow from './star-row.svelte';
let {
question,
answer = null,
disabled = false,
labels,
onAnswerChange = () => {}
}: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
const selectedValue = $derived.by(() => {
if (answer?.type !== 'STAR') return 0;
const value = answer.value;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maxStars) return 0;
return value;
});
function starLabel(starIndex: number) {
return label('star.take.star_sr', 'Star {n} of {max}')
.replace('{n}', String(starIndex))
.replace('{max}', String(maxStars));
}
</script>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-sm">{label('star.take.helper')}</p>
<StarRow
{maxStars}
value={selectedValue}
{disabled}
{starLabel}
onPick={(starIndex) => onAnswerChange({ type: 'STAR', value: starIndex })}
/>
</div>
@@ -17,6 +17,8 @@
return [answer.text];
case 'NUMERIC':
return [String(answer.value)];
case 'STAR':
return [String(answer.value)];
case 'LINK':
return answer.urls;
case 'FILE_UPLOAD':
@@ -0,0 +1,142 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { Button } from '../../../../base/button';
import { Input } from '../../../../base/input';
import { Textarea } from '../../../../base/textarea';
import { IconButton } from '../../../icon-button';
import { countWordBankBlanks } from './word-bank-utils';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
}
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const distractors = $derived.by(() => {
const raw = question.settings?.distractors;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
function onTemplateInput(value: string) {
const nextBlankCount = countWordBankBlanks(value);
const prev = correctAnswers;
const nextCorrect: string[] = [];
for (let i = 0; i < nextBlankCount; i++) {
nextCorrect.push(prev[i] ?? '');
}
patchSettings({ template: value, correctAnswers: nextCorrect });
}
function setCorrectAnswerAt(index: number, value: string) {
const next = [...correctAnswers];
next[index] = value;
patchSettings({ correctAnswers: next });
}
function addDistractor() {
patchSettings({ distractors: [...distractors, ''] });
}
function setDistractorAt(index: number, value: string) {
const next = [...distractors];
next[index] = value;
patchSettings({ distractors: next });
}
function removeDistractorAt(index: number) {
patchSettings({ distractors: distractors.filter((_, i) => i !== index) });
}
</script>
<div class="ui:space-y-4">
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.template_label')}</p>
<Textarea
class="ui:w-full"
rows={4}
value={template}
placeholder={label('word_bank.edit.template_placeholder')}
{disabled}
onchange={(event) => onTemplateInput(event.currentTarget.value)}
/>
<p class="ui:text-muted-foreground ui:text-xs">
{label('word_bank.edit.template_helper')}
</p>
{#if blankCount === 0 && template.trim().length > 0}
<p class="ui:text-destructive ui:text-xs">{label('word_bank.edit.no_blanks_warning')}</p>
{/if}
</div>
{#if blankCount > 0}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.correct_answers_heading')}</p>
<div class="ui:flex ui:flex-col ui:gap-2">
{#each Array.from({ length: blankCount }, (_, i) => i) as blankIndex (blankIndex)}
<div class="ui:flex ui:flex-col ui:gap-1 sm:ui:flex-row sm:ui:items-center sm:ui:gap-3">
<span class="ui:text-muted-foreground ui:min-w-18 ui:text-sm"
>{label('word_bank.edit.blank_label')} {blankIndex + 1}</span
>
<Input
class="ui:flex-1"
value={correctAnswers[blankIndex] ?? ''}
placeholder={label('word_bank.edit.correct_placeholder')}
{disabled}
onchange={(event) => setCorrectAnswerAt(blankIndex, event.currentTarget.value)}
/>
</div>
{/each}
</div>
</div>
{/if}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.distractors_heading')}</p>
<div class="ui:flex ui:flex-col ui:gap-2">
{#each distractors as _, distractorIndex (distractorIndex)}
<div class="ui:flex ui:items-center ui:gap-2">
<Input
class="ui:flex-1"
value={distractors[distractorIndex] ?? ''}
placeholder={label('word_bank.edit.distractor_placeholder')}
{disabled}
onchange={(event) => setDistractorAt(distractorIndex, event.currentTarget.value)}
/>
<IconButton
type="button"
class="ui:shrink-0 ui:text-muted-foreground"
{disabled}
tooltip={label('common.remove')}
onclick={() => removeDistractorAt(distractorIndex)}
>
<Trash2Icon class="ui:size-4" />
</IconButton>
</div>
{/each}
</div>
<Button type="button" variant="outline" size="sm" {disabled} onclick={addDistractor}>
<PlusIcon class="ui:mr-1 ui:size-4" />
{label('word_bank.edit.add_distractor')}
</Button>
</div>
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import { countWordBankBlanks, parseWordBankTemplate } from './word-bank-utils';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
const segments = $derived(parseWordBankTemplate(template));
</script>
<div class="ui:space-y-2">
{#if blankCount === 0}
<p class="ui:text-muted-foreground ui:text-sm">
{label('word_bank.preview.filled_label')}: {label('common.not_set')}
</p>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">{label('word_bank.preview.filled_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
{@const expected = correctAnswers[segment.index]?.trim()}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-primary ui:bg-background ui:px-2 ui:py-0.5"
>
{expected || label('common.not_set')}
</span>
{/if}
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,81 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import LabeledValueRow from '../shared/labeled-value-row.svelte';
import { countWordBankBlanks, parseWordBankTemplate } from './word-bank-utils';
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
const segments = $derived(parseWordBankTemplate(template));
const studentFilled = $derived(answer?.type === 'WORD_BANK' ? answer.filledBlanks : []);
function studentAtBlank(index: number): string {
const v = studentFilled[index];
return v != null && String(v).trim() ? String(v).trim() : label('common.not_set');
}
function correctAtBlank(index: number): string {
const v = correctAnswers[index];
return v != null && String(v).trim() ? String(v).trim() : label('common.not_set');
}
</script>
<div class="ui:space-y-3">
{#if blankCount === 0}
<p class="ui:text-muted-foreground ui:text-sm">{label('common.not_set')}</p>
{:else}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.review.your_answer_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-muted-foreground/40 ui:px-2 ui:py-0.5"
>
{studentAtBlank(segment.index)}
</span>
{/if}
{/each}
</div>
</div>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.review.correct_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-primary ui:px-2 ui:py-0.5"
>
{correctAtBlank(segment.index)}
</span>
{/if}
{/each}
</div>
</div>
<div class="ui:space-y-1">
{#each Array.from({ length: blankCount }, (_, i) => i) as blankIndex (blankIndex)}
<LabeledValueRow
label={`${label('word_bank.edit.blank_label')} ${blankIndex + 1}`}
value={`${studentAtBlank(blankIndex)} → ${correctAtBlank(blankIndex)}`}
/>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
question,
submissions = [],
labels,
maxSubmissionItems = 8
}: Pick<ExerciseQuestionRendererProps, 'question' | 'submissions' | 'labels' | 'maxSubmissionItems'> = $props();
const chartConfig = $derived({
responses: {
label: getSubmissionLabel(labels, 'submission.chart.responses', 'Responses'),
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig);
const series = $derived([
{
key: 'responses',
value: 'responses',
label: chartConfig.responses.label,
color: 'var(--color-responses)'
}
]);
function summarizeWordBankAnswer(answer: { filledBlanks: string[] } | null): string {
if (!answer || !Array.isArray(answer.filledBlanks)) return '';
return answer.filledBlanks.map((v) => String(v || '').trim()).join(' | ');
}
const chartData = $derived.by(() => {
const byValue = new Map<string, { key: string; label: string; responses: number }>();
let noAnswerCount = 0;
for (const submission of submissions || []) {
const answer = getAnswerForQuestion(submission, question);
if (!answer || answer.type !== 'WORD_BANK') {
noAnswerCount += 1;
continue;
}
const summary = summarizeWordBankAnswer(answer).trim().toLowerCase();
if (!summary) {
noAnswerCount += 1;
continue;
}
const key = summary;
const current = byValue.get(key);
if (!current) {
byValue.set(key, {
key,
label: summarizeWordBankAnswer(answer).trim() || key,
responses: 1
});
continue;
}
current.responses += 1;
}
const sorted = [...byValue.values()].sort((a, b) => b.responses - a.responses);
let rows = sorted;
if (rows.length > maxSubmissionItems) {
const kept = rows.slice(0, maxSubmissionItems);
const otherCount = rows.slice(maxSubmissionItems).reduce((sum, item) => sum + item.responses, 0);
rows = [
...kept,
{
key: 'other',
label: getSubmissionLabel(labels, 'submission.common.other', 'Other'),
responses: otherCount
}
];
}
if (noAnswerCount > 0) {
rows.push({
key: 'no-answer',
label: getSubmissionLabel(labels, 'submission.common.no_answer', 'No answer'),
responses: noAnswerCount
});
}
return withChartColors(rows.map((row) => ({ ...row, label: normalizeLabel(row.label, row.key) })));
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
@@ -0,0 +1,210 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import { scale } from 'svelte/transition';
import {
buildWordBankChips,
countWordBankBlanks,
filledBlanksFromPlacements,
matchFilledBlanksToChipIds,
parseWordBankTemplate,
shuffleWordBankChips,
type WordBankChip
} from './word-bank-utils';
let {
question,
answer,
disabled = false,
labels,
onAnswerChange = () => {}
}: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const distractors = $derived.by(() => {
const raw = question.settings?.distractors;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
const segments = $derived(parseWordBankTemplate(template));
const baseChips = $derived(buildWordBankChips(correctAnswers, distractors));
let shuffledChips = $state<WordBankChip[]>([]);
let chipsSourceKey = $state('');
/** chip id placed in each blank, or null */
let placementByBlank = $state<(string | null)[]>([]);
let placementSyncedKey = $state('');
const chipById = $derived.by(() => {
const map = new Map<string, WordBankChip>();
for (const c of baseChips) {
map.set(c.id, c);
}
return map;
});
function emitFromPlacements(next: (string | null)[]) {
placementByBlank = next;
const filled = filledBlanksFromPlacements(next, chipById);
onAnswerChange({ type: 'WORD_BANK', filledBlanks: filled });
}
function syncChipsOrder() {
const key = JSON.stringify({ template, ca: correctAnswers, d: distractors });
if (key === chipsSourceKey) return;
chipsSourceKey = key;
shuffledChips = shuffleWordBankChips(baseChips);
}
function syncPlacementFromAnswer() {
const key = `${chipsSourceKey}:${answer?.type === 'WORD_BANK' ? answer.filledBlanks.join('\0') : ''}:${blankCount}`;
if (key === placementSyncedKey) return;
placementSyncedKey = key;
const nextPlacement: (string | null)[] = Array.from({ length: blankCount }, () => null);
if (answer?.type === 'WORD_BANK' && answer.filledBlanks.length > 0) {
const matched = matchFilledBlanksToChipIds(answer.filledBlanks, baseChips);
for (let i = 0; i < blankCount; i++) {
nextPlacement[i] = matched[i] ?? null;
}
}
placementByBlank = nextPlacement;
}
$effect(() => {
syncChipsOrder();
syncPlacementFromAnswer();
});
function isChipPlaced(chipId: string): boolean {
return placementByBlank.some((id) => id === chipId);
}
function firstEmptyBlankIndex(): number {
const idx = placementByBlank.findIndex((id) => id === null);
return idx;
}
function placeChipInBlank(blankIndex: number, chipId: string) {
if (disabled || blankIndex < 0 || blankIndex >= blankCount) return;
const next = [...placementByBlank];
const prevChip = next[blankIndex];
if (prevChip === chipId) return;
for (let i = 0; i < next.length; i++) {
if (next[i] === chipId) next[i] = null;
}
if (prevChip) {
// returned to bank implicitly
}
next[blankIndex] = chipId;
emitFromPlacements(next);
}
function onChipClick(chip: WordBankChip) {
if (disabled || isChipPlaced(chip.id)) return;
const blankIndex = firstEmptyBlankIndex();
if (blankIndex < 0) return;
placeChipInBlank(blankIndex, chip.id);
}
function clearBlank(blankIndex: number) {
if (disabled) return;
const next = [...placementByBlank];
next[blankIndex] = null;
emitFromPlacements(next);
}
function onDragStart(event: DragEvent, chipId: string) {
if (disabled) return;
event.dataTransfer?.setData('text/plain', chipId);
event.dataTransfer!.effectAllowed = 'move';
}
function onBlankDragOver(event: DragEvent) {
if (disabled) return;
event.preventDefault();
event.dataTransfer!.dropEffect = 'move';
}
function onBlankDrop(event: DragEvent, blankIndex: number) {
if (disabled) return;
event.preventDefault();
const chipId = event.dataTransfer?.getData('text/plain');
if (!chipId) return;
if (isChipPlaced(chipId)) return;
placeChipInBlank(blankIndex, chipId);
}
</script>
<div class="ui:space-y-4">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.take.helper')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#if blankCount === 0}
<span class="ui:text-muted-foreground">{template || label('common.not_set')}</span>
{:else}
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
{@const blankIndex = segment.index}
{@const chipId = placementByBlank[blankIndex] ?? null}
{@const text = chipId ? (chipById.get(chipId)?.label ?? '') : ''}
<button
type="button"
class="ui:inline-flex ui:align-middle ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:max-w-[16rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:px-2 ui:py-0.5 ui:text-left ui:text-sm ui:transition-all ui:duration-200 ui:border-primary disabled:ui:cursor-not-allowed"
class:ui:border-dashed={!text}
class:ui:bg-background={!text}
class:ui:border-solid={!!text}
{disabled}
onclick={() => text && clearBlank(blankIndex)}
aria-label={label('word_bank.take.clear_blank_sr')}
ondragover={onBlankDragOver}
ondrop={(e) => onBlankDrop(e, blankIndex)}
>
{#if text}
<span in:scale={{ duration: 180, start: 0.92 }}>{text}</span>
{:else}
<span class="ui:text-muted-foreground ui:select-none">&nbsp;</span>
{/if}
</button>
{/if}
{/each}
{/if}
</div>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.take.bank_heading')}</p>
<div class="ui:flex ui:flex-wrap ui:gap-2">
{#each shuffledChips as chip (chip.id)}
{@const placed = isChipPlaced(chip.id)}
<button
type="button"
draggable={!disabled && !placed}
class="ui:rounded ui:border-2 ui:border-primary ui:bg-primary ui:px-3 ui:py-1.5 ui:text-sm ui:text-primary-foreground ui:transition-opacity disabled:ui:cursor-not-allowed"
class:ui:opacity-40={placed}
class:ui:pointer-events-none={placed}
{disabled}
onclick={() => onChipClick(chip)}
ondragstart={(e) => onDragStart(e, chip.id)}
>
{chip.label || '—'}
</button>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,99 @@
/** Marker teachers use in the template for each blank (three underscores). */
export const WORD_BANK_BLANK_MARKER = '___';
export type WordBankSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
export function countWordBankBlanks(template: string): number {
if (!template) return 0;
const parts = template.split(WORD_BANK_BLANK_MARKER);
return Math.max(0, parts.length - 1);
}
/** Splits template into alternating text runs and blank slots (indices0..n-1). */
export function parseWordBankTemplate(template: string): WordBankSegment[] {
const segments: WordBankSegment[] = [];
if (!template) return segments;
const parts = template.split(WORD_BANK_BLANK_MARKER);
let blankIndex = 0;
for (let i = 0; i < parts.length; i++) {
if (parts[i].length > 0) {
segments.push({ type: 'text', value: parts[i] });
}
if (i < parts.length - 1) {
segments.push({ type: 'blank', index: blankIndex });
blankIndex += 1;
}
}
return segments;
}
export type WordBankChip = { id: string; label: string };
/** One chip per correct blank (by index) plus each distractor; labels may repeat. */
export function buildWordBankChips(correctAnswers: string[], distractors: string[]): WordBankChip[] {
const chips: WordBankChip[] = [];
correctAnswers.forEach((raw, index) => {
const label = String(raw ?? '').trim();
chips.push({ id: `correct-${index}`, label });
});
distractors.forEach((raw, index) => {
const label = String(raw ?? '').trim();
if (!label) return;
chips.push({ id: `distractor-${index}`, label });
});
return chips;
}
/** Fisher–Yates shuffle (copy). */
export function shuffleWordBankChips<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
/**
* Maps student answer strings to chip ids (first unused chip matching each label, in order).
*/
export function matchFilledBlanksToChipIds(filledBlanks: string[], chips: WordBankChip[]): (string | null)[] {
const used = new Set<string>();
const result: (string | null)[] = [];
for (const raw of filledBlanks) {
const target = String(raw ?? '')
.trim()
.toLowerCase();
if (!target) {
result.push(null);
continue;
}
const chip = chips.find((c) => !used.has(c.id) && String(c.label).trim().toLowerCase() === target);
if (chip) {
used.add(chip.id);
result.push(chip.id);
} else {
result.push(null);
}
}
return result;
}
export function filledBlanksFromPlacements(
placementByBlank: (string | null)[],
chipById: Map<string, WordBankChip>
): string[] {
return placementByBlank.map((id) => {
if (!id) return '';
return chipById.get(id)?.label ?? '';
});
}
@@ -12,12 +12,32 @@
let { source, options = {}, class: className = '' }: Props = $props();
const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid);
const isGoogleDrive = $derived(source.type === 'google_drive');
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
const iframeTitle = $derived(source.metadata?.title?.trim() || 'Video');
const iframeMaxHeight = $derived(options.maxHeight ?? '400px');
const iframeWidth = $derived(options.width ?? '100%');
</script>
<div class={className}>
{#if isMuse}
<MusePlayer svid={source.metadata?.svid} {options} />
{:else if isGoogleDrive}
<div
class="ui:relative ui:overflow-hidden ui:rounded-md ui:border ui:border-border"
style:max-height={iframeMaxHeight}
style:width={iframeWidth}
>
<iframe
src={source.url}
title={iframeTitle}
class="ui:block ui:h-full ui:min-h-[240px] ui:w-full ui:border-0"
style:aspect-ratio="16 / 9"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowfullscreen
></iframe>
</div>
{:else}
<PlyrPlayer src={source.url} {poster} {options} />
{/if}
+2 -1
View File
@@ -1,4 +1,4 @@
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse';
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_drive';
export interface VideoSource {
type: VideoType;
@@ -6,6 +6,7 @@ export interface VideoSource {
metadata?: {
svid?: string;
thumbnailUrl?: string;
title?: string;
};
}
@@ -0,0 +1,109 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="braces" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="braces-icon"
class:motion-active={showMotion}
>
<g class="brace-left-wrap">
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2v5a2 2 0 0 0 2 2h1" />
</g>
<g class="brace-right-wrap">
<path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2v5a2 2 0 0 1-2 2h-1" />
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.braces-icon {
overflow: visible;
}
.brace-left-wrap,
.brace-right-wrap {
transform-box: fill-box;
transform-origin: center;
}
.braces-icon.motion-active .brace-left-wrap {
animation: braceLeft 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) forwards;
}
.braces-icon.motion-active .brace-right-wrap {
animation: braceRight 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) 0.07s forwards;
}
@keyframes braceLeft {
0%,
100% {
transform: translateX(0);
}
18% {
transform: translateX(-2.5px);
}
42% {
transform: translateX(0.6px);
}
65% {
transform: translateX(-0.35px);
}
}
@keyframes braceRight {
0%,
100% {
transform: translateX(0);
}
18% {
transform: translateX(2.5px);
}
42% {
transform: translateX(-0.6px);
}
65% {
transform: translateX(0.35px);
}
}
</style>
@@ -1,26 +1,36 @@
<script>
/**
* @typedef {Object} Props
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {string} [class]
*/
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
/** Local pulse when the icon itself is moused; row hover uses the `isHovered` prop from `HoverableItem`. */
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
isHovered = true;
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
isHovered = false;
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="layout-dashboard" role="img" onmouseenter={handleMouseEnter}>
<div class={className} aria-label="layout-grid" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
@@ -31,13 +41,13 @@
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="layout-dashboard-icon"
class:animate={isHovered}
class="layout-grid-icon"
class:motion-active={showMotion}
>
<rect width="7" height="9" x="3" y="3" rx="1" class="ui:top-left-panel" />
<rect width="7" height="5" x="14" y="3" rx="1" class="ui:top-right-panel" />
<rect width="7" height="9" x="14" y="12" rx="1" class="ui:bottom-right-panel" />
<rect width="7" height="5" x="3" y="16" rx="1" class="ui:bottom-left-panel" />
<rect width="7" height="7" x="3" y="3" rx="1" class="top-left-panel" />
<rect width="7" height="7" x="14" y="3" rx="1" class="top-right-panel" />
<rect width="7" height="7" x="14" y="14" rx="1" class="bottom-right-panel" />
<rect width="7" height="7" x="3" y="14" rx="1" class="bottom-left-panel" />
</svg>
</div>
@@ -45,28 +55,29 @@
div {
display: inline-block;
}
.layout-dashboard-icon {
.layout-grid-icon {
overflow: visible;
}
.layout-dashboard-icon.animate .top-left-panel {
.layout-grid-icon.motion-active .top-right-panel {
opacity: 0;
animation: fadeInLeft 0.35s ease-out forwards;
animation: fadeInTop 0.35s ease-out forwards;
}
.layout-dashboard-icon.animate .bottom-right-panel {
.layout-grid-icon.motion-active .bottom-right-panel {
opacity: 0;
animation: fadeInRight 0.35s ease-out 0.1s forwards;
}
.layout-dashboard-icon.animate .top-right-panel {
.layout-grid-icon.motion-active .bottom-left-panel {
opacity: 0;
animation: fadeInTop 0.35s ease-out 0.2s forwards;
animation: fadeInBottom 0.35s ease-out 0.2s forwards;
}
.layout-dashboard-icon.animate .bottom-left-panel {
.layout-grid-icon.motion-active .top-left-panel {
opacity: 0;
animation: fadeInBottom 0.35s ease-out 0.3s forwards;
animation: fadeInLeft 0.35s ease-out 0.3s forwards;
}
@keyframes fadeInLeft {
@@ -0,0 +1,81 @@
<script>
/**
* @typedef {Object} Props
* @property {boolean} [isHovered] - from parent HoverableItem; combined with internal mouseenter pulse
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [ariaHidden] - when true, icon is treated as decorative (e.g. next to visible text)
* @property {string} [class]
*/
/** @type {Props} */
let {
isHovered = false,
color = 'currentColor',
size = 24,
strokeWidth = 2,
ariaHidden = false,
class: className = ''
} = $props();
let pulseFromMouseEnter = $state(false);
function handleMouseEnter() {
if (pulseFromMouseEnter) return;
pulseFromMouseEnter = true;
setTimeout(() => {
pulseFromMouseEnter = false;
}, 500);
}
const arrowAnimates = $derived(isHovered || pulseFromMouseEnter);
</script>
<div
class={className}
aria-hidden={ariaHidden ? true : undefined}
aria-label={ariaHidden ? undefined : 'square-arrow-out-up-right'}
role={ariaHidden ? undefined : 'img'}
onmouseenter={handleMouseEnter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
<g class="arrow" class:animate={arrowAnimates}>
<path d="m21 3-9 9" />
<path d="M15 3h6v6" />
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.arrow path {
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.animate {
animation: moveUpRight 0.5s;
}
@keyframes moveUpRight {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-2px, 2px);
}
}
</style>
@@ -1,25 +1,45 @@
<script>
/**
* @typedef {Object} Props
* @property {boolean} [isHovered] - from parent HoverableItem; combined with internal mouseenter pulse
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {boolean} [ariaHidden] - when true, icon is treated as decorative (e.g. next to visible text)
* @property {string} [class]
*/
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
let {
isHovered = false,
color = 'currentColor',
size = 24,
strokeWidth = 2,
ariaHidden = false,
class: className = ''
} = $props();
let pulseFromMouseEnter = $state(false);
function handleMouseEnter() {
isHovered = true;
if (pulseFromMouseEnter) return;
pulseFromMouseEnter = true;
setTimeout(() => {
isHovered = false;
pulseFromMouseEnter = false;
}, 800);
}
const iconAnimates = $derived(isHovered || pulseFromMouseEnter);
</script>
<div class={className} aria-label="house" role="img" onmouseenter={handleMouseEnter}>
<div
class={className}
aria-hidden={ariaHidden ? true : undefined}
aria-label={ariaHidden ? undefined : 'house'}
role={ariaHidden ? undefined : 'img'}
onmouseenter={handleMouseEnter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
@@ -31,7 +51,7 @@
stroke-linecap="round"
stroke-linejoin="round"
class="house-icon"
class:animate={isHovered}
class:animate={iconAnimates}
>
<path
d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
@@ -1,4 +1,5 @@
export { default as AnalyticsIcon } from './analytics.svelte';
export { default as ApiIcon } from './api.svelte';
export { default as AttendanceIcon } from './attendance.svelte';
export { default as AttachmentIcon } from './attachment.svelte';
export { default as CertificateIcon } from './certificate.svelte';
@@ -9,6 +10,7 @@ export { default as CourseIcon } from './course.svelte';
export { default as DashboardIcon } from './dashboard.svelte';
export { default as ExerciseIcon } from './exercise.svelte';
export { default as ExploreIcon } from './explore.svelte';
export { default as ExternalLinkIcon } from './external-link.svelte';
export { default as GoalIcon } from './goal.svelte';
export { default as HeaderIcon } from './header.svelte';
export { default as HomeIcon } from './home.svelte';
@@ -26,4 +28,6 @@ export { default as ReviewIcon } from './review.svelte';
export { default as SettingsIcon } from './settings.svelte';
export { default as SetupIcon } from './setup.svelte';
export { default as SubmissionIcon } from './submission.svelte';
export { default as TagIcon } from './tag.svelte';
export { default as UploadIcon } from './upload.svelte';
export { default as ZapIcon } from './zap.svelte';
@@ -0,0 +1,77 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered = false,
class: className = ''
}: IconProps = $props();
function handleMouseEnter() {
if (isHovered) return;
isHovered = true;
setTimeout(() => {
isHovered = false;
}, 500);
}
</script>
<div class={className} aria-label="tag" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="tag-icon"
class:animate={isHovered}
>
<path
d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"
/>
<circle cx="7.5" cy="7.5" r=".5" fill={color} />
</svg>
</div>
<style>
div {
display: inline-block;
}
.tag-icon {
overflow: visible;
}
.tag-icon.animate {
transform-origin: top left;
animation: tagRotate 0.5s ease-in-out;
}
@keyframes tagRotate {
0% {
transform: rotate(0deg);
}
40% {
transform: rotate(10deg);
}
80% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="zap" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="zap-icon"
class:motion-active={showMotion}
>
<g class="zap-wrap">
<path
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
/>
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.zap-icon {
overflow: visible;
}
.zap-wrap {
transform-box: fill-box;
transform-origin: center;
}
.zap-icon.motion-active .zap-wrap {
animation: zapJolt 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) forwards;
}
@keyframes zapJolt {
0%,
100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
18% {
transform: translate(0.5px, -2px) rotate(-9deg) scale(1.07);
}
40% {
transform: translate(-0.5px, 1px) rotate(7deg) scale(1.03);
}
62% {
transform: translate(0.25px, -0.25px) rotate(-3deg) scale(1.01);
}
}
</style>
@@ -0,0 +1,22 @@
import NewsfeedReactions from './newsfeed-reactions.svelte';
import {
NEWSFEED_REACTION_OPTIONS,
buildNextNewsfeedReaction,
getNewsfeedReactionCounts,
getSelectedNewsfeedReactionType,
type NewsfeedReactionCounts,
type NewsfeedReactionRecord,
type NewsfeedReactionType
} from './newsfeed-reactions';
export {
NewsfeedReactions,
NEWSFEED_REACTION_OPTIONS,
buildNextNewsfeedReaction,
getNewsfeedReactionCounts,
getSelectedNewsfeedReactionType,
type NewsfeedReactionCounts,
type NewsfeedReactionRecord,
type NewsfeedReactionType
};
export { NewsfeedReactions as default };
@@ -0,0 +1,102 @@
<script lang="ts">
import * as Popover from '../../base/popover';
import { Button } from '../../base/button';
import { cn } from '../../tools';
import SmileIcon from '@lucide/svelte/icons/smile';
import {
NEWSFEED_REACTION_OPTIONS,
type NewsfeedReactionCounts,
type NewsfeedReactionType
} from './newsfeed-reactions';
interface Props {
reactionCounts?: NewsfeedReactionCounts;
selectedReactionType?: NewsfeedReactionType | null;
disabled?: boolean;
class?: string;
open?: boolean;
onReactionToggle?: (reactionType: NewsfeedReactionType) => void | Promise<void>;
}
let {
reactionCounts = {},
selectedReactionType = null,
disabled = false,
class: className = '',
open = $bindable(false),
onReactionToggle = () => {}
}: Props = $props();
const hasVisibleReactions = $derived(
NEWSFEED_REACTION_OPTIONS.some((reactionOption) => (reactionCounts[reactionOption.type] ?? 0) > 0)
);
function handleReactionToggle(reactionType: NewsfeedReactionType) {
if (disabled) {
return;
}
open = false;
void onReactionToggle(reactionType);
}
</script>
<div class={cn('ui:flex ui:items-center ui:gap-2', className)}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant={open ? 'light-default' : 'outline'}
size="icon-sm"
class="ui:rounded-xl ui:shadow-none"
{disabled}
aria-label="Open reactions"
>
<SmileIcon class="ui:size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content side="top" align="start" sideOffset={10} class="ui:w-auto ui:max-w-none ui:p-1!">
<div class="ui:grid ui:grid-cols-4 ui:gap-2">
{#each NEWSFEED_REACTION_OPTIONS as reactionOption (reactionOption.type)}
<Button
type="button"
variant={selectedReactionType === reactionOption.type ? 'light-default' : 'ghost'}
size="icon-xs"
class="ui:rounded-full"
{disabled}
aria-label={reactionOption.label}
onclick={() => handleReactionToggle(reactionOption.type)}
>
<span>{reactionOption.emoji}</span>
</Button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
{#if hasVisibleReactions}
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1">
{#each NEWSFEED_REACTION_OPTIONS as reactionOption (reactionOption.type)}
{@const count = reactionCounts[reactionOption.type] ?? 0}
{#if count > 0}
<Button
type="button"
variant={selectedReactionType === reactionOption.type ? 'light-default' : 'ghost'}
size="sm"
class="ui:h-8 ui:rounded-full ui:px-3"
{disabled}
aria-label={reactionOption.label}
onclick={() => handleReactionToggle(reactionOption.type)}
>
<span aria-hidden="true" class="ui:text-sm ui:leading-none">{reactionOption.emoji}</span>
<span class="ui:text-xs ui:font-medium">{count}</span>
</Button>
{/if}
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,78 @@
export const NEWSFEED_REACTION_OPTIONS = [
{ type: 'clap', emoji: '👏', label: 'Clap' },
{ type: 'smile', emoji: '😀', label: 'Smile' },
{ type: 'thumbsup', emoji: '👍', label: 'Thumbs up' },
{ type: 'thumbsdown', emoji: '👎', label: 'Thumbs down' }
] as const;
export type NewsfeedReactionType = (typeof NEWSFEED_REACTION_OPTIONS)[number]['type'];
export type NewsfeedReactionCounts = Partial<Record<NewsfeedReactionType, number>>;
export type NewsfeedReactionRecord = Record<NewsfeedReactionType, string[]>;
const EMPTY_REACTION_RECORD: NewsfeedReactionRecord = {
clap: [],
smile: [],
thumbsup: [],
thumbsdown: []
};
function cloneReactionRecord(reaction: Partial<NewsfeedReactionRecord> | undefined): NewsfeedReactionRecord {
return {
clap: [...(reaction?.clap ?? EMPTY_REACTION_RECORD.clap)],
smile: [...(reaction?.smile ?? EMPTY_REACTION_RECORD.smile)],
thumbsup: [...(reaction?.thumbsup ?? EMPTY_REACTION_RECORD.thumbsup)],
thumbsdown: [...(reaction?.thumbsdown ?? EMPTY_REACTION_RECORD.thumbsdown)]
};
}
export function getNewsfeedReactionCounts(
reaction: Partial<NewsfeedReactionRecord> | undefined
): NewsfeedReactionCounts {
return {
clap: reaction?.clap?.length ?? 0,
smile: reaction?.smile?.length ?? 0,
thumbsup: reaction?.thumbsup?.length ?? 0,
thumbsdown: reaction?.thumbsdown?.length ?? 0
};
}
export function getSelectedNewsfeedReactionType(
reaction: Partial<NewsfeedReactionRecord> | undefined,
authorId: string
): NewsfeedReactionType | null {
if (!authorId) {
return null;
}
for (const option of NEWSFEED_REACTION_OPTIONS) {
if (reaction?.[option.type]?.includes(authorId)) {
return option.type;
}
}
return null;
}
export function buildNextNewsfeedReaction(
reaction: Partial<NewsfeedReactionRecord> | undefined,
reactionType: NewsfeedReactionType,
authorId: string
): NewsfeedReactionRecord {
if (!authorId) {
return cloneReactionRecord(reaction);
}
const nextReaction = cloneReactionRecord(reaction);
const selectedReactionType = getSelectedNewsfeedReactionType(reaction, authorId);
for (const option of NEWSFEED_REACTION_OPTIONS) {
nextReaction[option.type] = nextReaction[option.type].filter((memberId) => memberId !== authorId);
}
if (selectedReactionType !== reactionType) {
nextReaction[reactionType] = [...nextReaction[reactionType], authorId];
}
return nextReaction;
}
@@ -0,0 +1,145 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
import BoldHero from './bold/hero.svelte';
import { Button } from '../../base/button';
import * as Card from '../../base/card';
import { BlurFade } from '../animation/blurfade';
import { BorderBeam } from '../animation/border-beam';
import { DotPattern } from '../animation/dot-pattern';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<BoldHero {hero} />
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden">
<DotPattern class="ui:opacity-[0.15]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">Latest Courses</h2>
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<Card.Root
class="ui:group ui:relative ui:overflow-hidden ui:bg-background ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-border/50 {disableCourseLinks
? ''
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
>
<BorderBeam
size={120}
duration={8}
colorFrom="var(--primary)"
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
/>
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<span>{course.duration}</span>
</div>
{/if}
{#if primaryTag}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TagIcon class="ui:size-3.5 ui:text-primary" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg"
>{course.price || formatCurrency(course.cost, course.currency)}</span
>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
Details
</Button>
</div>
</Card.Content>
</Card.Root>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
size="lg"
class="ui:rounded-xl ui:px-8"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</div>
</section>
</main>
<OrgLandingPageEmbed {embed} variant="bold" />
<OrgLandingPageCallout {callout} variant="bold" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="bold" />
</div>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
interface Props {
hero: OrgLandingPageProps['hero'];
}
let { hero }: Props = $props();
</script>
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
<div>
<BlurFade delay={0} once={true} class="ui:mb-6">
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<p class="ui:text-xl ui:text-muted-foreground ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
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"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
{/if}
</div>
</BlurFade>
</div>
{#if hero.image}
<BlurFade delay={0.2} once={true} class="ui:relative ui:hidden ui:lg:block">
<div
class="ui:absolute ui:inset-0 ui:bg-primary/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
></div>
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
</BlurFade>
{/if}
</section>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<header class="ui:sticky ui:top-0 ui:z-10 ui:bg-background/90 ui:backdrop-blur-xl ui:border-b ui:border-border/40">
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
<a href="/" class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:gap-8 ui:items-center">
{#each navItems as item}
<a
href={item.href}
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-primary ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button href={authAction.href} variant="outline" class="ui:rounded-xl ui:font-bold">
{authAction.label}
</Button>
{/if}
</div>
</div>
</header>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { LandingPageCallout } from './types';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: 'minimal' | 'bold' | 'classic' } =
$props();
const sectionClasses: Record<string, string> = {
minimal: 'ui:py-20 ui:px-6',
bold: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background'
};
const headingClasses: Record<string, string> = {
minimal: 'ui:text-3xl ui:font-semibold ui:tracking-tight',
bold: 'ui:text-4xl ui:lg:text-5xl ui:font-black ui:tracking-tighter',
classic: 'ui:text-3xl ui:font-bold ui:tracking-tight'
};
const descriptionClasses: Record<string, string> = {
minimal: 'ui:text-lg ui:text-foreground/70',
bold: 'ui:text-xl ui:text-background/70',
classic: 'ui:text-lg ui:text-background/80'
};
const buttonClasses: Record<string, string> = {
minimal: 'ui:rounded-full',
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20',
classic: 'ui:px-8 ui:font-semibold'
};
const minimalBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 10%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 4%, var(--background)) 100%
)
`;
</script>
{#if callout}
<section
class={sectionClasses[variant]}
style={variant === 'minimal' ? `background: ${minimalBackground};` : undefined}
>
<div class="ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
<BlurFade delay={0} once={true}>
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
</BlurFade>
{#if callout.description}
<BlurFade delay={0.15} once={true}>
<p class={descriptionClasses[variant]}>{callout.description}</p>
</BlurFade>
{/if}
<BlurFade delay={0.3} once={true}>
<Button
href={callout.action.href}
size="lg"
class={buttonClasses[variant]}
variant={variant === 'bold' || variant === 'classic' ? 'secondary' : 'default'}
>
{callout.action.label}
</Button>
</BlurFade>
</div>
</section>
{/if}
@@ -0,0 +1,152 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
import ClassicHero from './classic/hero.svelte';
import { Button } from '../../base/button';
import * as Item from '../../base/item';
import { BlurFade } from '../animation/blurfade';
import { CourseCard } from '../course-card';
import TagIcon from '@lucide/svelte/icons/tag';
import { calcCourseDiscount, formatExerciseCountLabel, getPrimaryCourseTag } from './landing-page-utils';
import { cn } from '../../tools';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
const COURSE_TAG_ICON: Component = TagIcon;
</script>
<div class="ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans">
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<ClassicHero {hero} />
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
<div class="ui:text-center ui:mb-8">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">Featured Programs</h2>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
</BlurFade>
</div>
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
{@const pricingData = {
cost: course.cost ?? 0,
discount: course.metadata?.discount ?? 0,
showDiscount: !!course.metadata?.showDiscount
}}
{@const displayCost = calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount)}
{@const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD'
})}
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
<CourseCard
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
bannerImage={course.logo || ''}
bannerAlt={course.title}
title={course.title}
description={course.description}
typeBadge={primaryTag
? {
label: primaryTag.name,
icon: COURSE_TAG_ICON,
iconClass: primaryTag.color ? '' : 'custom ui:text-primary'
}
: undefined}
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
>
{#snippet tags()}
{#if course.tags && course.tags.length > 0}
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
{#each course.tags as tag (tag.id)}
<Item.SubDescription
class="ui:border-border ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
>
<span
class="ui:bg-primary/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="ui:flex ui:justify-between ui:w-full">
<div class="ui:w-[80%]">
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
<span>{course.lessonCount || 0} lessons</span>
&
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
</p>
<div class="ui:py-2 ui:text-xs">
{#if course.price != null && String(course.price).length > 0}
<span class="ui:px-2">{course.price}</span>
{:else if !displayCost}
<span class="ui:px-2">Free</span>
{:else if pricingData.showDiscount}
<span class="ui:px-2">
{currencyFormatter.format(displayCost)}
<span class="line-through">
{currencyFormatter.format(pricingData.cost)}
</span>
</span>
{:else}
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
{/if}
</div>
</div>
</div>
{/snippet}
</CourseCard>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
class="ui:px-8"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</section>
</main>
<OrgLandingPageEmbed {embed} variant="classic" />
<OrgLandingPageCallout {callout} variant="classic" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="classic" />
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { BlurIn } from '../../animation/blurin';
import { DotPattern } from '../../animation/dot-pattern';
interface Props {
hero: OrgLandingPageProps['hero'];
}
let { hero }: Props = $props();
</script>
<section
class="ui:relative ui:bg-foreground ui:text-background ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
>
{#if hero.image}
<div class="ui:absolute ui:inset-0 ui:opacity-10">
<img src={hero.image} alt="Background" class="ui:w-full ui:h-full ui:object-cover ui:grayscale" />
</div>
{/if}
<DotPattern fillColor="rgb(255 255 255 / 0.35)" class="ui:opacity-[0.06]" />
<div class="ui:relative ui:max-w-3xl ui:mx-auto ui:text-center">
<BlurIn
class="ui:text-4xl ui:sm:text-5xl ui:lg:text-6xl ui:font-bold ui:tracking-tight ui:mb-6 ui:leading-tight ui:drop-shadow-none"
>
{hero.heading}
</BlurIn>
<BlurFade delay={0.15} once={true}>
<p class="ui:text-lg ui:sm:text-xl ui:text-background/80 ui:mb-10">{hero.subheading}</p>
</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">
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="classic"
/>
{/if}
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<header class="ui:bg-foreground ui:text-background">
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
<a href="/" class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:space-x-8">
{#each navItems as item}
<a
href={item.href}
class="ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button
href={authAction.href}
variant="outline"
class="ui:bg-transparent ui:text-background ui:border-background/40 ui:hover:bg-background/10 ui:hover:text-background ui:hover:border-background/60"
>
{authAction.label}
</Button>
{/if}
</div>
</div>
</header>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
import SecondaryActionButton from './secondary-action-button.svelte';
import { BlurFade } from '../animation/blurfade';
let { embed, variant = 'minimal' }: { embed?: LandingPageEmbed; variant?: OrgLandingPageTheme } = $props();
</script>
{#if embed}
<section class="ui:py-12 ui:px-6 ui:md:px-8">
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
<BlurFade delay={0} once={true}>
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
</BlurFade>
{#if embed.description?.trim()}
<BlurFade delay={0.12} once={true}>
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-muted-foreground">
{embed.description}
</p>
</BlurFade>
{/if}
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
<BlurFade delay={0.2} once={true}>
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
</BlurFade>
{/if}
{#if embed.code}
<BlurFade delay={0.28} once={true}>
<div class="ui:flex ui:w-full ui:justify-center">
{@html embed.code}
</div>
</BlurFade>
{/if}
</div>
</section>
{/if}
@@ -0,0 +1,14 @@
export { default as MinimalLandingPage } from './minimal.svelte';
export { default as BoldLandingPage } from './bold.svelte';
export { default as ClassicLandingPage } from './classic.svelte';
export { default as BoldLandingNav } from './bold/nav.svelte';
export { default as BoldLandingHero } from './bold/hero.svelte';
export { default as ClassicLandingNav } from './classic/nav.svelte';
export { default as ClassicLandingHero } from './classic/hero.svelte';
export { default as MinimalLandingNav } from './minimal/nav.svelte';
export { default as MinimalLandingHero } from './minimal/hero.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte';
export { default as OrgLandingPageFooter } from './landing-page-footer.svelte';
export { default as SecondaryActionButton } from './secondary-action-button.svelte';
export * from './types';
@@ -0,0 +1,52 @@
<script lang="ts">
import type { OrgLandingPageProps, OrgLandingPageTheme } from './types';
interface Props {
orgName: string;
logoUrl?: string;
footerLinks: OrgLandingPageProps['footerLinks'];
footerText: string;
variant: OrgLandingPageTheme;
}
let { orgName, logoUrl, footerLinks, footerText, variant }: Props = $props();
</script>
{#if variant === 'minimal'}
<footer
class="ui:py-12 ui:px-8 ui:max-w-[1200px] ui:mx-auto ui:border-t ui:border-border/50 ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-4 ui:text-sm ui:text-muted-foreground"
>
<a href="/" class="ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
{#each footerLinks as link}
<a href={link.href} class="ui:hover:text-foreground ui:transition-colors">{link.label}</a>
{/each}
</div>
<p>{footerText}</p>
</footer>
{:else}
<footer class="ui:bg-foreground ui:text-background/60 ui:py-16 ui:px-6">
<div class="ui:max-w-7xl ui:mx-auto ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-8">
<a
href="/"
class="ui:font-black ui:text-2xl ui:text-background ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto ui:grayscale ui:brightness-200" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
{#each footerLinks as link}
<a href={link.href} class="ui:hover:text-background ui:font-medium ui:transition-colors">{link.label}</a>
{/each}
</div>
<p class="ui:text-sm">{footerText}</p>
</div>
</footer>
{/if}
@@ -0,0 +1,19 @@
import type { CourseItem } from './types';
export function getPrimaryCourseTag(course: CourseItem) {
return course.tags?.[0];
}
/** Plain copy for org landing templates (no dashboard i18n). */
export function formatExerciseCountLabel(count: number) {
if (count === 1) return '1 exercise';
return `${count} exercises`;
}
/** Mirrors dashboard `calcCourseDiscount` for landing-page course cards. */
export function calcCourseDiscount(percent = 0, cost: number, showDiscount: boolean) {
if (!percent || !showDiscount) return cost;
const discountAmount = (percent / 100) * cost;
const discountedPrice = cost - discountAmount;
return Math.round(discountedPrice);
}
@@ -0,0 +1,122 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import MinimalNav from './minimal/nav.svelte';
import MinimalHero from './minimal/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import * as Card from '../../base/card';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
<main>
<MinimalHero {hero}>
{#snippet navigation()}
<MinimalNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</MinimalHero>
<section class="ui:py-20 ui:px-4">
<div class="ui:max-w-[1200px] ui:mx-auto">
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">Our Courses</h2>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<a
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<Card.Root
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
? ''
: 'ui:hover:border-border ui:transition-colors'}"
>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
{course.description}
</Card.Description>
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
{#if course.duration}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<ClockIcon class="ui:size-4" />
<span>{course.duration}</span>
</div>
{/if}
{#if primaryTag}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<TagIcon class="ui:size-4" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
<div class="ui:ml-auto ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</div>
</div>
</Card.Content>
</Card.Root>
</a>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-10 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</div>
</section>
</main>
<OrgLandingPageEmbed {embed} variant="minimal" />
<OrgLandingPageCallout {callout} variant="minimal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="minimal" />
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import type { Snippet } from 'svelte';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { DotPattern } from '../../animation/dot-pattern';
import { ShinnyText } from '../../animation/shinny-text';
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
}
let { hero, navigation }: Props = $props();
const heroGradientBackground = `
radial-gradient(
circle at top center,
color-mix(in oklab, var(--primary) 24%, transparent) 0%,
transparent 58%
),
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 12%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 6%, var(--background)) 48%,
var(--background) 100%
)
`;
</script>
<section
class="ui:relative ui:overflow-hidden ui:px-4 ui:pt-4 ui:pb-16 ui:md:pb-24"
style={`background: ${heroGradientBackground};`}
>
<DotPattern class="ui:opacity-[0.08]" />
<div class="ui:relative ui:max-w-[1200px] ui:mx-auto">
{@render navigation()}
</div>
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
<BlurFade delay={0} once={true}>
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<ShinnyText
shimmerWidth={120}
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-foreground/72"
>
{hero.subheading}
</ShinnyText>
</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">
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="minimal"
/>
{/if}
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
const navigationGradientBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 16%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 8%, var(--background)) 100%
)
`;
</script>
<header
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-border/60 ui:backdrop-blur-sm"
style={`background: ${navigationGradientBackground};`}
>
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:gap-8">
{#each navItems as item}
<a href={item.href} class="ui:text-sm ui:text-foreground/70 ui:hover:text-foreground ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button href={authAction.href} variant="outline" class="ui:rounded-full">
{authAction.label}
</Button>
{/if}
</div>
</header>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { OrgLandingPageTheme } from './types';
import { Button } from '../../base/button';
interface Props {
href: string;
label: string;
variant: OrgLandingPageTheme;
}
let { href, label, variant }: Props = $props();
const themeButtonClasses: Record<
OrgLandingPageTheme,
{
className: string;
variant: 'outline';
}
> = {
minimal: {
className: 'ui:rounded-full',
variant: 'outline'
},
bold: {
className:
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-primary ui:transition-all',
variant: 'outline'
},
classic: {
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
variant: 'outline'
}
};
</script>
<Button {href} variant={themeButtonClasses[variant].variant} size="lg" class={themeButtonClasses[variant].className}>
{label}
</Button>
@@ -0,0 +1,78 @@
export type NavItem = { label: string; href: string };
export type OrgLandingPageTheme = 'minimal' | 'bold' | 'classic';
export type CourseItem = {
id: string;
slug?: string;
logo?: string | null;
title: string;
description: string;
type?: string;
isPublished?: boolean;
cost?: number;
currency?: string;
lessonCount?: number;
exerciseCount?: number;
totalStudents?: number;
metadata?: {
discount?: number;
showDiscount?: boolean;
};
tags?: Array<{
id: string;
name: string;
slug: string;
color?: string | null;
}>;
// Fallbacks for specific templates that might still want to use these
image?: string;
link?: string;
price?: string;
duration?: string;
level?: string;
};
export type FooterLink = { label: string; href: string };
export type LandingPageEmbed = {
title: string;
description?: string;
code: string;
secondaryAction?: {
label: string;
href: string;
};
};
export type LandingPageCallout = {
heading: string;
description: string;
action: {
label: string;
href: string;
};
};
export interface OrgLandingPageProps {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
hero: {
heading: string;
subheading: string;
primaryAction: { label: string; href: string };
secondaryAction?: { label: string; href: string };
image?: string;
};
courses: CourseItem[];
hasMoreCourses?: boolean;
disableCourseLinks?: boolean;
embed?: LandingPageEmbed;
callout?: LandingPageCallout;
footerLinks: FooterLink[];
footerText: string;
}
@@ -0,0 +1,147 @@
import type { ExerciseQuestionLabels } from '@cio/question-types';
/**
* English labels for QuestionTypePicker embed demos (no dashboard i18n).
* Keep aligned with packages/storybook/src/molecules/exercise-question/question-labels.ts.
*/
export const QUESTION_TYPE_PICKER_EXERCISE_LABELS: ExerciseQuestionLabels = {
'common.option_prefix': 'Option',
'common.remove': 'Remove',
'common.add_option': 'Add option',
'common.correct_badge': 'Correct',
'common.incorrect_badge': 'Incorrect',
'common.not_set': 'Not set',
'navigation.previous': 'Previous',
'navigation.next': 'Next',
'navigation.finish': 'Finish',
'question.edit.title_label': 'Title',
'question.edit.title_placeholder': 'Question prompt',
'question.edit.description_label': 'Description (optional)',
'question.edit.description_placeholder': 'Add additional context for this question',
'question.edit.add_description': 'Add description',
'question.edit.remove_description': 'Remove description',
'question.edit.media_menu_sr': 'Question extras menu',
'question.edit.add_image': 'Add image',
'question.edit.add_video': 'Add video',
'question.edit.remove_image_tooltip': 'Remove image',
'question.edit.remove_image_sr': 'Remove image',
'question.edit.remove_video_tooltip': 'Remove video',
'question.edit.remove_video_sr': 'Remove video',
'question.edit.image_alt': 'Question context',
'question.edit.video_modal_title': 'Add YouTube video',
'question.edit.video_link_label': 'YouTube link',
'question.edit.video_link_placeholder': 'https://www.youtube.com/watch?v=',
'question.edit.video_add_button': 'Add video',
'question.edit.video_invalid_link': 'Please enter a valid YouTube link.',
'question.edit.upload_error_only_images': 'Only image files are supported.',
'question.edit.upload_error_skipped': 'Some files were skipped because they are not images.',
'question.edit.upload_error_failed': 'Failed to upload image. Try again.',
'radio.edit.helper': 'Select exactly one correct option.',
'radio.edit.correct_selected_tooltip': 'Correct answer selected',
'radio.edit.mark_correct_tooltip': 'Mark as correct answer',
'radio.edit.mark_correct_sr': 'Mark option as correct',
'checkbox.edit.helper': 'Mark one or more options as correct.',
'checkbox.edit.correct_selected_tooltip': 'Correct answer selected',
'checkbox.edit.toggle_correct_tooltip': 'Toggle correct answer',
'checkbox.edit.toggle_correct_sr': 'Toggle option as correct',
'true_false.true_label': 'True',
'true_false.false_label': 'False',
'true_false.edit.correct_answer_label': 'Correct answer',
'true_false.preview.correct_value_label': 'Correct value',
'short_answer.edit.instructions_placeholder': 'Optional instructions',
'short_answer.take.placeholder': 'Type your answer',
'short_answer.preview.helper': 'Free-text answer question.',
'short_answer.review.your_answer_label': 'Submitted:',
'short_answer.review.accepted_label': 'Accepted answers',
'numeric.edit.correct_value_label': 'Correct value',
'numeric.edit.correct_value_info': 'Expected numeric answer to mark as correct.',
'numeric.edit.tolerance_label': 'Tolerance',
'numeric.edit.tolerance_info': 'Allowed +/- range from the correct value.',
'numeric.edit.correct_value_placeholder': 'Correct value',
'numeric.edit.tolerance_placeholder': 'Tolerance',
'numeric.take.placeholder': 'Enter a number',
'numeric.preview.correct_value_label': 'Correct value',
'numeric.preview.tolerance_label': 'Tolerance',
'numeric.review.your_answer_label': 'Submitted:',
'numeric.review.in_range': 'Within tolerance',
'numeric.review.out_of_range': 'Outside tolerance',
'star.edit.correct_value_label': 'Correct star value',
'star.edit.correct_value_info': 'Expected star count for full credit.',
'star.edit.max_stars_label': 'Maximum stars',
'star.edit.max_stars_info': 'Stars shown to learners (1–10).',
'star.edit.correct_value_placeholder': 'e.g. 3',
'star.edit.max_stars_placeholder': 'e.g. 5',
'star.take.helper': 'Select your rating.',
'star.take.star_sr': 'Star {n} of {max}',
'star.preview.correct_label': 'Correct answer',
'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)',
'fill_blank.take.placeholder': 'Enter answers separated by commas',
'fill_blank.preview.accepted_answers_label': 'Accepted answers',
'fill_blank.review.your_answer_label': 'Submitted:',
'word_bank.edit.template_label': 'Sentence template',
'word_bank.edit.template_placeholder': 'Use ___ for each blank...',
'word_bank.edit.template_helper': 'Use three underscores (___) for each blank.',
'word_bank.edit.no_blanks_warning': 'Add at least one ___ blank marker in your template.',
'word_bank.edit.correct_answers_heading': 'Correct answer for each blank',
'word_bank.edit.blank_label': 'Blank',
'word_bank.edit.correct_placeholder': 'Correct term or phrase',
'word_bank.edit.distractors_heading': 'Extra words (distractors)',
'word_bank.edit.distractor_placeholder': 'Wrong option',
'word_bank.edit.add_distractor': 'Add distractor',
'word_bank.take.helper': 'Click or drag words into blanks.',
'word_bank.take.bank_heading': 'Word bank',
'word_bank.take.clear_blank_sr': 'Remove word from blank',
'word_bank.preview.filled_label': 'Expected answers in context',
'word_bank.review.your_answer_label': 'Your answers',
'word_bank.review.correct_label': 'Correct answers',
'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats',
'file_upload.edit.max_size_placeholder': 'Max size (MB)',
'file_upload.take.selected_file_label': 'Selected file',
'file_upload.take.upload_button': 'Upload',
'file_upload.take.upload_progress': 'Uploading...',
'file_upload.take.upload_error': 'Upload failed',
'file_upload.take.view': 'View',
'file_upload.take.download': 'Download',
'file_upload.preview.accepted_types_label': 'Accepted types',
'file_upload.preview.max_size_label': 'Max size (MB)',
'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.',
'link.take.placeholder': 'https://example.com/resource',
'link.take.remove_tooltip': 'Remove link',
'link.take.remove_sr': 'Remove link',
'link.take.invalid_url_error': 'Please enter a valid URL (http/https).',
'link.take.add_button': 'Add link',
'link.preview.empty': 'No links submitted.',
'link.preview.item_label': 'Link {index}',
'matching.edit.helper': 'Configure pairs in question settings via host app controls.',
'matching.take.placeholder': 'Provide matching pairs as JSON',
'matching.preview.helper': 'Matching pairs preview is provided by host app settings.',
'ordering.fallback_step_prefix': 'Step',
'ordering.edit.helper': 'Drag items to define the correct order.',
'ordering.edit.add_step': 'Add step',
'ordering.edit.empty': 'Add steps, then drag to set the expected order.',
'ordering.edit.step_placeholder': 'Step {index}',
'ordering.edit.remove_step_tooltip': 'Remove step',
'ordering.take.helper': 'Drag and drop to arrange the items in the correct order.',
'ordering.take.empty': 'No ordering items are configured for this question yet.',
'ordering.preview.submitted_label': 'Submitted order',
'ordering.preview.expected_label': 'Expected order',
'ordering.preview.matches': 'Submitted order matches expected order.',
'ordering.preview.differs': 'Submitted order differs from expected order.',
'hotspot.edit.helper': 'Configure hotspot image and zones in question settings via host app controls.',
'hotspot.take.placeholder': 'Provide hotspot coordinates as JSON',
'hotspot.preview.helper': 'Hotspot preview is provided by host app image and hotspot settings.',
'textarea.edit.placeholder': 'Learners will answer with long-form text',
'textarea.edit.min_characters_label': 'Minimum characters',
'textarea.edit.min_characters_placeholder': 'Minimum characters',
'textarea.edit.max_characters_label': 'Maximum characters',
'textarea.edit.max_characters_placeholder': 'Maximum characters',
'textarea.take.placeholder': 'Write your answer',
'submission.common.no_answer': 'No answer',
'submission.common.other': 'Other',
'submission.chart.responses': 'Responses',
'submission.chart.no_data': 'No responses yet',
'submission.list.responses': 'Responses',
'submission.list.no_responses': 'No responses yet'
};
@@ -0,0 +1,4 @@
export { default as QuestionTypePicker } from './question-type-picker.svelte';
export { QUESTION_TYPE_PICKER_ITEMS, getQuestionTypePickerItem } from './picker-data';
export type { QuestionTypePickerEntry } from './picker-data';
export { QUESTION_TYPE_PICKER_EXERCISE_LABELS } from './exercise-labels';
@@ -0,0 +1,308 @@
import { QUESTION_TYPE_KEY, type QuestionTypeKey } from '@cio/question-types';
/** Hidden in the exercise editor; omitted from the marketing picker. */
const HIDDEN_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([QUESTION_TYPE_KEY.MATCHING, QUESTION_TYPE_KEY.HOTSPOT]);
export type QuestionTypePickerEntry = {
key: QuestionTypeKey;
/** Sidebar label */
displayLabel: string;
/** One-line benefit shown above the preview */
description: string;
/** Letter or symbol shown inside the sidebar icon tile (design); omit to use a Lucide icon. */
sidebarGlyph?: string;
/** Passed to QuestionRenderer as `question` */
demoQuestion: Record<string, unknown>;
};
/**
* Option images: Unsplash (free to use under the Unsplash License).
* https://unsplash.com/license
*/
const UNSPLASH = {
/** Small group collaborative learning */
teamWorkshop: 'https://images.unsplash.com/photo-1523240795612-9a054b0db644?auto=format&fit=crop&w=640&q=80',
/** Focused individual on laptop */
selfPacedOnline: 'https://images.unsplash.com/photo-1516321497487-e288fb19713f?auto=format&fit=crop&w=640&q=80',
/** Books / reading */
readingStudy: 'https://images.unsplash.com/photo-1456513080510-7bf3a84b82fc?auto=format&fit=crop&w=640&q=80',
/** Documents / paperwork — PDFs and policy packs */
documentStack: 'https://images.unsplash.com/photo-1586281380349-632531db7ed4?auto=format&fit=crop&w=640&q=80',
/** Large meeting / town hall */
townHall: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?auto=format&fit=crop&w=640&q=80',
/** Desk with notes — formative practice */
notesPractice: 'https://images.unsplash.com/photo-1434030216411-0b793f4b4173?auto=format&fit=crop&w=640&q=80',
/** Lecture hall — evokes long recorded sessions */
lectureRecording: 'https://images.unsplash.com/photo-1544531586-fde5298cdd40?auto=format&fit=crop&w=640&q=80',
/** Diverse team discussion */
peerDiscussion: 'https://images.unsplash.com/photo-1529156069898-49953e39b3ac?auto=format&fit=crop&w=640&q=80',
/** Deep work / concentration */
focusedWork: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=640&q=80'
} as const;
/** Order matches the product marketing layout (see question type picker design). */
const QUESTION_TYPE_PICKER_ENTRIES: QuestionTypePickerEntry[] = [
{
key: QUESTION_TYPE_KEY.RADIO,
displayLabel: 'Single answer',
description: 'Several options, one correct choice — the classic multiple-choice format.',
demoQuestion: {
id: 'picker-radio',
key: 'picker-radio',
title:
'Your company is redesigning onboarding. Which approach typically improves completion rates for mandatory compliance training?',
questionType: 'RADIO',
settings: {
description: 'Choose the option that best matches evidence-based practice for busy professionals.'
},
options: [
{
id: 1001,
label: 'Short modules (about 5–10 minutes) learners can finish between meetings',
value: 'microlearning',
isCorrect: true,
settings: { imageUrl: UNSPLASH.selfPacedOnline }
},
{
id: 1002,
label: 'A single long video recording of a live session',
value: 'long-lecture',
isCorrect: false,
settings: { imageUrl: UNSPLASH.lectureRecording }
},
{
id: 1003,
label: 'Printable PDFs only, with no knowledge checks',
value: 'pdf-only',
isCorrect: false,
settings: { imageUrl: UNSPLASH.documentStack }
},
{
id: 1004,
label: 'A once-a-year company briefing with no tracked follow-up online',
value: 'annual-only',
isCorrect: false,
settings: { imageUrl: UNSPLASH.townHall }
}
]
}
},
{
key: QUESTION_TYPE_KEY.CHECKBOX,
displayLabel: 'Multiple answers',
description: 'Learners choose every correct option — ideal for “select all that apply.”',
demoQuestion: {
id: 'picker-checkbox',
key: 'picker-checkbox',
title:
'Which of these habits most often help adults remember and apply what they learned? Select all that apply.',
questionType: 'CHECKBOX',
settings: {
description: 'There is more than one correct answer.'
},
options: [
{
id: 2001,
label: 'Revisiting key ideas on a schedule over several days or weeks',
value: 'spaced-practice',
isCorrect: true,
settings: { imageUrl: UNSPLASH.notesPractice }
},
{
id: 2002,
label: 'Cramming everything the night before a deadline',
value: 'cramming',
isCorrect: false,
settings: { imageUrl: UNSPLASH.focusedWork }
},
{
id: 2003,
label: 'Practicing with realistic tasks soon after the lesson',
value: 'immediate-practice',
isCorrect: true,
settings: { imageUrl: UNSPLASH.teamWorkshop }
},
{
id: 2004,
label: 'Discussing takeaways with peers and comparing approaches',
value: 'peer-discussion',
isCorrect: true,
settings: { imageUrl: UNSPLASH.peerDiscussion }
}
]
}
},
{
key: QUESTION_TYPE_KEY.TRUE_FALSE,
displayLabel: 'True / false',
sidebarGlyph: 'T',
description: 'Binary judgment — fast to answer, easy to score.',
demoQuestion: {
id: 'picker-true-false',
key: 'picker-true-false',
title:
'Spaced practice—reviewing material over time—usually leads to better long-term retention than cramming the same amount of study into one session.',
questionType: 'TRUE_FALSE',
options: [
{ id: 4001, label: 'True', value: 'true', isCorrect: true },
{ id: 4002, label: 'False', value: 'false', isCorrect: false }
],
settings: {
correctValue: true
}
}
},
{
key: QUESTION_TYPE_KEY.SHORT_ANSWER,
displayLabel: 'Short answer',
sidebarGlyph: 'A',
description: 'Brief free text — ideal for definitions and one-line explanations.',
demoQuestion: {
id: 'picker-short-answer',
key: 'picker-short-answer',
title:
'In instructional design, what do we call a concise statement that describes what a learner will be able to do after a lesson? (One or two words.)',
questionType: 'SHORT_ANSWER',
settings: {
instructions: 'Use the term educators often write at the top of a lesson plan.',
acceptedAnswers: 'learning objective,objective,learning outcome,outcome'
}
}
},
{
key: QUESTION_TYPE_KEY.TEXTAREA,
displayLabel: 'Long answer',
sidebarGlyph: '\u00B6',
description: 'Paragraph responses — rubrics and manual review when you need depth.',
demoQuestion: {
id: 'picker-textarea',
key: 'picker-textarea',
title:
'You are launching a customer service course for new hires. In a short paragraph, describe two ways you would use feedback loops so learners improve before they go live with customers.',
questionType: 'TEXTAREA',
settings: {
description: 'Aim for 2–4 sentences (about 40–220 characters).',
minCharacters: 40,
maxCharacters: 220
}
}
},
{
key: QUESTION_TYPE_KEY.NUMERIC,
displayLabel: 'Numeric',
sidebarGlyph: '#',
description: 'Numbers with optional tolerance — perfect for STEM and data tasks.',
demoQuestion: {
id: 'picker-numeric',
key: 'picker-numeric',
title:
'A certification exam allows 90 minutes of working time. How many hours is that? (Enter a decimal—e.g. 1.5 for one and a half hours.)',
questionType: 'NUMERIC',
settings: { correctValue: 1.5, tolerance: 0 }
}
},
{
key: QUESTION_TYPE_KEY.FILL_BLANK,
displayLabel: 'Fill in blank',
sidebarGlyph: '_',
description: 'Cloze-style prompts — quick checks of vocabulary and facts.',
demoQuestion: {
id: 'picker-fill-blank',
key: 'picker-fill-blank',
title:
'Every strong course begins with a ___ of learner needs, then aligns ___ and practice activities to stated goals.',
questionType: 'FILL_BLANK',
settings: { acceptedAnswers: 'analysis,content' }
}
},
{
key: QUESTION_TYPE_KEY.WORD_BANK,
displayLabel: 'Word bank',
description: 'Drag-and-drop terms — engaging without giving away the answer.',
demoQuestion: {
id: 'picker-word-bank',
key: 'picker-word-bank',
title: 'Complete the sentence with the correct terms from the word bank.',
questionType: 'WORD_BANK',
settings: {
template:
'A well-designed lesson ___ states the goal up front, builds in timely ___, and closes with a quick check for ___.',
correctAnswers: ['objective', 'feedback', 'understanding'],
distractors: ['deadline', 'marketing', 'entertainment']
}
}
},
{
key: QUESTION_TYPE_KEY.ORDERING,
displayLabel: 'Ordering',
description: 'Sequence steps — ideal for processes, timelines, and procedures.',
demoQuestion: {
id: 'picker-ordering',
key: 'picker-ordering',
title: 'Put these steps in the order you would run a typical training pilot before a company-wide rollout.',
questionType: 'ORDERING',
options: [
{ id: 'order-1', label: 'Confirm goals and success metrics with stakeholders', value: 'order-1' },
{ id: 'order-2', label: 'Run a small pilot with one team and collect feedback', value: 'order-2' },
{ id: 'order-3', label: 'Revise content and assessments based on pilot results', value: 'order-3' },
{ id: 'order-4', label: 'Launch to all eligible employees with support resources', value: 'order-4' }
],
settings: {}
}
},
{
key: QUESTION_TYPE_KEY.FILE_UPLOAD,
displayLabel: 'File upload',
description: 'Collect documents and artifacts — great for assignments and portfolios.',
demoQuestion: {
id: 'picker-file-upload',
key: 'picker-file-upload',
title:
'Upload your one-page training outline (PDF or Word). Include the topic, target audience, and three learning objectives.',
questionType: 'FILE_UPLOAD',
settings: {
acceptedTypes: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
],
maxSizeMb: 10
}
}
},
{
key: QUESTION_TYPE_KEY.LINK,
displayLabel: 'Link',
description: 'Learners share URLs — useful for references, portfolios, and research.',
demoQuestion: {
id: 'picker-link',
key: 'picker-link',
title:
'Share one reputable article, talk, or resource that shaped how you designed your lesson. Paste a full https:// link.',
questionType: 'LINK',
settings: {
instructions: 'Use a stable URL (avoid expiring share links when possible).'
}
}
},
{
key: QUESTION_TYPE_KEY.STAR,
displayLabel: 'Star rating',
description: 'Quick sentiment — lightweight feedback on clarity or difficulty.',
demoQuestion: {
id: 'picker-star',
key: 'picker-star',
title: 'How clear were this module’s outcomes and expectations before you started the activities?',
questionType: 'STAR',
settings: { correctValue: 4, maxStars: 5 }
}
}
];
export const QUESTION_TYPE_PICKER_ITEMS = QUESTION_TYPE_PICKER_ENTRIES.filter(
(entry) => !HIDDEN_QUESTION_TYPE_KEYS.has(entry.key)
);
export function getQuestionTypePickerItem(key: QuestionTypeKey): QuestionTypePickerEntry | undefined {
return QUESTION_TYPE_PICKER_ITEMS.find((item) => item.key === key);
}
@@ -0,0 +1,145 @@
<script lang="ts">
import type { AnswerData } from '@cio/question-types';
import { QUESTION_TYPE_KEY, type QuestionTypeKey } from '@cio/question-types';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
import Link2Icon from '@lucide/svelte/icons/link-2';
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 { 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 }> {
switch (key) {
case QUESTION_TYPE_KEY.RADIO:
return CircleDotIcon;
case QUESTION_TYPE_KEY.CHECKBOX:
return SquareCheckIcon;
case QUESTION_TYPE_KEY.WORD_BANK:
return LayoutGridIcon;
case QUESTION_TYPE_KEY.ORDERING:
return ArrowUpDownIcon;
case QUESTION_TYPE_KEY.FILE_UPLOAD:
return UploadIcon;
case QUESTION_TYPE_KEY.LINK:
return Link2Icon;
case QUESTION_TYPE_KEY.STAR:
return StarIcon;
default:
return CircleDotIcon;
}
}
let selectedKey = $state<QuestionTypeKey>(QUESTION_TYPE_PICKER_ITEMS[0]!.key);
let answer = $state<AnswerData | null>(null);
const currentItem = $derived(getQuestionTypePickerItem(selectedKey));
const previewHeading = $derived(currentItem?.displayLabel ?? '');
const previewDescription = $derived(currentItem?.description ?? '');
const activeQuestion = $derived(
currentItem?.demoQuestion ?? {
id: 'picker-fallback',
key: 'picker-fallback',
title: '',
questionType: 'SHORT_ANSWER',
settings: {}
}
);
function selectItem(key: QuestionTypeKey) {
selectedKey = key;
answer = null;
}
async function demoFileUpload(file: File) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 350);
});
return {
fileKey: `demo/${file.name}`,
fileName: file.name,
fileUrl: URL.createObjectURL(file)
};
}
</script>
<div
class={cn(
'ui:box-border ui:flex ui:min-h-[320px] ui:w-full ui:max-w-full ui:min-w-0 ui:flex-col ui:overflow-hidden ui:rounded-xl ui:border ui:border-border ui:bg-background ui:text-foreground ui:md:flex-row'
)}
>
<nav
class="ui:flex ui:w-full ui:shrink-0 ui:flex-row ui:gap-0 ui:overflow-x-auto ui:overflow-y-hidden ui:border-b ui:border-border ui:bg-background ui:p-0 ui:md:w-52 ui:md:flex-col ui:md:overflow-x-visible ui:md:overflow-y-visible ui:md:border-b-0 ui:md:border-r"
aria-label="Question types"
>
{#each QUESTION_TYPE_PICKER_ITEMS as item (item.key)}
{@const isActive = item.key === selectedKey}
<button
type="button"
class={cn(
'ui:flex ui:shrink-0 ui:cursor-pointer ui:items-center ui:gap-2.5 ui:rounded-none ui:px-3 ui:py-2 ui:text-left ui:text-sm ui:transition-colors ui:border-l-4 ui:border-solid ui:md:w-full',
isActive
? 'ui:border-l-foreground ui:bg-secondary ui:text-secondary-foreground'
: 'ui:border-l-transparent ui:bg-transparent ui:text-foreground',
!isActive && 'hover:ui:bg-muted/40'
)}
aria-current={isActive ? 'true' : undefined}
onclick={() => selectItem(item.key)}
>
<span
class={cn(
'ui:flex ui:size-8 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-md ui:bg-muted ui:text-foreground'
)}
aria-hidden="true"
>
{#if item.sidebarGlyph}
<span class="ui:text-[13px] ui:font-semibold ui:leading-none">{item.sidebarGlyph}</span>
{:else}
{@const Icon = iconForKey(item.key)}
<Icon class="ui:size-4 ui:opacity-90" aria-hidden="true" />
{/if}
</span>
<span class="ui:leading-tight">{item.displayLabel}</span>
</button>
{/each}
</nav>
<div class="ui:flex ui:min-h-0 ui:min-w-0 ui:flex-1 ui:flex-col ui:bg-background ui:p-4 ui:sm:p-6">
<p class="ui:text-xs ui:font-semibold ui:tracking-wide ui:text-muted-foreground">
{previewHeading.toUpperCase()}
</p>
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">
{previewDescription}
</p>
<div class="ui:mt-4 ui:grid ui:min-h-0 ui:flex-1 ui:overflow-hidden" style="grid-template-areas: 'slot';">
{#key selectedKey}
<div class="ui:min-w-0 [grid-area:slot]" in:fly={{ y: 14, duration: 400, opacity: 0, easing: cubicOut }}>
<QuestionRenderer
showContainer={false}
contract={{
mode: 'take',
question: activeQuestion,
answer,
labels: QUESTION_TYPE_PICKER_EXERCISE_LABELS,
onFileUpload: demoFileUpload
}}
onAnswerChange={(nextAnswer) => {
answer = nextAnswer;
}}
/>
</div>
{/key}
</div>
</div>
</div>
@@ -0,0 +1,8 @@
import End from './resource-list-row-end.svelte';
import Group from './resource-list-group.svelte';
import Lead from './resource-list-row-lead.svelte';
import Main from './resource-list-row-main.svelte';
import Root from './resource-list-row.svelte';
export { Root, Lead, Main, End, Group };
export type { ResourceListRowAlign, ResourceListRowDensity } from './resource-list-row.svelte';
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="list"
data-slot="resource-list-group"
class={cn('ui:group/resource-list ui:flex ui:w-full ui:flex-col ui:rounded-md ui:border ui:border-border', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="resource-list-row-end"
class={cn('ui:flex ui:shrink-0 ui:items-center ui:justify-end ui:gap-2 ui:self-center', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="resource-list-row-lead"
class={cn('ui:flex ui:shrink-0 ui:items-center ui:justify-center ui:self-center', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="resource-list-row-main"
class={cn('ui:flex ui:min-w-0 ui:flex-1 ui:flex-col ui:gap-0.5', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,72 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const resourceListRowVariants = tv({
base: 'ui:flex-nowrap! ui:rounded-none ui:border-0 ui:border-b ui:border-border last:ui:border-b-0 ui:hover:bg-muted/50',
variants: {
align: {
center: 'ui:items-center',
start: 'ui:items-start'
},
/** Tighter block padding than `Item` size alone (e.g. toolbar / “select all” row). */
density: {
default: '',
toolbar: 'ui:gap-2 ui:py-1.5'
}
},
defaultVariants: {
align: 'center',
density: 'default'
}
});
export type ResourceListRowAlign = VariantProps<typeof resourceListRowVariants>['align'];
export type ResourceListRowDensity = VariantProps<typeof resourceListRowVariants>['density'];
</script>
<script lang="ts">
import * as Item from '../../base/item';
import { cn, type WithElementRef } from '../../tools';
import type { ItemSize, ItemVariant } from '../../base/item/item.svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
child,
children,
variant = 'outline',
size = 'sm',
align = 'center',
density = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
children?: Snippet;
variant?: ItemVariant;
size?: ItemSize;
align?: ResourceListRowAlign;
density?: ResourceListRowDensity;
} = $props();
const layoutClass = $derived(cn(resourceListRowVariants({ align, density }), className));
</script>
{#if child}
<Item.Root {variant} {size} data-density={density} {...restProps}>
{#snippet child({ props })}
{@render child!({
props: {
...props,
'data-density': density,
class: cn(props.class as string | undefined, layoutClass)
}
})}
{/snippet}
</Item.Root>
{:else}
<Item.Root bind:ref {variant} {size} class={layoutClass} data-density={density} {...restProps}>
{@render children?.()}
</Item.Root>
{/if}