feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { DotField } from '../animation/dot-field';
|
||||
import { Waves } from '../animation/waves';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseCalloutData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData } from './types';
|
||||
|
||||
interface Props {
|
||||
callout: PublicCourseCalloutData | null;
|
||||
@@ -10,6 +12,7 @@
|
||||
/** Shown when callout is null and variant === 'full'. */
|
||||
lockedFallbackTitle?: string;
|
||||
lockedFallbackDescription?: string;
|
||||
animation?: PublicCourseCalloutAnimation;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -18,45 +21,78 @@
|
||||
variant = 'inline',
|
||||
lockedFallbackTitle = 'This lesson is locked',
|
||||
lockedFallbackDescription = 'The creator hasn’t shared a link for this lesson yet. Check back later.',
|
||||
animation = 'waves',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const cardBase = 'ui:rounded-xl ui:border ui:border-border ui:bg-card ui:text-card-foreground ui:transition-colors';
|
||||
const motionClass = 'ui:pointer-events-none ui:absolute ui:inset-0 ui:z-0 ui:h-full ui:w-full';
|
||||
|
||||
const cardBase = 'ui:rounded-xl ui:border ui:border-primary/50 ui:text-card-foreground ui:transition-colors';
|
||||
|
||||
const rootClass = $derived(
|
||||
variant === 'full'
|
||||
? cn(
|
||||
cardBase,
|
||||
'ui:my-10 ui:mx-auto ui:w-full ui:max-w-xl ui:p-8 ui:text-center ui:border-dashed ui:bg-muted/30',
|
||||
'ui:relative ui:overflow-hidden ui:my-10 ui:mx-auto ui:w-full ui:h-full ui:max-w-4xl ui:max-h-4xl ui:p-8 ui:flex ui:flex-col ui:items-center ui:justify-center ui:border-2 ui:border-primary/50 ui:border-dashed',
|
||||
className
|
||||
)
|
||||
: cn(cardBase, 'ui:mt-10 ui:p-6', className)
|
||||
: cn(cardBase, 'ui:relative ui:overflow-hidden ui:mt-10 ui:p-6', className)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet calloutMotionBackground()}
|
||||
{#if animation === 'waves'}
|
||||
<Waves class={motionClass} />
|
||||
{:else if animation === 'dotted'}
|
||||
<DotField
|
||||
dotRadius={1.5}
|
||||
dotSpacing={14}
|
||||
cursorRadius={420}
|
||||
bulgeOnly={true}
|
||||
bulgeStrength={56}
|
||||
glowRadius={140}
|
||||
sparkle={false}
|
||||
waveAmplitude={0}
|
||||
maxGlowOpacity={0.5}
|
||||
class={motionClass}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if callout}
|
||||
<aside class={rootClass}>
|
||||
<h3 class={cn('ui:font-semibold ui:text-foreground', variant === 'full' ? 'ui:text-xl' : 'ui:text-base')}>
|
||||
{callout.title}
|
||||
</h3>
|
||||
<p class={cn('ui:mt-2 ui:text-sm ui:text-muted-foreground', variant === 'full' && 'ui:mx-auto ui:max-w-md')}>
|
||||
{callout.description}
|
||||
</p>
|
||||
<a
|
||||
href={callout.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class={cn(
|
||||
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{callout.buttonLabel}
|
||||
<ArrowUpRightIcon class="ui:size-4" />
|
||||
</a>
|
||||
{@render calloutMotionBackground()}
|
||||
<div class="ui:relative ui:z-10 ui:flex ui:flex-col ui:items-center">
|
||||
<h3 class={cn('', variant === 'full' ? 'ui:text-3xl' : 'ui:text-xl')}>
|
||||
{callout.title}
|
||||
</h3>
|
||||
<p
|
||||
class={cn(
|
||||
'ui:mt-2 ui:text-md ui:text-muted-foreground ui:text-center',
|
||||
variant === 'full' && 'ui:mx-auto ui:max-w-md'
|
||||
)}
|
||||
>
|
||||
{callout.description}
|
||||
</p>
|
||||
<a
|
||||
href={callout.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class={cn(
|
||||
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{callout.buttonLabel}
|
||||
<ArrowUpRightIcon class="ui:size-4 custom" />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
{:else if variant === 'full'}
|
||||
<aside class={rootClass}>
|
||||
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
|
||||
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
|
||||
{@render calloutMotionBackground()}
|
||||
<div class="ui:relative ui:z-10">
|
||||
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
|
||||
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
import { QuestionList } from '../exercise-question';
|
||||
import { cn } from '../../tools';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
||||
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
|
||||
|
||||
interface Props {
|
||||
exercise: PublicExerciseViewData;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
calloutAnimation?: PublicCourseCalloutAnimation;
|
||||
labels?: ExerciseQuestionLabels;
|
||||
/** When set, completed attempts persist and hydrate from `localStorage`. */
|
||||
attemptsPersistenceKey?: string | null;
|
||||
@@ -36,6 +37,7 @@
|
||||
let {
|
||||
exercise,
|
||||
callout = null,
|
||||
calloutAnimation,
|
||||
labels,
|
||||
attemptsPersistenceKey = null,
|
||||
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
|
||||
@@ -48,6 +50,10 @@
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
|
||||
calloutAnimation ?? callout?.animation ?? 'waves'
|
||||
);
|
||||
|
||||
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
|
||||
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
|
||||
let selectValue = $state<string>('live');
|
||||
@@ -335,7 +341,7 @@
|
||||
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !exercise.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
|
||||
{:else}
|
||||
{#if exercise.sectionTitle}
|
||||
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
@@ -417,6 +423,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
@@ -34,6 +34,23 @@
|
||||
|
||||
const prevHref = $derived(prev ? hrefFor?.(prev) : undefined);
|
||||
const nextHref = $derived(next ? hrefFor?.(next) : undefined);
|
||||
|
||||
function handleAnchorClick(event: MouseEvent, item: PublicCourseSidebarItem) {
|
||||
if (
|
||||
!onNavigate ||
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onNavigate(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet prevBody(item: PublicCourseSidebarItem, label: string)}
|
||||
@@ -67,7 +84,7 @@
|
||||
>
|
||||
{#if prev}
|
||||
{#if prevHref}
|
||||
<a href={prevHref} class={cardClass} onclick={() => onNavigate?.(prev)}>
|
||||
<a href={prevHref} class={cardClass} onclick={(event) => handleAnchorClick(event, prev)}>
|
||||
{@render prevBody(prev, prevLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
@@ -81,7 +98,11 @@
|
||||
|
||||
{#if next}
|
||||
{#if nextHref}
|
||||
<a href={nextHref} class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
|
||||
<a
|
||||
href={nextHref}
|
||||
class={cn(cardClass, 'ui:justify-end ui:text-right')}
|
||||
onclick={(event) => handleAnchorClick(event, next)}
|
||||
>
|
||||
{@render nextBody(next, nextLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
export type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
|
||||
export type {
|
||||
PublicCourseCalloutAnimation,
|
||||
PublicCourseCalloutData,
|
||||
PublicCourseOrgData,
|
||||
PublicCourseSidebarItem,
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import { SafeHtmlContent } from '../safe-html-content';
|
||||
import { MediaPlayer } from '../media-player';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicLessonViewData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicLessonViewData } from './types';
|
||||
|
||||
interface Props {
|
||||
lesson: PublicLessonViewData;
|
||||
/** Shown in the player captions menu; supplied by the host app (`$t(...)`) — see authenticated `lesson-video-player`. */
|
||||
videoCaptionsLabel: string;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
calloutAnimation?: PublicCourseCalloutAnimation;
|
||||
class?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { lesson, callout = null, class: className }: Props = $props();
|
||||
let { lesson, videoCaptionsLabel, callout = null, calloutAnimation, class: className, id }: Props = $props();
|
||||
|
||||
const isYoutube = $derived(lesson.video?.type === 'youtube');
|
||||
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
|
||||
calloutAnimation ?? callout?.animation ?? 'waves'
|
||||
);
|
||||
|
||||
function toEmbedUrl(link: string): string {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
if (url.hostname.includes('youtu.be')) {
|
||||
const videoId = url.pathname.replace(/^\//, '');
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
if (url.hostname.includes('youtube.com')) {
|
||||
const videoId = url.searchParams.get('v');
|
||||
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return link;
|
||||
} catch {
|
||||
return link;
|
||||
const captionTracks = $derived.by(() => {
|
||||
const transcript = lesson.video?.transcript;
|
||||
|
||||
if (!transcript?.vttUrl) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: transcript.vttUrl,
|
||||
srclang: transcript.language,
|
||||
label: videoCaptionsLabel,
|
||||
default: false
|
||||
}
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -39,26 +47,29 @@
|
||||
a public link. The `prose` class (no `ui:` prefix) hooks into the dashboard's
|
||||
app.css typography rules.
|
||||
-->
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
<div {id} class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !lesson.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
|
||||
{:else}
|
||||
{#if lesson.video}
|
||||
<div class="ui:mb-6 ui:overflow-hidden ui:rounded-xl ui:bg-black">
|
||||
{#if isYoutube}
|
||||
<iframe
|
||||
class="ui:aspect-video ui:w-full"
|
||||
src={toEmbedUrl(lesson.video.link)}
|
||||
title={lesson.title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{:else}
|
||||
<video class="ui:aspect-video ui:w-full" controls playsinline src={lesson.video.link}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:bg-muted ui:rounded-lg">
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: lesson.video.type,
|
||||
url: lesson.video.link,
|
||||
metadata: {
|
||||
...(lesson.video.metadata ?? {}),
|
||||
title: lesson.video.metadata?.title ?? lesson.title
|
||||
},
|
||||
tracks: captionTracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -80,6 +91,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Course slug for attribution (passed as ?utm_source=...&utm_content=...). */
|
||||
/** Course slug for attribution (passed as ?utm_content=...). */
|
||||
courseSlug?: string | null;
|
||||
/** Org siteName (or org name) for attribution. */
|
||||
orgSlug?: string | null;
|
||||
@@ -10,6 +10,12 @@
|
||||
label?: string;
|
||||
/** Localized "ClassroomIO" brand label. */
|
||||
brand?: string;
|
||||
/** Logo-only footer (narrow sidebars). */
|
||||
compact?: boolean;
|
||||
/** Row alignment inside the attribution strip. */
|
||||
align?: 'center' | 'start';
|
||||
/** Override default utm_source (default mirrors public-course pages). */
|
||||
utmSource?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -18,13 +24,16 @@
|
||||
orgSlug = null,
|
||||
label = 'Powered by',
|
||||
brand = 'ClassroomIO',
|
||||
compact = false,
|
||||
align = 'center',
|
||||
utmSource = 'public-course',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const href = $derived.by(() => {
|
||||
const url = new URL('https://classroomio.com');
|
||||
|
||||
url.searchParams.set('utm_source', 'public-course');
|
||||
url.searchParams.set('utm_source', utmSource);
|
||||
url.searchParams.set('utm_medium', 'powered-by');
|
||||
|
||||
if (orgSlug) url.searchParams.set('utm_campaign', orgSlug);
|
||||
@@ -32,22 +41,39 @@
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
const rowAlignClass = $derived(align === 'start' ? 'ui:justify-start' : 'ui:justify-center');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:px-3 ui:flex ui:items-center ui:justify-center ui:gap-1 ui:py-3 ui:text-xs ui:text-muted-foreground ui:w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
|
||||
{label}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
|
||||
{#if compact}
|
||||
<div class={cn('ui:flex ui:items-center ui:gap-1 ui:w-full', rowAlignClass, className)}>
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:leading-none ui:opacity-70 ui:hover:opacity-100"
|
||||
aria-label={`${label} ${brand}`}
|
||||
>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="" />
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
'ui:px-3 ui:flex ui:items-center ui:gap-1 ui:text-xs ui:text-muted-foreground ui:w-full',
|
||||
rowAlignClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
</a>
|
||||
</div>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
|
||||
{label}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
|
||||
>
|
||||
{brand}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Button } from '../../base/button';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseOrgData, PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
import { ModeSwitcher } from '../../base/dark-mode';
|
||||
|
||||
interface Props {
|
||||
sections: PublicCourseSidebarSection[];
|
||||
@@ -120,7 +121,7 @@
|
||||
<div class={cn('ui:min-h-dvh ui:bg-background ui:text-foreground', className)}>
|
||||
<!--
|
||||
Top header. Sticky so it stays visible as guests scroll long lessons.
|
||||
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in]
|
||||
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in] [Theme]
|
||||
Height intentionally compact (h-12) to maximize content vertical space.
|
||||
-->
|
||||
<header
|
||||
@@ -168,6 +169,7 @@
|
||||
</Button>
|
||||
<Button href={signInHref} variant="default" size="sm">{signInLabel}</Button>
|
||||
{/if}
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -186,7 +188,7 @@
|
||||
{sections}
|
||||
{activeSlug}
|
||||
{hrefFor}
|
||||
{onItemClick}
|
||||
onItemClick={hrefFor ? undefined : onItemClick}
|
||||
{showPoweredBy}
|
||||
{courseSlug}
|
||||
orgSlug={org?.siteName ?? org?.name ?? null}
|
||||
@@ -204,7 +206,7 @@
|
||||
prev={prevItem}
|
||||
next={nextItem}
|
||||
{hrefFor}
|
||||
onNavigate={(item) => onItemClick?.(item)}
|
||||
onNavigate={hrefFor ? undefined : (item) => onItemClick?.(item)}
|
||||
prevLabel={footerPrevLabel}
|
||||
nextLabel={footerNextLabel}
|
||||
/>
|
||||
@@ -236,7 +238,9 @@
|
||||
{poweredByLabel}
|
||||
{poweredByBrand}
|
||||
onItemClick={(item) => {
|
||||
onItemClick?.(item);
|
||||
if (!hrefFor) {
|
||||
onItemClick?.(item);
|
||||
}
|
||||
sheetOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,12 +29,29 @@
|
||||
|
||||
const titleClass = $derived(
|
||||
cn(
|
||||
'ui:text-wrap ui:transition-colors',
|
||||
'ui:text-wrap ui:transition-colors ui:text-[13px]',
|
||||
'ui:group-hover:text-primary',
|
||||
active ? 'ui:text-primary' : 'ui:text-foreground/70 ui:group-hover:text-foreground',
|
||||
locked && !active && 'ui:text-muted-foreground'
|
||||
)
|
||||
);
|
||||
|
||||
// function handleAnchorClick(event: MouseEvent) {
|
||||
// if (
|
||||
// !onClick ||
|
||||
// event.defaultPrevented ||
|
||||
// event.button !== 0 ||
|
||||
// event.metaKey ||
|
||||
// event.ctrlKey ||
|
||||
// event.shiftKey ||
|
||||
// event.altKey
|
||||
// ) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// event.preventDefault();
|
||||
// onClick();
|
||||
// }
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
@@ -43,7 +60,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} onclick={onClick}>
|
||||
<a {href} class={rowClass} aria-current={active ? 'page' : undefined}>
|
||||
{@render body()}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { mergeProps } from 'bits-ui';
|
||||
import * as Tooltip from '../../base/tooltip';
|
||||
import { cn } from '../../tools';
|
||||
import PoweredBy from './powered-by.svelte';
|
||||
import SidebarRow from './sidebar-row.svelte';
|
||||
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
|
||||
interface Props {
|
||||
// Sections
|
||||
sections: PublicCourseSidebarSection[];
|
||||
/** Slug of the active item. Matches against `item.slug`. */
|
||||
activeSlug?: string | null;
|
||||
@@ -43,27 +46,47 @@
|
||||
|
||||
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
|
||||
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
|
||||
{#each sections as section (section.id)}
|
||||
<div class="ui:flex ui:flex-col ui:gap-2">
|
||||
<h2
|
||||
class="ui:px-3 ui:text-xs ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:tracking-wider ui:transition-colors ui:whitespace-nowrap"
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
<div class="ui:flex ui:flex-col">
|
||||
{#each section.items as item, index (item.id)}
|
||||
<SidebarRow
|
||||
number={index + 1}
|
||||
title={item.title}
|
||||
active={item.slug === activeSlug}
|
||||
locked={!item.isUnlocked}
|
||||
href={hrefFor?.(item)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
{#each sections as section (section.id)}
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
|
||||
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span
|
||||
{...mergeProps(
|
||||
{
|
||||
class: cn(
|
||||
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
|
||||
)
|
||||
},
|
||||
props
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
|
||||
{section.title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</h2>
|
||||
<div class="ui:flex ui:flex-col">
|
||||
{#each section.items as item, index (item.id)}
|
||||
<SidebarRow
|
||||
number={index + 1}
|
||||
title={item.title}
|
||||
active={item.slug === activeSlug}
|
||||
locked={!item.isUnlocked}
|
||||
href={hrefFor?.(item)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</Tooltip.Provider>
|
||||
</nav>
|
||||
|
||||
{#if showPoweredBy}
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
|
||||
import type { ExerciseQuestionLabels, ExerciseQuestionModel } from '@cio/question-types';
|
||||
|
||||
/** Background motion behind the public course marketing callout. */
|
||||
export type PublicCourseCalloutAnimation = 'dotted' | 'waves' | 'none';
|
||||
|
||||
export interface PublicCourseCalloutData {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
animation?: PublicCourseCalloutAnimation;
|
||||
}
|
||||
|
||||
/** Org / creator info shown in the top header (logo, name) of public course pages. */
|
||||
@@ -52,6 +56,16 @@ export interface PublicLessonViewData {
|
||||
video: {
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
metadata?: {
|
||||
svid?: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
};
|
||||
transcript?: {
|
||||
vttUrl: string;
|
||||
vttUrlExpiresAt: string;
|
||||
language: string;
|
||||
} | null;
|
||||
} | null;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user