feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -15,10 +15,12 @@
MoneyIcon,
ReviewIcon,
PersonIcon,
ContentIcon,
HoverableItem,
PreviewIcon
} from '@cio/ui/custom/moving-icons';
import type { TCourseUpdate } from '@cio/utils/validation/course';
import type { LandingSectionKey } from '@cio/ui/custom/org-landing-page';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseButton } from '$features/ui';
@@ -30,6 +32,7 @@
import ReviewsForm from './reviews-form.svelte';
import CertificateForm from './certificate-form.svelte';
import InstructorForm from './instructor-form.svelte';
import SkillsToolsForm from './skills-tools-form.svelte';
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { generateSlug } from '@cio/utils/functions';
@@ -37,7 +40,6 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
@@ -46,112 +48,122 @@
courseId: string;
syncCourseStore: (course: Course) => void;
onClose?: () => void;
selectedSectionKey?: LandingSectionKey | null;
}
let { course = $bindable(), courseId, syncCourseStore, onClose }: Props = $props();
let {
course = $bindable(),
courseId,
syncCourseStore,
onClose,
selectedSectionKey = $bindable(null)
}: Props = $props();
const sidebar = useSidebar();
let loading = $state(false);
interface Section {
key: number;
path: string;
key: LandingSectionKey;
title: string;
icon: Component;
enableAIWriter?: boolean;
initPrompt?: string;
domId: string;
}
const sections: Section[] = [
const sections: Section[] = $derived([
{
key: 1,
path: '',
key: 'header',
title: $t('course.navItem.landing_page.editor.title.header'),
icon: HeaderIcon
icon: HeaderIcon,
domId: 'header'
},
{
key: 2,
path: 'metadata.requirements',
key: 'requirement',
title: $t('course.navItem.landing_page.editor.title.requirement'),
icon: ExerciseIcon,
enableAIWriter: true,
initPrompt: $t('course.navItem.landing_page.editor.title.requirement')
domId: 'requirement'
},
{
key: 3,
path: 'metadata.description',
key: 'description',
title: $t('course.navItem.landing_page.editor.title.description'),
icon: LessonIcon,
enableAIWriter: true,
initPrompt: 'Please write a detailed course description for this course:'
domId: 'description'
},
{
key: 4,
path: 'metadata.goals',
key: 'goals',
title: $t('course.navItem.landing_page.editor.title.goals'),
icon: GoalIcon,
enableAIWriter: true,
initPrompt: 'What should a student expect to learn from this course:'
domId: 'goals'
},
{
key: 5,
path: '',
key: 'chips',
title: $t('course.navItem.landing_page.editor.title.chips'),
icon: ContentIcon,
domId: 'course-chips'
},
{
key: 'certificate',
title: $t('course.navItem.landing_page.editor.title.certificate'),
icon: CertificateIcon
icon: CertificateIcon,
domId: 'certificate'
},
{
key: 6,
path: '',
key: 'reviews',
title: $t('course.navItem.landing_page.editor.title.reviews'),
icon: ReviewIcon
icon: ReviewIcon,
domId: 'reviews'
},
{
key: 7,
path: '',
key: 'instructor',
title: $t('course.navItem.landing_page.editor.title.instructor'),
icon: PersonIcon
icon: PersonIcon,
domId: 'instructor'
},
{
key: 8,
path: '',
key: 'pricing',
title: $t('course.navItem.landing_page.editor.title.pricing'),
icon: MoneyIcon
icon: MoneyIcon,
domId: 'pricing'
}
];
let selectedSection: Section | null = $state(null);
]);
const selectedSection = $derived(
selectedSectionKey ? (sections.find((s) => s.key === selectedSectionKey) ?? null) : null
);
function handleClose() {
if (!selectedSection) {
if (!selectedSectionKey) {
onClose?.();
return;
}
selectedSection = null;
selectedSectionKey = null;
}
function handleSectionSelect(sectionKey: number) {
function handleSectionSelect(key: LandingSectionKey) {
return () => {
selectedSection = sections.find((section) => section.key === sectionKey) || null;
if (selectedSection) {
const sectionId = selectedSection.title.toLowerCase();
const sectionEl = document.getElementById(sectionId);
if (sectionEl) {
sectionEl.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
selectedSectionKey = key;
const section = sections.find((s) => s.key === key);
if (section) {
const el = document.getElementById(section.domId);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
}
$effect(() => {
if (!selectedSection) return;
const el = document.getElementById(selectedSection.domId);
if (el) {
untrack(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
});
async function handleSave() {
loading = true;
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
console.log('course', course);
const updatePayload = {
...course,
type: course.type!,
@@ -185,7 +197,6 @@
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') return;
// Untrack the course change to avoid unnecessary re-renders
const _course = untrack(() => cloneDeep(course));
set(_course, setterKey, value);
@@ -254,21 +265,23 @@
</Sidebar.Group>
{:else}
<div class="p-4">
{#if selectedSection.key === 1}
{#if selectedSection.key === 'header'}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
{:else if selectedSection.key === 'requirement'}
<RequirementForm bind:course {setter} />
{:else if selectedSection.key === 3}
{:else if selectedSection.key === 'description'}
<DescriptionForm bind:course {setter} />
{:else if selectedSection.key === 4}
{:else if selectedSection.key === 'goals'}
<GoalsForm bind:course {setter} />
{:else if selectedSection.key === 5}
{:else if selectedSection.key === 'chips'}
<SkillsToolsForm bind:course {setter} />
{:else if selectedSection.key === 'certificate'}
<CertificateForm bind:course {setter} />
{:else if selectedSection.key === 6}
{:else if selectedSection.key === 'reviews'}
<ReviewsForm bind:course {setter} />
{:else if selectedSection.key === 7}
{:else if selectedSection.key === 'instructor'}
<InstructorForm bind:course {setter} />
{:else if selectedSection.key === 8}
{:else if selectedSection.key === 'pricing'}
<PricingForm bind:course {setter} />
{/if}
</div>
@@ -54,8 +54,28 @@
bind:value={course.metadata.videoUrl}
/>
<div class="mt-7">
<p class="mb-3">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
<Button variant="outline" onclick={widgetControl}>
{$t('course.navItem.landing_page.editor.header_form.replace')}
</Button>
<p class="mb-3 font-bold">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
{#if course.logo}
<div class="border-border mb-3 overflow-hidden rounded-md border">
<img src={course.logo} alt={course.title ?? ''} class="block aspect-video w-full object-cover" />
</div>
{:else}
<div
class="border-border bg-muted text-muted-foreground mb-3 flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
>
{$t('course.navItem.landing_page.editor.header_form.no_cover')}
</div>
{/if}
<div class="flex gap-2">
<Button variant="outline" onclick={widgetControl}>
{$t('course.navItem.landing_page.editor.header_form.replace')}
</Button>
{#if course.logo}
<Button variant="outline" onclick={() => (course.logo = '')}>
{$t('course.navItem.landing_page.editor.header_form.remove')}
</Button>
{/if}
</div>
</div>
@@ -19,7 +19,7 @@
let role = $state(get(course, 'metadata.instructor.role'));
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
let description = $state(get(course, 'metadata.instructor.description'));
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
let courseNo = $state(get(course, 'metadata.instructor.coursesNo'));
let avatar: File | undefined = $state();
let isUploading = $state(false);
@@ -48,7 +48,7 @@
setter(description, 'metadata.instructor.description');
});
$effect(() => {
setter(courseNo, 'metadata.instructor.courseNo');
setter(courseNo, 'metadata.instructor.coursesNo');
});
$effect(() => {
@@ -0,0 +1,155 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
course: Course;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let skillsDraft = $state('');
let toolsDraft = $state('');
const skills = $derived((course.metadata?.skills ?? []) as string[]);
const tools = $derived((course.metadata?.tools ?? []) as string[]);
function addSkill() {
const value = skillsDraft.trim();
if (!value) return;
if (skills.includes(value)) {
skillsDraft = '';
return;
}
setter([...skills, value], 'metadata.skills');
skillsDraft = '';
}
function removeSkill(skill: string) {
setter(
skills.filter((s) => s !== skill),
'metadata.skills'
);
}
function addTool() {
const value = toolsDraft.trim();
if (!value) return;
if (tools.includes(value)) {
toolsDraft = '';
return;
}
setter([...tools, value], 'metadata.tools');
toolsDraft = '';
}
function removeTool(tool: string) {
setter(
tools.filter((t) => t !== tool),
'metadata.tools'
);
}
function handleSkillKey(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
addSkill();
}
}
function handleToolKey(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
addTool();
}
}
</script>
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-sm font-semibold">
{$t('course.navItem.landing_page.editor.title.skills')}
</p>
<p class="mb-3 text-xs text-gray-500">
{$t('course.navItem.landing_page.editor.skills_help')}
</p>
<div class="flex gap-2">
<Input
bind:value={skillsDraft}
onkeydown={handleSkillKey}
placeholder={$t('course.navItem.landing_page.editor.skills_placeholder')}
/>
<Button type="button" variant="outline" size="sm" onclick={addSkill} disabled={!skillsDraft.trim()}>
<PlusIcon size={14} />
</Button>
</div>
{#if skills.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each skills as skill (skill)}
<span
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
>
{skill}
<button
type="button"
class="text-gray-400 hover:text-gray-700"
onclick={() => removeSkill(skill)}
aria-label={$t('course.navItem.landing_page.editor.remove')}
>
<XIcon size={12} />
</button>
</span>
{/each}
</div>
{/if}
</div>
<div>
<p class="mb-2 text-sm font-semibold">
{$t('course.navItem.landing_page.editor.title.tools')}
</p>
<p class="mb-3 text-xs text-gray-500">
{$t('course.navItem.landing_page.editor.tools_help')}
</p>
<div class="flex gap-2">
<Input
bind:value={toolsDraft}
onkeydown={handleToolKey}
placeholder={$t('course.navItem.landing_page.editor.tools_placeholder')}
/>
<Button type="button" variant="outline" size="sm" onclick={addTool} disabled={!toolsDraft.trim()}>
<PlusIcon size={14} />
</Button>
</div>
{#if tools.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each tools as tool (tool)}
<span
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
>
{tool}
<button
type="button"
class="text-gray-400 hover:text-gray-700"
onclick={() => removeTool(tool)}
aria-label={$t('course.navItem.landing_page.editor.remove')}
>
<XIcon size={12} />
</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
@@ -1,74 +1,31 @@
<script lang="ts">
import get from 'lodash/get';
import pluralize from 'pluralize';
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import type { Component } from 'svelte';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play';
import UserIcon from '@lucide/svelte/icons/user';
import StarIcon from '@lucide/svelte/icons/star';
import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ClockIcon from '@lucide/svelte/icons/clock';
import { resolve } from '$app/paths';
import get from 'lodash/get';
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { Sparkle } from '@cio/ui/custom/animation/sparkle';
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
import { getLectureNo } from '$features/course/utils/functions';
import {
importThemeNavHero,
normalizeLandingPageSettings,
themeHeaderShellClass,
themeStyle,
themeRendersNavInsideHero
} from '$features/org/utils/landing-page';
import { currentOrg } from '$lib/utils/store/org';
import { basePath } from '$lib/utils/store/app';
import { user } from '$lib/utils/store/user';
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { handleOpenWidget, reviewsModalStore } from './store';
import type { Course, CourseMetadata } from '$features/course/utils/types';
import { courseApi } from '$features/course/api';
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import * as Dialog from '@cio/ui/base/dialog';
import Image from '$features/ui/image.svelte';
import PoweredBy from '$features/ui/powered-by.svelte';
import { importCourseLandingPageTheme, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import type { Course } from '$features/course/utils/types';
import type { AccountOrg } from '$features/app/types';
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
import HTMLRender from '$features/ui/html-render.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/pricing-section.svelte';
import NavSection from './components/nav-section.svelte';
import SectionsDisplay from './components/sections-display.svelte';
import { observeIntersection } from './components/intersection-observer';
import { buildCourseLandingPageProps } from './utils';
import { handleOpenWidget } from './store';
interface Props {
editMode?: boolean;
courseData: Course;
showStandaloneShell?: boolean;
org?: AccountOrg | null;
/** Pre-resolved theme component from the route's load function (eliminates the flash on SSR pages). */
themeComponent?: Component | null;
}
let { editMode = false, courseData = $bindable(), showStandaloneShell = false }: Props = $props();
let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props();
/** Editor / standalone shell: show org-theme hero + nav from `currentOrg.landingpage`. Org-site embed omits (parent provides hero). */
const showMarketingHero = $derived(showStandaloneShell || editMode);
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`);
const heroShellStyle = $derived(themeStyle(landingSettings.theme));
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
const activeOrg = $derived(org ?? $currentOrg);
const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage));
const authAction = $derived(
$user.isLoggedIn
@@ -82,47 +39,35 @@
}
);
const courseHero = $derived.by(() => {
const enrollHref = $derived.by(() => {
const slug = typeof courseData.slug === 'string' && courseData.slug.length > 0 ? courseData.slug : '';
const enrollHref = slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
const enrollmentsOpen = get(courseData, 'metadata.allowNewStudent') === true;
return {
...landingSettings.hero,
heading: get(courseData, 'title', '') || landingSettings.hero.heading,
subheading: get(courseData, 'description', '') || landingSettings.hero.subheading,
primaryAction: {
label: t.get('course.navItem.landing_page.start_course'),
href: enrollHref,
disabled: editMode || !enrollmentsOpen
},
image: get(courseData, 'logo', '') || landingSettings.hero.image
};
return slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
});
/** Matches API `buildCourseContent` — do not use `metadata.isContentGroupingEnabled` (dashboard normalizes undefined → true). */
const lessonsLayoutIsGrouped = $derived(courseData.content?.grouped === true);
const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true);
const enrollDisabled = $derived(editMode || !enrollmentsOpen);
const landingProps = $derived(
buildCourseLandingPageProps(courseData, activeOrg, {
enrollHref,
enrollDisabled,
authAction
})
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let NavComponent = $state<Component<any> | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let HeroComponent = $state<Component<any> | null>(null);
let ThemeComponent = $state<Component<any> | null>(themeComponent);
$effect(() => {
if (!showMarketingHero) {
NavComponent = null;
HeroComponent = null;
return;
}
// Skip client-side loading if the theme was already resolved server-side.
if (ThemeComponent) return;
const theme = landingSettings.theme;
let cancelled = false;
void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => {
void importCourseLandingPageTheme(theme).then((mod) => {
if (!cancelled) {
NavComponent = nav;
HeroComponent = hero;
ThemeComponent = mod.default;
}
});
@@ -130,473 +75,13 @@
cancelled = true;
};
});
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
const lessons = $derived(getCourseLessons(courseData));
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
const averageRating = $derived(reviews?.length ? (totalRatings / reviews.length).toFixed(1) : null);
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
const instructor = $derived((get(courseData, 'metadata.instructor') || {}) as Record<string, unknown>);
const organizationName = $derived(get(courseData, 'org.name') || $currentOrg.name || '');
const instructorName = $derived.by(() => {
const name = get(instructor, 'name');
return typeof name === 'string' && name.trim().length > 0 ? name : organizationName;
});
const instructorImageUrl = $derived.by(() => {
const imageUrl = get(instructor, 'imgUrl');
return typeof imageUrl === 'string' && imageUrl.trim().length > 0 ? imageUrl : '/images/avatar.svg';
});
const certificate = $derived(
get(courseData, 'metadata.certificate', {
templateUrl: '/images/certificate-template.svg'
}) as CourseMetadata['certificate']
);
const navItems = $derived(filterNavItems(courseData, reviews));
const navItemKeys = $derived(navItems.map((item) => item.key));
let startCoursePayment = $state(false);
let isVisible = $state(false);
let observer: { destroy: () => void };
let activeNav = $state(NAV_ITEMS[0].key);
const courseSections = $derived(getCourseSections(courseData));
const totalLessons = $derived(getTotalLessons(courseSections));
const studentCount = $derived(courseData.group?.members?.length ?? 0);
function locationHashChanged() {
activeNav = window.location.hash;
}
function toggleDescription(id: number) {
expandDescription[id] = !expandDescription[id];
}
onMount(() => {
window.onhashchange = locationHashChanged;
const targetNode = document.querySelector('.target-component');
observer = observeIntersection(targetNode, (inView: boolean) => {
isVisible = inView;
});
});
onDestroy(() => {
observer?.destroy();
});
</script>
{#if !editMode && showStandaloneShell}
<PoweredBy />
{/if}
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
{#if showMarketingHero}
<div class={heroShellClass} style={heroShellStyle}>
{#if NavComponent && HeroComponent}
{#if navInsideHero}
<HeroComponent hero={courseHero} orgName={orgNameForNav}>
{#snippet navigation()}
<NavComponent
orgName={orgNameForNav}
logoUrl={logoUrlForNav}
navItems={landingSettings.navItems}
{authAction}
/>
{/snippet}
</HeroComponent>
{:else}
<NavComponent
orgName={orgNameForNav}
logoUrl={logoUrlForNav}
navItems={landingSettings.navItems}
{authAction}
/>
<HeroComponent hero={courseHero} orgName={orgNameForNav} />
{/if}
{/if}
{#if editMode && $handleOpenWidget.open}
<div class="mx-auto w-full max-w-7xl px-6 py-2">
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
</div>
{/if}
{#if ThemeComponent}
{#if editMode && $handleOpenWidget.open}
<div class="mx-auto w-full max-w-7xl px-6 py-3">
<UploadWidget imageURL={courseData.logo} onchange={(newLogo) => (courseData.logo = newLogo)} />
</div>
<!-- Social Proof Strip -->
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
<div class="w-full border-b border-gray-200 bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900">
<div
class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-center gap-6 px-6 py-4 text-sm md:justify-start"
>
{#if averageRating}
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<StarIcon size={16} class="fill-yellow-400 text-yellow-400" />
<span class="font-semibold">{averageRating}</span>
<span class="text-gray-500 dark:text-gray-400"
>({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'})</span
>
</div>
{/if}
{#if studentCount > 0}
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<UsersIcon size={16} />
<span>{studentCount} {studentCount === 1 ? 'student' : 'students'}</span>
</div>
{/if}
{#if lessons.length > 0}
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<BookOpenIcon size={16} />
<span>{pluralize('lesson', lessons.length, true)}</span>
</div>
{/if}
{#if courseData.type}
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<ClockIcon size={16} />
<span>{courseData.type === 'LIVE_CLASS' ? 'Live Class' : 'Self-Paced'}</span>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Body -->
<div class="w-full bg-white dark:bg-black">
<div
class="mx-auto my-2 flex w-full max-w-6xl flex-col-reverse items-center justify-between px-4 lg:flex-row lg:items-start lg:py-8"
>
<!-- Course Details -->
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
<!-- Navigation -->
<UnderlineTabs.Root bind:value={activeNav} class="py-2">
<UnderlineTabs.List>
{#each navItems as navItem (navItem.key)}
<UnderlineTabs.Trigger value={navItem.key}>
<a href={resolve(page.url.pathname + navItem.key, {})}>
{$t(navItem.label)}
</a>
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
</UnderlineTabs.Root>
<!-- Sections - Requirement -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
<NavSection id="requirement">
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-3 text-2xl font-semibold">
{$t('course.navItem.landing_page.requirement')}
</h3>
<HTMLRender className="text-sm font-light leading-relaxed">
<SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} />
</HTMLRender>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Course Description -->
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
<NavSection id="description">
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-3 text-2xl font-semibold">
{$t('course.navItem.landing_page.description')}
</h3>
<HTMLRender className="dark:text-white text-sm font-light leading-relaxed">
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Goal -->
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
<NavSection id="goals">
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
<HTMLRender className="text-sm font-light leading-relaxed">
<SafeHtmlContent content={get(courseData, 'metadata.goals', '')} />
</HTMLRender>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Certificate -->
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
<NavSection id="certificate">
<BlurFade delay={0.1}>
<h3 class="mt-0 text-2xl font-semibold">{$t('course.navItem.landing_page.certificate')}</h3>
<p class="mb-4 text-sm font-light dark:text-white">
{$t('course.navItem.landing_page.certificate_text')}
</p>
<MagicCard
gradientColor="#3b82f6"
gradientOpacity={0.15}
class="inline-block w-auto border-0 bg-transparent py-0 dark:bg-transparent"
>
<Image src={certificate?.templateUrl} alt="certificate template" className="max-h-[215px] w-auto" />
</MagicCard>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Lessons (layout follows `content.grouped` from API, not client-normalized metadata) -->
{#if !lessonsLayoutIsGrouped}
<NavSection id="lessons">
<BlurFade delay={0.1}>
<div class="mb-3 flex w-full items-center justify-between">
<h3 class="mt-0 mb-3 text-2xl font-semibold">
{$t('course.navItem.landing_page.content')}
</h3>
<p class="text-sm font-light dark:text-white">
{pluralize('lesson', lessons.length, true)}
</p>
</div>
<div class="flex flex-wrap gap-2">
{#each lessons as lesson, index (lesson.id)}
<BlurFade delay={0.05 * Math.min(index, 10)}>
<div
class="rounded-lg border border-gray-200 px-3 py-1.5 transition-colors hover:border-gray-400 dark:border-neutral-700 dark:hover:border-neutral-500"
>
<Chip value={getLectureNo(index + 1, '0')} />
<p class="ml-2 inline text-xs font-light dark:text-white">
{lesson.title}
</p>
</div>
</BlurFade>
{/each}
</div>
</BlurFade>
</NavSection>
{:else}
<NavSection id="lessons">
<BlurFade delay={0.1}>
<!-- header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">{$t('course.navItem.landing_page.course_content')}</h1>
<span class="text-xs font-normal">
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
</span>
</div>
{#each courseSections as section, index (section.id)}
<BlurFade delay={0.05 * Math.min(index, 8)}>
<SectionsDisplay
exerciseCount={section.exerciseCount}
lessonCount={section.lessons?.length}
lessons={section.lessons}
title={section.title!}
/>
</BlurFade>
{/each}
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Reviews -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
<NavSection id="reviews">
<BlurFade delay={0.1}>
<div class="mb-6 flex items-center gap-3">
<h2 class="text-2xl font-semibold">
{$t('course.navItem.landing_page.reviews')}
</h2>
{#if averageRating}
<Sparkle
text={averageRating}
colors={{ first: '#facc15', second: '#f97316' }}
sparklesCount={3}
class="text-xl font-bold"
/>
{/if}
</div>
</BlurFade>
<div class="grid gap-4 sm:grid-cols-2">
{#each reviews.slice(0, 4) as review, id (review.id)}
{#if !review.hide}
<BlurFade delay={0.1 + id * 0.08}>
<div
class="flex flex-row items-start gap-3 rounded-lg border border-gray-100 p-4 dark:border-neutral-800"
>
<!-- image container -->
<Avatar.Root class="mt-1 size-10 shrink-0">
{#if review.avatar_url}
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<!-- profile content -->
<div class="min-w-0 flex-1">
<p class="mb-0.5 font-medium">{review.name}</p>
<!-- ratings -->
<div class="mb-2 flex items-center gap-0.5">
{#each Array(5) as _, starIndex (starIndex)}
<StarIcon
size={14}
class={starIndex < (review.rating || 0)
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 dark:text-neutral-600'}
/>
{/each}
</div>
<div class="mb-2 overflow-hidden" style:max-height={expandDescription[id] ? 'none' : '60px'}>
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
{review.description}
</p>
</div>
{#if !expandDescription[id] && review.description.split(' ').length > 12}
<button
class="ui:text-primary text-sm font-normal underline"
onclick={() => toggleDescription(id)}>See More</button
>
{/if}
{#if expandDescription[id]}
<button
class="ui:text-primary text-sm font-normal underline"
onclick={() => toggleDescription(id)}>See Less</button
>
{/if}
</div>
</div>
</BlurFade>
{/if}
{/each}
</div>
{#if reviews.length > 4}
<Button class="mt-4" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
{$t('course.navItem.landing_page.see_all')}
</Button>
{/if}
<!-- Reviews Modal -->
<Dialog.Root bind:open={$reviewsModalStore.open}>
<Dialog.Content class="w-9/12">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.landing_page.reviews_modal.title')}</Dialog.Title>
</Dialog.Header>
<div class="flex">
<!-- ratings -->
<div class="w-1/3">
<h2 class="text-xl">
{averageRating}
{$t('course.navItem.landing_page.reviews_modal.rating')}
</h2>
<h2 class="mt-2 text-lg font-semibold">
{reviews.length}
{$t('course.navItem.landing_page.reviews_modal.ratings')}
</h2>
</div>
<!-- reviews -->
<div class="flex w-4/6 flex-wrap">
{#each reviews as review, id (review.id)}
<!-- review -->
<div class="my-2 flex w-full flex-row items-start">
<!-- image container -->
<Avatar.Root class="mt-1 h-10 w-10">
{#if review.avatar_url}
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<!-- profile content -->
<div class="w-11/12 pl-2.5">
<p class="mb-0.5 font-medium">{review.name}</p>
<!-- ratings -->
<div class="flex flex-row items-center gap-1">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, starIndex (starIndex)}
<StarIcon
size={14}
class={starIndex < (review.rating || 0)
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 dark:text-neutral-600'}
/>
{/each}
</div>
<p class="text-xs text-gray-600">
{calDateDiff(review.created_at)}
</p>
</div>
<div
class="mb-2 overflow-hidden transition-all"
style:max-height={expandDescription[id] ? 'none' : '60px'}
>
<p class="my-2 text-sm leading-relaxed text-gray-600">
{review.description}
</p>
</div>
</div>
</div>
{/each}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</NavSection>
{/if}
<!-- Sections - Instructor -->
<NavSection id="instructor">
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-4 text-2xl font-semibold">
{$t('course.navItem.landing_page.instructor')}
</h3>
<div class="mb-4 flex items-center gap-4">
<img
alt="Author Avatar"
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
src={instructorImageUrl}
/>
<div>
<p class="text-base font-medium dark:text-white">
{instructorName}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{get(instructor, 'role', '')}
</p>
{#if get(instructor, 'courseNo', '')}
<p class="mt-1 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
<PlayIcon size={14} class="filled" />
<span>
{get(instructor, 'courseNo', '')}
{$t('course.navItem.landing_page.courses')}
</span>
</p>
{/if}
</div>
</div>
<p class="text-sm leading-relaxed font-light dark:text-white">
{get(instructor, 'description', '')}
</p>
</BlurFade>
</NavSection>
</div>
<!-- Pricing Details -->
<PricingSection {courseData} {editMode} bind:startCoursePayment className="target-component" />
</div>
{#if !isVisible}
<PricingSection {courseData} {editMode} bind:startCoursePayment mobile={true} className="w-full" />
{/if}
</div>
</div>
<style>
:global(.list ul li) {
margin-left: 1rem;
list-style-type: disc;
}
</style>
<svelte:component this={ThemeComponent} {...landingProps} />
{/if}
@@ -4,6 +4,9 @@ import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
import { ContentType } from '@cio/utils/constants/content';
import type { Review } from '$features/course/utils/types';
import get from 'lodash/get';
import type { AccountOrg } from '$features/app/types';
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import type { CourseLandingPageProps, OrgLandingPageTheme } from '@cio/ui/custom/org-landing-page';
export type LandingPageLesson = {
id: string;
@@ -105,3 +108,138 @@ export function filterNavItems(course: Course, reviews: Review[]) {
return true;
});
}
export function buildCourseLandingPageProps(
course: Course,
org: AccountOrg,
options: {
enrollHref: string;
enrollDisabled: boolean;
authAction?: { label: string; href: string };
}
): CourseLandingPageProps {
const landing = normalizeLandingPageSettings(org.landingpage);
const metadata = course.metadata ?? null;
const reviewsRaw = (metadata?.reviews ?? []) as NonNullable<NonNullable<Course['metadata']>['reviews']>;
const reviewsVisible = reviewsRaw.filter((r) => !r?.hide);
const sectionDisplay = (metadata?.sectionDisplay ?? {}) as Record<string, boolean>;
const isVisible = (key: string, fallback = true) =>
typeof sectionDisplay[key] === 'boolean' ? sectionDisplay[key] : fallback;
const sections = getCourseSections(course);
const totalLessons = getTotalLessons(sections);
const averageRating =
reviewsVisible.length > 0
? reviewsVisible.reduce((sum, r) => sum + (r.rating || 0), 0) / reviewsVisible.length
: undefined;
const hasCertificate = Boolean(metadata?.certificate?.templateUrl || course.certificate?.design?.templateId);
const courseTypeLabel =
course.type === 'LIVE_CLASS' ? 'Live class' : course.type === 'COMPLIANCE' ? 'Compliance' : 'Self-paced';
const includeRequirement = isVisible(NAV_ITEM_KEY.REQUIREMENT) && Boolean(metadata?.requirements);
const includeDescription = isVisible(NAV_ITEM_KEY.DESCRIPTION) && Boolean(metadata?.description);
const includeGoals = isVisible(NAV_ITEM_KEY.GOALS) && Boolean(metadata?.goals);
const includeCertificate = isVisible(NAV_ITEM_KEY.CERTIFICATE) && Boolean(metadata?.certificate?.templateUrl);
const includeReviews = isVisible(NAV_ITEM_KEY.REVIEWS, reviewsVisible.length > 0);
const includeInstructor = isVisible(NAV_ITEM_KEY.INSTRUCTOR);
const instructor = (metadata?.instructor ?? {}) as NonNullable<Course['metadata']>['instructor'] & {
name?: string;
role?: string;
description?: string;
imgUrl?: string;
coursesNo?: number;
};
const stats = [
{ label: 'Lessons', value: totalLessons.toString() },
averageRating !== undefined ? { label: 'Rating', value: averageRating.toFixed(1) } : null,
hasCertificate ? { label: 'Certificate', value: 'Included' } : null
].filter(Boolean) as Array<{ label: string; value: string }>;
const pricingCtaLabel = course.cost && course.cost > 0 ? 'Enrol now' : 'Start free';
return {
theme: landing.theme as OrgLandingPageTheme,
orgName: org.name ?? '',
logoUrl: org.avatarUrl ?? undefined,
navItems: landing.navItems,
authAction: options.authAction,
hero: {
heading: course.title ?? landing.hero.heading,
subheading: course.description ?? landing.hero.subheading,
primaryAction: {
label: pricingCtaLabel,
href: options.enrollHref,
disabled: options.enrollDisabled
},
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
image: course.logo || undefined,
stats
},
socialProof: {
rating: averageRating,
lessons: totalLessons > 0 ? totalLessons : undefined,
type: courseTypeLabel,
hasCertificate
},
info: {
requirements: includeRequirement ? (metadata?.requirements ?? undefined) : undefined,
description: includeDescription ? (metadata?.description ?? undefined) : undefined,
goals: includeGoals ? (metadata?.goals ?? undefined) : undefined,
certificateUrl: includeCertificate ? metadata?.certificate?.templateUrl : undefined
},
curriculum: {
grouped: course.content?.grouped ?? false,
sections: sections.map((section) => ({
id: section.id,
title: section.title ?? 'Lessons',
lessons: section.lessons.map((lesson) => ({
id: lesson.id,
title: lesson.title ?? 'Untitled lesson'
})),
exerciseCount: section.exerciseCount
}))
},
chips: {
skills: metadata?.skills ?? undefined,
tools: metadata?.tools ?? undefined
},
instructor: includeInstructor
? {
name: instructor.name?.trim() || org.name || '',
role: instructor.role || undefined,
imgUrl: instructor.imgUrl || org.avatarUrl || undefined,
description: instructor.description || undefined,
coursesNo: instructor.coursesNo || undefined
}
: { name: '' },
reviews: {
items: includeReviews
? reviewsVisible.map((r, idx) => ({
id: `review-${r.id ?? idx}`,
name: r.name ?? 'Anonymous',
avatarUrl: r.avatar_url || undefined,
rating: r.rating ?? 0,
description: r.description ?? '',
createdAt: r.created_at ? new Date(r.created_at).toISOString() : undefined
}))
: [],
averageRating
},
pricing: {
cost: course.cost ?? 0,
currency: course.currency ?? 'USD',
discount: metadata?.discount,
showDiscount: metadata?.showDiscount,
ctaLabel: pricingCtaLabel,
ctaHref: options.enrollHref,
reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined
},
footer: landing.footer
};
}
@@ -17,12 +17,13 @@
});
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
const isHls = $derived(Boolean(source.hls));
</script>
<div class="mb-5 {className}">
{#if isMuse}
<MusePlayer svid={source.metadata?.svid} {options} />
{:else}
<PlyrPlayer src={source.url} {poster} {options} />
<PlyrPlayer src={source.url} {poster} hls={isHls} {options} />
{/if}
</div>
@@ -4,18 +4,47 @@
import type { MediaPlayerOptions } from '../types';
import { isYoutubeUrl, getYoutubeVideoId } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
import { classroomio } from '$lib/utils/services/api';
import type Plyr from 'plyr';
/**
* Resolve an HLS source. The caller passes either a full URL or the
* relative shape `/hls/{assetId}/{rest}` we store in `lesson.videos[].link`.
* Routes through the typed Hono client so playback uses the same base
* URL as every other API call — direct to `PUBLIC_SERVER_URL` in local dev,
* and `${origin}/proxy/...` in production. Cloud tenant-router intercepts
* `/proxy/hls/*` and serves directly from R2.
*
* Hono's `$url()` only substitutes named `:params`, leaving wildcards as
* a literal `/*` suffix — we strip it and append the real path.
*/
function resolveHlsUrl(src: string): string {
if (/^https?:\/\//i.test(src)) return src;
const match = src.match(/^\/?hls\/([^/]+)\/(.+)$/);
if (!match) return src;
const [, assetId, rest] = match;
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
return built.toString().replace(/\/\*$/, '') + '/' + rest;
}
interface Props {
src: string;
poster?: string;
/**
* When true, `src` points at an HLS master playlist (.m3u8) and the
* player attaches hls.js to handle adaptive bitrate streaming.
*/
hls?: boolean;
options?: MediaPlayerOptions;
}
let { src, poster, options = {} }: Props = $props();
let { src, poster, hls = false, options = {} }: Props = $props();
let videoElement: HTMLVideoElement | null = null;
let containerElement: HTMLDivElement | null = null;
let playerInstance: Plyr | null = null;
let hlsInstance: import('hls.js').default | null = null;
const playsinline = $derived(options.playsinline !== false);
const maxHeight = $derived(options.maxHeight || '400px');
@@ -43,6 +72,36 @@
iconPrefix: 'plyr'
});
} else if (videoElement) {
// For HLS, attach hls.js to feed the manifest into the video element
// before Plyr wraps it. Safari uses the native HLS playback path
// (`canPlayType('application/vnd.apple.mpegurl')`), no hls.js needed.
if (hls) {
const hlsUrl = resolveHlsUrl(src);
const supportsNativeHls = videoElement.canPlayType('application/vnd.apple.mpegurl') !== '';
if (supportsNativeHls) {
// Native HLS (Safari) — set src here since the markup branch sees
// canPlayType before videoElement mounts and may render undefined.
videoElement.src = hlsUrl;
} else {
const HlsModule = await import('hls.js');
const Hls = HlsModule.default;
if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
// Browser needs to send session cookies to the API when the
// dashboard is on a different origin (dev / self-hosted).
xhrSetup: (xhr) => {
xhr.withCredentials = true;
}
});
hlsInstance.loadSource(hlsUrl);
hlsInstance.attachMedia(videoElement);
} else {
videoElement.src = hlsUrl;
}
}
}
// For regular HTML5 video
// playsinline is set as an HTML attribute on the video element, not a Plyr option
playerInstance = new Plyr(videoElement, {
@@ -56,6 +115,15 @@
});
onDestroy(() => {
if (hlsInstance) {
try {
hlsInstance.destroy();
} catch {
// Ignore — hls.js may have already torn itself down.
} finally {
hlsInstance = null;
}
}
if (playerInstance) {
try {
// Check if player is an embed (YouTube/Vimeo) and handle accordingly
@@ -93,8 +161,12 @@
{:else}
<!-- HTML5 video -->
<!-- svelte-ignore a11y_media_has_caption -->
<!-- crossorigin omitted so cross-origin poster (e.g. R2 r2.dev) displays without CORS; r2.dev may not send CORS headers even when bucket CORS is set -->
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
<!-- For HLS, hls.js or native HLS attaches programmatically in onMount; the bare <video> has no src here. -->
{#if hls}
<video bind:this={videoElement} {poster} {playsinline} class="plyr-player"></video>
{:else}
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
{/if}
{/if}
<!-- ClassroomIO Logo Overlay -->
@@ -3,6 +3,11 @@ export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_driv
export interface VideoSource {
type: VideoType;
url: string;
/**
* When set, `url` points at an HLS master playlist (.m3u8). The player
* attaches hls.js instead of feeding the URL into a raw HTML5 video.
*/
hls?: boolean;
metadata?: {
svid?: string; // muse.ai video ID
thumbnailUrl?: string; // upload thumbnail / poster
@@ -2,6 +2,7 @@
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { PublicCoursePoweredBy } from '@cio/ui/custom/public-course';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { cn } from '@cio/ui/tools';
@@ -44,7 +45,7 @@
/>
{:else if $isFreePlan}
<a
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}`}
target="_blank"
class={cn('group fixed right-9 bottom-14 z-50 hover:no-underline', className)}
>
@@ -2,6 +2,8 @@
import { profile } from '$lib/utils/store/user';
import { courseApi } from '$features/course/api';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { ROLE } from '@cio/utils/constants';
interface Props {
allowedRoles?: number[];
@@ -14,16 +16,21 @@
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
const userRole: number | null = $derived.by(() => {
// In the student experience everyone is treated as a student, so a teacher
// previews exactly what a student sees — no matter their real course role.
if ($isStudentExperience) return ROLE.STUDENT;
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
return user ? Number(user.roleId) : null;
});
const show = $derived.by(() => {
if (onlyStudent) {
// No org-admin bypass while in the student experience — treat the viewer as a student.
if (onlyStudent || $isStudentExperience) {
return isAllowed(userRole);
} else {
return isAllowed(userRole) || !!$isOrgAdmin;
}
return isAllowed(userRole) || !!$isOrgAdmin;
});
function isAllowed(userRole: number | null) {
@@ -32,7 +39,8 @@
}
$effect(() => {
if (!$isOrgAdmin && userRole !== null && !isAllowed(userRole)) {
const determined = $isStudentExperience || userRole !== null;
if (determined && !show) {
onDenied();
}
});
@@ -3,10 +3,11 @@
import { goto } from '$app/navigation';
import { basePath, globalStore } from '$lib/utils/store/app';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import RocketIcon from '@lucide/svelte/icons/rocket';
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -16,8 +17,12 @@
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { showUserJotWidget } from '$lib/utils/services/userjot';
import { ROLE } from '@cio/utils/constants';
const SUPPORT_EMAIL = 'help@classroomio.com';
const DOCS_URL = 'https://classroomio.com/docs';
const sidebar = useSidebar();
function getRoleLabel(roleId: number): string {
@@ -107,33 +112,31 @@
<div class="cursor-pointer space-y-2">
{#if !$globalStore.isOrgSite}
<div class="space-y-4">
<DropdownMenu.Item class="m-0">
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('updates')}>
<span class="flex w-full items-center gap-2">
<BellPlusIcon size={16} />
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
</span>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('feedback')}>
<span class="flex w-full items-center gap-2">
<MessageSquarePlusIcon size={16} />
<p class="text-sm">{$t('profileMenu.feedback')}</p>
</span>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" class="flex w-full items-center gap-2">
<BookOpenIcon size={16} />
<p class="text-sm">{$t('profileMenu.documentation')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a
href="https://classroomio.com/blog/launch-week"
target="_blank"
class="flex w-full items-center gap-2"
>
<RocketIcon size={16} />
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a
href="https://classroomio.com/contact"
target="_blank"
rel="noopener noreferrer"
class="flex h-full w-full items-center justify-start space-x-2 text-start"
>
<BadgeHelpIcon size={20} />
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
<a href="mailto:{SUPPORT_EMAIL}" class="flex w-full items-center gap-2">
<BadgeHelpIcon size={16} />
<p class="text-sm">{$t('profileMenu.need_help')}</p>
</a>
</DropdownMenu.Item>
</div>
@@ -1,26 +1,29 @@
<script module lang="ts">
// Module-level singleton: one Promise shared across all mounted instances.
// Dynamic imports are already cached by the JS engine after the first load,
// but reusing the same Promise avoids redundant microtask chains on remounts.
let pending: Promise<typeof import('@cio/ui/custom/editor')> | null = null;
function loadEditor() {
pending ??= import('@cio/ui/custom/editor');
return pending;
}
</script>
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
// Type-only imports are erased at build time — no static TipTap dependency.
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
interface Props {
placeholder?: string | ((node: any) => string);
// Content of the editor
content?: HTMLContent;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onChange?: (content: HTMLContent) => void;
onReady?: (editor: TiptapEditor) => void;
onEditorDestroy?: () => void;
@@ -42,17 +45,21 @@
}: Props = $props();
</script>
<Editor
{content}
{showToolBar}
{editable}
{enablePersistence}
{contentStorageKey}
{editableStorageKey}
class={className}
{editorClass}
{placeholder}
onContentChange={onChange}
onEditorReady={onReady}
{onEditorDestroy}
/>
{#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-32 ui:w-full" />
{:then { Editor }}
<Editor
{content}
{showToolBar}
{editable}
{enablePersistence}
{contentStorageKey}
{editableStorageKey}
class={className}
{editorClass}
{placeholder}
onContentChange={onChange}
onEditorReady={onReady}
{onEditorDestroy}
/>
{/await}
@@ -46,10 +46,10 @@
const MAX_IMAGE_SIZE = 500 * FileDropZone.KILOBYTE;
async function handleImageClick(img: string) {
$handleOpenWidget.open = false;
onchange?.(img);
imageURL = img;
$handleOpenWidget.open = false;
}
async function handleFilesUpload(files: File[]) {
@@ -84,7 +84,7 @@
isSearching = true;
try {
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
unsplashImages = await queryUnsplash(searchQuery || 'nature landscape architecture');
} catch (error) {
snackbar.error('snackbar.landing_page_settings.error.fetch_error');
console.error('Error fetching images from Unsplash:', error);
@@ -102,7 +102,7 @@
if (!isOpen) $handleOpenWidget.open = false;
}}
>
<Dialog.Content class="ui:z-300! w-3/5">
<Dialog.Content class="ui:z-300! w-[95%] max-w-2xl!">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
</Dialog.Header>
@@ -142,10 +142,10 @@
</Button>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
<div class="hide-scrollbar grid max-h-[400px] grid-cols-4 gap-3 px-[10px]">
{#each unsplashImages as unsplashImages (unsplashImages.id)}
<div>
<div class="relative h-[130px] w-[195px] overflow-hidden">
<div class="relative aspect-[3/2] overflow-hidden">
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
<img
src={unsplashImages.urls.regular}
@@ -155,7 +155,7 @@
</button>
</div>
{#if unsplashImages.user.name}
<p class="mt-1 text-center text-xs font-light text-gray-500">
<p class="mt-1 truncate text-center text-xs font-light text-gray-500">
By <a
href={`https://unsplash.com/@${unsplashImages.user.username}`}
target="_blank"