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

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -11,6 +11,7 @@
import { Separator } from '@cio/ui/base/separator';
import { preventDefault } from '$lib/utils/functions/svelte';
import { ROUTE } from '$lib/utils/constants/routes';
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
interface Props {
isLogin?: boolean;
@@ -67,8 +68,9 @@
}
</script>
<div class="auth-ui-background flex min-h-screen w-full items-center justify-center p-4">
<Card.Root class="ui:w-full max-w-[400px]">
<div class="auth-ui-background relative flex min-h-screen w-full items-center justify-center overflow-hidden p-4">
<DotPattern fillColor="rgb(2 51 189 / 0.25)" class="absolute inset-0 h-full w-full" />
<Card.Root class="ui:w-full relative z-10 max-w-[400px]">
{#if !showOnlyContent || showLogo}
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<Avatar.Root>
@@ -132,13 +134,3 @@
{/if}
</Card.Root>
</div>
<style>
/* .auth-ui-background {
background: url(https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg) no-repeat center center fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
} */
</style>
@@ -11,6 +11,7 @@
import type { Course } from '$features/course/utils/types';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
interface Props {
className?: string;
@@ -81,14 +82,10 @@
<div
class="sticky bottom-0 flex h-fit w-full items-center justify-center bg-gray-50 transition duration-300 lg:hidden dark:bg-neutral-800"
>
<aside
class="price-container sticky lg:hidden {editMode
? 'lg:bottom-2'
: 'lg:top-10'} m-h-fit z-0 bg-gray-50 dark:bg-neutral-800 {className}"
>
<aside class="sticky z-0 w-[90%] max-w-[405px] bg-gray-50 lg:hidden dark:bg-neutral-800 {className}">
<div class="flex items-center justify-center gap-3 px-3 py-3">
<!-- Pricing -->
<div class=" text-center">
<div class="text-center">
{#if courseData?.metadata?.allowNewStudent}
<p class="flex items-center gap-1 text-sm font-medium dark:text-white">
{formatter?.format(calculatedCost) || calculatedCost}
@@ -126,24 +123,26 @@
</div>
{:else}
<aside
class="price-container border border-gray-200 lg:sticky {editMode
class="relative w-[90%] max-w-[405px] overflow-hidden rounded-xl border border-gray-200 shadow-lg lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
? 'lg:top-0'
: 'lg:top-10'} m-h-fit dark:bg-neutral-800 {className}"
: 'lg:top-10'} h-fit dark:border-neutral-700 dark:bg-neutral-800 {className}"
>
<div class="p-2 lg:p-10">
<BorderBeam size={150} duration={12} colorFrom="#3b82f6" colorTo="#8b5cf6" borderWidth={1.5} />
<div class="p-4 lg:p-10">
<!-- Pricing -->
<div class="mb-6">
{#if courseData?.metadata?.allowNewStudent}
<p class="text-lg font-medium dark:text-white">
<p class="text-2xl font-bold dark:text-white">
{formatter?.format(calculatedCost) || calculatedCost}
{#if isFree}
<span class="text-sm">
<span class="text-base font-normal">
({$t('course.navItem.landing_page.pricing_section.free')})
</span>
{/if}
</p>
{#if courseData?.metadata?.showDiscount}
<p class="text-sm font-light text-gray-500 dark:text-white">
<p class="mt-1 text-sm font-light text-gray-500 dark:text-white">
{discount}% {$t('course.navItem.landing_page.pricing_section.discount')}.
<span class="line-through">
{formatter?.format(courseData?.cost || 0) || courseData.cost}
@@ -159,7 +158,11 @@
<!-- Call To Action Buttons -->
<div class="flex w-full flex-col items-center">
<Button class="mb-3 w-full" onclick={handleJoinCourse} disabled={!courseData.metadata?.allowNewStudent}>
<Button
class="mb-3 h-12 w-full text-base font-semibold"
onclick={handleJoinCourse}
disabled={!courseData.metadata?.allowNewStudent}
>
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
@@ -174,80 +177,11 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
>
<div class="flex flex-col items-center border-t border-gray-200 p-10 dark:border-neutral-700">
<HTMLRender>
<SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} />
</HTMLRender>
</div>
{/if}
</aside>
{/if}
<style lang="scss">
.banner {
background-color: #040f2d;
min-height: 472px;
}
.author {
color: #7888b7;
}
.banner-image {
max-width: 559px;
}
.backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.active {
position: relative;
display: inline-block;
}
.active::after {
position: absolute;
content: '';
width: 100%;
height: 3px;
background-color: var(--main-primary-color);
display: block;
bottom: -14px;
left: 0px;
}
.price-container {
width: 90%;
max-width: 405px;
height: fit-content;
}
@media screen and (min-width: 768px) {
.price-container {
width: 50%;
height: fit-content;
}
}
@media screen and (min-width: 1024px) {
.price-container {
width: 350px;
min-width: 250px;
}
}
@media screen and (min-width: 1280px) {
.price-container {
width: 405px;
min-width: 330px;
height: fit-content;
}
}
.course-content {
max-width: 608px;
}
:global(.list ul li) {
margin-left: 1rem;
list-style-type: disc;
}
</style>
@@ -7,6 +7,17 @@
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 { 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 { currentOrg } from '$lib/utils/store/org';
@@ -18,7 +29,7 @@
import { handleOpenWidget, reviewsModalStore } from './store';
import type { Course, CourseMetadata } from '$features/course/utils/types';
import { courseApi } from '$features/course/api';
import { MediaPlayer, isYoutubeUrl } from '@cio/ui/custom/media-player';
import { formatYoutubeEmbedUrl, isYoutubeUrl } from '@cio/ui/custom/media-player';
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
import { Button } from '@cio/ui/base/button';
@@ -38,16 +49,17 @@
interface Props {
editMode?: boolean;
courseData: Course;
showStandaloneShell?: boolean;
}
let { editMode = false, courseData = $bindable() }: Props = $props();
let { editMode = false, courseData = $bindable(), showStandaloneShell = true }: Props = $props();
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(totalRatings / reviews?.length);
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'));
@@ -70,6 +82,9 @@
const courseSections = $derived(getCourseSections(courseData));
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;
@@ -93,11 +108,11 @@
});
</script>
{#if !editMode}
{#if !editMode && showStandaloneShell}
<PoweredBy />
{/if}
{#if !editMode}
{#if !editMode && showStandaloneShell}
<Navigation
logo={$currentOrg.avatarUrl || '/logo-192.png'}
orgName={$currentOrg.name}
@@ -108,93 +123,127 @@
{/if}
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
<!-- Header Section -->
<header id="header" class="banner p- flex w-full items-center justify-center">
<div class="flex w-full flex-col-reverse items-center justify-between md:w-5/6 md:flex-row">
<!-- Course Description -->
<div class="w-11/12 py-10 md:w-2/5">
<h1 class="my-4 text-5xl text-white dark:text-white">
{get(courseData, 'title', '')}
</h1>
<p class="text-md mb-6 text-white dark:text-white">
{get(courseData, 'description', '')}
</p>
{#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)"
/>
<p class="author my-3 text-sm dark:text-white">
{get(courseData, 'metadata.instructor.name', '')}
</p>
<Button
class="mt-6 hidden sm:w-fit md:block"
onclick={() => {
if (editMode) return;
startCoursePayment = true;
}}
disabled={!allowNewStudent}
>
{$t('course.navItem.landing_page.start_course')}
</Button>
{#if $handleOpenWidget.open}
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
{/if}
<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">
{get(courseData, 'metadata.instructor.name', '')}
</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"
/>
{: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>
</div>
</div>
</header>
<!-- Banner Image -->
{#if video}
<div class="banner-image flex w-full md:w-2/3">
<MediaPlayer
source={{
type: isYoutubeUrl(video) ? 'youtube' : 'generic',
url: video
}}
options={{
width: '100%',
height: '400'
}}
class="w-full"
/>
</div>
{:else}
<div class="banner-image relative overflow-hidden rounded-md md:w-2/3">
<img
style="min-width:280px; min-height:200px"
alt="About us"
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-full max-h-[400px] w-full max-w-[500px] rounded-md md:mt-0"
/>
</div>
<!-- <div
class="banner-image w-2/3 h-96 relative cursor-pointer"
on:click={playVideo}
<!-- 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"
>
<div
aria-hidden="true"
class="absolute top-0 left-0 backdrop w-full h-full"
/>
<img
src="/images/course-banner-image.png"
alt="banner"
class="w-full h-full"
/>
<div
class="absolute top-0 left-0 w-full h-full z-50 flex items-center justify-center"
>
<img src="/images/play_video.png" alt="play video" />
</div>
</div> -->
{/if}
</div>
</header>
{#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-0 my-2 flex w-full max-w-[1200px] flex-col-reverse items-center justify-between lg:m-auto lg:w-11/12 lg:flex-row lg:items-start lg:py-8"
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="course-content w-full p-3 lg:mr-10 lg:w-10/12">
<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-10 py-2">
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 z-20 py-2">
<UnderlineTabs.List>
{#each navItems as navItem (navItem.key)}
<UnderlineTabs.Trigger value={navItem.key}>
@@ -207,155 +256,194 @@
</UnderlineTabs.Root>
<!-- Sections - Requirement -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
<NavSection id="requirement">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.requirement')}
</h3>
<ul class="list font-light">
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul>
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-3 text-2xl font-semibold">
{$t('course.navItem.landing_page.requirement')}
</h3>
<ul class="list font-light">
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Course Description -->
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
<NavSection id="description">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.description')}
</h3>
<HTMLRender className="dark:text-white text-sm font-light">
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender>
<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">
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul>
<BlurFade delay={0.1}>
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul>
</BlurFade>
</NavSection>
{/if}
<!-- Sections - Certificate -->
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
<NavSection id="certificate">
<h3 class="mt-0 text-2xl">{$t('course.navItem.landing_page.certificate')}</h3>
<p class="mb-3 text-sm font-light dark:text-white">
{$t('course.navItem.landing_page.certificate_text')}
</p>
<Image
src={certificate?.templateUrl}
alt="certificate template"
className="certificate-img max-h-[215px]"
/>
<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 -->
{#if !courseData.metadata?.isContentGroupingEnabled}
<NavSection id="lessons">
<div class="mb-3 flex w-full items-center justify-between">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.content')}
</h3>
<p class="text-sm font-light dark:text-white">
{pluralize('lesson', lessons.length, true)}
</p>
</div>
<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">
{#each lessons as lesson, index (lesson.id)}
<div class="m-2 rounded border px-2 py-1">
<Chip value={getLectureNo(index + 1, '0')} />
<p class="ml-2 inline text-xs font-light dark:text-white">
{lesson.title}
</p>
</div>
{/each}
</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 if courseData.metadata?.isContentGroupingEnabled}
<NavSection id="lessons">
<!-- header -->
<div class="flex items-center justify-between">
<h1>{$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>
<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 (section.id)}
<SectionsDisplay
exerciseCount={section.exerciseCount}
lessonCount={section.lessons?.length}
lessons={section.lessons}
title={section.title!}
/>
{/each}
{#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">
<h2 class="my-16 mr-0 mb-6 ml-0 font-semibold">
{$t('course.navItem.landing_page.reviews')}
</h2>
<div class="flex flex-wrap">
<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}
<!-- review -->
<div class="item-start my-2 flex w-2/4 flex-row">
<!-- image container -->
<Avatar.Root class="mt-1 size-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>
<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="w-11/12 pl-2.5">
<p class="mb-0.5 font-medium">{review.name}</p>
<!-- ratings -->
<div class="flex flex-row items-center">
{#if review.rating}
<img src="/images/rating-{review.rating}.svg" class="mt-1 mr-4 w-24" alt="" />
<!-- 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 _, i}
<StarIcon
size={14}
class={i < (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 class="read-more-content mb-2" style="max-height: {expandDescription[id] ? 'none' : '50px'}">
<p class="my-2 text-sm leading-5 text-gray-600">
{review.description}
</p>
</div>
{#if !expandDescription[id] && review.description.split(' ').length > 9}
<button class="ui:text-primary mt-2 font-normal underline" onclick={() => toggleDescription(id)}
>See More</button
>
{/if}
{#if expandDescription[id]}
<button class="ui:text-primary mt-2 font-normal underline" onclick={() => toggleDescription(id)}
>See Less</button
>
{/if}
</div>
</div>
</BlurFade>
{/if}
{/each}
</div>
{#if reviews.length > 4}
<Button class="mt-2" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
<Button class="mt-4" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
{$t('course.navItem.landing_page.see_all')}
</Button>
{/if}
@@ -382,7 +470,7 @@
<div class="flex w-4/6 flex-wrap">
{#each reviews as review, id (review.id)}
<!-- review -->
<div class="item-start my-2 flex w-full flex-row">
<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}
@@ -397,17 +485,26 @@
<div class="w-11/12 pl-2.5">
<p class="mb-0.5 font-medium">{review.name}</p>
<!-- ratings -->
<div class="flex flex-row">
<img src="/images/rating-full.svg" alt="" class="mr-2" />
<div class="flex flex-row items-center gap-1">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<StarIcon
size={14}
class={i < (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="read-more-content mb-2"
style="max-height: {expandDescription[id] ? 'none' : '50px'}"
class="mb-2 overflow-hidden transition-all"
style:max-height={expandDescription[id] ? 'none' : '60px'}
>
<p class="my-2 text-sm leading-5 text-gray-600">
<p class="my-2 text-sm leading-relaxed text-gray-600">
{review.description}
</p>
</div>
@@ -423,35 +520,39 @@
<!-- Sections - Instructor -->
<NavSection id="instructor">
<h3 class="mt-0 mb-3 text-2xl">
{$t('course.navItem.landing_page.instructor')}
</h3>
<div class="mb-4 flex items-center">
<img
alt="Author Avatar"
class="mr-3 block h-20 w-20 rounded-full"
src={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
/>
<div>
<p class="text-md font-light dark:text-white">
{get(instructor, 'name', $currentOrg.name)}
</p>
<p class="text-xs font-light dark:text-white">
{get(instructor, 'role', '')}
</p>
<p class="text-md flex items-center font-light dark:text-white">
<PlayIcon size={16} class="filled" />
<span class="ml-1"
>{get(instructor, 'courseNo', '')}
{$t('course.navItem.landing_page.courses')}</span
>
</p>
<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={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
/>
<div>
<p class="text-base font-medium dark:text-white">
{get(instructor, 'name', $currentOrg.name)}
</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>
</div>
<p class="text-sm font-light dark:text-white">
{get(instructor, 'description', '')}
</p>
<p class="text-sm leading-relaxed font-light dark:text-white">
{get(instructor, 'description', '')}
</p>
</BlurFade>
</NavSection>
</div>
@@ -464,45 +565,9 @@
</div>
</div>
<!-- TODO: CONVERT TO TAILWIND -->
<style>
.banner {
background-color: #040f2d;
min-height: 472px;
}
.author {
color: #7888b7;
}
.banner-image {
max-width: 559px;
}
:global(.certificate-img) {
width: unset !important;
}
.course-content {
max-width: 608px;
min-width: 60%;
}
:global(.list ul li) {
margin-left: 1rem;
list-style-type: disc;
}
.read-more-content {
max-height: 100px; /* Adjust this value to set the maximum height for the truncated content */
overflow: hidden;
transition: max-height 0.3s ease;
text-overflow: ellipsis;
}
@media screen and (max-width: 1023px) {
.course-content {
min-width: 80%;
}
}
</style>
@@ -30,3 +30,6 @@ export { default as UpgradeModal } from './upgrade-modal.svelte';
export { default as VisitOrgSiteButton } from './visit-org-site-btn.svelte';
export { default as Vote } from './vote.svelte';
export { default as PageRestricted } from './page-restricted.svelte';
export { default as SortSelect } from './sort-select.svelte';
export type { SortOption } from './sort-select.svelte';
export { default as SortPopover } from './sort-popover.svelte';
@@ -1,4 +1,4 @@
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse';
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_drive';
export interface VideoSource {
type: VideoType;
@@ -6,6 +6,7 @@ export interface VideoSource {
metadata?: {
svid?: string; // muse.ai video ID
thumbnailUrl?: string; // upload thumbnail / poster
title?: string;
};
}
@@ -3,6 +3,7 @@ import {
CourseIcon,
ExerciseIcon,
ExploreIcon,
GoalIcon,
HomeIcon,
SettingsIcon
} from '@cio/ui/custom/moving-icons';
@@ -56,6 +57,12 @@ const baseNavConfig: NavItemConfig[] = [
icon: CourseIcon,
matchPattern: '^/lms/mylearning(/.*)?$'
},
{
titleKey: 'lms_navigation.programs',
path: '/programs',
icon: GoalIcon,
matchPattern: '^/lms/programs(/.*)?$'
},
{
titleKey: 'lms_navigation.exercise',
path: '/exercises',
@@ -1,17 +1,20 @@
import {
ApiIcon,
AttachmentIcon,
CommunityIcon,
CourseIcon,
DashboardIcon,
GoalIcon,
PeopleIcon,
SettingsIcon,
SetupIcon
SetupIcon,
TagIcon,
ZapIcon
} from '@cio/ui/custom/moving-icons';
import type { AccountOrg } from '$features/app/types';
import type { Component } from 'svelte';
import BracesIcon from '@lucide/svelte/icons/braces';
import TagIcon from '@lucide/svelte/icons/tag';
import BotIcon from '@lucide/svelte/icons/bot';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
@@ -42,6 +45,12 @@ export interface NavItemConfig {
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
isPaid?: boolean; // Show upgrade indicator for free plan users
group?: string | null; // Group label key for sidebar grouping
}
export interface NavGroup {
labelKey: string | null;
items: NavItem[];
}
export interface NestedRouteConfig {
@@ -52,18 +61,43 @@ export interface NestedRouteConfig {
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
{
group: 'home',
titleKey: 'org_navigation.dashboard',
path: '',
icon: DashboardIcon,
matchPattern: '^/org/[^/]+/?$' // Exact match only
},
{
group: 'home',
titleKey: 'org_navigation.setup',
path: '/setup',
icon: SetupIcon,
requiresAdmin: true,
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
},
{
group: 'content',
titleKey: 'org_navigation.courses',
path: '/courses',
icon: CourseIcon,
matchPattern: '^/org/[^/]+/courses(/.*)?$' // Matches nested routes
},
{
group: 'content',
titleKey: 'org_navigation.programs',
path: '/programs',
icon: GoalIcon,
matchPattern: '^/org/[^/]+/programs(/.*)?$'
},
{
group: 'content',
titleKey: 'org_navigation.media',
path: '/media',
icon: AttachmentIcon,
matchPattern: '^/org/[^/]+/media(/.*)?$'
},
{
group: 'content',
titleKey: 'org_navigation.tags',
path: '/tags',
icon: TagIcon,
@@ -71,6 +105,7 @@ const baseNavConfig: NavItemConfig[] = [
matchPattern: '^/org/[^/]+/tags(/.*)?$'
},
{
group: 'people',
titleKey: 'org_navigation.community',
path: '/community',
icon: CommunityIcon,
@@ -84,60 +119,38 @@ const baseNavConfig: NavItemConfig[] = [
]
},
{
group: 'people',
titleKey: 'org_navigation.audience',
path: '/audience',
icon: PeopleIcon,
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.media',
path: '/media',
icon: AttachmentIcon,
matchPattern: '^/org/[^/]+/media(/.*)?$'
},
{
titleKey: 'org_navigation.setup',
path: '/setup',
icon: SetupIcon,
requiresAdmin: true,
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.automation',
path: '/automation',
icon: BracesIcon,
group: 'automation',
titleKey: 'automation.tabs.mcp',
path: '/mcp',
icon: BotIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
useHashUrl: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$',
items: [
{
titleKey: 'automation.tabs.mcp',
path: '/automation/mcp'
},
{
titleKey: 'automation.tabs.zapier',
path: '/automation/zapier'
},
{
titleKey: 'automation.tabs.api',
path: '/automation/api'
}
],
nestedRoutes: [
{
path: 'mcp',
titleKey: 'automation.tabs.mcp'
},
{
path: 'zapier',
titleKey: 'automation.tabs.zapier'
},
{
path: 'api',
titleKey: 'automation.tabs.api'
}
]
matchPattern: '^/org/[^/]+/mcp(/.*)?$'
},
{
group: 'automation',
titleKey: 'automation.tabs.api',
path: '/api',
icon: ApiIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/api(/.*)?$'
},
{
group: 'automation',
titleKey: 'automation.tabs.zapier',
path: '/zapier',
icon: ZapIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/zapier(/.*)?$'
},
{
titleKey: 'org_navigation.settings',
@@ -261,3 +274,79 @@ export function getOrgNavigationItems(
return items;
}
const GROUP_ORDER: Array<{ key: string | null; labelKey: string | null }> = [
{ key: 'home', labelKey: 'org_navigation.home' },
{ key: 'content', labelKey: 'org_navigation.content' },
{ key: 'people', labelKey: 'org_navigation.people' },
{ key: 'automation', labelKey: 'org_navigation.automation' },
{ key: null, labelKey: null }
];
/**
* Get navigation items grouped for the sidebar
*/
export function getOrgNavigationGroups(
currentOrgPath: string,
currentOrg: AccountOrg,
isOrgAdmin: boolean | null,
t: (key: string) => string,
pagePathname: string
): NavGroup[] {
const groupedMap = new Map<string | null, NavItem[]>();
for (const groupDef of GROUP_ORDER) {
groupedMap.set(groupDef.key, []);
}
for (const config of baseNavConfig) {
if (config.requiresAdmin && !isOrgAdmin && !config.disableWhenNotAdmin) {
continue;
}
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
const matchPattern =
typeof config.matchPattern === 'function' ? config.matchPattern(currentOrg.siteName!) : config.matchPattern;
const item: NavItem = {
title: t(config.titleKey),
url: config.useHashUrl ? '#' : url,
path: config.path,
icon: config.icon,
isActive: isActive(pagePathname, fullPath, matchPattern),
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin),
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment,
isPaid: config.isPaid
};
if (config.items) {
item.items = config.items.map((subConfig) => {
const subMatchPattern =
typeof subConfig.matchPattern === 'function'
? subConfig.matchPattern(currentOrg.siteName!)
: subConfig.matchPattern;
const subUrl = `${currentOrgPath}${subConfig.path}`;
return {
title: t(subConfig.titleKey),
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
url: subUrl,
path: subConfig.path,
isPaid: subConfig.isPaid
};
});
}
const groupKey = config.group !== undefined ? config.group : null;
const bucket = groupedMap.get(groupKey) ?? groupedMap.get(null)!;
bucket.push(item);
}
return GROUP_ORDER.filter(({ key }) => (groupedMap.get(key) ?? []).length > 0).map(({ key, labelKey }) => ({
labelKey,
items: groupedMap.get(key) ?? []
}));
}
@@ -4,7 +4,8 @@ import { QUESTION_TYPE_KEY, QUESTION_TYPE_REGISTRY, type QuestionTypeKey } from
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.LINK,
QUESTION_TYPE_KEY.STAR
]);
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
@@ -21,7 +22,9 @@ const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
[QUESTION_TYPE_KEY.MATCHING]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.matching',
[QUESTION_TYPE_KEY.ORDERING]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.ordering',
[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.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'
};
// Temp hidden: MATCHING, HOTSPOT
@@ -6,87 +6,91 @@
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { getOrgNavigationItems } from '$features/ui/navigation/org-navigation';
import { getOrgNavigationGroups } from '$features/ui/navigation/org-navigation';
import { HoverableItem } from '@cio/ui/custom/moving-icons';
const items = $derived(
getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search)
const groups = $derived(
getOrgNavigationGroups($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search)
);
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>{$t('org_navigation.platform')}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.title)}
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
{#if item.disabled}
<span
{...props}
aria-disabled="true"
class="flex cursor-not-allowed items-center gap-2 opacity-50"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</span>
{:else}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
{#each groups as group (group.labelKey)}
<Sidebar.Group class="pt-0!">
{#if group.labelKey}
<Sidebar.GroupLabel>{$t(group.labelKey)}</Sidebar.GroupLabel>
{/if}
<Sidebar.Menu>
{#each group.items as item (item.title)}
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
{#if item.disabled}
<span
{...props}
aria-disabled="true"
class="flex cursor-not-allowed items-center gap-2 opacity-50"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</span>
{:else}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
{#if item.items}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/if}
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{#if item.items}
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={subItem.isActive}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
{#if subItem.isPaid && $isFreePlan}
<RocketIcon class="text-primary size-4" />
{/if}
</a>
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
{#if item.items}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/if}
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{#if item.items}
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={subItem.isActive}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
{#if subItem.isPaid && $isFreePlan}
<RocketIcon class="text-primary size-4" />
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
{/each}
@@ -20,7 +20,7 @@
<AppLogo />
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Content class="gap-0!">
<NavMain />
</Sidebar.Content>
@@ -0,0 +1,126 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import FilterIcon from '@lucide/svelte/icons/filter';
import { t } from '$lib/utils/functions/translations';
import type { Snippet } from 'svelte';
export interface SortOption {
label: string;
value: string;
}
interface Props {
sortOptions: SortOption[];
sortKey?: string;
selectedOrder?: 'asc' | 'desc';
defaultSortKey?: string;
defaultSortOrder?: 'asc' | 'desc';
isFiltering?: boolean;
hasActiveFilters?: boolean;
title?: string;
onClearFilters?: () => void | Promise<void>;
onSortKeyChange?: (key: string) => void;
onOrderChange?: (order: 'asc' | 'desc') => void;
additionalContent?: Snippet;
}
let {
sortOptions,
sortKey = $bindable(''),
selectedOrder = $bindable('desc'),
defaultSortKey = '',
defaultSortOrder = 'desc',
isFiltering = false,
hasActiveFilters,
title,
onClearFilters = () => {},
onSortKeyChange,
onOrderChange,
additionalContent
}: Props = $props();
let isOpen = $state(false);
const isActive = $derived(
hasActiveFilters !== undefined ? hasActiveFilters : sortKey !== defaultSortKey || selectedOrder !== defaultSortOrder
);
function setSortKey(value: string) {
sortKey = value;
onSortKeyChange?.(value);
}
function setOrder(value: string) {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
onOrderChange?.(value);
}
}
</script>
<Popover.Root bind:open={isOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<div class="relative">
<Button {...props} variant="outline" size="sm" aria-label={$t('courses.tag_filters.filter')}>
{#if isFiltering}
<Spinner class="size-4" />
{:else}
<FilterIcon size={16} />
{/if}
<span class="hidden md:inline">{$t('courses.tag_filters.filter')}</span>
</Button>
{#if isActive}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
></span>
{/if}
</div>
{/snippet}
</Popover.Trigger>
<Popover.Content align="end" class="w-[360px] p-3">
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold">{title ?? $t('courses.tag_filters.popover_title')}</p>
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!isActive}>
{$t('courses.tag_filters.clear_all')}
</Button>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
<Button
type="button"
size="sm"
variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => setSortKey(option.value)}
>
{option.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root type="single" variant="outline" size="sm" value={selectedOrder} onValueChange={setOrder}>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
{@render additionalContent?.()}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,46 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
export interface SortOption {
label: string;
value: string;
}
interface Props {
options: SortOption[];
value?: string;
placeholder?: string;
class?: string;
onValueChange?: (value: string) => void;
}
let {
options,
value = $bindable(''),
placeholder,
class: className = 'min-w-[150px]',
onValueChange
}: Props = $props();
const selectedLabel = $derived(
options.find((o) => o.value === value)?.label ?? placeholder ?? options[0]?.label ?? ''
);
function handleChange(newValue: string) {
value = newValue;
onValueChange?.(newValue);
}
</script>
<Select.Root type="single" {value} onValueChange={handleChange}>
<Select.Trigger class={className}>
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -4,10 +4,11 @@
interface Props {
hasUnsavedChanges?: boolean;
skipPrompt?: boolean;
children?: import('svelte').Snippet;
}
let { hasUnsavedChanges = $bindable(false), children }: Props = $props();
let { hasUnsavedChanges = $bindable(false), skipPrompt = false, children }: Props = $props();
beforeNavigate(({ cancel }) => {
if (!shouldAbandonChanges()) {
@@ -20,6 +21,7 @@
function shouldAbandonChanges() {
if (!hasUnsavedChanges) return true;
if (skipPrompt) return true;
return window.confirm($t('common.unsaved_changes.message'));
}
</script>
@@ -97,7 +97,7 @@
if (!isOpen) $handleOpenWidget.open = false;
}}
>
<Dialog.Content class="w-3/5">
<Dialog.Content class="ui:z-300! w-3/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
</Dialog.Header>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Button, type ButtonVariant } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
@@ -9,22 +9,27 @@
interface Props {
className?: string;
isLMS?: boolean;
labelKey?: string;
variant?: ButtonVariant;
}
let { className = '', isLMS = false }: Props = $props();
let {
className = '',
isLMS = false,
labelKey = 'settings.subheadings.view_site',
variant = 'default'
}: Props = $props();
let href = $derived(isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain);
</script>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
<Button variant="default">
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
{:else}
{$t('settings.subheadings.view_site')}
{/if}
<Button {href} target="_blank" {variant} class={`ml-2 ${className}`.trim()}>
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
{:else}
{$t(labelKey)}
{/if}
<ArrowUpRightIcon class="custom" />
</Button>
</a>
{/if}
<ArrowUpRightIcon class="custom" />
</Button>