feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -0,0 +1,41 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Activity {
icon: Component;
title: string;
percentage: number | string;
description: string;
hidePercentage?: boolean;
}
interface Props {
activity: Activity;
}
let { activity }: Props = $props();
</script>
<div
class="flex flex-col items-center justify-center gap-5 rounded-xl border p-3 md:flex-row md:p-5 dark:border-neutral-600"
>
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<activity.icon />
</div>
<div>
<p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200">
{activity.title}
</p>
<p class="text-center text-2xl md:text-left">
{activity.percentage}
{#if !activity.hidePercentage}
%
{/if}
</p>
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
{activity.description}
</p>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import type { UserAnalytics } from '$lib/utils/types/analytics';
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
import MailIcon from '@lucide/svelte/icons/mail';
interface Props {
user: UserAnalytics['user'];
}
let { user }: Props = $props();
</script>
<div class="rounded-md border p-5 dark:border-neutral-600">
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
<Avatar.Root class="h-16 w-16">
<Avatar.Image
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
alt={user.fullName ? user.fullName : 'User'}
/>
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col space-y-2">
<p class="text-center text-2xl md:text-left dark:text-white">
{user.fullName}
</p>
<div class="flex flex-col items-center gap-1 md:flex-row md:gap-4">
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
<MailIcon size={16} />
{user.email}
</p>
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
<AlarmClockIcon size={16} />
{$t('analytics.last_seen')}:
<span class="italic">
{user.lastSeen ? calDateDiff(user.lastSeen) : $t('analytics.a_while_ago')}
</span>
</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,13 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mx-auto max-w-4xl pt-14">
<Skeleton class="h-40 w-full" />
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<Skeleton class="h-40 w-[270px]" />
<Skeleton class="h-40 w-[270px]" />
<Skeleton class="h-40 w-[270px]" />
</div>
</div>
@@ -3,7 +3,7 @@
import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
import { GoogleIconColored } from '$features/ui/icons';
import { authClient } from '$lib/utils/services/auth/client';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
@@ -0,0 +1,34 @@
<script lang="ts">
interface Props {
disableCenteredContent?: boolean;
className?: string;
children?: import('svelte').Snippet;
}
let { disableCenteredContent = false, className = '', children }: Props = $props();
</script>
<div class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent && 'center'} {className}">
{@render children?.()}
</div>
<style>
.root {
top: 0;
left: 0;
right: 0;
bottom: 0;
position: fixed;
background-color: rgba(0, 0, 0, 0.3);
-webkit-tap-highlight-color: transparent;
color: #fff;
z-index: 1201;
transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Spinner } from '@cio/ui/base/spinner';
import { IconButton } from '@cio/ui/custom/icon-button';
interface Props {
onClick?: (value: string) => void;
isClosing?: boolean;
tooltip?: string;
}
let { onClick = () => {}, isClosing = false, tooltip }: Props = $props();
function handleClick() {
onClick('');
}
</script>
<IconButton {tooltip} onclick={handleClick} disabled={isClosing}>
{#if isClosing}
<Spinner class="text-blue-700! size-5" />
{:else}
<XIcon size={16} class="dark:text-black" />
{/if}
</IconButton>
@@ -0,0 +1,11 @@
<script lang="ts">
interface Props {
code?: string;
}
let { code = '' }: Props = $props();
</script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html code}
</div>
@@ -5,6 +5,6 @@
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
</script>
<Badge variant="outline" class="ui:text-white border-none! bg-blue-600 dark:bg-blue-700 dark:text-black">
<Badge variant="default" class="">
{label}
</Badge>
@@ -0,0 +1,64 @@
<script>
import { onMount } from 'svelte';
import confetti from 'canvas-confetti';
import { confettiStore } from './store';
let myCanvas = $state();
let myConfetti;
function showConfetti(show) {
if (!show) return;
const duration = 5 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
const interval = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
// since particles fall down, start a bit higher than random
myConfetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
})
);
myConfetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
})
);
}, 250);
}
onMount(() => {
myConfetti = confetti.create(myCanvas, {
resize: true,
useWorker: true
});
});
$effect(() => {
showConfetti($confettiStore.show);
});
</script>
<canvas bind:this={myCanvas}></canvas>
<style>
canvas {
position: absolute;
width: 60%;
height: 70%;
}
</style>
@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
export const confettiStore = writable({
show: false
});
export function toggleConfetti() {
confettiStore.update((confetti) => ({
...confetti,
show: !confetti.show
}));
}
@@ -0,0 +1,19 @@
export function observeIntersection(node, callback, options = {}) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback(true);
} else {
callback(false);
}
});
}, options);
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
@@ -0,0 +1,71 @@
<script lang="ts">
import get from 'lodash/get';
import { untrack } from 'svelte';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { UploadImage } from '$features/ui';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let avatar: File | undefined = $state();
let templateUrl = $derived(get(course, 'metadata.certificate.templateUrl', '/images/certificate-template.svg'));
let isUploading = $state(false);
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.CERTIFICATE}`) ?? true);
async function onTemplateChange(_avatar: File | undefined) {
untrack(async () => {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadImage(_avatar);
if (logo) {
templateUrl = logo;
setter(templateUrl, 'metadata.certificate.templateUrl');
}
isUploading = false;
});
}
$effect(() => {
onTemplateChange(avatar);
});
$effect(() => {
setter(show, `metadata.sectionDisplay.${NAV_ITEM_KEY.CERTIFICATE}`);
});
</script>
<div class="flex flex-col items-center">
<div class="border-bottom-c mt-5">
<p class="text-center font-bold">
{$t('course.navItem.landing_page.editor.certificate_form.upload_template')}
</p>
<UploadImage bind:avatar src={templateUrl} shape="rounded-none" bind:isUploading />
</div>
<div class="mt-5 flex flex-col gap-2">
<p class="font-semibold">
{$t('course.navItem.landing_page.editor.display_section')}
</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={show} />
<Label class="text-gray-600">
{show ? $t('settings.landing_page.show_section') : $t('settings.landing_page.hide_section')}
</Label>
</div>
</div>
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
interface Props {
show?: boolean;
children?: import('svelte').Snippet;
}
let { show = true, children }: Props = $props();
</script>
<section class="root z-10">
{#if show}
<div class="relative h-full bg-white dark:bg-black">
{@render children?.()}
</div>
{/if}
</section>
<style lang="scss">
.root {
height: 100vh;
display: flex;
flex-direction: column;
min-width: 300px;
max-width: 300px;
top: 0;
left: 0;
border-right: 1px solid var(--border-color);
background-color: rgb(250, 251, 252);
}
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media (max-width: 760px) {
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import get from 'lodash/get';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { TextEditor } from '$features/ui';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.DESCRIPTION}`) ?? true);
$effect(() => {
setter(show, `metadata.sectionDisplay.${NAV_ITEM_KEY.DESCRIPTION}`);
});
</script>
<div>
<TextEditor content={course.metadata.description} onChange={(content) => setter(content, `metadata.description`)} />
</div>
<div class="mt-5 flex flex-col gap-2">
<p class="font-semibold">
{$t('course.navItem.landing_page.editor.display_section')}
</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={show} />
<Label class="font-bold">
{show ? $t('settings.landing_page.show_section') : $t('settings.landing_page.hide_section')}
</Label>
</div>
</div>
@@ -0,0 +1,277 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-right';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { currentOrgDomain } from '$lib/utils/store/org';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseButton } from '$features/ui';
import HeaderForm from './header-form.svelte';
import RequirementForm from './requirement-form.svelte';
import DescriptionForm from './description-form.svelte';
import PricingForm from './pricing-form.svelte';
import GoalsForm from './goals-form.svelte';
import ReviewsForm from './reviews-form.svelte';
import CertificateForm from './certificate-form.svelte';
import InstructorForm from './instructor-form.svelte';
import { Button } from '@cio/ui/base/button';
import { updateCourse } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { isMobile } from '$lib/utils/store/useMobile';
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
import type { Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
course: Course;
courseId: string;
syncCourseStore: (course: Course) => void;
}
let { course = $bindable(), courseId, syncCourseStore }: Props = $props();
let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300';
let loading = $state(false);
let show = $state(false);
interface Section {
key: number;
path: string;
title: string;
enableAIWriter?: boolean;
initPrompt?: string;
}
const sections: Section[] = [
{
key: 1,
path: '',
title: $t('course.navItem.landing_page.editor.title.header')
},
{
key: 2,
path: 'metadata.requirements',
title: $t('course.navItem.landing_page.editor.title.requirement'),
enableAIWriter: true,
initPrompt: $t('course.navItem.landing_page.editor.title.requirement')
},
{
key: 3,
path: 'metadata.description',
title: $t('course.navItem.landing_page.editor.title.description'),
enableAIWriter: true,
initPrompt: 'Please write a detailed course description for this course:'
},
{
key: 4,
path: 'metadata.goals',
title: $t('course.navItem.landing_page.editor.title.goals'),
enableAIWriter: true,
initPrompt: 'What should a student expect to learn from this course:'
},
{
key: 5,
path: '',
title: $t('course.navItem.landing_page.editor.title.certificate')
},
{
key: 6,
path: '',
title: $t('course.navItem.landing_page.editor.title.reviews')
},
{
key: 7,
path: '',
title: $t('course.navItem.landing_page.editor.title.instructor')
},
{
key: 8,
path: '',
title: $t('course.navItem.landing_page.editor.title.pricing')
}
];
let selectedSection: Section | null = $state(null);
function handleClose() {
if (!selectedSection) {
goto(`/courses/${courseId}`);
}
selectedSection = null;
}
function handleSectionSelect(sectionKey: number) {
return () => {
selectedSection = sections.find((section) => section.key === sectionKey) || null;
if (selectedSection) {
const sectionId = selectedSection.title.toLowerCase();
const sectionEl = document.getElementById(sectionId);
if (sectionEl) {
sectionEl.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
};
}
async function handleSave() {
loading = true;
course.slug = course.slug || generateSlug(course.title);
console.log('course', course);
await updateCourse(courseId, undefined, {
...course,
id: courseId,
attendance: undefined,
group: undefined,
lessons: undefined,
lesson_section: undefined,
polls: undefined,
slug: course.slug
});
loading = false;
syncCourseStore(course);
}
async function handlePreview() {
const link = `${$currentOrgDomain}/course/${course.slug}`;
window.open(link, '_blank');
}
function setter(value: any, setterKey: string) {
if (typeof value === 'undefined') return;
// Untrack the course change to avoid unnecessary re-renders
const _course = untrack(() => cloneDeep(course));
set(_course, setterKey, value);
course = _course;
}
</script>
<aside
class={`${
show ? 'fixed z-50 -translate-x-full md:absolute' : 'fixed z-50 translate-x-0 md:relative'
}left-0 z-50 h-full w-[90vw] min-w-[300px] max-w-[350px] border border-b-0 border-l-0 border-r border-t-0 bg-gray-100 transition dark:bg-neutral-800`}
>
<div class="toggler absolute rounded-full shadow-lg">
<IconButton
onclick={() => (show = !show)}
tooltip={$isMobile ? undefined : 'Toggle editor'}
tooltipSide={$isMobile ? undefined : 'right'}
>
{#if show}
<ChevronRightIcon size={16} />
{:else}
<ChevronLeftIcon size={16} />
{/if}
</IconButton>
</div>
<div class="flex h-full flex-col">
{#if !selectedSection}
<div class="flex w-full items-center justify-between px-2">
<CloseButton onClick={handleClose} />
<div class="flex items-center">
<Button type="button" class="mr-1" variant="outline" onclick={handleSave} {loading}>
{$t('course.navItem.landing_page.editor.save')}
</Button>
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<ArrowUpRightIcon size={16} />
</IconButton>
</div>
</div>
<div class="mb-2 flex w-full items-center justify-between px-2">
<h3 class="dark:text-white">{$t('course.navItem.landing_page.editor.page_builder')}</h3>
</div>
{#each sections as section, index}
<button
class="flex w-full items-center justify-between border border-l-0 px-2 py-3 {index + 1 < sections.length &&
'border-b-0'} border-gray-300"
onclick={handleSectionSelect(section.key)}
>
<p class="mr-2 dark:text-white">
{section.title}
{$t('course.navItem.landing_page.editor.section')}
</p>
<ChevronRightIcon size={16} />
</button>
{/each}
{:else}
<!-- Title -->
<div class="flex items-center {borderBottomGrey} w-full">
<IconButton onclick={handleClose}>
<ArrowLeftIcon size={16} />
</IconButton>
<div class=" flex items-center">
<h3 class="dark:text-white">
{selectedSection.title}
</h3>
<!-- {#if selectedSection.enableAIWriter}
<CustomPromptBtn
className="w-fit ml-2"
alignPopover="bottom-left"
defaultPrompt={`${selectedSection.initPrompt} ${course.title}. Please format in html`}
isHTML={true}
handleInsert={(v) => {
if (!selectedSection) return;
const _course = cloneDeep(course);
set(_course, selectedSection.path, v);
course = _course;
}}
/>
{/if} -->
</div>
</div>
<div class="title-content overflow-y-auto p-2">
{#if selectedSection.key === 1}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
<RequirementForm bind:course {setter} />
{:else if selectedSection.key === 3}
<DescriptionForm bind:course {setter} />
{:else if selectedSection.key === 4}
<GoalsForm bind:course {setter} />
{:else if selectedSection.key === 5}
<CertificateForm bind:course {setter} />
{:else if selectedSection.key === 6}
<ReviewsForm bind:course {setter} />
{:else if selectedSection.key === 7}
<InstructorForm bind:course {setter} />
{:else if selectedSection.key === 8}
<PricingForm bind:course {setter} />
{/if}
</div>
{/if}
</div>
</aside>
<style>
.title-content {
height: 90%;
}
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media screen and (max-width: 767px) {
.toggler {
right: -25px;
}
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import get from 'lodash/get';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { TextEditor } from '$features/ui';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.GOALS}`) ?? true);
$effect(() => {
setter(show, `metadata.sectionDisplay.${NAV_ITEM_KEY.GOALS}`);
});
</script>
<div class="">
<TextEditor content={course.metadata.goals} onChange={(content) => setter(content, `metadata.goals`)} />
</div>
<div class="mt-5 flex flex-col gap-2">
<p class="font-semibold">
{$t('course.navItem.landing_page.editor.display_section')}
</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={show} />
<Label class="text-gray-600">
{show ? $t('settings.landing_page.show_section') : $t('settings.landing_page.hide_section')}
</Label>
</div>
</div>
@@ -0,0 +1,44 @@
<script lang="ts">
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import { handleOpenWidget } from '../../store';
import { t } from '$lib/utils/functions/translations';
let { course = $bindable() } = $props();
function widgetControl() {
$handleOpenWidget.open = !$handleOpenWidget.open;
}
</script>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.header_form.title')}
bind:value={course.title}
/>
<TextareaField
label={$t('course.navItem.landing_page.editor.header_form.description')}
bind:value={course.description}
rows={6}
className="mt-5"
labelClassName="font-bold"
/>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.header_form.short_video')}
placeholder="www.youtube.com/watch?v=uYRq60G5XTk"
helperMessage={$t('course.navItem.landing_page.editor.header_form.helper')}
type="text"
bind:value={course.metadata.videoUrl}
/>
<div class="mt-7">
<p class="mb-3">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
<Button variant="outline" onclick={widgetControl}>
{$t('course.navItem.landing_page.editor.header_form.replace')}
</Button>
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import get from 'lodash/get';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import UploadImage from '$features/ui/upload-image.svelte';
import { uploadImage } from '$lib/utils/services/upload';
import type { Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let name = $state(get(course, 'metadata.instructor.name'));
let role = $state(get(course, 'metadata.instructor.role'));
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
let description = $state(get(course, 'metadata.instructor.description'));
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
let avatar: File | undefined = $state();
let isUploading = $state(false);
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadImage(_avatar);
if (!logo) return;
imgUrl = logo;
isUploading = false;
}
$effect(() => {
setter(name, 'metadata.instructor.name');
});
$effect(() => {
setter(role, 'metadata.instructor.role');
});
$effect(() => {
setter(imgUrl, 'metadata.instructor.imgUrl');
});
$effect(() => {
setter(description, 'metadata.instructor.description');
});
$effect(() => {
setter(courseNo, 'metadata.instructor.courseNo');
});
$effect(() => {
onAvatarChange(avatar);
});
</script>
<div class="mt-5 w-full">
<label for="upload" class="font-bold">
{$t('course.navItem.landing_page.editor.instructor_form.upload')}
</label>
<div class="flex w-full justify-center">
<UploadImage bind:avatar src={imgUrl} bind:isUploading />
</div>
</div>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.instructor_form.name')}
placeholder={$t('course.navItem.landing_page.editor.instructor_form.name_placeholder')}
bind:value={name}
/>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.instructor_form.role')}
placeholder="e.g Software developer"
bind:value={role}
/>
<TextareaField
label={$t('course.navItem.landing_page.editor.instructor_form.about')}
placeholder={$t('course.navItem.landing_page.editor.instructor_form.about_placeholder')}
rows={6}
className="mt-5"
labelClassName="font-bold"
bind:value={description}
/>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.instructor_form.total')}
type="number"
bind:value={courseNo}
/>
@@ -0,0 +1,128 @@
<script lang="ts">
import get from 'lodash/get';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import type { Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { isCourseFree } from '$lib/utils/functions/course';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let discount = $derived(get(course, 'metadata.discount', 0));
let paymentLink = $derived(get(course, 'metadata.paymentLink', ''));
let showDiscount = $derived(get(course, 'metadata.showDiscount', false));
let giftToggled = $derived(get(course, 'metadata.reward.show', false));
function handleChange(content: string) {
setter(content, 'metadata.reward.description');
}
$effect(() => {
setter(showDiscount, 'metadata.showDiscount');
});
$effect(() => {
setter(paymentLink, 'metadata.paymentLink');
});
$effect(() => {
setter(discount, 'metadata.discount');
});
$effect(() => {
setter(giftToggled, 'metadata.reward.show');
});
</script>
{#if typeof course !== 'undefined'}
<div>
<Label class="mb-2 font-bold">{$t('course.navItem.landing_page.editor.pricing_form.currency')}</Label>
<Select.Root type="single" value={course.currency} onValueChange={(value) => setter(value, 'currency')}>
<Select.Trigger class="w-full">
<p>{course.currency === 'NGN' ? 'NGN' : 'USD'}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value="NGN">NGN</Select.Item>
<Select.Item value="USD">USD</Select.Item>
</Select.Content>
</Select.Root>
</div>
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.pricing_form.cost')}
type="number"
bind:value={course.cost}
/>
{#if !isCourseFree(course.cost || 0)}
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.pricing_form.payment')}
helperMessage="Stripe, Lemon Squeezy or any payment link"
bind:value={paymentLink}
/>
{/if}
<div class="mt-5">
<div class="mb-2">
<Label class="font-bold">{$t('course.navItem.landing_page.editor.pricing_form.discount')}</Label>
</div>
<div class="flex items-center space-x-2">
<Switch bind:checked={showDiscount} />
<Label class="text-gray-600">
{showDiscount
? $t('course.navItem.landing_page.editor.pricing_form.yes')
: $t('course.navItem.landing_page.editor.pricing_form.no')}
</Label>
</div>
</div>
{#if showDiscount}
<InputField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.pricing_form.percent')}
type="number"
bind:value={discount}
helperMessage="In Percentage %"
/>
{/if}
<div class="mt-5">
<div class="mb-2">
<Label class="font-bold">Gift on Completion</Label>
</div>
<div class="flex items-center space-x-2">
<Switch bind:checked={giftToggled} />
<Label class="text-gray-600">
{giftToggled
? $t('course.navItem.landing_page.editor.pricing_form.yes')
: $t('course.navItem.landing_page.editor.pricing_form.no')}
</Label>
</div>
</div>
{#if giftToggled}
<p class="mt-5 dark:text-white">
{$t('course.navItem.landing_page.editor.pricing_form.gift')}
</p>
<div class="h-2/5">
<TextEditor
content={get(course, 'metadata.reward.description', '')}
onChange={(content) => handleChange(content)}
/>
</div>
{/if}
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
import get from 'lodash/get';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { TextEditor } from '$features/ui';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.REQUIREMENT}`) ?? true);
$effect(() => {
setter(show, `metadata.sectionDisplay.${NAV_ITEM_KEY.REQUIREMENT}`);
});
</script>
<div>
<TextEditor content={course.metadata.requirements} onChange={(content) => setter(content, `metadata.requirements`)} />
</div>
<div class="mt-5 flex flex-col gap-2">
<p class="font-semibold">
{$t('course.navItem.landing_page.editor.display_section')}
</p>
<div class="flex items-center space-x-2">
<Switch bind:checked={show} />
<Label class="text-gray-600">
{show ? $t('settings.landing_page.show_section') : $t('settings.landing_page.hide_section')}
</Label>
</div>
</div>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import SaveIcon from '@lucide/svelte/icons/save';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import { UploadImage } from '$features/ui';
let { reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
let avatar = $state<File | undefined>();
// function to delete review
function deleteReviewData() {
reviews = reviews.filter((r) => r.id !== review.id);
}
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar) return;
review.avatar_url = await uploadImage(_avatar);
}
$effect(() => {
onAvatarChange(avatar);
});
</script>
<div class="flex w-full flex-col items-center">
<UploadImage bind:avatar src={review.avatar_url} widthHeight="w-20 h-20" errorMessage={errors.avatar_url} />
<InputField
className="mt-2 w-full"
labelClassName="font-normal"
label={$t('course.navItem.landing_page.editor.reviews_form.fullname')}
placeholder=""
type="text"
bind:value={review.name}
errorMessage={errors.name}
/>
<!-- Description -->
<TextareaField
label={$t('course.navItem.landing_page.editor.reviews_form.description')}
rows={4}
className="mt-2 w-full"
labelClassName="font-normal"
placeholder=""
bind:value={review.description}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Generate a 20 word review from a student saying good things about me teaching"
aiAlignPopover="end"
/>
<div class="mt-2 flex w-full items-end justify-between">
<!-- Rating -->
<InputField
className="mt-2 w-20"
labelClassName="font-normal"
label={$t('course.navItem.landing_page.editor.reviews_form.rating')}
type="number"
min={1}
max={5}
bind:value={review.rating}
errorMessage={errors.rating}
/>
<!-- Toggle -->
<div class="flex w-24 flex-col items-center gap-2 space-x-2">
<Switch bind:checked={review.hide} />
<Label class="text-sm">
{review.hide ? $t('settings.landing_page.show_section') : $t('settings.landing_page.hide_section')}
</Label>
</div>
</div>
<div class="mt-8 flex w-full items-center justify-between">
<IconButton onclick={deleteReviewData}>
<TrashIcon size={16} />
</IconButton>
<IconButton onclick={() => onExpand(review.id)}>
<SaveIcon size={16} />
</IconButton>
</div>
</div>
@@ -0,0 +1,126 @@
<script lang="ts">
import get from 'lodash/get';
import z from 'zod';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import ReviewFormEditor from './review-form-editor.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import { processErrors } from '$lib/utils/functions/validator';
import { shortenName } from '$lib/utils/functions/string';
import { t } from '$lib/utils/functions/translations';
import type { Course } from '$lib/utils/types';
interface Props {
course: Course;
setter: (value: any, key: string) => void;
}
let { course = $bindable(), setter }: Props = $props();
let reviews = $state(get(course, 'metadata.reviews', []));
let reviewToExpand = $state<number | null>(null);
let errors = $state({});
function addReviewForm() {
const _review = {
id: new Date().getTime(),
hide: false,
name: '',
avatar_url: '',
rating: 1,
created_at: new Date().getTime(),
description: ''
};
reviews = [...reviews, _review];
reviewToExpand = _review.id;
}
function validateReviews(id) {
const review = reviews.find((r) => r.id === id);
const reviewSchema = z.object({
name: z.string().min(5, {
message: `${$t('course.navItem.landing_page.editor.reviews_form.validations.name.min_char')}`
}),
avatar_url: z.string().min(6, {
message: `${$t('course.navItem.landing_page.editor.reviews_form.validations.avatar_url.message')}`
}),
rating: z
.number()
.min(1, {
message: `${$t('course.navItem.landing_page.editor.reviews_form.validations.rating.message')}`
})
.max(5, {
message: `${$t('course.navItem.landing_page.editor.reviews_form.validations.rating.message')}`
}),
description: z.string().min(10, {
message: `${$t('course.navItem.landing_page.editor.reviews_form.validations.description.min_char')}`
})
});
const { error } = reviewSchema.safeParse(review);
return processErrors(error);
}
function onExpand(id) {
errors = {};
if (reviewToExpand) {
const validationRes = validateReviews(reviewToExpand);
if (Object.keys(validationRes).length) {
errors = Object.assign(errors, validationRes);
return;
}
}
if (id === reviewToExpand) {
reviewToExpand = null;
return;
}
reviewToExpand = id;
}
$effect(() => {
setter(reviews, 'metadata.reviews');
});
</script>
<!-- Sections - Reviews -->
<section id="reviews">
<div class="">
{#each reviews || [] as review, index}
<div
id={String(review.id)}
class="relative my-2.5 flex flex-col items-center rounded-lg border border-gray-300 p-2"
>
{#if review.id !== reviewToExpand}
<!-- the headers -->
<div class="flex w-full items-center justify-between">
<Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Reviewer'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
</Avatar.Root>
<p class="text-sm">{review.name}</p>
<IconButton onclick={() => onExpand(review.id)}>
<ChevronDownIcon size={16} />
</IconButton>
</div>
{/if}
<!-- the body -->
{#if review.id === reviewToExpand}
<ReviewFormEditor bind:reviews bind:review={reviews[index]} {errors} courseId={course.id} {onExpand} />
{/if}
</div>
{/each}
<!-- create reviews button -->
<Button class="mt-8" onclick={addReviewForm}>
{$t('course.navItem.landing_page.editor.reviews_form.add_reviews')}
</Button>
</div>
</section>
@@ -0,0 +1,19 @@
export function observeIntersection(node, callback, options = {}) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback(true);
} else {
callback(false);
}
});
}, options);
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
@@ -0,0 +1,14 @@
<script lang="ts">
import { fade } from 'svelte/transition';
interface Props {
id: string;
children: import('svelte').Snippet;
}
let { id, children }: Props = $props();
</script>
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pb-10 pt-12">
{@render children()}
</section>
@@ -0,0 +1,118 @@
<script lang="ts">
import { currentOrg } from '$lib/utils/store/org';
import { preventDefault } from '$lib/utils/functions/svelte';
import { coursePaymentValidation } from '$lib/utils/functions/validator';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import * as Dialog from '@cio/ui/base/dialog';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
interface Props {
open?: boolean;
paymentLink?: string;
teacherEmail?: string;
courseName?: string;
}
let { open = $bindable(false), paymentLink = '', teacherEmail = '', courseName = '' }: Props = $props();
let fields = $state({
fullname: '',
email: ''
});
let errors: Record<string, string> = $state({
fullname: '',
email: ''
});
const STEPS = {
STEP_1: 'ENTER_DETAILS',
STEP_2: 'GO_TO_PAYMENT'
};
let step = $state(STEPS.STEP_1);
function onSubmit() {
errors = coursePaymentValidation(fields);
console.log('coursePayment errors', errors);
if (Object.keys(errors).length) {
return;
}
if (paymentLink) {
step = STEPS.STEP_2;
// Send email to first added teacher of student wanting to join
triggerSendEmail(NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST, {
to: teacherEmail,
courseName,
studentEmail: fields.email,
studentFullname: fields.fullname
});
} else {
open = false;
}
}
function onClickPaymentLink() {
// Send email to student to send proof of payment to teacher
triggerSendEmail(NOTIFICATION_NAME.STUDENT_PROOVE_COURSE_PAYMENT, {
to: fields.email,
courseName,
teacherEmail,
studentFullname: fields.fullname,
orgName: $currentOrg.name
});
fields.fullname = '';
fields.email = '';
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>Process course payment</Dialog.Title>
</Dialog.Header>
{#if step === STEPS.STEP_1}
<form onsubmit={preventDefault(onSubmit)}>
<InputField
label="Your Fullname"
bind:value={fields.fullname}
autoFocus={true}
placeholder="John Doe"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.fullname}
/>
<InputField
label="Your Email"
bind:value={fields.email}
placeholder="johndoe@email.com"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.email}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<Button type="submit">
{paymentLink ? 'Next' : 'Finish'}
</Button>
</div>
</form>
{:else if step === STEPS.STEP_2}
<p>You will now be taken to a payment page, once you've paid send a proof of payment to the course admin</p>
<div class="mt-5 flex flex-row-reverse items-center">
<a href={paymentLink} target="_blank" onclick={onClickPaymentLink} class="text-sm font-semibold"
>Go to payment</a
>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,264 @@
<script lang="ts">
import get from 'lodash/get';
import { Button } from '@cio/ui/base/button';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$lib/utils/types';
import { ROLE } from '@cio/utils/constants';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
interface Props {
className?: string;
editMode?: boolean;
courseData: Course;
startCoursePayment?: boolean;
mobile?: boolean;
}
let {
className = '',
editMode = false,
courseData,
startCoursePayment = $bindable(false),
mobile = false
}: Props = $props();
let openModal = $state(false);
let formatter: Intl.NumberFormat | undefined = $state();
const discount = $derived(get(courseData, 'metadata.discount', 0));
const calculatedCost = $derived(
calcCourseDiscount(discount, courseData.cost || 0, !!courseData.metadata.showDiscount)
);
const isFree = $derived(isCourseFree(calculatedCost));
function handleJoinCourse() {
if (editMode) return;
capturePosthogEvent('join_course', {
course_id: courseData.id,
course_title: courseData.title,
course_cost: courseData.cost,
course_free: isFree
});
if (isFree) {
const link = getStudentInviteLink(courseData, $currentOrg.siteName, $currentOrgDomain);
goto(link);
} else {
openModal = true;
}
startCoursePayment = false;
}
function setFormatter(currency: string | undefined) {
if (!currency) return;
formatter = getCurrencyFormatter(currency);
}
function getTeacherEmail(group: Course['group']) {
const firstTutor = group?.members?.find((m) => m.role_id === ROLE.TUTOR);
return firstTutor?.profile?.email || '';
}
$effect(() => {
setFormatter(courseData.currency);
});
$effect(() => {
startCoursePayment && handleJoinCourse();
});
</script>
<PaymentModal
bind:open={openModal}
paymentLink={get(courseData, 'metadata.paymentLink', '')}
courseName={courseData.title}
teacherEmail={getTeacherEmail(courseData.group)}
/>
<!-- Pricing Details -->
{#if mobile}
<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 lg:rounded-lg lg:shadow-2xl dark:bg-neutral-800 {className}"
>
<div class="flex items-center justify-center gap-3 px-3 py-3">
<!-- Pricing -->
<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}
{#if isFree}
<span class="text-xs">
({$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">
{discount}% {$t('course.navItem.landing_page.pricing_section.discount')}.
<span class="line-through">
{formatter?.format(courseData?.cost || 0) || courseData.cost}
</span>
</p>
{/if}
{:else}
<p class="text-lg dark:text-white">
{$t('course.navItem.landing_page.pricing_section.not_accepting')}
</p>
{/if}
</div>
<!-- Call To Action Buttons -->
<div class="flex h-full w-full flex-col items-center">
<Button onclick={handleJoinCourse} disabled={!courseData.metadata.allowNewStudent} class="w-full">
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
</Button>
</div>
</div>
</aside>
</div>
{:else}
<aside
class="price-container lg:sticky {editMode
? 'lg:top-0'
: 'lg:top-10'} m-h-fit lg:rounded-lg lg:shadow-2xl dark:bg-neutral-800 {className}"
>
<div class="p-2 lg:p-10">
<!-- Pricing -->
<div class="mb-6">
{#if courseData?.metadata?.allowNewStudent}
<p class="text-lg font-medium dark:text-white">
{formatter?.format(calculatedCost) || calculatedCost}
{#if isFree}
<span class="text-sm">
({$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">
{discount}% {$t('course.navItem.landing_page.pricing_section.discount')}.
<span class="line-through">
{formatter?.format(courseData?.cost || 0) || courseData.cost}
</span>
</p>
{/if}
{:else}
<p class="text-lg dark:text-white">
{$t('course.navItem.landing_page.pricing_section.not_accepting')}
</p>
{/if}
</div>
<!-- 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}>
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
</Button>
{#if courseData?.metadata?.showDiscount && courseData.metadata.allowNewStudent}
<p class="text-sm font-light text-gray-500 dark:text-white">
{$t('course.navItem.landing_page.pricing_section.bird')}
</p>
{/if}
</div>
</div>
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-b border-t border-gray-300 p-10">
<HtmlRender>{@html get(courseData, 'metadata.reward.description', '')}</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;
}
:global(.plyr) {
width: 100% !important;
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import pluralize from 'pluralize';
import { t } from '$lib/utils/functions/translations';
import type { Lesson } from '$lib/utils/types';
interface Props {
title: string;
lessonCount: number;
exerciseCount: number;
lessons: Lesson[];
}
let { title, lessonCount, exerciseCount, lessons }: Props = $props();
let expand = $state(false);
</script>
<div class="my-3 w-full rounded-md">
<!-- lesson header -->
<button
class="flex w-full items-center justify-between bg-[#F7F7F7] py-3 pl-3 pr-4 text-sm font-medium dark:bg-neutral-700"
onclick={() => (expand = !expand)}
>
<span class="flex items-center gap-2">
{#if expand}
<ChevronUpIcon size={16} />
{:else}
<ChevronDownIcon size={16} />
{/if}
{title}
</span>
<span class="text-xs font-normal">
{pluralize($t('course.navItem.landing_page.lessons'), lessonCount, true)},
{pluralize($t('course.navItem.landing_page.exercises'), exerciseCount, true)}
</span>
</button>
{#if expand}
{#each lessons as lesson (lesson.id)}
<div class="lesson-section flex items-center gap-2 py-3 pl-3 text-[13px] text-[#656565]">
<CircleCheckIcon size={16} />
{lesson.title}
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,41 @@
export const NAV_ITEM_KEY = {
HEADER: 'header',
REQUIREMENT: '#requirement',
DESCRIPTION: '#description',
GOALS: '#goals',
CERTIFICATE: '#certificate',
LESSONS: '#lessons',
REVIEWS: '#reviews',
INSTRUCTOR: '#instructor'
};
export const NAV_ITEMS = [
{
key: NAV_ITEM_KEY.REQUIREMENT,
label: 'course.navItem.landing_page.editor.title.requirement'
},
{
key: NAV_ITEM_KEY.DESCRIPTION,
label: 'course.navItem.landing_page.editor.title.description'
},
{
key: NAV_ITEM_KEY.GOALS,
label: 'course.navItem.landing_page.editor.title.goals'
},
{
key: NAV_ITEM_KEY.CERTIFICATE,
label: 'course.navItem.landing_page.editor.title.certificate'
},
{
key: NAV_ITEM_KEY.LESSONS,
label: 'course.navItems.nav_content'
},
{
key: NAV_ITEM_KEY.REVIEWS,
label: 'course.navItem.landing_page.editor.title.reviews'
},
{
key: NAV_ITEM_KEY.INSTRUCTOR,
label: 'course.navItem.landing_page.editor.title.instructor'
}
];
@@ -0,0 +1,559 @@
<script lang="ts">
import get from 'lodash/get';
import pluralize from 'pluralize';
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$lib/components/Course/function';
import { currentOrg } from '$lib/utils/store/org';
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { handleOpenWidget, reviewsModalStore } from './store';
import { COURSE_VERSION, type Course } from '$lib/utils/types';
import { course, sortLesson } from '$lib/components/Course/store';
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
import { getExerciseCount, getLessonSections, getTotalLessons, filterNavItems } from './utils';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import * as Dialog from '@cio/ui/base/dialog';
import { Image, PoweredBy, UploadWidget } from '$features/ui';
import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/pricing-section.svelte';
import NavSection from './components/nav-section.svelte';
import { shortenName } from '$lib/utils/functions/string';
import SectionsDisplay from './components/sections-display.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { observeIntersection } from './components/intersection-observer';
interface Props {
editMode?: boolean;
courseData: Course;
}
let { editMode = false, courseData = $bindable() }: Props = $props();
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
const video = $derived(get(courseData, 'metadata.videoUrl'));
const lessons = $derived.by(() => {
const _lessons = get(courseData, 'lessons', []);
return sortLesson([..._lessons]);
});
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
const averageRating = $derived(totalRatings / reviews?.length);
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
const bannerImage = $derived(get(courseData, 'logo'));
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
const certificate: Course['metadata']['certificate'] = $derived(
get(courseData, 'metadata.certificate', {
templateUrl: '/images/certificate-template.svg'
})
);
const navItems = $derived(filterNavItems(courseData, reviews));
const navItemKeys = $derived(navItems.map((item) => item.key));
let player: any = $state();
let startCoursePayment = $state(false);
let isVisible = $state(false);
let observer: { destroy: () => void };
let activeNav = $state(NAV_ITEMS[0].key);
const lessonSections = getLessonSections(courseData);
const totalLessons = getTotalLessons(lessonSections);
function locationHashChanged() {
activeNav = window.location.hash;
}
function initPlyr(_player: any, _video: string | undefined) {
if (!player) return;
// @ts-ignore
const plyr = new Plyr('#player', {});
// @ts-ignore
window.player = plyr;
}
function toggleDescription(id: number) {
expandDescription[id] = !expandDescription[id];
}
onMount(() => {
window.onhashchange = locationHashChanged;
const targetNode = document.querySelector('.target-component');
observer = observeIntersection(targetNode, (inView: boolean) => {
isVisible = inView;
});
});
onDestroy(() => {
observer?.destroy();
});
$effect(() => {
initPlyr(player, video);
});
</script>
{#if !editMode}
<PoweredBy />
{/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>
<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 bind:imageURL={$course.logo} />
{/if}
</div>
<!-- Banner Image getEmbedId(videoUrl) -->
{#if video}
<div class="banner-image flex w-full md:w-2/3">
<div bind:this={player} id="player" data-plyr-provider="youtube" data-plyr-embed-id={getEmbedId(video)}></div>
</div>
<!-- <div class="container">
<div
bind:this={player}
id="player"
class="banner-image w-2/3 h-96 relative"
data-plyr-provider="youtube"
data-plyr-embed-id="bTqVqk7FSmY"
/>
</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}
>
<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>
<!-- 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"
>
<!-- Course Details -->
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
<!-- Navigation -->
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 py-2">
<UnderlineTabs.List>
{#each navItems as navItem (navItem.key)}
<UnderlineTabs.Trigger value={navItem.key}>
<a href={resolve(page.url.pathname + navItem.key, {})}>
{$t(navItem.label)}
</a>
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
</UnderlineTabs.Root>
<!-- Sections - Requirement -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
<NavSection id="requirement">
<h3 class="mb-3 mt-0 text-2xl">
{$t('course.navItem.landing_page.requirement')}
</h3>
<ul class="list font-light">
<HtmlRender>{@html get(courseData, 'metadata.requirements', '')}</HtmlRender>
</ul>
</NavSection>
{/if}
<!-- Sections - Course Description -->
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
<NavSection id="description">
<h3 class="mb-3 mt-0 text-2xl">
{$t('course.navItem.landing_page.description')}
</h3>
<HtmlRender className="dark:text-white text-sm font-light">
{@html get(courseData, 'metadata.description', '')}
</HtmlRender>
</NavSection>
{/if}
<!-- Sections - Goal -->
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
<NavSection id="goals">
<h3 class="mb-3 mt-0 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HtmlRender>{@html get(courseData, 'metadata.goals', '')}</HtmlRender>
</ul>
</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]"
/>
</NavSection>
{/if}
<!-- Sections - Lessons -->
{#if courseData.version === COURSE_VERSION.V1}
<NavSection id="lessons">
<div class="mb-3 flex w-full items-center justify-between">
<h3 class="mb-3 mt-0 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>
<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>
</NavSection>
{:else if courseData.version === COURSE_VERSION.V2}
<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'), lessonSections.length, true)},
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
</span>
</div>
{#each lessonSections as section (section.id)}
<SectionsDisplay
exerciseCount={getExerciseCount(section.lessons)}
lessonCount={section.lessons?.length}
lessons={section.lessons}
title={section.title}
/>
{/each}
</NavSection>
{/if}
<!-- Sections - Reviews -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
<NavSection id="reviews">
<h2 class="my-16 mb-6 ml-0 mr-0 font-semibold">
{$t('course.navItem.landing_page.reviews')}
</h2>
<div class="flex flex-wrap">
{#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 -->
{#if review.avatar_url}
<Avatar.Root class="mt-1 size-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Avatar'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
</Avatar.Root>
{/if}
<!-- 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="mr-4 mt-1 w-24" alt="" />
{/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>
{/if}
{/each}
</div>
{#if reviews.length > 4}
<Button class="mt-2" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
{$t('course.navItem.landing_page.see_all')}
</Button>
{/if}
<!-- Reviews Modal -->
<Dialog.Root bind:open={$reviewsModalStore.open}>
<Dialog.Content class="w-9/12">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.landing_page.reviews_modal.title')}</Dialog.Title>
</Dialog.Header>
<div class="flex">
<!-- ratings -->
<div class="w-1/3">
<h2 class="text-xl">
{averageRating}
{$t('course.navItem.landing_page.reviews_modal.rating')}
</h2>
<h2 class="mt-2 text-lg font-semibold">
{reviews.length}
{$t('course.navItem.landing_page.reviews_modal.ratings')}
</h2>
</div>
<!-- reviews -->
<div class="flex w-4/6 flex-wrap">
{#each reviews as review, id (review.id)}
<!-- review -->
<div class="item-start my-2 flex w-full flex-row">
<!-- image container -->
{#if review.avatar_url}
<Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Avatar'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
</Avatar.Root>
{/if}
<!-- 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">
<img src="/images/rating-full.svg" alt="" class="mr-2" />
<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'}"
>
<p class="my-2 text-sm leading-5 text-gray-600">
{review.description}
</p>
</div>
</div>
</div>
{/each}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</NavSection>
{/if}
<!-- Sections - Instructor -->
<NavSection id="instructor">
<h3 class="mb-3 mt-0 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>
</div>
</div>
<p class="text-sm font-light dark:text-white">
{get(instructor, 'description', '')}
</p>
</NavSection>
</div>
<!-- Pricing Details -->
<PricingSection {courseData} {editMode} bind:startCoursePayment className="target-component" />
</div>
{#if !isVisible}
<PricingSection {courseData} {editMode} bind:startCoursePayment mobile={true} className="w-full" />
{/if}
</div>
</div>
<style 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);
}
:global(.certificate-img) {
width: unset !important;
}
.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: 405px;
min-width: 330px;
height: fit-content;
position: sticky;
top: 0;
}
.course-content {
max-width: 608px;
min-width: 60%;
}
nav {
overflow: auto;
margin: 0;
overflow-y: hidden;
width: 100%;
}
:global(.list ul li) {
margin-left: 1rem;
list-style-type: disc;
}
:global(.plyr) {
width: 100% !important;
}
.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;
}
.read-more-button {
cursor: pointer;
color: blue;
text-decoration: underline;
}
.lesson-section:not(:last-child) {
border-bottom: 1px solid #f7f7f7;
}
@media screen and (max-width: 1023px) {
.course-content {
min-width: 80%;
}
}
</style>
@@ -0,0 +1,65 @@
import { writable } from 'svelte/store';
export const landingPageStore = writable({
header: {
title: 'Online Business Masterclass: Sell Your Own Digital Products',
description:
'Create, Launch & Market Your Own Online Business w/ Digital Products such as Online Courses, Coaching, eBooks, Webinars+',
duration: 'Estimated 8 weeks',
cost: 300,
currency: '$',
buttonLabel: 'Enrol'
},
requirement: {
content:
"You don't need any experience creating an online business or digital product. \nYou don't need an online presence, website, or following - but it will help kickstart your online business if you do."
},
description: {
content:
"### Have you ever dreamed of starting your own online business?. \n\nOf course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. \n### Enroll today and get instant access to. \n\nOf course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though.\n ### You will learn many types of digital marketing:\n\n Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though."
},
syllabus: {
lessons: [
{
title: 'What is FinTech?'
},
{
title: 'Payments, Cryptocurrencies and Blockchain'
},
{
title: 'Digital Finance and Alternative Finance'
},
{
title: 'FinTech Regulation and RegTech'
},
{
title: 'Data & TechFin'
}
]
},
aboutUs: {
companyName: 'The Brad School',
logoUrl: 'https://img-c.udemycdn.com/user/200_H/19701574_95d1_13.jpg',
description:
"*Learn creative skills, from absolute beginner to advanced mastery.*\nVideo School exists to help you succeed in life. Each course has been hand-tailored to teach a specific skill from photography and video to art, design and business.\n**Whether you're trying to learn a new skill from scratch, or want to refresh your memory on something you've learned in the past, you've come to the right place.**\nEducation makes the world a better place. Make your world better with new skills!\nOur courses can be watched 24/7 wherever you are. Most are fully downloadable so you can take them with you. You can also view them on mobile devices with the Udemy mobile app.\nAll courses have a 30-day money-back guarantee so that you can check it out, make sure it's the right course for you, and get a refund if it's not!"
},
reviews: [
{
id: 1,
show: true,
name: 'Adeyemi Adetunji',
avatar_url: '/logo-192.png',
rating: 1,
created_at: new Date(),
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
}
]
});
export const reviewsModalStore = writable({
open: false
});
export const handleOpenWidget = writable({
open: false
});
@@ -0,0 +1,46 @@
import type { Course, Review } from '$lib/utils/types';
import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
import get from 'lodash/get';
export function getExerciseCount(lessons) {
return lessons.reduce((total, lesson) => total + (lesson.totalExercises?.[0]?.count || 0), 0);
}
export function getLessonSections(data) {
const sections =
data?.lesson_section?.map((section) => {
return {
...section,
lessons: data.lessons.filter((lesson) => lesson.section_id === section.id)
};
}) || [];
return sections || [];
}
export function getTotalLessons(sections) {
return sections.reduce((total, section) => {
return total + section.lessons.length;
}, 0);
}
export function filterNavItems(course: Course, reviews: Review[]) {
const rules = get(course, 'metadata.sectionDisplay', {});
return NAV_ITEMS.filter((item) => {
const key = item.key;
if (typeof rules[key] === 'boolean') {
return !!rules[key];
}
if (key === NAV_ITEM_KEY.REVIEWS) {
// Show only if there are reviews added
return reviews.length > 0;
}
// In case they are undefined
return true;
});
}
@@ -0,0 +1,49 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import * as Dialog from '@cio/ui/base/dialog';
interface Props {
open?: boolean;
onDelete?: () => void;
isLoading?: boolean;
}
let { open = $bindable(false), onDelete = () => {}, isLoading = false }: Props = $props();
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen && !isLoading) open = false;
}}
>
<Dialog.Content class="w-96 p-6">
<Dialog.Header class="p-2">
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-center text-xl dark:text-white">
{$t('delete_modal.content')}
</p>
<div class="mt-8 flex items-center justify-between">
<Button
variant="outline"
onclick={() => (open = false)}
disabled={isLoading}
>
{$t('delete_modal.no')}
</Button>
<Button
variant="destructive"
onclick={onDelete}
loading={isLoading}
>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,18 @@
<span class="button-trailing">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-up-right"
width="20"
height="20"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" />
</svg>
</span>
@@ -0,0 +1,54 @@
<script lang="ts">
// import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
interface Props {
filled?: Boolean;
size?: number;
}
let { filled, size = 16 }: Props = $props();
</script>
<!-- <CircleCheckIcon
{size}
class={filled ? 'filled' : ''}
fill={filled ? 'red' : 'none'}
strokeWidth={filled ? 0 : undefined}
/> -->
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="custom lucide-icon lucide lucide-circle-check {filled ? 'filled' : ''}"
><!----><circle cx="12" cy="12" r="10"></circle><!----><path d="m9 12 2 2 4-4"></path><!----><!----><!----></svg
>
<style>
svg circle {
stroke: var(--icon-stroke-color, currentColor); /* Circle border */
fill: none; /* Circle is notsvg by default */
}
/* Target the path (checkmark) specifically */
svg path {
stroke: var(--icon-stroke-color, currentColor); /* Checkmark color */
fill: none; /* Checkmark is not filled, just stroked */
}
svg.filled circle {
fill: var(--icon-fill-color, currentColor); /* Fill the circle with color */
stroke: var(--icon-fill-color, currentColor); /* Make the circle's border match its fill */
}
svg.filled path {
stroke: white; /* Make the checkmark white so it's visible against the fill */
fill: none; /* Ensure the checkmark path itself is not filled */
}
</style>
@@ -0,0 +1,44 @@
<svg width="115" height="68" viewBox="0 0 115 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="21.8751"
y="1.61458"
width="82.9271"
height="58.3854"
rx="7.125"
fill="#FAF8F8"
stroke="#1339A6"
stroke-width="1.97917"
/>
<path
d="M81.1511 64.4538H81.547V64.0579V61.3861H114.104V63.3652C114.104 65.4421 112.421 67.1257 110.344 67.1257H16.1355C14.0587 67.1257 12.3751 65.4421 12.3751 63.3652V61.3861H46.7136V64.0579V64.4538H47.1095H81.1511Z"
stroke="#0F62FE"
stroke-width="0.791667"
/>
<ellipse cx="55.224" cy="17.2487" rx="11.776" ry="0.791667" fill="#D9E0F5" />
<circle cx="47.901" cy="30.2126" r="7.96354" fill="white" stroke="#1339A6" stroke-width="0.5" />
<path d="M43.2327 28.6708L51.8044 27.0029L48.963 35.2601L43.2327 28.6708Z" stroke="#1339A6" stroke-width="0.5" />
<rect
x="71.0573"
y="13.9837"
width="16.0312"
height="33.8438"
rx="1.90104"
stroke="#1339A6"
stroke-width="0.197917"
/>
<circle cx="78.974" cy="17.3499" r="0.692708" fill="#D9E0F5" />
<rect x="73.7291" y="22" width="9.10417" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="75.1146" y="24.1768" width="9.10417" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="73.7291" y="26.3535" width="9.10417" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="73.7291" y="32.8848" width="9.10417" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="73.7291" y="36.0518" width="5.34375" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="73.7291" y="39.2178" width="5.34375" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<rect x="73.7291" y="42.3838" width="9.10417" height="0.395833" rx="0.197917" fill="#D9E0F5" />
<path
d="M3.26438 11.0703C1.94083 10.2071 2.52742 9.10995 3.3773 8.34703C3.9627 7.82153 4.55669 7.26974 5.17889 6.78836C6.20593 5.99377 7.88967 5.42142 9.39949 6.8151C11.9724 9.1901 9.30054 11.664 8.60783 8.89319C8.05366 6.67653 11.8736 6.78207 13.8528 7.11192C20.0872 8.59632 12.2691 18.888 4.94637 16.1172C-3.21625 13.0285 1.97783 7.50776 3.957 7.11192C5.93617 6.71609 5.54043 12.5546 3.26438 11.0703Z"
stroke="#0F62FE"
stroke-width="0.989583"
stroke-linejoin="bevel"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,18 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.3281 31H34.6719C33.4724 31 32.5 31.9724 32.5 33.1719V70.8281C32.5 72.0276 33.4724 73 34.6719 73H85.3281C86.5276 73 87.5 72.0276 87.5 70.8281V33.1719C87.5 31.9724 86.5276 31 85.3281 31ZM34.6719 27C31.2632 27 28.5 29.7632 28.5 33.1719V70.8281C28.5 74.2368 31.2632 77 34.6719 77H85.3281C88.7368 77 91.5 74.2368 91.5 70.8281V33.1719C91.5 29.7632 88.7368 27 85.3281 27H34.6719Z"
fill="#0233BD"
/>
<rect x="40.9844" y="37.875" width="36.4141" height="3.08594" rx="1.54297" fill="#658DFE" />
<rect x="40.9844" y="44.0469" width="36.4141" height="3.08594" rx="1.54297" fill="#658DFE" />
<rect x="40.9844" y="50.2188" width="36.4141" height="3.08594" rx="1.54297" fill="#658DFE" />
<path
d="M23 94.0391V62.5938H97V94.0391C97 96.067 95.356 97.7109 93.3281 97.7109H26.6719C24.644 97.7109 23 96.067 23 94.0391Z"
fill="#658DFE"
stroke="#0233BD"
stroke-width="5"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,186 @@
<svg width="312" height="312" viewBox="0 0 312 312" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M155.722 138.894C157.24 138.894 158.471 140.124 158.471 141.642C158.471 143.16 157.24 144.391 155.722 144.391C154.204 144.391 152.973 143.16 152.973 141.642C152.973 140.124 154.204 138.894 155.722 138.894ZM163.129 138.894C164.646 138.894 165.877 140.124 165.877 141.642C165.877 143.16 164.646 144.391 163.129 144.391C161.61 144.391 160.38 143.16 160.38 141.642C160.38 140.124 161.61 138.894 163.129 138.894ZM170.382 138.894C171.9 138.894 173.131 140.124 173.131 141.642C173.131 143.16 171.9 144.391 170.382 144.391C168.865 144.391 167.634 143.16 167.634 141.642C167.634 140.124 168.865 138.894 170.382 138.894ZM182.428 135.959C185.622 135.959 188.211 138.549 188.211 141.744C188.211 144.938 185.622 147.528 182.428 147.528C179.233 147.528 176.644 144.938 176.644 141.744C176.644 138.549 179.233 135.959 182.428 135.959ZM78.6013 128.764V130.094C79.0637 130.264 79.5304 130.435 80.0009 130.605V128.764H84.5824V132.217C85.0458 132.375 85.5121 132.533 85.9823 132.691V128.764H90.5638V134.186C91.0272 134.333 91.4941 134.479 91.9637 134.624V128.764H96.5452V136.006C97.0089 136.141 97.4755 136.277 97.9448 136.411V128.764H102.526V137.683C102.991 137.808 103.457 137.931 103.926 138.055V128.764H108.508V139.222C108.972 139.336 109.439 139.45 109.908 139.561V128.764H114.489V140.625C114.954 140.728 115.42 140.831 115.889 140.934V128.764H120.47V141.895C120.935 141.988 121.401 142.082 121.87 142.174V128.764H126.451V143.036C126.917 143.119 127.384 143.203 127.852 143.285V128.764H132.433V144.048C132.898 144.122 133.365 144.196 133.832 144.267V128.764H138.414V144.936C155.445 147.282 174.378 148.38 194.918 147.326V118.124H69.082V126.377C70.9835 127.164 72.9778 127.961 75.0641 128.764H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 144.931V154.72H133.833V144.264C133.364 144.192 132.898 144.119 132.433 144.045V154.72H127.852V143.281C127.383 143.198 126.917 143.116 126.452 143.032V154.72H121.87V142.17C121.402 142.078 120.936 141.984 120.47 141.891V154.72H115.889V140.93C115.42 140.828 114.954 140.724 114.489 140.621V154.72H109.908V139.558C109.439 139.446 108.972 139.332 108.508 139.217V154.72H103.926V138.052C103.457 137.928 102.991 137.804 102.526 137.68V154.72H97.9447V136.407C97.4755 136.273 97.0089 136.137 96.5452 136.002V154.72H91.9637V134.621C91.4942 134.475 91.0271 134.329 90.5637 134.182V154.72H85.9822V132.687C85.5121 132.53 85.0457 132.372 84.5823 132.213V154.72H80.0009V130.601C79.5304 130.431 79.0637 130.261 78.6013 130.09V154.72H74.0199V128.76H75.0641C72.9778 127.958 70.9834 127.159 69.082 126.373V165.359H194.918V147.321C174.378 148.375 155.444 147.279 138.414 144.931Z"
fill="#BABABA"
/>
<path
d="M74.0195 128.763V154.725H78.601V130.093C77.3945 129.65 76.2151 129.205 75.0638 128.763H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 128.761C76.2158 129.204 77.3952 129.648 78.6017 130.092V128.761H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 128.766V130.609C81.4896 131.146 83.0167 131.684 84.5833 132.22V128.766H80.002Z" fill="#AAAAAA" />
<path d="M80.002 154.725H84.5833V132.217C83.0167 131.682 81.4896 131.144 80.002 130.606V154.725Z" fill="#919191" />
<path d="M85.9824 154.725H90.5638V134.186C89.0026 133.692 87.4752 133.193 85.9824 132.692V154.725Z" fill="#919191" />
<path d="M85.9824 128.761V132.689C87.4753 133.19 89.0026 133.688 90.5638 134.184V128.761H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 128.763V134.625C93.4596 135.09 94.9873 135.55 96.5443 136.006V128.763H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 154.725H96.5443V136.006C94.9873 135.551 93.4597 135.09 91.9629 134.625V154.725Z" fill="#919191" />
<path d="M97.9453 128.768V136.416C99.4459 136.846 100.973 137.271 102.527 137.688V128.768H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 154.725H102.527V137.684C100.973 137.265 99.4459 136.842 97.9453 136.411V154.725Z" fill="#919191" />
<path d="M103.928 154.725H108.509V139.222C106.958 138.84 105.431 138.451 103.928 138.056V154.725Z" fill="#919191" />
<path d="M103.926 128.765V138.057C105.429 138.452 106.956 138.841 108.507 139.223V128.765H103.926Z" fill="#AAAAAA" />
<path d="M109.908 154.725H114.49V140.625C112.941 140.278 111.414 139.925 109.908 139.562V154.725Z" fill="#919191" />
<path d="M109.908 128.754V139.552C111.413 139.915 112.941 140.269 114.49 140.615V128.754H109.908Z" fill="#AAAAAA" />
<path d="M115.889 154.725H120.47V141.895C118.923 141.584 117.396 141.263 115.889 140.934V154.725Z" fill="#919191" />
<path d="M115.889 128.763V140.933C117.396 141.262 118.923 141.583 120.47 141.896V128.763H115.889Z" fill="#AAAAAA" />
<path d="M121.869 154.73H126.451V143.042C124.905 142.763 123.378 142.476 121.869 142.18V154.73Z" fill="#919191" />
<path d="M121.871 128.764V142.175C123.38 142.471 124.907 142.759 126.453 143.037V128.764H121.871Z" fill="#AAAAAA" />
<path d="M127.852 154.725H132.433V144.048C130.888 143.803 129.362 143.549 127.852 143.284V154.725Z" fill="#919191" />
<path d="M127.852 128.76V143.281C129.362 143.545 130.889 143.8 132.432 144.045V128.76H127.852Z" fill="#AAAAAA" />
<path d="M133.832 128.763V144.268C135.343 144.5 136.871 144.722 138.414 144.935V128.763H133.832Z" fill="#AAAAAA" />
<path d="M133.832 154.725H138.414V144.936C136.872 144.723 135.344 144.5 133.832 144.268V154.725Z" fill="#919191" />
<path
d="M155.721 144.395C157.239 144.395 158.47 143.163 158.47 141.646C158.47 140.127 157.239 138.897 155.721 138.897C154.203 138.897 152.973 140.127 152.973 141.646C152.973 143.163 154.203 144.395 155.721 144.395Z"
fill="#494949"
/>
<path
d="M163.128 144.395C164.647 144.395 165.877 143.163 165.877 141.646C165.877 140.127 164.647 138.897 163.128 138.897C161.61 138.897 160.379 140.127 160.379 141.646C160.379 143.163 161.61 144.395 163.128 144.395Z"
fill="#494949"
/>
<path
d="M170.384 144.395C171.901 144.395 173.133 143.163 173.133 141.646C173.133 140.127 171.901 138.897 170.384 138.897C168.865 138.897 167.635 140.127 167.635 141.646C167.635 143.163 168.865 144.395 170.384 144.395Z"
fill="#494949"
/>
<path
d="M182.429 147.528C185.623 147.528 188.213 144.939 188.213 141.745C188.213 138.55 185.623 135.96 182.429 135.96C179.234 135.96 176.645 138.55 176.645 141.745C176.645 144.939 179.234 147.528 182.429 147.528Z"
fill="#FFEA00"
/>
<path d="M189.568 171.967H74.4316V165.361H189.568V171.967Z" fill="#595959" />
<path d="M189.568 168.02H74.4316V165.365H189.568V168.02Z" fill="#2B2B2B" />
<path
d="M155.721 192.737C157.24 192.737 158.47 193.968 158.47 195.486C158.47 197.004 157.24 198.235 155.721 198.235C154.203 198.235 152.973 197.004 152.973 195.486C152.973 193.968 154.203 192.737 155.721 192.737ZM163.128 192.737C164.647 192.737 165.877 193.968 165.877 195.486C165.877 197.004 164.647 198.235 163.128 198.235C161.61 198.235 160.38 197.004 160.38 195.486C160.38 193.968 161.61 192.737 163.128 192.737ZM170.382 192.737C171.901 192.737 173.131 193.968 173.131 195.486C173.131 197.004 171.901 198.235 170.382 198.235C168.864 198.235 167.633 197.004 167.633 195.486C167.633 193.968 168.864 192.737 170.382 192.737ZM182.427 189.803C185.621 189.803 188.212 192.394 188.212 195.588C188.212 198.782 185.621 201.372 182.427 201.372C179.233 201.372 176.644 198.782 176.644 195.588C176.644 192.394 179.233 189.803 182.427 189.803ZM78.6013 182.607V183.939C79.0637 184.108 79.5304 184.279 80.0009 184.45V182.607H84.5823V186.061C85.0457 186.219 85.5121 186.377 85.9822 186.536V182.607H90.5637V188.03C91.0271 188.176 91.4942 188.323 91.9637 188.468V182.607H96.5452V189.85C97.0089 189.986 97.4755 190.12 97.9447 190.255V182.607H102.526V191.527C102.991 191.652 103.457 191.775 103.926 191.899V182.607H108.508V193.066C108.972 193.18 109.439 193.293 109.908 193.406V182.607H114.489V194.469C114.954 194.573 115.42 194.675 115.889 194.778V182.607H120.47V195.739C120.936 195.833 121.402 195.926 121.87 196.018V182.607H126.452V196.88C126.917 196.964 127.383 197.046 127.852 197.128V182.607H132.433V197.893C132.898 197.967 133.364 198.039 133.833 198.111V182.607H138.414V198.779C155.444 201.127 174.378 202.223 194.918 201.17V171.968H69.082V180.221C70.9834 181.008 72.9778 181.805 75.0641 182.607H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.416 198.775V208.564H133.835V198.107C133.366 198.035 132.9 197.962 132.435 197.888V208.564H127.854V197.124C127.385 197.042 126.919 196.959 126.454 196.875V208.564H121.872V196.013C121.404 195.921 120.938 195.828 120.472 195.734V208.564H115.891V194.773C115.422 194.671 114.956 194.568 114.491 194.464V208.564H109.91V193.402C109.441 193.289 108.974 193.175 108.51 193.061V208.564H103.928V191.894C103.459 191.771 102.993 191.647 102.528 191.522V208.564H97.9467V190.251C97.4775 190.115 97.0109 189.981 96.5471 189.846V208.564H91.9657V188.463C91.4961 188.319 91.0291 188.171 90.5656 188.025V208.564H85.9842V186.531C85.5141 186.372 85.0477 186.215 84.5843 186.057V208.564H80.0028V184.445C79.5323 184.274 79.0656 184.104 78.6032 183.934V208.564H74.0218V182.602H75.0661C72.9798 181.801 70.9854 181.003 69.084 180.217V219.203H194.92V201.165C174.38 202.219 155.446 201.122 138.416 198.775Z"
fill="#BABABA"
/>
<path
d="M74.0215 182.607V208.568H78.6029V183.938C77.3965 183.493 76.2171 183.05 75.0658 182.607H74.0215Z"
fill="#919191"
/>
<path d="M75.0645 182.611C76.2158 183.054 77.3952 183.498 78.6016 183.942V182.611H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 182.609V184.452C81.4897 184.99 83.0168 185.528 84.5833 186.063V182.609H80.002Z" fill="#AAAAAA" />
<path d="M80.002 208.565H84.5835V186.058C83.0167 185.522 81.4897 184.984 80.002 184.446V208.565Z" fill="#919191" />
<path d="M85.9824 208.568H90.5637V188.029C89.0025 187.534 87.4752 187.036 85.9824 186.535V208.568Z" fill="#919191" />
<path d="M85.9824 182.612V186.541C87.4753 187.041 89.0022 187.54 90.564 188.034V182.612H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 182.607V188.468C93.4589 188.934 94.9866 189.395 96.5437 189.85V182.607H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 208.565H96.5446V189.846C94.9871 189.391 93.4599 188.93 91.9629 188.465V208.565Z" fill="#919191" />
<path d="M97.9453 182.61V190.258C99.4461 190.689 100.973 191.113 102.527 191.53V182.61H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 208.565H102.527V191.524C100.973 191.106 99.4459 190.682 97.9453 190.252V208.565Z" fill="#919191" />
<path d="M103.926 208.565H108.507V193.062C106.956 192.681 105.429 192.292 103.926 191.896V208.565Z" fill="#919191" />
<path d="M103.926 182.606V191.898C105.429 192.293 106.957 192.683 108.507 193.064V182.606H103.926Z" fill="#AAAAAA" />
<path d="M109.908 208.565H114.489V194.465C112.941 194.12 111.413 193.765 109.908 193.403V208.565Z" fill="#919191" />
<path d="M109.908 182.604V193.402C111.413 193.764 112.941 194.119 114.49 194.465V182.604H109.908Z" fill="#AAAAAA" />
<path d="M115.889 208.565H120.47V195.736C118.923 195.425 117.396 195.103 115.889 194.775V208.565Z" fill="#919191" />
<path d="M115.889 182.601V194.771C117.397 195.101 118.923 195.422 120.47 195.733V182.601H115.889Z" fill="#AAAAAA" />
<path d="M121.871 208.565H126.452V196.877C124.907 196.599 123.38 196.311 121.871 196.015V208.565Z" fill="#919191" />
<path d="M121.871 182.608V196.02C123.38 196.316 124.907 196.603 126.453 196.881V182.608H121.871Z" fill="#AAAAAA" />
<path d="M127.852 208.568H132.433V197.892C130.889 197.648 129.362 197.392 127.852 197.128V208.568Z" fill="#919191" />
<path d="M127.852 182.606V197.127C129.362 197.39 130.889 197.646 132.433 197.891V182.606H127.852Z" fill="#AAAAAA" />
<path d="M133.832 182.613V198.117C135.344 198.349 136.871 198.572 138.414 198.784V182.613H133.832Z" fill="#AAAAAA" />
<path d="M133.832 208.565H138.414V198.776C136.871 198.564 135.344 198.34 133.832 198.109V208.565Z" fill="#919191" />
<path
d="M155.722 198.23C157.24 198.23 158.47 197 158.47 195.482C158.47 193.963 157.24 192.733 155.722 192.733C154.203 192.733 152.973 193.963 152.973 195.482C152.973 197 154.203 198.23 155.722 198.23Z"
fill="#494949"
/>
<path
d="M163.13 198.23C164.648 198.23 165.879 196.999 165.879 195.482C165.879 193.963 164.648 192.733 163.13 192.733C161.612 192.733 160.381 193.963 160.381 195.482C160.381 196.999 161.612 198.23 163.13 198.23Z"
fill="#494949"
/>
<path
d="M170.383 198.23C171.902 198.23 173.132 197 173.132 195.481C173.132 193.964 171.902 192.733 170.383 192.733C168.865 192.733 167.635 193.964 167.635 195.481C167.635 197 168.865 198.23 170.383 198.23Z"
fill="#494949"
/>
<path
d="M182.429 201.372C185.623 201.372 188.213 198.783 188.213 195.588C188.213 192.394 185.623 189.804 182.429 189.804C179.234 189.804 176.645 192.394 176.645 195.588C176.645 198.783 179.234 201.372 182.429 201.372Z"
fill="#FFEA00"
/>
<path d="M189.568 225.811H74.4316V219.205H189.568V225.811Z" fill="#595959" />
<path d="M189.568 221.863H74.4316V219.209H189.568V221.863Z" fill="#2B2B2B" />
<path
d="M155.721 246.581C157.24 246.581 158.47 247.812 158.47 249.33C158.47 250.848 157.24 252.079 155.721 252.079C154.203 252.079 152.973 250.848 152.973 249.33C152.973 247.812 154.203 246.581 155.721 246.581ZM163.128 246.581C164.647 246.581 165.877 247.812 165.877 249.33C165.877 250.848 164.647 252.079 163.128 252.079C161.61 252.079 160.38 250.848 160.38 249.33C160.38 247.812 161.61 246.581 163.128 246.581ZM170.382 246.581C171.901 246.581 173.131 247.812 173.131 249.33C173.131 250.848 171.901 252.079 170.382 252.079C168.864 252.079 167.633 250.848 167.633 249.33C167.633 247.812 168.864 246.581 170.382 246.581ZM182.427 243.648C185.621 243.648 188.212 246.237 188.212 249.432C188.212 252.626 185.621 255.216 182.427 255.216C179.233 255.216 176.644 252.626 176.644 249.432C176.644 246.237 179.233 243.648 182.427 243.648ZM78.6013 236.451V237.782C79.0637 237.952 79.5304 238.123 80.0009 238.293V236.451H84.5823V239.905C85.0457 240.063 85.5121 240.221 85.9822 240.379V236.451H90.5637V241.873C91.0271 242.02 91.4942 242.166 91.9637 242.312V236.451H96.5452V243.693C97.0089 243.829 97.4755 243.964 97.9447 244.099V236.451H102.526V245.371C102.991 245.496 103.457 245.62 103.926 245.743V236.451H108.508V246.909C108.972 247.023 109.439 247.137 109.908 247.25V236.451H114.489V248.312C114.954 248.416 115.42 248.519 115.889 248.622V236.451H120.47V249.583C120.936 249.677 121.402 249.77 121.87 249.862V236.451H126.452V250.724C126.917 250.807 127.383 250.89 127.852 250.972V236.451H132.433V251.736C132.898 251.81 133.364 251.883 133.833 251.955V236.451H138.414V252.623C155.444 254.97 174.378 256.067 194.918 255.013V225.812H69.082V234.065C70.9834 234.851 72.9778 235.649 75.0641 236.451H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 252.618V262.408H133.833V251.951C133.364 251.879 132.898 251.806 132.433 251.732V262.408H127.852V250.968C127.383 250.886 126.917 250.803 126.452 250.719V262.408H121.87V249.857C121.402 249.765 120.936 249.672 120.47 249.578V262.408H115.889V248.617C115.42 248.515 114.954 248.412 114.489 248.308V262.408H109.908V247.245C109.439 247.132 108.972 247.019 108.508 246.905V262.408H103.926V245.739C103.457 245.615 102.991 245.491 102.526 245.366V262.408H97.9447V244.094C97.4755 243.96 97.0089 243.824 96.5452 243.689V262.408H91.9637V242.308C91.4942 242.162 91.0271 242.016 90.5637 241.869V262.408H85.9822V240.375C85.5121 240.217 85.0457 240.059 84.5823 239.9V262.408H80.0009V238.288C79.5304 238.118 79.0637 237.948 78.6013 237.777V262.408H74.0199V236.447H75.0641C72.9778 235.644 70.9834 234.847 69.082 234.06V273.047H194.918V255.009C174.378 256.063 155.444 254.966 138.414 252.618Z"
fill="#BABABA"
/>
<path
d="M74.0195 236.451V262.412H78.601V237.781C77.3947 237.338 76.2152 236.893 75.064 236.451H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 236.461C76.2156 236.904 77.3951 237.348 78.6015 237.792V236.461H75.0645Z" fill="#AAAAAA" />
<path d="M80 236.452V238.294C81.488 238.832 83.015 239.37 84.5816 239.905V236.452H80Z" fill="#AAAAAA" />
<path d="M80 262.412H84.5816V239.904C83.015 239.369 81.488 238.831 80 238.293V262.412Z" fill="#919191" />
<path d="M85.9824 262.412H90.5637V241.873C89.0026 241.379 87.4753 240.88 85.9824 240.379V262.412Z" fill="#919191" />
<path d="M85.9824 236.456V240.384C87.4753 240.885 89.0026 241.384 90.5637 241.878V236.456H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 236.451V242.312C93.4596 242.777 94.9873 243.238 96.5443 243.693V236.451H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 262.412H96.5443V243.693C94.9873 243.238 93.4596 242.777 91.9629 242.312V262.412Z" fill="#919191" />
<path d="M97.9453 236.455V244.102C99.4455 244.532 100.973 244.956 102.527 245.374V236.455H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 262.412H102.527V245.371C100.973 244.953 99.4455 244.529 97.9453 244.098V262.412Z" fill="#919191" />
<path d="M103.928 262.412H108.509V246.909C106.958 246.528 105.431 246.139 103.928 245.743V262.412Z" fill="#919191" />
<path d="M103.928 236.45V245.742C105.431 246.138 106.958 246.527 108.509 246.908V236.45H103.928Z" fill="#AAAAAA" />
<path d="M109.908 262.412H114.49V248.312C112.94 247.966 111.413 247.612 109.908 247.249V262.412Z" fill="#919191" />
<path d="M109.908 236.449V247.247C111.413 247.609 112.94 247.963 114.49 248.31V236.449H109.908Z" fill="#AAAAAA" />
<path d="M115.889 262.412H120.47V249.583C118.923 249.271 117.396 248.95 115.889 248.621V262.412Z" fill="#919191" />
<path d="M115.889 236.454V248.625C117.396 248.953 118.923 249.274 120.47 249.586V236.454H115.889Z" fill="#AAAAAA" />
<path d="M121.871 262.412H126.452V250.723C124.907 250.445 123.38 250.158 121.871 249.861V262.412Z" fill="#919191" />
<path d="M121.871 236.452V249.863C123.38 250.159 124.907 250.446 126.452 250.725V236.452H121.871Z" fill="#AAAAAA" />
<path d="M127.852 262.412H132.433V251.736C130.889 251.491 129.362 251.236 127.852 250.972V262.412Z" fill="#919191" />
<path d="M127.852 236.447V250.968C129.362 251.232 130.889 251.487 132.433 251.732V236.447H127.852Z" fill="#AAAAAA" />
<path d="M133.834 236.443V251.948C135.346 252.18 136.873 252.403 138.416 252.615V236.443H133.834Z" fill="#AAAAAA" />
<path d="M133.832 262.412H138.414V252.623C136.871 252.41 135.344 252.188 133.832 251.955V262.412Z" fill="#919191" />
<path
d="M155.722 252.074C157.24 252.074 158.47 250.844 158.47 249.325C158.47 247.807 157.24 246.576 155.722 246.576C154.203 246.576 152.973 247.807 152.973 249.325C152.973 250.844 154.203 252.074 155.722 252.074Z"
fill="#494949"
/>
<path
d="M163.13 252.074C164.648 252.074 165.879 250.844 165.879 249.325C165.879 247.807 164.648 246.576 163.13 246.576C161.612 246.576 160.381 247.807 160.381 249.325C160.381 250.844 161.612 252.074 163.13 252.074Z"
fill="#494949"
/>
<path
d="M170.383 252.074C171.902 252.074 173.132 250.844 173.132 249.325C173.132 247.807 171.902 246.576 170.383 246.576C168.865 246.576 167.635 247.807 167.635 249.325C167.635 250.844 168.865 252.074 170.383 252.074Z"
fill="#494949"
/>
<path
d="M182.429 255.222C185.623 255.222 188.213 252.632 188.213 249.438C188.213 246.243 185.623 243.654 182.429 243.654C179.234 243.654 176.645 246.243 176.645 249.438C176.645 252.632 179.234 255.222 182.429 255.222Z"
fill="#FFEA00"
/>
<path d="M189.568 279.663H74.4316V273.058H189.568V279.663Z" fill="#595959" />
<path d="M189.568 275.707H74.4316V273.052H189.568V275.707Z" fill="#2B2B2B" />
<path
d="M202.508 19.04C180.343 18.0043 162.736 37.2854 162.736 55.7051C160.664 79.1135 183.658 112.881 206.031 93.2011C208.516 89.9557 212.784 82.0148 214.11 77.0431C215.197 72.9634 221.335 65.4895 225.562 60.4589C227.165 58.5526 228.397 56.3297 228.937 53.8987C234.929 26.8994 214.15 19.3707 202.508 19.04Z"
fill="#E4E4E4"
/>
<path
d="M185.938 29.3584H195.502C195.871 29.3584 196.056 29.8047 195.795 30.0657L186.645 39.216C186.384 39.477 186.569 39.9232 186.938 39.9232H196.502"
stroke="#494949"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M202.717 46.1367H212.281C212.651 46.1367 212.835 46.583 212.574 46.844L203.424 55.9943C203.163 56.2553 203.348 56.7016 203.717 56.7016H213.282"
stroke="#494949"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M184.486 68.9229H196.864C197.342 68.9229 197.581 69.5004 197.243 69.8381L185.402 81.6797C185.064 82.0175 185.303 82.595 185.781 82.595H198.158"
stroke="#494949"
stroke-width="3.21698"
stroke-linecap="round"
/>
<path
d="M167.293 90.5064C168.832 93.7017 170.805 97.4846 168.95 98.3783C167.095 99.2719 161.582 97.0161 160.043 93.8208C158.504 90.6255 159.866 85.4339 161.722 84.5402C163.577 83.6466 165.754 87.3111 167.293 90.5064Z"
fill="#E4E4E4"
/>
<path
d="M156.205 106.632C154.75 106.487 152.987 106.448 153.192 104.399C153.397 102.349 155.609 98.5627 157.064 98.7083C158.519 98.8538 159.849 102.485 159.644 104.534C159.439 106.583 157.66 106.778 156.205 106.632Z"
fill="#E4E4E4"
/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,186 @@
<svg width="312" height="312" viewBox="0 0 312 312" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M155.722 138.894C157.24 138.894 158.471 140.124 158.471 141.642C158.471 143.16 157.24 144.391 155.722 144.391C154.204 144.391 152.973 143.16 152.973 141.642C152.973 140.124 154.204 138.894 155.722 138.894ZM163.129 138.894C164.646 138.894 165.877 140.124 165.877 141.642C165.877 143.16 164.646 144.391 163.129 144.391C161.61 144.391 160.38 143.16 160.38 141.642C160.38 140.124 161.61 138.894 163.129 138.894ZM170.382 138.894C171.9 138.894 173.131 140.124 173.131 141.642C173.131 143.16 171.9 144.391 170.382 144.391C168.865 144.391 167.634 143.16 167.634 141.642C167.634 140.124 168.865 138.894 170.382 138.894ZM182.428 135.959C185.622 135.959 188.211 138.549 188.211 141.744C188.211 144.938 185.622 147.528 182.428 147.528C179.233 147.528 176.644 144.938 176.644 141.744C176.644 138.549 179.233 135.959 182.428 135.959ZM78.6013 128.764V130.094C79.0637 130.264 79.5304 130.435 80.0009 130.605V128.764H84.5824V132.217C85.0458 132.375 85.5121 132.533 85.9823 132.691V128.764H90.5638V134.186C91.0272 134.333 91.4941 134.479 91.9637 134.624V128.764H96.5452V136.006C97.0089 136.141 97.4755 136.277 97.9448 136.411V128.764H102.526V137.683C102.991 137.808 103.457 137.931 103.926 138.055V128.764H108.508V139.222C108.972 139.336 109.439 139.45 109.908 139.561V128.764H114.489V140.625C114.954 140.728 115.42 140.831 115.889 140.934V128.764H120.47V141.895C120.935 141.988 121.401 142.082 121.87 142.174V128.764H126.451V143.036C126.917 143.119 127.384 143.203 127.852 143.285V128.764H132.433V144.048C132.898 144.122 133.365 144.196 133.832 144.267V128.764H138.414V144.936C155.445 147.282 174.378 148.38 194.918 147.326V118.124H69.082V126.377C70.9835 127.164 72.9778 127.961 75.0641 128.764H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 144.931V154.72H133.833V144.264C133.364 144.192 132.898 144.119 132.433 144.045V154.72H127.852V143.281C127.383 143.198 126.917 143.116 126.452 143.032V154.72H121.87V142.17C121.402 142.078 120.936 141.984 120.47 141.891V154.72H115.889V140.93C115.42 140.828 114.954 140.724 114.489 140.621V154.72H109.908V139.558C109.439 139.446 108.972 139.332 108.508 139.217V154.72H103.926V138.052C103.457 137.928 102.991 137.804 102.526 137.68V154.72H97.9447V136.407C97.4755 136.273 97.0089 136.137 96.5452 136.002V154.72H91.9637V134.621C91.4942 134.475 91.0271 134.329 90.5637 134.182V154.72H85.9822V132.687C85.5121 132.53 85.0457 132.372 84.5823 132.213V154.72H80.0009V130.601C79.5304 130.431 79.0637 130.261 78.6013 130.09V154.72H74.0199V128.76H75.0641C72.9778 127.958 70.9834 127.159 69.082 126.373V165.359H194.918V147.321C174.378 148.375 155.444 147.279 138.414 144.931Z"
fill="#BABABA"
/>
<path
d="M74.0195 128.763V154.725H78.601V130.093C77.3945 129.65 76.2151 129.205 75.0638 128.763H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 128.761C76.2158 129.204 77.3952 129.648 78.6017 130.092V128.761H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 128.766V130.609C81.4896 131.146 83.0167 131.684 84.5833 132.22V128.766H80.002Z" fill="#AAAAAA" />
<path d="M80.002 154.725H84.5833V132.217C83.0167 131.682 81.4896 131.144 80.002 130.606V154.725Z" fill="#919191" />
<path d="M85.9824 154.725H90.5638V134.186C89.0026 133.692 87.4752 133.193 85.9824 132.692V154.725Z" fill="#919191" />
<path d="M85.9824 128.761V132.689C87.4753 133.19 89.0026 133.688 90.5638 134.184V128.761H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 128.763V134.625C93.4596 135.09 94.9873 135.55 96.5443 136.006V128.763H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 154.725H96.5443V136.006C94.9873 135.551 93.4597 135.09 91.9629 134.625V154.725Z" fill="#919191" />
<path d="M97.9453 128.768V136.416C99.4459 136.846 100.973 137.271 102.527 137.688V128.768H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 154.725H102.527V137.684C100.973 137.265 99.4459 136.842 97.9453 136.411V154.725Z" fill="#919191" />
<path d="M103.928 154.725H108.509V139.222C106.958 138.84 105.431 138.451 103.928 138.056V154.725Z" fill="#919191" />
<path d="M103.926 128.765V138.057C105.429 138.452 106.956 138.841 108.507 139.223V128.765H103.926Z" fill="#AAAAAA" />
<path d="M109.908 154.725H114.49V140.625C112.941 140.278 111.414 139.925 109.908 139.562V154.725Z" fill="#919191" />
<path d="M109.908 128.754V139.552C111.413 139.915 112.941 140.269 114.49 140.615V128.754H109.908Z" fill="#AAAAAA" />
<path d="M115.889 154.725H120.47V141.895C118.923 141.584 117.396 141.263 115.889 140.934V154.725Z" fill="#919191" />
<path d="M115.889 128.763V140.933C117.396 141.262 118.923 141.583 120.47 141.896V128.763H115.889Z" fill="#AAAAAA" />
<path d="M121.869 154.73H126.451V143.042C124.905 142.763 123.378 142.476 121.869 142.18V154.73Z" fill="#919191" />
<path d="M121.871 128.764V142.175C123.38 142.471 124.907 142.759 126.453 143.037V128.764H121.871Z" fill="#AAAAAA" />
<path d="M127.852 154.725H132.433V144.048C130.888 143.803 129.362 143.549 127.852 143.284V154.725Z" fill="#919191" />
<path d="M127.852 128.76V143.281C129.362 143.545 130.889 143.8 132.432 144.045V128.76H127.852Z" fill="#AAAAAA" />
<path d="M133.832 128.763V144.268C135.343 144.5 136.871 144.722 138.414 144.935V128.763H133.832Z" fill="#AAAAAA" />
<path d="M133.832 154.725H138.414V144.936C136.872 144.723 135.344 144.5 133.832 144.268V154.725Z" fill="#919191" />
<path
d="M155.721 144.395C157.239 144.395 158.47 143.163 158.47 141.646C158.47 140.127 157.239 138.897 155.721 138.897C154.203 138.897 152.973 140.127 152.973 141.646C152.973 143.163 154.203 144.395 155.721 144.395Z"
fill="#494949"
/>
<path
d="M163.128 144.395C164.647 144.395 165.877 143.163 165.877 141.646C165.877 140.127 164.647 138.897 163.128 138.897C161.61 138.897 160.379 140.127 160.379 141.646C160.379 143.163 161.61 144.395 163.128 144.395Z"
fill="#494949"
/>
<path
d="M170.384 144.395C171.901 144.395 173.133 143.163 173.133 141.646C173.133 140.127 171.901 138.897 170.384 138.897C168.865 138.897 167.635 140.127 167.635 141.646C167.635 143.163 168.865 144.395 170.384 144.395Z"
fill="#494949"
/>
<path
d="M182.429 147.528C185.623 147.528 188.213 144.939 188.213 141.745C188.213 138.55 185.623 135.96 182.429 135.96C179.234 135.96 176.645 138.55 176.645 141.745C176.645 144.939 179.234 147.528 182.429 147.528Z"
fill="#5A231C"
/>
<path d="M189.568 171.967H74.4316V165.361H189.568V171.967Z" fill="#595959" />
<path d="M189.568 168.02H74.4316V165.365H189.568V168.02Z" fill="#2B2B2B" />
<path
d="M155.721 192.737C157.24 192.737 158.47 193.968 158.47 195.486C158.47 197.004 157.24 198.235 155.721 198.235C154.203 198.235 152.973 197.004 152.973 195.486C152.973 193.968 154.203 192.737 155.721 192.737ZM163.128 192.737C164.647 192.737 165.877 193.968 165.877 195.486C165.877 197.004 164.647 198.235 163.128 198.235C161.61 198.235 160.38 197.004 160.38 195.486C160.38 193.968 161.61 192.737 163.128 192.737ZM170.382 192.737C171.901 192.737 173.131 193.968 173.131 195.486C173.131 197.004 171.901 198.235 170.382 198.235C168.864 198.235 167.633 197.004 167.633 195.486C167.633 193.968 168.864 192.737 170.382 192.737ZM182.427 189.803C185.621 189.803 188.212 192.394 188.212 195.588C188.212 198.782 185.621 201.372 182.427 201.372C179.233 201.372 176.644 198.782 176.644 195.588C176.644 192.394 179.233 189.803 182.427 189.803ZM78.6013 182.607V183.939C79.0637 184.108 79.5304 184.279 80.0009 184.45V182.607H84.5823V186.061C85.0457 186.219 85.5121 186.377 85.9822 186.536V182.607H90.5637V188.03C91.0271 188.176 91.4942 188.323 91.9637 188.468V182.607H96.5452V189.85C97.0089 189.986 97.4755 190.12 97.9447 190.255V182.607H102.526V191.527C102.991 191.652 103.457 191.775 103.926 191.899V182.607H108.508V193.066C108.972 193.18 109.439 193.293 109.908 193.406V182.607H114.489V194.469C114.954 194.573 115.42 194.675 115.889 194.778V182.607H120.47V195.739C120.936 195.833 121.402 195.926 121.87 196.018V182.607H126.452V196.88C126.917 196.964 127.383 197.046 127.852 197.128V182.607H132.433V197.893C132.898 197.967 133.364 198.039 133.833 198.111V182.607H138.414V198.779C155.444 201.127 174.378 202.223 194.918 201.17V171.968H69.082V180.221C70.9834 181.008 72.9778 181.805 75.0641 182.607H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.416 198.775V208.564H133.835V198.107C133.366 198.035 132.9 197.962 132.435 197.888V208.564H127.854V197.124C127.385 197.042 126.919 196.959 126.454 196.875V208.564H121.872V196.013C121.404 195.921 120.938 195.828 120.472 195.734V208.564H115.891V194.773C115.422 194.671 114.956 194.568 114.491 194.464V208.564H109.91V193.402C109.441 193.289 108.974 193.175 108.51 193.061V208.564H103.928V191.894C103.459 191.771 102.993 191.647 102.528 191.522V208.564H97.9467V190.251C97.4775 190.115 97.0109 189.981 96.5471 189.846V208.564H91.9657V188.463C91.4961 188.319 91.0291 188.171 90.5656 188.025V208.564H85.9842V186.531C85.5141 186.372 85.0477 186.215 84.5843 186.057V208.564H80.0028V184.445C79.5323 184.274 79.0656 184.104 78.6032 183.934V208.564H74.0218V182.602H75.0661C72.9798 181.801 70.9854 181.003 69.084 180.217V219.203H194.92V201.165C174.38 202.219 155.446 201.122 138.416 198.775Z"
fill="#BABABA"
/>
<path
d="M74.0215 182.607V208.568H78.6029V183.938C77.3965 183.493 76.2171 183.05 75.0658 182.607H74.0215Z"
fill="#919191"
/>
<path d="M75.0645 182.611C76.2158 183.054 77.3952 183.498 78.6016 183.942V182.611H75.0645Z" fill="#AAAAAA" />
<path d="M80.002 182.609V184.452C81.4897 184.99 83.0168 185.528 84.5833 186.063V182.609H80.002Z" fill="#AAAAAA" />
<path d="M80.002 208.565H84.5835V186.058C83.0167 185.522 81.4897 184.984 80.002 184.446V208.565Z" fill="#919191" />
<path d="M85.9824 208.568H90.5637V188.029C89.0025 187.534 87.4752 187.036 85.9824 186.535V208.568Z" fill="#919191" />
<path d="M85.9824 182.612V186.541C87.4753 187.041 89.0022 187.54 90.564 188.034V182.612H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 182.607V188.468C93.4589 188.934 94.9866 189.395 96.5437 189.85V182.607H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 208.565H96.5446V189.846C94.9871 189.391 93.4599 188.93 91.9629 188.465V208.565Z" fill="#919191" />
<path d="M97.9453 182.61V190.258C99.4461 190.689 100.973 191.113 102.527 191.53V182.61H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 208.565H102.527V191.524C100.973 191.106 99.4459 190.682 97.9453 190.252V208.565Z" fill="#919191" />
<path d="M103.926 208.565H108.507V193.062C106.956 192.681 105.429 192.292 103.926 191.896V208.565Z" fill="#919191" />
<path d="M103.926 182.606V191.898C105.429 192.293 106.957 192.683 108.507 193.064V182.606H103.926Z" fill="#AAAAAA" />
<path d="M109.908 208.565H114.489V194.465C112.941 194.12 111.413 193.765 109.908 193.403V208.565Z" fill="#919191" />
<path d="M109.908 182.604V193.402C111.413 193.764 112.941 194.119 114.49 194.465V182.604H109.908Z" fill="#AAAAAA" />
<path d="M115.889 208.565H120.47V195.736C118.923 195.425 117.396 195.103 115.889 194.775V208.565Z" fill="#919191" />
<path d="M115.889 182.601V194.771C117.397 195.101 118.923 195.422 120.47 195.733V182.601H115.889Z" fill="#AAAAAA" />
<path d="M121.871 208.565H126.452V196.877C124.907 196.599 123.38 196.311 121.871 196.015V208.565Z" fill="#919191" />
<path d="M121.871 182.608V196.02C123.38 196.316 124.907 196.603 126.453 196.881V182.608H121.871Z" fill="#AAAAAA" />
<path d="M127.852 208.568H132.433V197.892C130.889 197.648 129.362 197.392 127.852 197.128V208.568Z" fill="#919191" />
<path d="M127.852 182.606V197.127C129.362 197.39 130.889 197.646 132.433 197.891V182.606H127.852Z" fill="#AAAAAA" />
<path d="M133.832 182.613V198.117C135.344 198.349 136.871 198.572 138.414 198.784V182.613H133.832Z" fill="#AAAAAA" />
<path d="M133.832 208.565H138.414V198.776C136.871 198.564 135.344 198.34 133.832 198.109V208.565Z" fill="#919191" />
<path
d="M155.722 198.23C157.24 198.23 158.47 197 158.47 195.482C158.47 193.963 157.24 192.733 155.722 192.733C154.203 192.733 152.973 193.963 152.973 195.482C152.973 197 154.203 198.23 155.722 198.23Z"
fill="#494949"
/>
<path
d="M163.13 198.23C164.648 198.23 165.879 196.999 165.879 195.482C165.879 193.963 164.648 192.733 163.13 192.733C161.612 192.733 160.381 193.963 160.381 195.482C160.381 196.999 161.612 198.23 163.13 198.23Z"
fill="#494949"
/>
<path
d="M170.383 198.23C171.902 198.23 173.132 197 173.132 195.481C173.132 193.964 171.902 192.733 170.383 192.733C168.865 192.733 167.635 193.964 167.635 195.481C167.635 197 168.865 198.23 170.383 198.23Z"
fill="#494949"
/>
<path
d="M182.429 201.372C185.623 201.372 188.213 198.783 188.213 195.588C188.213 192.394 185.623 189.804 182.429 189.804C179.234 189.804 176.645 192.394 176.645 195.588C176.645 198.783 179.234 201.372 182.429 201.372Z"
fill="#5A231C"
/>
<path d="M189.568 225.811H74.4316V219.205H189.568V225.811Z" fill="#595959" />
<path d="M189.568 221.863H74.4316V219.209H189.568V221.863Z" fill="#2B2B2B" />
<path
d="M155.721 246.581C157.24 246.581 158.47 247.812 158.47 249.33C158.47 250.848 157.24 252.079 155.721 252.079C154.203 252.079 152.973 250.848 152.973 249.33C152.973 247.812 154.203 246.581 155.721 246.581ZM163.128 246.581C164.647 246.581 165.877 247.812 165.877 249.33C165.877 250.848 164.647 252.079 163.128 252.079C161.61 252.079 160.38 250.848 160.38 249.33C160.38 247.812 161.61 246.581 163.128 246.581ZM170.382 246.581C171.901 246.581 173.131 247.812 173.131 249.33C173.131 250.848 171.901 252.079 170.382 252.079C168.864 252.079 167.633 250.848 167.633 249.33C167.633 247.812 168.864 246.581 170.382 246.581ZM182.427 243.648C185.621 243.648 188.212 246.237 188.212 249.432C188.212 252.626 185.621 255.216 182.427 255.216C179.233 255.216 176.644 252.626 176.644 249.432C176.644 246.237 179.233 243.648 182.427 243.648ZM78.6013 236.451V237.782C79.0637 237.952 79.5304 238.123 80.0009 238.293V236.451H84.5823V239.905C85.0457 240.063 85.5121 240.221 85.9822 240.379V236.451H90.5637V241.873C91.0271 242.02 91.4942 242.166 91.9637 242.312V236.451H96.5452V243.693C97.0089 243.829 97.4755 243.964 97.9447 244.099V236.451H102.526V245.371C102.991 245.496 103.457 245.62 103.926 245.743V236.451H108.508V246.909C108.972 247.023 109.439 247.137 109.908 247.25V236.451H114.489V248.312C114.954 248.416 115.42 248.519 115.889 248.622V236.451H120.47V249.583C120.936 249.677 121.402 249.77 121.87 249.862V236.451H126.452V250.724C126.917 250.807 127.383 250.89 127.852 250.972V236.451H132.433V251.736C132.898 251.81 133.364 251.883 133.833 251.955V236.451H138.414V252.623C155.444 254.97 174.378 256.067 194.918 255.013V225.812H69.082V234.065C70.9834 234.851 72.9778 235.649 75.0641 236.451H78.6013Z"
fill="#D3D3D3"
/>
<path
d="M138.414 252.618V262.408H133.833V251.951C133.364 251.879 132.898 251.806 132.433 251.732V262.408H127.852V250.968C127.383 250.886 126.917 250.803 126.452 250.719V262.408H121.87V249.857C121.402 249.765 120.936 249.672 120.47 249.578V262.408H115.889V248.617C115.42 248.515 114.954 248.412 114.489 248.308V262.408H109.908V247.245C109.439 247.132 108.972 247.019 108.508 246.905V262.408H103.926V245.739C103.457 245.615 102.991 245.491 102.526 245.366V262.408H97.9447V244.094C97.4755 243.96 97.0089 243.824 96.5452 243.689V262.408H91.9637V242.308C91.4942 242.162 91.0271 242.016 90.5637 241.869V262.408H85.9822V240.375C85.5121 240.217 85.0457 240.059 84.5823 239.9V262.408H80.0009V238.288C79.5304 238.118 79.0637 237.948 78.6013 237.777V262.408H74.0199V236.447H75.0641C72.9778 235.644 70.9834 234.847 69.082 234.06V273.047H194.918V255.009C174.378 256.063 155.444 254.966 138.414 252.618Z"
fill="#BABABA"
/>
<path
d="M74.0195 236.451V262.412H78.601V237.781C77.3947 237.338 76.2152 236.893 75.064 236.451H74.0195Z"
fill="#919191"
/>
<path d="M75.0645 236.461C76.2156 236.904 77.3951 237.348 78.6015 237.792V236.461H75.0645Z" fill="#AAAAAA" />
<path d="M80 236.452V238.294C81.488 238.832 83.015 239.37 84.5816 239.905V236.452H80Z" fill="#AAAAAA" />
<path d="M80 262.412H84.5816V239.904C83.015 239.369 81.488 238.831 80 238.293V262.412Z" fill="#919191" />
<path d="M85.9824 262.412H90.5637V241.873C89.0026 241.379 87.4753 240.88 85.9824 240.379V262.412Z" fill="#919191" />
<path d="M85.9824 236.456V240.384C87.4753 240.885 89.0026 241.384 90.5637 241.878V236.456H85.9824Z" fill="#AAAAAA" />
<path d="M91.9629 236.451V242.312C93.4596 242.777 94.9873 243.238 96.5443 243.693V236.451H91.9629Z" fill="#AAAAAA" />
<path d="M91.9629 262.412H96.5443V243.693C94.9873 243.238 93.4596 242.777 91.9629 242.312V262.412Z" fill="#919191" />
<path d="M97.9453 236.455V244.102C99.4455 244.532 100.973 244.956 102.527 245.374V236.455H97.9453Z" fill="#AAAAAA" />
<path d="M97.9453 262.412H102.527V245.371C100.973 244.953 99.4455 244.529 97.9453 244.098V262.412Z" fill="#919191" />
<path d="M103.928 262.412H108.509V246.909C106.958 246.528 105.431 246.139 103.928 245.743V262.412Z" fill="#919191" />
<path d="M103.928 236.45V245.742C105.431 246.138 106.958 246.527 108.509 246.908V236.45H103.928Z" fill="#AAAAAA" />
<path d="M109.908 262.412H114.49V248.312C112.94 247.966 111.413 247.612 109.908 247.249V262.412Z" fill="#919191" />
<path d="M109.908 236.449V247.247C111.413 247.609 112.94 247.963 114.49 248.31V236.449H109.908Z" fill="#AAAAAA" />
<path d="M115.889 262.412H120.47V249.583C118.923 249.271 117.396 248.95 115.889 248.621V262.412Z" fill="#919191" />
<path d="M115.889 236.454V248.625C117.396 248.953 118.923 249.274 120.47 249.586V236.454H115.889Z" fill="#AAAAAA" />
<path d="M121.871 262.412H126.452V250.723C124.907 250.445 123.38 250.158 121.871 249.861V262.412Z" fill="#919191" />
<path d="M121.871 236.452V249.863C123.38 250.159 124.907 250.446 126.452 250.725V236.452H121.871Z" fill="#AAAAAA" />
<path d="M127.852 262.412H132.433V251.736C130.889 251.491 129.362 251.236 127.852 250.972V262.412Z" fill="#919191" />
<path d="M127.852 236.447V250.968C129.362 251.232 130.889 251.487 132.433 251.732V236.447H127.852Z" fill="#AAAAAA" />
<path d="M133.834 236.443V251.948C135.346 252.18 136.873 252.403 138.416 252.615V236.443H133.834Z" fill="#AAAAAA" />
<path d="M133.832 262.412H138.414V252.623C136.871 252.41 135.344 252.188 133.832 251.955V262.412Z" fill="#919191" />
<path
d="M155.722 252.074C157.24 252.074 158.47 250.844 158.47 249.325C158.47 247.807 157.24 246.576 155.722 246.576C154.203 246.576 152.973 247.807 152.973 249.325C152.973 250.844 154.203 252.074 155.722 252.074Z"
fill="#494949"
/>
<path
d="M163.13 252.074C164.648 252.074 165.879 250.844 165.879 249.325C165.879 247.807 164.648 246.576 163.13 246.576C161.612 246.576 160.381 247.807 160.381 249.325C160.381 250.844 161.612 252.074 163.13 252.074Z"
fill="#494949"
/>
<path
d="M170.383 252.074C171.902 252.074 173.132 250.844 173.132 249.325C173.132 247.807 171.902 246.576 170.383 246.576C168.865 246.576 167.635 247.807 167.635 249.325C167.635 250.844 168.865 252.074 170.383 252.074Z"
fill="#494949"
/>
<path
d="M182.429 255.222C185.623 255.222 188.213 252.632 188.213 249.438C188.213 246.243 185.623 243.654 182.429 243.654C179.234 243.654 176.645 246.243 176.645 249.438C176.645 252.632 179.234 255.222 182.429 255.222Z"
fill="#5A231C"
/>
<path d="M189.568 279.663H74.4316V273.058H189.568V279.663Z" fill="#595959" />
<path d="M189.568 275.707H74.4316V273.052H189.568V275.707Z" fill="#2B2B2B" />
<path
d="M202.508 19.04C180.343 18.0043 162.736 37.2854 162.736 55.7051C160.664 79.1135 183.658 112.881 206.031 93.2011C208.516 89.9557 212.784 82.0148 214.11 77.0431C215.197 72.9634 221.335 65.4895 225.562 60.4589C227.165 58.5526 228.397 56.3297 228.937 53.8987C234.929 26.8994 214.15 19.3707 202.508 19.04Z"
fill="#5C6065"
/>
<path
d="M185.938 29.3584H195.502C195.871 29.3584 196.056 29.8047 195.795 30.0657L186.645 39.216C186.384 39.477 186.569 39.9232 186.938 39.9232H196.502"
stroke="#FBFEFC"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M202.717 46.1367H212.281C212.651 46.1367 212.835 46.583 212.574 46.844L203.424 55.9943C203.163 56.2553 203.348 56.7016 203.717 56.7016H213.282"
stroke="#FBFEFC"
stroke-width="2.48585"
stroke-linecap="round"
/>
<path
d="M184.486 68.9229H196.864C197.342 68.9229 197.581 69.5004 197.243 69.8381L185.402 81.6797C185.064 82.0175 185.303 82.595 185.781 82.595H198.158"
stroke="#FBFEFC"
stroke-width="3.21698"
stroke-linecap="round"
/>
<path
d="M167.293 90.5064C168.832 93.7017 170.805 97.4846 168.95 98.3783C167.095 99.2719 161.582 97.0161 160.043 93.8208C158.504 90.6255 159.866 85.4339 161.722 84.5402C163.577 83.6466 165.754 87.3111 167.293 90.5064Z"
fill="#5C6065"
/>
<path
d="M156.205 106.632C154.75 106.487 152.987 106.448 153.192 104.399C153.397 102.349 155.609 98.5627 157.064 98.7083C158.519 98.8538 159.849 102.485 159.644 104.534C159.439 106.583 157.66 106.778 156.205 106.632Z"
fill="#5C6065"
/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,27 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.75 8.76233C3.75 7.96961 3.96841 7.22701 4.34784 6.59117V3.89404H1.65072C0.58025 5.28429 0 6.97864 0 8.76233C0 10.546 0.58025 12.2404 1.65072 13.6306H4.34784V10.9335C3.96841 10.2976 3.75 9.55504 3.75 8.76233Z"
fill="#FBBD00"
/>
<path
d="M8 13.0124L6.125 14.8874L8 16.7624C9.78372 16.7624 11.478 16.1822 12.8683 15.1117V12.4175H10.174C9.53262 12.7983 8.78691 13.0124 8 13.0124Z"
fill="#0F9D58"
/>
<path
d="M4.34739 10.9336L1.65027 13.6307C1.86221 13.906 2.09299 14.1696 2.34271 14.4193C3.85371 15.9303 5.86267 16.7624 7.99955 16.7624V13.0124C6.4488 13.0124 5.08964 12.1774 4.34739 10.9336Z"
fill="#31AA52"
/>
<path
d="M16 8.76258C16 8.27586 15.9559 7.78824 15.869 7.3133L15.7987 6.92896H8V10.679H11.7954C11.4268 11.4121 10.8601 12.0103 10.174 12.4176L12.8682 15.1119C13.1435 14.9 13.4071 14.6692 13.6568 14.4195C15.1678 12.9084 16 10.8995 16 8.76258Z"
fill="#3C79E6"
/>
<path
d="M11.0052 5.75723L11.3367 6.08867L13.9883 3.43705L13.6569 3.10561C12.1459 1.59461 10.1369 0.762451 8 0.762451L6.125 2.63745L8 4.51245C9.13519 4.51245 10.2025 4.95451 11.0052 5.75723Z"
fill="#CF2D48"
/>
<path
d="M7.99958 4.51245V0.762451C5.86271 0.762451 3.85374 1.59461 2.34271 3.10558C2.09299 3.3553 1.86221 3.61889 1.65027 3.89417L4.34739 6.5913C5.08967 5.34745 6.44883 4.51245 7.99958 4.51245Z"
fill="#EB4132"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
export { default as ArrowTopRight } from './arrow-top-right.svelte';
export { default as CircleCheckIcon } from './circle-check-icon.svelte';
export { default as CoursesEmptyIcon } from './courses-empty-icon.svelte';
export { default as EmailSentIcon } from './email-sent-icon.svelte';
export { default as ErrorIcon } from './error-icon.svelte';
export { default as ErrorIconDarkMode } from './error-icon-dark-mode.svelte';
export { default as GoogleIconColored } from './google-icon-colored.svelte';
export { default as UserProfileIcon } from './user-profile-icon.svelte';
@@ -0,0 +1,18 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_174_5581)">
<path
d="M16 8C15.0111 8 14.0444 8.29325 13.2222 8.84265C12.3999 9.39206 11.759 10.173 11.3806 11.0866C11.0022 12.0002 10.9031 13.0055 11.0961 13.9755C11.289 14.9454 11.7652 15.8363 12.4645 16.5355C13.1637 17.2348 14.0546 17.711 15.0246 17.9039C15.9945 18.0969 16.9998 17.9978 17.9134 17.6194C18.8271 17.241 19.6079 16.6001 20.1574 15.7779C20.7068 14.9556 21 13.9889 21 13C21 11.6739 20.4732 10.4021 19.5355 9.46447C18.5979 8.52678 17.3261 8 16 8Z"
fill="#ADADAD"
/>
<path
d="M16 2C13.2311 2 10.5243 2.82109 8.22202 4.35943C5.91973 5.89777 4.12532 8.08427 3.06569 10.6424C2.00607 13.2006 1.72882 16.0155 2.26901 18.7313C2.80921 21.447 4.14258 23.9416 6.10051 25.8995C8.05845 27.8574 10.553 29.1908 13.2687 29.731C15.9845 30.2712 18.7994 29.9939 21.3576 28.9343C23.9157 27.8747 26.1022 26.0803 27.6406 23.778C29.1789 21.4757 30 18.7689 30 16C29.9958 12.2883 28.5195 8.72973 25.8949 6.10512C23.2703 3.48052 19.7118 2.00418 16 2ZM23.9925 24.9258C23.9726 23.614 23.4382 22.3626 22.5043 21.4412C21.5704 20.5198 20.3119 20.0022 19 20H13C11.6881 20.0022 10.4296 20.5198 9.49573 21.4412C8.56186 22.3626 8.02739 23.614 8.00751 24.9258C6.19407 23.3065 4.91524 21.1747 4.34034 18.8125C3.76545 16.4503 3.92161 13.9691 4.78814 11.6977C5.65468 9.42618 7.19073 7.47147 9.19288 6.09237C11.195 4.71327 13.5688 3.97483 16 3.97483C18.4312 3.97483 20.805 4.71327 22.8071 6.09237C24.8093 7.47147 26.3453 9.42618 27.2119 11.6977C28.0784 13.9691 28.2346 16.4503 27.6597 18.8125C27.0848 21.1747 25.8059 23.3065 23.9925 24.9258Z"
fill="#ADADAD"
/>
</g>
<defs>
<clipPath id="clip0_174_5581">
<rect width="32" height="32" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,25 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
let { src, className, alt }: { src: string | undefined; className?: string; alt?: string } = $props();
function loadImage(src: string | undefined): Promise<void> {
if (!src) return Promise.reject();
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = reject;
img.src = src;
});
}
</script>
{#await loadImage(src)}
<Skeleton class={className} />
{:then}
<img {src} {alt} class={className} />
{:catch}
<div class="flex h-[200px] w-full items-center justify-center rounded bg-gray-100 dark:bg-neutral-800">
<p class="text-sm text-red-600">Failed to load</p>
</div>
{/await}
@@ -1,8 +1,28 @@
export { default as ActivityCard } from './analytics/activity-card.svelte';
export { default as AppHeader } from './navigation/app-header.svelte';
export { default as AuthUI } from './auth-ui.svelte';
export { default as Backdrop } from './backdrop.svelte';
export { default as CloseButton } from './close-btn.svelte';
export { default as CodeSnippet } from './code-snippet.svelte';
export { default as CourseLandingPage } from './course-landing-page/course-landing-page.svelte';
export { default as ComingSoon } from './coming-soon.svelte';
export { default as Confetti } from './confetti/confetti.svelte';
export { default as DeleteModal } from './delete-modal.svelte';
export { default as HeroProfileCard } from './analytics/hero-profile-card.svelte';
export { default as Image } from './image.svelte';
export { default as LoadingPage } from './analytics/loading-page.svelte';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte';
export { default as Snackbar } from './snackbar/snackbar.svelte';
export { default as TextEditor } from './text-editor.svelte';
export { default as UnsavedChanges } from './unsaved-changes.svelte';
export { default as UploadImage } from './upload-image.svelte';
export { default as UpgradeBanner } from './upgrade-banner.svelte';
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';
@@ -4,18 +4,20 @@
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import * as Popover from '@cio/ui/base/popover';
import Search from '../search.svelte';
import Search from '$features/ui/search.svelte';
import AppBreadcrumbs from './app-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import AppSetup from './app-setup.svelte';
const siteName = $derived($currentOrg.siteName);
$effect(() => {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
</script>
@@ -34,7 +36,6 @@
<span class="grow"></span>
<AppSetup />
<Search />
<Popover.Root>
@@ -3,12 +3,12 @@
import { Button } from '@cio/ui/base/button';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import CircleIcon from '@lucide/svelte/icons/circle';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import SetupProgress from '$features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import { calculateProgress, setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { calculateProgress, setupProgressApi } from '$features/setup/api/setup-progress.svelte';
function handleRefresh() {
if ($currentOrg.siteName) {
@@ -0,0 +1,78 @@
/**
* Pre-configured breadcrumb generators for different contexts
* These wrap the core generator with context-specific configurations
*/
import type { BreadcrumbConfig } from './breadcrumb-core';
import type { NavItem as LmsNavItem } from './lms-navigation';
import type { NavItem } from './org-navigation';
/**
* Configuration for Org breadcrumbs
*/
export function createOrgBreadcrumbConfig(
currentOrgPath: string,
pageData?: { breadcrumb?: string }
): BreadcrumbConfig {
return {
pathPrefix: (pathname: string) => {
// Extract org path dynamically (e.g., '/org/myorg')
const match = pathname.match(/^\/org\/[^/]+/);
return match ? match[0] : '';
},
basePath: currentOrgPath,
dynamicTitleResolver: (segment, parentPath, fullPathname) => {
// Use page.data.breadcrumb for server-side resolved titles
return pageData?.breadcrumb || null;
},
enableCache: true,
maxDepth: 10
};
}
/**
* Configuration for LMS breadcrumbs
*/
export function createLmsBreadcrumbConfig(
dynamicTitleResolver?: (segment: string, parentPath: string, fullPathname: string) => string | null
): BreadcrumbConfig {
return {
pathPrefix: '/lms',
basePath: '/lms',
dynamicTitleResolver: dynamicTitleResolver || null,
enableCache: true,
maxDepth: 10
};
}
/**
* Helper to create LMS breadcrumb config with store-based resolver
* The getStoreValue function will be called reactively in Svelte components
*/
export function createLmsBreadcrumbConfigWithStore(getStoreValue: () => string | null | undefined): BreadcrumbConfig {
return createLmsBreadcrumbConfig((segment, parentPath, fullPathname) => {
return getStoreValue() || null;
});
}
/**
* Helper to create Org breadcrumb config with store-based resolver
* The getStoreValue function will be called reactively in Svelte components
*/
export function createOrgBreadcrumbConfigWithStore(
currentOrgPath: string,
getStoreValue: () => string | null | undefined
): BreadcrumbConfig {
return {
pathPrefix: (pathname: string) => {
const match = pathname.match(/^\/org\/[^/]+/);
return match ? match[0] : '';
},
basePath: currentOrgPath,
dynamicTitleResolver: (segment, parentPath, fullPathname) => {
return getStoreValue() || null;
},
enableCache: false, // Disable cache for reactive stores
maxDepth: 10
};
}
@@ -0,0 +1,563 @@
import { isActive } from '$lib/utils/functions/app';
/**
* Unified breadcrumb system that addresses all scalability limitations
*/
export interface BreadcrumbItem {
label: string;
href: string;
metadata?: Record<string, unknown>;
}
export interface NestedRouteConfig {
path: string;
titleKey: string;
// Optional: Custom resolver for dynamic titles
titleResolver?: (params: Record<string, string>) => string | Promise<string>;
// Optional: Nested routes can have their own nested routes (multi-level support)
nestedRoutes?: NestedRouteConfig[];
// Optional: Supports dynamic segments within nested routes
supportsDynamicSegment?: boolean;
}
export interface NavItem {
title: string;
url: string;
path: string;
icon?: unknown;
isActive?: boolean;
isExpanded?: boolean;
items?: NavItem[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
// NEW: Route-specific breadcrumb overrides
breadcrumbLabel?: string;
breadcrumbResolver?: (params: Record<string, string>) => string | Promise<string>;
}
/**
* Configuration for breadcrumb generation context
*/
export interface BreadcrumbConfig {
/**
* Path prefix to remove from pathname (e.g., '/lms', '/org/[slug]')
* Can be a string or a function that extracts the prefix dynamically
*/
pathPrefix: string | ((pathname: string) => string);
/**
* Base path for generating hrefs (e.g., '/lms', '/org/myorg')
*/
basePath: string | ((pathname: string) => string);
/**
* Resolver for dynamic segment titles
* Can resolve from page.data, stores, or API calls
*/
dynamicTitleResolver?: (
segment: string,
parentPath: string,
fullPathname: string,
context?: unknown
) => string | Promise<string> | null | undefined;
/**
* Optional: Cache breadcrumbs for better performance
*/
enableCache?: boolean;
/**
* Optional: Maximum breadcrumb depth (prevents infinite recursion)
*/
maxDepth?: number;
}
/**
* Cache for breadcrumb generation (simple in-memory cache)
*/
const breadcrumbCache = new Map<string, BreadcrumbItem[]>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const cacheTimestamps = new Map<string, number>();
/**
* Clear expired cache entries
*/
function clearExpiredCache() {
const now = Date.now();
for (const [key, timestamp] of cacheTimestamps.entries()) {
if (now - timestamp > CACHE_TTL) {
breadcrumbCache.delete(key);
cacheTimestamps.delete(key);
}
}
}
/**
* Get cached breadcrumbs or generate new ones
*/
function getCachedOrGenerate(
cacheKey: string,
generator: () => BreadcrumbItem[],
enableCache: boolean
): BreadcrumbItem[] {
if (!enableCache) {
return generator();
}
clearExpiredCache();
const cached = breadcrumbCache.get(cacheKey);
if (cached) {
return cached;
}
const result = generator();
breadcrumbCache.set(cacheKey, result);
cacheTimestamps.set(cacheKey, Date.now());
return result;
}
/**
* Extract path prefix from pathname
*/
function extractPathPrefix(
pathname: string,
prefixConfig: string | ((pathname: string) => string)
): string {
if (typeof prefixConfig === 'function') {
return prefixConfig(pathname);
}
// Handle dynamic prefixes like '/org/[slug]'
if (prefixConfig.includes('[')) {
const pattern = prefixConfig.replace(/\[.*?\]/g, '[^/]+');
const match = pathname.match(new RegExp(`^${pattern}`));
return match ? match[0] : '';
}
return pathname.startsWith(prefixConfig) ? prefixConfig : '';
}
/**
* Get base path for href generation
*/
function getBasePath(
pathname: string,
basePathConfig: string | ((pathname: string) => string)
): string {
if (typeof basePathConfig === 'function') {
return basePathConfig(pathname);
}
return basePathConfig;
}
/**
* Find matching navigation item for a path segment
*/
function findMatchingNavItem(
segment: string,
navItems: NavItem[],
fullPathname: string,
basePath: string
): NavItem | undefined {
// First try exact path match
for (const item of navItems) {
const itemPath = item.path.replace(/^\//, '');
if (itemPath === segment || (itemPath === '' && segment === undefined)) {
return item;
}
}
// Fallback: use isActive matching
for (const item of navItems) {
const fullItemPath = `${basePath}${item.path}`;
if (isActive(fullPathname, fullItemPath)) {
return item;
}
}
return undefined;
}
/**
* Resolve dynamic title for a segment
*/
async function resolveDynamicTitle(
segment: string,
parentPath: string,
fullPathname: string,
navItem: NavItem,
config: BreadcrumbConfig,
context?: unknown
): Promise<string | null> {
// Check for route-specific resolver first
if (navItem.breadcrumbResolver) {
const params = extractParamsFromPath(fullPathname, parentPath);
const result = navItem.breadcrumbResolver(params);
return result instanceof Promise ? await result : result;
}
// Check for nested route resolver
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => {
// Check if this is a dynamic segment
if (route.supportsDynamicSegment) {
return true;
}
return route.path === segment;
});
if (nestedRoute?.titleResolver) {
const params = extractParamsFromPath(fullPathname, parentPath);
const result = nestedRoute.titleResolver(params);
return result instanceof Promise ? await result : result;
}
}
// Use global dynamic title resolver
if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver(segment, parentPath, fullPathname, context);
return result instanceof Promise ? await result : result || null;
}
return null;
}
/**
* Extract parameters from pathname (e.g., { slug: 'my-post' })
*/
function extractParamsFromPath(
pathname: string,
parentPath: string
): Record<string, string> {
const params: Record<string, string> = {};
const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
const segments = relativePath.split('/').filter(Boolean);
// Simple extraction - can be enhanced for more complex patterns
segments.forEach((segment, index) => {
params[`param${index}`] = segment;
});
return params;
}
/**
* Recursively generate breadcrumbs for nested routes
*/
async function generateNestedBreadcrumbs(
segments: string[],
navItem: NavItem,
currentPath: string,
basePath: string,
fullPathname: string,
searchParams: string,
config: BreadcrumbConfig,
context?: unknown,
depth: number = 0
): Promise<BreadcrumbItem[]> {
const breadcrumbs: BreadcrumbItem[] = [];
const maxDepth = config.maxDepth ?? 10;
if (depth >= maxDepth || segments.length === 0) {
return breadcrumbs;
}
const currentSegment = segments[0];
const remainingSegments = segments.slice(1);
const segmentPath = `${currentPath}/${currentSegment}`;
// Check for static nested route
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey,
href: `${basePath}${segmentPath}`
});
// Recursively handle nested routes within nested routes
if (remainingSegments.length > 0 && nestedRoute.nestedRoutes) {
const nestedBreadcrumbs = await generateNestedBreadcrumbs(
remainingSegments,
{ ...navItem, nestedRoutes: nestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
}
// Handle dynamic segment
if (navItem.supportsDynamicSegment ||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) {
const dynamicTitle = await resolveDynamicTitle(
currentSegment,
currentPath,
fullPathname,
navItem,
config,
context
);
if (dynamicTitle) {
breadcrumbs.push({
label: dynamicTitle,
href: `${basePath}${segmentPath}`
});
// Continue with remaining segments if there are nested routes that support deeper nesting
if (remainingSegments.length > 0) {
// Check if current nav item has nested routes that support further nesting
const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes
);
if (supportingNestedRoute) {
const nestedBreadcrumbs = await generateNestedBreadcrumbs(
remainingSegments,
{ ...navItem, nestedRoutes: supportingNestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
}
}
}
return breadcrumbs;
}
/**
* Unified breadcrumb generator that works for all contexts
*/
export async function generateBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
config: BreadcrumbConfig,
context?: unknown
): Promise<BreadcrumbItem[]> {
const cacheKey = `${pathname}${searchParams}`;
return getCachedOrGenerate(
cacheKey,
() => generateBreadcrumbsSync(pathname, searchParams, navItems, config, context),
config.enableCache ?? false
);
}
/**
* Synchronous version (for cases where async isn't needed)
* Note: Dynamic title resolvers that return promises won't work here
*/
export function generateBreadcrumbsSync(
pathname: string,
searchParams: string,
navItems: NavItem[],
config: BreadcrumbConfig,
context?: unknown
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const pathWithQuery = pathname + searchParams;
// Extract path prefix and base path
const pathPrefix = extractPathPrefix(pathname, config.pathPrefix);
const basePath = getBasePath(pathname, config.basePath);
// Get relative path after removing prefix
const relativePath = pathname.replace(pathPrefix, '').replace(/^\//, '') || '';
const pathSegments = relativePath.split('/').filter(Boolean);
// Handle root/dashboard
if (!relativePath || relativePath === '') {
return breadcrumbs;
}
// Find matching top-level nav item
const firstSegment = pathSegments[0];
const matchedNavItem = findMatchingNavItem(firstSegment, navItems, pathname, basePath);
if (!matchedNavItem) {
return breadcrumbs;
}
// Add parent breadcrumb
const parentHref = matchedNavItem.useHashUrl
? `${basePath}${matchedNavItem.path}`
: matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
href: parentHref
});
// Handle query params (settings tabs, etc.)
if (matchedNavItem.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) =>
isActive(pathWithQuery, sub.url, undefined, true)
);
if (subItem) {
breadcrumbs.push({
label: subItem.breadcrumbLabel || subItem.title,
href: subItem.url
});
return breadcrumbs;
}
}
// Handle nested routes (recursive)
if (pathSegments.length > 1) {
const remainingSegments = pathSegments.slice(1);
const parentPath = `${basePath}${matchedNavItem.path}`;
// Note: For async resolvers, you'd need to use generateBreadcrumbs instead
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
matchedNavItem,
matchedNavItem.path,
basePath,
pathname,
searchParams,
config,
context
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
/**
* Synchronous version of nested breadcrumb generation
*/
function generateNestedBreadcrumbsSync(
segments: string[],
navItem: NavItem,
currentPath: string,
basePath: string,
fullPathname: string,
searchParams: string,
config: BreadcrumbConfig,
context?: unknown,
depth: number = 0
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const maxDepth = config.maxDepth ?? 10;
if (depth >= maxDepth || segments.length === 0) {
return breadcrumbs;
}
const currentSegment = segments[0];
const remainingSegments = segments.slice(1);
const segmentPath = `${currentPath}/${currentSegment}`;
// Check for static nested route
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey,
href: `${basePath}${segmentPath}`
});
// Recursively handle nested routes
if (remainingSegments.length > 0 && nestedRoute.nestedRoutes) {
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
{ ...navItem, nestedRoutes: nestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
}
// Handle dynamic segment
if (navItem.supportsDynamicSegment) {
// Try to resolve title synchronously (from context if available)
let dynamicTitle: string | null = null;
if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver(
currentSegment,
currentPath,
fullPathname,
context
);
// Only use if it's not a promise
if (typeof result === 'string') {
dynamicTitle = result;
}
}
if (dynamicTitle) {
breadcrumbs.push({
label: dynamicTitle,
href: `${basePath}${segmentPath}`
});
// Continue with remaining segments
if (remainingSegments.length > 0) {
const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes
);
if (supportingNestedRoute?.nestedRoutes) {
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
{ ...navItem, nestedRoutes: supportingNestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
}
}
}
return breadcrumbs;
}
/**
* Clear breadcrumb cache (useful for testing or manual cache invalidation)
*/
export function clearBreadcrumbCache() {
breadcrumbCache.clear();
cacheTimestamps.clear();
}
@@ -0,0 +1,108 @@
import type { NavItem } from './lms-navigation';
import { isActive } from '$lib/utils/functions/app';
export interface BreadcrumbItem {
label: string;
href: string;
}
/**
* Generate breadcrumbs from the current pathname using LMS navigation structure
*/
export function generateLmsBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const pathWithQuery = pathname + searchParams;
// Remove the /lms prefix to get the relative path
const relativePath = pathname.replace(/^\/lms/, '').replace(/^\//, '') || '';
const pathSegments = relativePath.split('/').filter(Boolean);
// Handle root/dashboard
if (!relativePath || relativePath === '') {
return breadcrumbs;
}
// Find the matching top-level nav item
const firstSegment = pathSegments[0];
let matchedNavItem: NavItem | undefined;
for (const item of navItems) {
// Use path for matching (works for both hash URLs and regular URLs)
const itemPath = item.path.replace(/^\//, '');
if (itemPath === firstSegment || (itemPath === '' && firstSegment === undefined)) {
matchedNavItem = item;
break;
}
}
if (!matchedNavItem) {
// Try to match by checking if pathname matches the item path
for (const item of navItems) {
const fullItemPath = `/lms${item.path}`;
if (isActive(pathname, fullItemPath)) {
matchedNavItem = item;
break;
}
}
}
if (matchedNavItem) {
// Handle hash URLs (like settings) - use the actual path instead of '#'
const href = matchedNavItem.useHashUrl ? `/lms${matchedNavItem.path}` : matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.title,
href: href
});
}
// Handle items with query params (like settings tabs)
if (matchedNavItem?.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,
href: subItem.url
});
return breadcrumbs;
}
}
// Handle nested routes
if (pathSegments.length > 1 && matchedNavItem) {
const secondSegment = pathSegments[1];
// Check for static nested routes (like community/ask, settings/integrations)
if (matchedNavItem.nestedRoutes) {
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey, // Could be translated if needed
href: `/lms${matchedNavItem.path}/${nestedRoute.path}`
});
return breadcrumbs;
}
}
// Check for dynamic segments (like community/[slug])
if (matchedNavItem.supportsDynamicSegment && secondSegment) {
// Check if it's not a static nested route
const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment);
if (!isStaticRoute && pageData?.breadcrumb) {
breadcrumbs.push({
label: pageData.breadcrumb,
href: `/lms${matchedNavItem.path}/${secondSegment}`
});
}
return breadcrumbs;
}
}
return breadcrumbs;
}
@@ -0,0 +1,101 @@
<script lang="ts">
import { MediaQuery } from 'svelte/reactivity';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import * as Drawer from '@cio/ui/base/drawer';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { buttonVariants } from '@cio/ui/base/button';
import { page } from '$app/state';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { getLmsNavigationItems } from './lms-navigation';
import { generateLmsBreadcrumbs } from './lms-breadcrumb';
import { currentCommunityQuestion } from '$features/community/utils/store';
const navItems = $derived(getLmsNavigationItems($currentOrg, $t, page.url.pathname));
// Get breadcrumb title from store for LMS community posts
const breadcrumbTitle = $derived($currentCommunityQuestion.title);
const breadcrumbs = $derived(
generateLmsBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
breadcrumbTitle ? { breadcrumb: breadcrumbTitle } : undefined
)
);
let open = $state(false);
const isDesktop = new MediaQuery('(min-width: 768px)');
const ITEMS_TO_DISPLAY = $derived(isDesktop.current ? 2 : 1);
</script>
<Breadcrumb.Root>
<Breadcrumb.List class="flex-nowrap!">
{#if breadcrumbs.length > 0}
{#if breadcrumbs.length > ITEMS_TO_DISPLAY}
<!-- Show ellipsis for hidden items -->
<Breadcrumb.Item>
{#if isDesktop.current}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="flex items-center gap-1" aria-label="Toggle menu">
<Breadcrumb.Ellipsis class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
<DropdownMenu.Item>
<a href={breadcrumb.href ? breadcrumb.href : '##'}>
{breadcrumb.label}
</a>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Trigger aria-label="Toggle Menu">
<Breadcrumb.Ellipsis class="size-4" />
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header class="text-start">
<Drawer.Title>Navigate to</Drawer.Title>
<Drawer.Description>Select a page to navigate to.</Drawer.Description>
</Drawer.Header>
<div class="grid gap-1 px-4">
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
<a href={breadcrumb.href ? breadcrumb.href : '##'} class="py-1 text-sm">
{breadcrumb.label}
</a>
{/each}
</div>
<Drawer.Footer class="pt-4">
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Close</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{/if}
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/if}
<!-- Show last ITEMS_TO_DISPLAY items (or all if <= ITEMS_TO_DISPLAY) -->
{#each breadcrumbs.slice(-ITEMS_TO_DISPLAY) as breadcrumb, index (breadcrumb.label)}
<Breadcrumb.Item>
{@const isLast = index === breadcrumbs.slice(-ITEMS_TO_DISPLAY).length - 1}
{#if !isLast}
<Breadcrumb.Link href={breadcrumb.href} class="max-w-20 truncate md:max-w-none">
{breadcrumb.label}
</Breadcrumb.Link>
<Breadcrumb.Separator />
{:else}
<Breadcrumb.Page class="max-w-20 truncate md:max-w-none">
{breadcrumb.label}
</Breadcrumb.Page>
{/if}
</Breadcrumb.Item>
{/each}
{/if}
</Breadcrumb.List>
</Breadcrumb.Root>
@@ -0,0 +1,63 @@
<script lang="ts">
import { Separator } from '@cio/ui/base/separator';
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import * as Popover from '@cio/ui/base/popover';
import Search from '../search.svelte';
import LmsBreadcrumbs from './lms-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
interface Props {
hideSearch?: boolean;
}
let { hideSearch = false }: Props = $props();
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
<div class="h-4 w-2">
<Separator orientation="vertical" />
</div>
<LmsBreadcrumbs />
<span class="grow"></span>
{#if !hideSearch}
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
<BellIcon class="custom rounded-full" />
</Button>
</Popover.Trigger>
<Popover.Content>
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
{/if}
</div>
</header>
@@ -0,0 +1,147 @@
import type { AccountOrg } from '$features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import MapPlusIcon from '@lucide/svelte/icons/map-plus';
import UsersIcon from '@lucide/svelte/icons/users';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
title: string;
url: string;
path: string;
icon?: Component;
isActive?: boolean;
isExpanded?: boolean;
show?: () => boolean;
items?: NavItem[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
}
export interface NestedRouteConfig {
path: string; // Relative to parent (e.g., 'ask', 'integrations')
titleKey: string; // Translation key or plain text
}
export interface NavItemConfig {
titleKey: string;
path: string;
icon?: Component;
show?: (currentOrg: AccountOrg | null) => boolean;
matchPattern?: string;
items?: NavItemConfig[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
}
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'lms_navigation.home',
path: '',
icon: LayoutDashboardIcon,
matchPattern: '^/lms/?$'
},
{
titleKey: 'lms_navigation.my_learning',
path: '/mylearning',
icon: LibraryBigIcon,
matchPattern: '^/lms/mylearning(/.*)?$'
},
{
titleKey: 'lms_navigation.exercise',
path: '/exercises',
icon: ListTodoIcon,
matchPattern: '^/lms/exercises(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.exercise === true
},
{
titleKey: 'lms_navigation.community',
path: '/community',
icon: UsersIcon,
matchPattern: '^/lms/community(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true,
supportsDynamicSegment: true, // Supports /community/[slug]
nestedRoutes: [
{
path: 'ask',
titleKey: 'Ask Question' // Could be translated
}
]
},
{
titleKey: 'lms_navigation.explore',
path: '/explore',
icon: MapPlusIcon,
matchPattern: '^/lms/explore(/.*)?$'
},
{
titleKey: 'lms_navigation.settings',
path: '/settings',
icon: CogIcon,
useHashUrl: true,
matchPattern: '^/lms/settings(/.*)?$',
items: [
{
titleKey: 'Profile',
path: '/settings'
},
{
titleKey: 'Integrations',
path: '/settings/integrations'
}
]
}
];
/**
* Get LMS navigation items based on organization context
*/
export function getLmsNavigationItems(
currentOrg: AccountOrg | null,
t: (key: string) => string,
pagePathname: string
): NavItem[] {
const items: NavItem[] = [];
for (const config of baseNavConfig) {
// Skip items that should be hidden based on customization
if (config.show && !config.show(currentOrg)) {
continue;
}
const url = config.path === '' ? '/lms' : `/lms${config.path}`;
const fullPath = config.path === '' ? '/lms' : `/lms${config.path}`;
const item: NavItem = {
title: t(config.titleKey),
url: config.useHashUrl ? '#' : url,
path: config.path,
icon: config.icon,
isActive: isActive(pagePathname, fullPath, config.matchPattern),
isExpanded: config.items ? isActive(pagePathname, fullPath, config.matchPattern) : undefined,
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment,
show: config.show ? () => config.show!(currentOrg) : undefined
};
// Handle nested items (like settings sub-items)
if (config.items) {
item.items = config.items.map((subConfig) => ({
title: t(`settings.tabs.${subConfig.titleKey.toLowerCase()}_tab`) || subConfig.titleKey,
url: `/lms${subConfig.path}`,
path: subConfig.path
}));
}
items.push(item);
}
return items;
}
@@ -1,4 +1,4 @@
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
@@ -0,0 +1,18 @@
<script>
import { Button } from '@cio/ui/base/button';
import LockIcon from '@lucide/svelte/icons/lock';
import { Empty } from '@cio/ui/custom/empty';
</script>
<div class="flex h-screen w-screen flex-col items-center justify-center">
<Empty
title="Restricted Page"
description="This organization is restricted. In the mean time, you can checkout our features."
icon={LockIcon}
variant="page"
layout="full-page"
showLogo={true}
>
<Button href="https://classroomio.com">Go Home</Button>
</Empty>
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { embedSenjaWidget } from '@cio/utils/senja';
import { onMount } from 'svelte';
interface Props {
id?: string;
}
let { id = '' }: Props = $props();
onMount(() => {
embedSenjaWidget(id);
});
</script>
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
@@ -0,0 +1 @@
export { default as LMSSidebar } from './lms-sidebar.svelte';
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { profile } from '$lib/utils/store/user';
import { orgs } from '$lib/utils/store/org';
import NavMain from './nav-main.svelte';
import OrgLogo from '../org-sidebar/org-logo.svelte';
import { SidebarFooterMenu } from '../footer';
import SidebarSkeleton from '../sidebar-skeleton.svelte';
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
</script>
{#if !isOrgLoaded}
<SidebarSkeleton />
{:else}
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<OrgLogo />
</Sidebar.Header>
<Sidebar.Content>
<NavMain />
</Sidebar.Content>
{#if $profile.id}
<Sidebar.Footer class="gap-4!">
<SidebarFooterMenu />
</Sidebar.Footer>
{/if}
<Sidebar.Rail />
</Sidebar.Root>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import { currentOrg } from '$lib/utils/store/org';
import * as Collapsible from '@cio/ui/base/collapsible';
import * as Sidebar from '@cio/ui/base/sidebar';
import { t } from '$lib/utils/functions/translations';
import { isActive } from '$lib/utils/functions/app';
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { getLmsNavigationItems } from '$features/ui/navigation/lms-navigation';
const items = $derived(getLmsNavigationItems($currentOrg, $t, page.url.pathname));
const pathWithQuery = $derived(page.url.pathname + page.url.search);
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>LMS Navigation</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.path)}
{#if item.items}
<!-- Collapsible Settings Menu -->
<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 })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</a>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.path)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, undefined, true)}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<!-- Regular Menu Item -->
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>
@@ -6,7 +6,7 @@
import { isActive } from '$lib/utils/functions/app';
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { getOrgNavigationItems } from '$lib/features/ui/navigation/org-navigation';
import { getOrgNavigationItems } from '$features/ui/navigation/org-navigation';
const items = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
@@ -26,12 +26,12 @@
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon />
<item.icon class="custom" />
{/if}
<span>{item.title}</span>
{#if item.items}
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
@@ -0,0 +1,33 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { shortenName } from '$lib/utils/functions/string';
import { globalStore } from '$lib/utils/store/app';
const orgPath = $derived($globalStore.isOrgSite ? '/lms' : $currentOrgPath);
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="sm"
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
>
{#snippet child({ props })}
<a href={orgPath} {...props}>
{#if $currentOrg.name}
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
<span class="truncate text-sm">{$currentOrg.name}</span>
{:else}
<Skeleton class="h-4 w-24" />
{/if}
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -9,12 +9,12 @@
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { shortenName } from '$lib/utils/functions/string';
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import { setTheme } from '$lib/utils/functions/theme';
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
import { ComingSoon } from '$lib/features/ui';
import { ComingSoon } from '$features/ui';
const sidebar = useSidebar();
@@ -11,14 +11,14 @@
{#if $isFreePlan}
<div class="animate-icon">
{#if sidebar.open || sidebar.isMobile}
{#if sidebar.open || !sidebar.isMobile}
<div
class="animate-gradient-border mx-2 flex flex-col items-center justify-center gap-4 rounded-md px-2 py-6 text-center transition-all ease-in-out"
>
<RocketIcon class="rocket-launch my-3 size-6" />
<RocketIcon class="rocket-launch my-3 size-6" color="var(--primary)" />
<span class="flex flex-col gap-1">
<p class="text-base font-semibold">{$t('org_navigation.early_adopter')}</p>
<p class="text-xs">{$t('org_navigation.unlock')}</p>
<p class="text-base font-medium">{$t('org_navigation.early_adopter')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('org_navigation.unlock')}</p>
</span>
<Button data-sidebar="upgrade-trigger" data-slot="upgrade-trigger" type="button" onclick={openUpgradeModal}>
<RocketIcon class="rocket-launch custom size-4" />
@@ -0,0 +1,15 @@
export const SNACKBAR_SEVERITY = {
ERROR: 'ERROR',
WARNING: 'WARNING',
DEFAULT: 'DEFAULT',
INFO: 'INFO',
SUCCESS: 'SUCCESS'
};
export const SNACKBAR_SEVERITY_COLOR = {
ERROR: 'bg-red-600',
WARNING: 'bg-yellow-600',
INFO: 'ui:bg-primary',
DEFAULT: 'bg-gray-600',
SUCCESS: 'bg-green-600'
};
@@ -0,0 +1,44 @@
<script lang="ts">
import { Toaster, toast } from '@cio/ui/base/sonner';
import { SNACKBAR_SEVERITY } from './constants';
import { snackbarStore, snackbarStoreInitialState } from './store';
import { t } from '$lib/utils/functions/translations';
function handleClose() {
if (typeof $snackbarStore.handleClose === 'function') {
$snackbarStore.handleClose();
}
snackbarStore.update((_s) => ({
..._s,
...snackbarStoreInitialState
}));
}
function showToast() {
const message = $t($snackbarStore.message);
const { autoHideDuration } = $snackbarStore;
const options = {
duration: autoHideDuration || 5000,
onDismiss: handleClose,
onAutoClose: handleClose
};
if ($snackbarStore.severity === SNACKBAR_SEVERITY.SUCCESS) {
toast.success(message, options);
} else if ($snackbarStore.severity === SNACKBAR_SEVERITY.ERROR) {
toast.error(message, options);
} else {
toast.info(message, options);
}
}
$effect(() => {
if ($snackbarStore.open) {
showToast();
}
});
</script>
<Toaster position="top-right" />
@@ -0,0 +1,42 @@
import { SNACKBAR_SEVERITY } from './constants';
import { writable } from 'svelte/store';
export const snackbarStoreInitialState = {
open: false,
message: '',
severity: SNACKBAR_SEVERITY.DEFAULT,
handleClose: () => {},
autoHideDuration: 6000
};
export const snackbarStore = writable({ ...snackbarStoreInitialState });
export const snackbar = {
success(message = 'snackbar.success') {
snackbarStore.update((store) => {
store.open = true;
store.message = message;
store.severity = SNACKBAR_SEVERITY.SUCCESS;
return store;
});
},
error(message = 'snackbar.something') {
snackbarStore.update((store) => {
store.open = true;
store.message = message;
store.severity = SNACKBAR_SEVERITY.ERROR;
return store;
});
},
info(message = 'snackbar.updates') {
snackbarStore.update((store) => {
store.open = true;
store.message = message;
store.severity = SNACKBAR_SEVERITY.INFO;
return store;
});
}
};
@@ -0,0 +1,59 @@
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
interface Props {
placeholder?: string | ((node: any) => string);
// Content of the editor
content?: HTMLContent;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onChange?: (content: HTMLContent) => void;
onReady?: (editor: TiptapEditor) => void;
onEditorDestroy?: () => void;
}
let {
content,
showToolBar = true,
editable = true,
enablePersistence = false,
contentStorageKey = 'edra-content',
editableStorageKey = 'edra-editable',
class: className = '',
editorClass = '',
placeholder = 'Welcome to ClassroomIO',
onChange,
onReady,
onEditorDestroy
}: Props = $props();
</script>
<Editor
content={content || ''}
{showToolBar}
{editable}
{enablePersistence}
{contentStorageKey}
{editableStorageKey}
class={className}
{editorClass}
{placeholder}
onContentChange={onChange}
onEditorReady={onReady}
{onEditorDestroy}
/>
@@ -0,0 +1,34 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
interface Props {
hasUnsavedChanges?: boolean;
children?: import('svelte').Snippet;
}
let { hasUnsavedChanges = $bindable(false), children }: Props = $props();
beforeNavigate(({ cancel }) => {
if (!shouldAbandonChanges()) {
cancel();
} else {
// User clicked OK, reset the flag
hasUnsavedChanges = false;
}
});
function shouldAbandonChanges() {
if (!hasUnsavedChanges) return true;
return window.confirm($t('common.unsaved_changes.message'));
}
</script>
<svelte:window
onbeforeunload={(e) => {
const result = shouldAbandonChanges();
if (!result) e.preventDefault();
}}
/>
{@render children?.()}
@@ -15,7 +15,7 @@
</script>
{#if $isFreePlan}
<Item.Root variant="outline" size="sm" class="border-blue-700! py-2! {className}">
<Item.Root variant="outline" size="sm" class="border-blue-700! py-2! h-fit w-full {className}">
{#snippet child({ props })}
<button
{...props}
@@ -13,16 +13,16 @@
import { PLANS } from '@cio/utils/plans';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { snackbar } from '$features/ui/snackbar/store';
import { toggleConfetti } from './confetti/store';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import Confetti from '$lib/components/Confetti/index.svelte';
import Confetti from './confetti/confetti.svelte';
const planNames = Object.keys(PLANS);
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(false);
let isYearlyPlan = $state(true);
const query = $derived(new URLSearchParams(page.url.search));
let open = $state(false);
@@ -117,7 +117,7 @@
</Dialog.Header>
{#if upgraded}
<div class="animate-icon flex w-full flex-col items-center justify-center gap-4">
<RocketIcon class="rocket-launch my-3 size-6" />
<RocketIcon class="rocket-launch my-3 size-6" color="var(--primary)" />
<p class="text-lg">{$t('pricing.modal.thanks')}</p>
<p class="ui:mb-4 ui:text-center">
{$t('pricing.modal.plan')}
@@ -141,26 +141,22 @@
<div class="flex h-full flex-col items-center justify-center">
<div class="white 0 relative mb-6 flex items-center rounded-full border p-1">
<Button
variant="ghost"
variant={isYearlyPlan ? 'ghost' : 'secondary'}
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out {isYearlyPlan
? 'ui:text-foreground bg-transparent'
: 'bg-[#1D4EE2] text-white'}"
class="rounded-full! transition-all duration-500 ease-in-out "
onclick={() => (isYearlyPlan = false)}
>
{$t('pricing.modal.monthly')}
</Button>
<Button
variant="ghost"
variant={isYearlyPlan ? 'secondary' : 'ghost'}
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out {isYearlyPlan
? 'bg-[#1D4EE2] text-white'
: 'ui:text-foreground bg-transparent'}"
class="rounded-full! transition-all duration-500 ease-in-out"
onclick={() => (isYearlyPlan = true)}
>
{$t('pricing.modal.annually')}
</Button>
<Badge variant="default" class="rotate-5 absolute -top-3 right-[-20%]">
<Badge variant="default" class="rotate-5 absolute -top-3 right-[-20%] shadow-2xl">
<SparklesIcon class="text-amber-500! size-2" />
{$t('pricing.modal.save')}
</Badge>
@@ -1,27 +1,40 @@
<script>
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { cn } from '@cio/ui/tools';
type Props = {
class?: string;
showOnlyLogo?: boolean;
};
let { class: className, showOnlyLogo = false }: Props = $props();
</script>
{#if $isFreePlan}
<a
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
target="_blank"
class="group fixed bottom-14 right-9 z-50 hover:no-underline"
class={cn('group fixed bottom-14 right-9 z-50 hover:no-underline', className)}
>
<span
class="flex items-center gap-1 overflow-hidden rounded-md border border-gray-50 bg-white px-2 py-1 text-sm font-medium text-black shadow-md transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:text-black"
class={cn(
'relative flex items-center gap-1 overflow-hidden rounded-md border border-gray-100 bg-white text-sm font-medium text-black shadow-sm transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:text-black',
showOnlyLogo ? 'border-none p-0' : 'border px-2 py-1'
)}
>
<ArrowUpRightIcon
size={16}
class="
custom absolute left-3 -translate-x-full translate-y-full text-white
class="custom absolute left-[5%] -translate-x-full translate-y-full text-white
opacity-0 transition duration-500 group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100"
/>
<img src="/logo-192.png" alt="logo" class="h-[24px] opacity-100 group-hover:opacity-0" />
{$t('course.navItem.landing_page.powered_by')} ClassroomIO
{#if !showOnlyLogo}
{$t('course.navItem.landing_page.powered_by')} ClassroomIO
{/if}
</span>
</a>
{/if}
@@ -0,0 +1,130 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import CameraIcon from '@lucide/svelte/icons/camera';
import { t } from '$lib/utils/functions/translations';
interface Props {
avatar: string | File | undefined;
src: string | undefined | null;
widthHeight?: string;
shape?: string;
errorMessage?: string | null;
isDisabled?: boolean;
maxFileSizeInMb?: number; // Default max file size 2MB
flexDirection?: string;
isUploading?: boolean;
change?: () => void;
}
let {
avatar = $bindable(),
src,
widthHeight = '',
shape = 'rounded-full',
errorMessage = $bindable(null),
isDisabled = false,
maxFileSizeInMb = 2,
flexDirection = 'flex-col',
isUploading = $bindable(false),
change
}: Props = $props();
const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png';
let fileinput: HTMLInputElement | undefined = $state();
let imgRef: HTMLImageElement | undefined = $state();
const onFileSelected = (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
}
) => {
const image = e.currentTarget?.files?.[0];
if (!image) return;
const maxFileSize = maxFileSizeInMb * 1024 * 1024;
if (image.size > maxFileSize) {
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} ${maxFileSize / (1024 * 1024)} MB`;
return;
}
const reader = new FileReader();
reader.addEventListener('load', function () {
if (!imgRef || !reader.result) return;
// pass image as avatar to parent component
avatar = image;
// update the image src on the DOM
imgRef.setAttribute('src', reader.result.toString());
// trigger onchange to parent
change?.();
// clear error
errorMessage = null;
});
reader.readAsDataURL(image);
};
</script>
<section class="width-fit flex p-3 {flexDirection} items-center justify-between gap-5">
<div
class="avatar-container {widthHeight ||
'setwidthheight'} pointer relative border-2 border-gray-200 dark:border-neutral-600 {shape}"
>
<img bind:this={imgRef} class="h-full w-full {shape}" src={src || defaultImg} alt="" />
</div>
<div class="flex flex-col items-center">
<button
class="width-fit ui:text-primary flex flex-col items-center text-sm {isDisabled || isUploading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'}"
onclick={() => {
if (!isDisabled || isUploading) {
fileinput?.click();
}
}}
disabled={isDisabled || isUploading}
>
{#if isUploading}
<Progress />
{:else}
<CameraIcon size={16} />
{/if}
<span class="ml-2">{$t('settings.profile.profile_picture.upload_image')}</span>
</button>
<p class="text-center text-xs text-gray-500">
{$t('settings.profile.profile_picture.file_size')}
{maxFileSizeInMb}MB<br />
{$t('settings.profile.profile_picture.accepted')}: jpeg, jpg, png, webp
</p>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/if}
</div>
<input
style="display:none"
type="file"
accept=".jpg, .jpeg, .png, .webp"
onchange={(e) => onFileSelected(e)}
bind:this={fileinput}
/>
</section>
<style>
.width-fit {
width: fit-content;
}
.avatar-container.setwidthheight {
height: 128px;
width: 128px;
}
.upload-icon {
display: none;
}
</style>
@@ -0,0 +1,197 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { onMount } from 'svelte';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { snackbar } from '$features/ui/snackbar/store';
import * as Dialog from '@cio/ui/base/dialog';
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
import { queryUnsplash } from './utils';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { Button } from '@cio/ui/base/button';
interface Props {
imageURL?: string;
onchange?: (_v: string) => void;
}
let { imageURL = $bindable(''), onchange }: Props = $props();
const tabs = [
{ label: 'Unsplash', value: 'unsplash' },
{ label: 'Upload', value: 'upload' }
];
let isUploading = $state(false);
let currentTab = $state(tabs[0].value);
let searchQuery = $state('');
let unsplashImages: {
id: string | number;
user: {
name: string;
username: string;
};
urls: {
regular: string;
};
alt_description: string;
}[] = $state([]);
let fileInput: HTMLInputElement | undefined = $state();
let label = $state($t('snackbar.landing_page_settings.error.label'));
async function handleImageClick(img: string) {
$handleOpenWidget.open = false;
onchange?.(img);
imageURL = img;
}
const onFileSelected = () => {
const file = fileInput?.files?.[0];
const sizeInkb = file?.size! / 1024;
if (sizeInkb > 500) {
snackbar.error('snackbar.landing_page_settings.error.file_size');
label = $t('snackbar.landing_page_settings.error.try_again');
return;
}
if (file) {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
handleUploadImage(file);
};
}
};
const handleUploadImage = async (image: File) => {
isUploading = true;
if (!image) {
return;
}
imageURL = await uploadImage(image);
onchange?.(imageURL);
isUploading = false;
snackbar.success(`snackbar.landing_page_settings.success.complete`);
$handleOpenWidget.open = false;
};
function handleUpload() {
fileInput?.click();
}
async function handleSubmit() {
try {
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
} catch (error) {
snackbar.error('snackbar.landing_page_settings.error.fetch_error');
console.error('Error fetching images from Unsplash:', error);
}
}
onMount(handleSubmit);
</script>
<Dialog.Root
bind:open={$handleOpenWidget.open}
onOpenChange={(isOpen) => {
if (!isOpen) $handleOpenWidget.open = false;
}}
>
<Dialog.Content class="w-3/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
</Dialog.Header>
<div class="w-full bg-white p-5 dark:bg-inherit">
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={tab.value}>
{tab.label}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={tabs[1].value}>
<!-- Your Upload content here -->
<div class="w-full">
<input
type="file"
style="display: none;"
bind:this={fileInput}
onchange={onFileSelected}
disabled={isUploading}
/>
<Button onclick={handleUpload} loading={isUploading}>
{label}
</Button>
<p class="my-2 text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.width')}
</p>
<p class="text-center text-sm text-gray-500">
{$t('course.navItem.landing_page.upload_widget.size')}
</p>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={tabs[0].value}>
<!-- Your Images content here -->
<div class="h-full overflow-y-scroll">
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
<input type="text" bind:value={searchQuery} name="" id="" class="ml-2 w-[85%] rounded-lg dark:text-black" />
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
{#each unsplashImages as unsplashImages (unsplashImages.id)}
<div>
<div class="relative h-[130px] w-[195px] overflow-hidden">
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
<img
src={unsplashImages.urls.regular}
alt={unsplashImages.alt_description}
class="h-full w-full cursor-pointer rounded-md object-cover hover:opacity-80"
/>
</button>
</div>
{#if unsplashImages.user.name}
<p class="mt-1 text-center text-xs font-light text-gray-500">
By <a
href={`https://unsplash.com/@${unsplashImages.user.username}`}
target="_blank"
class="hover:text-red-700">{unsplashImages.user.name}</a
>
</p>
{/if}
</div>
{/each}
</div>
{:else}
<p class="pt-7 text-center">
{$t('course.navItem.landing_page.upload_widget.no_images')}
</p>
{/if}
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</div>
</Dialog.Content>
</Dialog.Root>
<style>
.hide-scrollbar {
width: 100%;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
width: 0;
background: transparent;
}
</style>
@@ -0,0 +1,21 @@
import { getAccessToken } from '$lib/utils/functions/supabase';
export async function queryUnsplash(searchQuery: string) {
try {
const response = await fetch('/api/unsplash', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: await getAccessToken()
},
body: JSON.stringify({ searchQuery })
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
return result.photos;
} catch (error) {
console.error('Error sending fetch request', error);
}
}
@@ -0,0 +1,30 @@
<script lang="ts">
import { Button } 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';
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
interface Props {
className?: string;
isLMS?: boolean;
}
let { className = '', isLMS = false }: 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}
{/if}
<ArrowUpRightIcon class="custom" />
</Button>
</a>
@@ -0,0 +1,19 @@
<script lang="ts">
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import { Chip } from '@cio/ui/custom/chip';
import { Button } from '@cio/ui/base/button';
interface Props {
value?: number;
upVote?: any;
disabled?: boolean;
}
let { value = 0, upVote = () => {}, disabled = false }: Props = $props();
</script>
<Button onclick={disabled ? undefined : upVote} {disabled} variant="ghost" class="h-fit! flex-col">
<ArrowUpIcon size={16} />
<Chip {value} />
</Button>