chore: ai, public course and other ui improvements
This commit is contained in:
+131
-104
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
@@ -13,23 +14,20 @@
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { BlurIn } from '@cio/ui/custom/animation/blurin';
|
||||
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
|
||||
import { HeroVideoDialog } from '@cio/ui/custom/animation/hero-video-dialog';
|
||||
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 { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
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 { formatYoutubeEmbedUrl, isYoutubeUrl } from '@cio/ui/custom/media-player';
|
||||
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
@@ -52,27 +50,110 @@
|
||||
showStandaloneShell?: boolean;
|
||||
}
|
||||
|
||||
let { editMode = false, courseData = $bindable(), showStandaloneShell = true }: Props = $props();
|
||||
let { editMode = false, courseData = $bindable(), showStandaloneShell = false }: 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(
|
||||
landingSettings.theme === 'classic'
|
||||
? 'ui:w-full ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
||||
: 'ui:w-full ui:bg-background ui:text-foreground ui:font-sans'
|
||||
);
|
||||
|
||||
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
|
||||
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
? {
|
||||
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
|
||||
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
|
||||
}
|
||||
: {
|
||||
label: t.get('navigation.login'),
|
||||
href: '/login'
|
||||
}
|
||||
);
|
||||
|
||||
const courseHero = $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
|
||||
};
|
||||
});
|
||||
|
||||
/** Matches API `buildCourseContent` — do not use `metadata.isContentGroupingEnabled` (dashboard normalizes undefined → true). */
|
||||
const lessonsLayoutIsGrouped = $derived(courseData.content?.grouped === true);
|
||||
|
||||
// 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);
|
||||
|
||||
$effect(() => {
|
||||
if (!showMarketingHero) {
|
||||
NavComponent = null;
|
||||
HeroComponent = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = landingSettings.theme;
|
||||
let cancelled = false;
|
||||
|
||||
void Promise.all([
|
||||
theme === 'bold'
|
||||
? import('@cio/ui/custom/org-landing-page/bold/nav.svelte')
|
||||
: theme === 'classic'
|
||||
? import('@cio/ui/custom/org-landing-page/classic/nav.svelte')
|
||||
: import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
|
||||
theme === 'bold'
|
||||
? import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
|
||||
: theme === 'classic'
|
||||
? import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
|
||||
: import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
|
||||
]).then(([navMod, heroMod]) => {
|
||||
if (!cancelled) {
|
||||
NavComponent = navMod.default;
|
||||
HeroComponent = heroMod.default;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
|
||||
const video = $derived(get(courseData, 'metadata.videoUrl'));
|
||||
|
||||
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 allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
|
||||
const bannerImage = $derived(get(courseData, 'logo'));
|
||||
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
|
||||
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() ? name : organizationName;
|
||||
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() ? imageUrl : '/images/avatar.svg';
|
||||
return typeof imageUrl === 'string' && imageUrl.trim().length > 0 ? imageUrl : '/images/avatar.svg';
|
||||
});
|
||||
const certificate = $derived(
|
||||
get(courseData, 'metadata.certificate', {
|
||||
@@ -93,8 +174,6 @@
|
||||
const totalLessons = $derived(getTotalLessons(courseSections));
|
||||
const studentCount = $derived(courseData.group?.members?.length ?? 0);
|
||||
|
||||
const videoEmbedUrl = $derived(video ? (isYoutubeUrl(video) ? formatYoutubeEmbedUrl(video) : video) : '');
|
||||
|
||||
function locationHashChanged() {
|
||||
activeNav = window.location.hash;
|
||||
}
|
||||
@@ -121,90 +200,38 @@
|
||||
<PoweredBy />
|
||||
{/if}
|
||||
|
||||
{#if !editMode && showStandaloneShell}
|
||||
<Navigation
|
||||
logo={$currentOrg.avatarUrl || '/logo-192.png'}
|
||||
orgName={$currentOrg.name}
|
||||
disableSignup={$currentOrg.disableSignup || ($currentOrg.settings?.signup?.inviteOnly ?? false)}
|
||||
isOrgSite={$globalStore.isOrgSite}
|
||||
customLinks={$currentOrg.landingpage?.customLinks}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
||||
{#if showStandaloneShell}
|
||||
<!-- Hero Section -->
|
||||
<header class="relative w-full overflow-hidden bg-[#040f2d] py-12 md:py-20">
|
||||
<DotPattern
|
||||
class="[mask-image:radial-gradient(ellipse_at_center,white,transparent_80%)] opacity-10"
|
||||
fillColor="rgb(255 255 255 / 0.5)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 mx-auto flex w-full max-w-6xl flex-col-reverse items-center gap-8 px-6 md:flex-row md:gap-12"
|
||||
>
|
||||
<!-- Course Description -->
|
||||
<div class="w-full py-4 md:w-1/2">
|
||||
<BlurIn class="my-4 text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{get(courseData, 'title', '')}
|
||||
</BlurIn>
|
||||
|
||||
<BlurFade delay={0.2}>
|
||||
<p class="mb-6 text-base leading-relaxed text-gray-300 md:text-lg">
|
||||
{get(courseData, 'description', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.3}>
|
||||
<p class="mb-6 text-sm text-blue-300">
|
||||
{instructorName}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.4}>
|
||||
<Button
|
||||
class="hidden h-12 px-8 text-base font-semibold sm:w-fit md:block"
|
||||
onclick={() => {
|
||||
if (editMode) return;
|
||||
startCoursePayment = true;
|
||||
}}
|
||||
disabled={!allowNewStudent}
|
||||
>
|
||||
{$t('course.navItem.landing_page.start_course')}
|
||||
</Button>
|
||||
</BlurFade>
|
||||
|
||||
{#if $handleOpenWidget.open}
|
||||
<UploadWidget
|
||||
imageURL={courseApi.course?.logo}
|
||||
onchange={(newLogo) => (courseApi.course!.logo = newLogo)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Banner Image / Video -->
|
||||
<div class="w-full md:w-1/2">
|
||||
<BlurFade delay={0.3} yOffset={20}>
|
||||
{#if video}
|
||||
<HeroVideoDialog
|
||||
videoSrc={videoEmbedUrl}
|
||||
thumbnailSrc={bannerImage || '/images/classroomio-course-img-template.jpg'}
|
||||
thumbnailAlt={get(courseData, 'title', 'Course video')}
|
||||
animationStyle="from-center"
|
||||
{#if showMarketingHero}
|
||||
<div class={heroShellClass}>
|
||||
{#if NavComponent && HeroComponent}
|
||||
{#if landingSettings.theme === 'minimal'}
|
||||
<HeroComponent hero={courseHero}>
|
||||
{#snippet navigation()}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-xl shadow-2xl">
|
||||
<img
|
||||
alt="Course banner"
|
||||
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
|
||||
class="h-auto w-full rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</BlurFade>
|
||||
{/snippet}
|
||||
</HeroComponent>
|
||||
{:else}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
<HeroComponent hero={courseHero} />
|
||||
{/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>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Social Proof Strip -->
|
||||
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
|
||||
@@ -252,7 +279,7 @@
|
||||
<!-- Course Details -->
|
||||
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 z-20 py-2">
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
@@ -323,8 +350,8 @@
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Lessons -->
|
||||
{#if !courseData.metadata?.isContentGroupingEnabled}
|
||||
<!-- 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">
|
||||
@@ -352,7 +379,7 @@
|
||||
</div>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{:else if courseData.metadata?.isContentGroupingEnabled}
|
||||
{:else}
|
||||
<NavSection id="lessons">
|
||||
<BlurFade delay={0.1}>
|
||||
<!-- header -->
|
||||
@@ -419,10 +446,10 @@
|
||||
<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 _, i}
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={i < (review.rating || 0)
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
@@ -496,10 +523,10 @@
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={i < (review.rating || 0)
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CourseIcon,
|
||||
DashboardIcon,
|
||||
GoalIcon,
|
||||
HomeIcon,
|
||||
LandingPageIcon,
|
||||
PeopleIcon,
|
||||
SettingsIcon,
|
||||
@@ -63,10 +64,17 @@ export interface NestedRouteConfig {
|
||||
export const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
titleKey: 'org_navigation.home',
|
||||
path: '',
|
||||
icon: HomeIcon,
|
||||
matchPattern: '^/org/[^/]+/?$'
|
||||
},
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
path: '/dash',
|
||||
icon: DashboardIcon,
|
||||
matchPattern: '^/org/[^/]+/?$' // Exact match only
|
||||
matchPattern: '^/org/[^/]+/dash(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'home',
|
||||
@@ -74,7 +82,7 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
path: '/setup',
|
||||
icon: SetupIcon,
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
|
||||
matchPattern: '^/org/[^/]+/setup(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
@@ -183,6 +191,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
titleKey: 'settings.tabs.billing_tab',
|
||||
path: '/settings/billing'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.ai_credits_tab',
|
||||
path: '/settings/ai-credits'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.auth_tab',
|
||||
matchPattern: '^/org/[^/]+/settings/auth(/.*)?$',
|
||||
@@ -191,6 +203,14 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
}
|
||||
],
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'billing',
|
||||
titleKey: 'settings.tabs.billing_tab'
|
||||
},
|
||||
{
|
||||
path: 'ai-credits',
|
||||
titleKey: 'settings.tabs.ai_credits_tab'
|
||||
},
|
||||
{
|
||||
path: 'customize-lms',
|
||||
titleKey: 'settings.tabs.customize_lms_tab'
|
||||
|
||||
@@ -5,7 +5,8 @@ export const PREMIUM_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([
|
||||
QUESTION_TYPE_KEY.FILE_UPLOAD,
|
||||
QUESTION_TYPE_KEY.ORDERING,
|
||||
QUESTION_TYPE_KEY.LINK,
|
||||
QUESTION_TYPE_KEY.STAR
|
||||
QUESTION_TYPE_KEY.STAR,
|
||||
QUESTION_TYPE_KEY.VIDEO_RECORDING
|
||||
]);
|
||||
|
||||
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
|
||||
@@ -24,7 +25,9 @@ const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
|
||||
[QUESTION_TYPE_KEY.HOTSPOT]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.hotspot',
|
||||
[QUESTION_TYPE_KEY.LINK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.link',
|
||||
[QUESTION_TYPE_KEY.WORD_BANK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.word_bank',
|
||||
[QUESTION_TYPE_KEY.STAR]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.star'
|
||||
[QUESTION_TYPE_KEY.STAR]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.star',
|
||||
[QUESTION_TYPE_KEY.VIDEO_RECORDING]:
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.video_recording'
|
||||
};
|
||||
|
||||
// Temp hidden: MATCHING, HOTSPOT
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import { ComingSoon } from '$features/ui';
|
||||
import ComingSoon from '$features/ui/coming-soon.svelte';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Self-hosted: show org name only, no switching -->
|
||||
{#if variant === 'breadcrumb'}
|
||||
<Breadcrumb.Link href={$currentOrgPath} class="flex items-center gap-2">
|
||||
{#if $currentOrg.name}sfsf
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||
|
||||
Reference in New Issue
Block a user