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:
@@ -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>
|
||||
|
||||
+20
-86
@@ -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>
|
||||
|
||||
+317
-252
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user