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 <[email protected]>

* 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 <[email protected]>
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
@@ -71,7 +71,7 @@
<div class="{className} relative z-[100]">
<IconButton
contained={true}
color="text-primary-700 dark:bg-neutral-500 dark:text-white"
color="ui:text-primary dark:bg-neutral-500 dark:text-white"
size="large"
onClick={() => (openPopover = !openPopover)}
>
@@ -1,40 +0,0 @@
<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>
@@ -1,46 +0,0 @@
<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>
@@ -1,12 +0,0 @@
<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>
@@ -1,3 +0,0 @@
export { default as ActivityCard } from './ActivityCard.svelte';
export { default as HeroProfileCard } from './HeroProfileCard.svelte';
export { default as LoadingPage } from './LoadingPage.svelte';
@@ -1,34 +0,0 @@
<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>
@@ -1,14 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
children?: import('svelte').Snippet;
}
let { className = '', children }: Props = $props();
</script>
<div
class="{className} flex h-[517px] w-full flex-col items-center justify-center rounded border border-gray-200 dark:border-neutral-600"
>
{@render children?.()}
</div>
@@ -1,30 +0,0 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Spinner } from '@cio/ui/base/spinner';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
interface Props {
onClick?: IconButtonProps['onClick'];
contained?: boolean;
isClosing?: boolean;
color?: string;
size?: IconButtonProps['size'];
toolTipProps?: IconButtonProps['toolTipProps'];
}
let {
onClick = () => {},
contained = false,
isClosing = false,
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
size = 'small'
}: Props = $props();
</script>
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
{#if isClosing}
<Spinner class="text-blue-700! size-5" />
{:else}
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
{/if}
</IconButton>
@@ -1 +0,0 @@
export { default as CloseButton } from './index.svelte';
@@ -1,10 +0,0 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { IconButton } from '$lib/components/IconButton';
let { onClick = () => {} } = $props();
</script>
<IconButton {onClick}>
<XIcon size={16} class="dark:text-white" />
</IconButton>
@@ -1,10 +0,0 @@
<script lang="ts">
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import { IconButton } from '$lib/components/IconButton';
let { onClick } = $props();
</script>
<IconButton {onClick}>
<SendHorizontalIcon size={16} class="filled" />
</IconButton>
@@ -1,30 +0,0 @@
<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>
@@ -1,11 +0,0 @@
<script lang="ts">
interface Props {
name?: string;
}
let { name = '' }: Props = $props();
</script>
<p class="w-fit rounded-md border border-blue-700 px-1.5 text-center text-xs font-semibold text-blue-700">
{name}
</p>
@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
value?: string | number;
size?: string;
shape?: string;
}
let { className = '', value = '', size = '', shape = 'rounded-md' }: Props = $props();
</script>
<p
class="{className} {size === 'sm' ? 'px-1 py-1' : 'px-2 py-2'} org-avatar {className.includes('bg-')
? ''
: 'bg-gray-200 dark:text-black'} {shape} flex items-center justify-center text-center"
>
{value}
</p>
<style>
.org-avatar {
width: fit-content;
}
</style>
@@ -1,31 +0,0 @@
<script lang="ts">
interface Props {
value?: number;
className?: string;
}
let { value = 0, className = '' }: Props = $props();
</script>
<p class={className ? className : 'bg-set mt-2'}>{value}</p>
<style>
p {
display: inline-block;
min-width: 20px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
text-align: center;
border: 1px solid transparent;
border-radius: 2em;
width: 24px;
height: 24px;
padding-top: 2px;
}
.bg-set {
background-color: #d1d5da80;
}
</style>
@@ -1,11 +0,0 @@
<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>
@@ -1,64 +0,0 @@
<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>
@@ -1,12 +0,0 @@
import { writable } from 'svelte/store';
export const confettiStore = writable({
show: false
});
export function toggleConfetti() {
confettiStore.update((confetti) => ({
...confetti,
show: !confetti.show
}));
}
@@ -1,6 +0,0 @@
<script>
import { PageNav } from '$lib/components/Page';
</script>
<PageNav title="Attendance" />
All attendance will be here with table
@@ -7,18 +7,17 @@
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { updateCourse } from '$lib/utils/services/courses';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/Plain.svelte';
import Professional from './templates/Professional.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
import BlueProfessionalBadge from './templates/BlueProfessionalBadge.svelte';
import PurpleProfessionalBadge from './templates/PurpleProfessionalBadge.svelte';
@@ -122,18 +121,15 @@
{$t('course.navItem.certificates.and_upload')}
</p>
<PrimaryButton
label={$t('course.navItem.certificates.goto_settings')}
variant={VARIANTS.OUTLINED}
className="rounded-md mt-3"
onClick={() => goto(`/org/${$currentOrg.siteName}/settings`)}
/>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mb-2 mt-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextArea
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
bind:value={$course.description}
@@ -173,18 +169,12 @@
</section>
</div>
<div class="h-1/5">
<PrimaryButton
className="rounded-md flex gap-2 items-center"
variant={VARIANTS.CONTAINED_DARK}
onClick={saveCertificate}
isLoading={isSaving}
isDisabled={$isFreePlan}
>
<Button variant="secondary" onclick={saveCertificate} loading={isSaving} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</PrimaryButton>
</Button>
</div>
</main>
@@ -5,10 +5,10 @@
import { issueCertificateModal, resetForm } from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let isAutomatic = $state(false);
@@ -17,14 +17,17 @@
};
</script>
<Modal
onClose={resetForm}
<Dialog.Root
bind:open={$issueCertificateModal.open}
width="w-3/5"
maxWidth=""
modalHeading="Send Certificate"
onOpenChange={(isOpen) => {
if (!isOpen) resetForm();
}}
>
<main>
<Dialog.Content class="w-3/5">
<Dialog.Header>
<Dialog.Title>Send Certificate</Dialog.Title>
</Dialog.Header>
<main>
<div>
<div class="mb-4 flex items-center space-x-2">
<Switch id="auto-certificate" bind:checked={isAutomatic} />
@@ -37,25 +40,23 @@
<p class="my-4 text-xs font-normal text-gray-500">or send a personalised/ custom certificate below:</p>
<form onsubmit={preventDefault(issueCertificate)}>
<div class="flex w-full flex-col gap-2 md:flex-row">
<TextField
<InputField
label="Email address of the student"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="email,comma seperated"
bind:value={$issueCertificateModal.email}
/>
<TextField
<InputField
label="Schedule date"
className="w-full my-4"
labelClassName="text-xs font-normal"
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
placeholder="12/06/2023"
bind:value={$issueCertificateModal.date}
/>
</div>
<TextArea
<TextareaField
label="Add a personalized message"
labelClassName="text-xs font-normal"
bind:value={$issueCertificateModal.message}
@@ -65,8 +66,9 @@
/>
<div class="mt-5 flex w-full items-end justify-end">
<PrimaryButton className="px-6 py-3 rounded-md text-sm font-medium" label="Issue certificate" type="submit" />
<Button type="submit">Issue certificate</Button>
</div>
</form>
</main>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -85,7 +85,7 @@
<div class="flex items-center gap-4">
<span class="text-sm">{student.issued}</span>
{#if student.peronalized}
<Badge variant="secondary" class="bg-primary-100 text-primary-600">personalized</Badge>
<Badge variant="secondary" class="bg-primary-100 ui:text-primary">personalized</Badge>
{/if}
</div>
</Table.Cell>
@@ -1,17 +1,14 @@
<script>
import Box from '$lib/components/Box/index.svelte';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import LockIcon from '@lucide/svelte/icons/lock';
</script>
<Box>
<div class="flex h-full w-max flex-col items-center justify-center gap-2">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-center text-xl font-normal">
{$t('course.navItem.certificates.locked_certificate')}
</p>
<p class="text-center text-sm font-normal">
{$t('course.navItem.certificates.instructor_unlock')} <br />
{$t('course.navItem.certificates.contact_instructor')}
</p>
</div>
</Box>
<div class="w-full flex-1">
<Empty
title={$t('course.navItem.certificates.locked_certificate')}
description={`${$t('course.navItem.certificates.instructor_unlock')} ${$t('course.navItem.certificates.contact_instructor')}`}
icon={LockIcon}
variant="page"
/>
</div>
@@ -5,13 +5,12 @@
import { course } from '$lib/components/Course/store';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Box from '$lib/components/Box/index.svelte';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { t } from '$lib/utils/functions/translations';
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
let isLoading = $state(false);
@@ -19,7 +18,7 @@
let progress: ProfileCourseProgress | undefined;
const downLoadCertificate = async () => {
if (!isCourseComplete) return;
if (!isCourseComplete || !$course.id) return;
isLoading = true;
try {
@@ -85,24 +84,11 @@
);
</script>
<Box>
<div class="flex h-full w-max flex-col items-center justify-center gap-5">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-center text-xl font-normal">
{$t(title)}
</p>
<p class="max-w-md text-center text-sm font-normal">
{$t(subtitle)}
</p>
<PrimaryButton
className="flex items-center gap-2"
onClick={downLoadCertificate}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!isCourseComplete}
{isLoading}
>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</PrimaryButton>
</div>
</Box>
</Button>
</Empty>
</div>
@@ -1,11 +1,10 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { course } from '../../store';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
open?: boolean;
@@ -34,14 +33,17 @@
}
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open
width="w-[80%] md:w-[65%]"
maxWidth="max-w-xl"
modalHeading={$t(`course.navItem.lessons.section_prompt.header`)}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<div class="flex w-full flex-col items-center">
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl">
<Dialog.Header>
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
</Dialog.Header>
<div class="flex w-full flex-col items-center">
<div class="mb-8">
<h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')}
@@ -52,16 +54,19 @@
</div>
<div class="flex gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.section_prompt.cancel')}
onClick={handleClose}
/>
<PrimaryButton
label={$t('course.navItem.lessons.section_prompt.activate')}
onClick={activate}
isLoading={isActivating}
/>
<Button
variant="outline"
onclick={handleClose}
>
{$t('course.navItem.lessons.section_prompt.cancel')}
</Button>
<Button
onclick={activate}
loading={isActivating}
>
{$t('course.navItem.lessons.section_prompt.activate')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,7 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -17,31 +16,35 @@
}
</script>
<Modal
onClose={() => (openDeleteModal = false)}
<Dialog.Root
bind:open={openDeleteModal}
width="w-96"
containerClass="px-6 pt-2 pb-6"
modalHeading={$t('delete_modal.label')}
onOpenChange={(isOpen) => {
if (!isOpen) openDeleteModal = false;
}}
>
<div>
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
<Dialog.Header>
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}?
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.yes')}
onClick={handleDelete}
/>
<Button
variant="outline"
onclick={() => (openDeleteModal = false)}
>
{$t('delete_modal.no')}
</Button>
<Button
variant="outline"
onclick={handleDelete}
>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,44 +1,43 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { deleteConfirmation } from '../store/exercise';
let { onDelete = () => {}, onCancel = () => {} } = $props();
</script>
<Modal
onClose={() => ($deleteConfirmation.open = false)}
<Dialog.Root
bind:open={$deleteConfirmation.open}
width="w-96"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
>
<div>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
onClick={() => {
<Button
variant="outline"
onclick={() => {
$deleteConfirmation.open = false;
onCancel();
}}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
onClick={() => {
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
</Button>
<Button
onclick={() => {
onDelete();
$deleteConfirmation.open = false;
}}
/>
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -16,22 +16,20 @@
} from '../store/exercise';
import { preventDefault } from '$lib/utils/functions/svelte';
import { deleteExercise } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
import { Button } from '@cio/ui/base/button';
import { lesson } from '../store/lessons';
import OrderModal from './OrderModal.svelte';
import { t } from '$lib/utils/functions/translations';
import Modal from '$lib/components/Modal/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Checkbox from '$lib/components/Form/Checkbox.svelte';
import RadioItem from '$lib/components/Form/RadioItem.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { RadioItem } from '@cio/ui/custom/radio-item';
import { InputField } from '@cio/ui/custom/input-field';
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CircleCheckIcon } from '$features/ui/icons';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
const initialQuestionsLength = $questionnaire.questions.length;
@@ -90,49 +88,43 @@
<OrderModal />
<Modal
onClose={() => (shouldDeleteExercise = false)}
<Dialog.Root
bind:open={shouldDeleteExercise}
width="w-2/4"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}
onOpenChange={(isOpen) => {
if (!isOpen) shouldDeleteExercise = false;
}}
>
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<Dialog.Content class="w-2/4">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
type="submit"
onClick={() => (shouldDeleteExercise = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
isDisabled={isDeleting}
onClick={handleDelete}
/>
</div>
</form>
</Modal>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
</Button>
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
{isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
{#snippet optionActions(question, option)}
<div data-name="option-action" class="ml-2 flex items-center gap-2">
<IconButton value={`${option.id}`} onClick={handleRemoveOption(question.id, option.id)} size="small" bordered>
<IconButton onclick={handleRemoveOption(question.id, option.id)}>
<TrashIcon size={18} />
</IconButton>
<IconButton
value={`${option.id}`}
onClick={handleAnswerSelect(question.id, option.id)}
buttonClassName={option.is_correct ? 'success' : ''}
size="small"
bordered
onclick={handleAnswerSelect(question.id, option.id)}
class={option.is_correct ? 'text-green-500! fill-green-500!' : ''}
>
<CircleCheckIcon size={18} filled={option.is_correct} />
</IconButton>
@@ -142,7 +134,7 @@
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
<ErrorMessage message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')} />
<p class="text-sm text-red-500">{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}</p>
</div>
{/if}
<div class="questions px-6 pt-6">
@@ -160,11 +152,11 @@
>
<div class="flex items-center justify-between">
<div class="mr-5 w-3/5">
<TextField
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
bind:value={question.title}
isRequired={true}
onChange={() => {
onchange={() => {
question.is_dirty = true;
}}
/>
@@ -194,12 +186,12 @@
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextArea
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton value="write-code" onClick={() => handleCode(question.id, false)}>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -213,45 +205,40 @@
isEditable={true}
name={question.title || 'radio-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</RadioItem>
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
<Checkbox
<CheckboxField
isEditable={true}
name={question?.name || 'checkbox-name'}
bind:label={option.label}
onChange={addDynamicValue(question.id, option.id)}
onchange={addDynamicValue(question.id, option.id)}
>
{@render optionActions(question, option)}
</Checkbox>
</CheckboxField>
{/if}
{/if}
{/each}
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
<TextArea bind:value={question.value} disabled={true} />
<TextareaField bind:value={question.value} disabled={true} />
{/if}
{#if getQuestionErrorMsg(errors, question, 'option')}
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
<div class="mt-3 flex items-center">
<PrimaryButton
disablePadding={true}
className="p-2"
variant={VARIANTS.OUTLINED}
onClick={handleAddOption(question.id)}
>
<Button variant="outline" onclick={handleAddOption(question.id)}>
<CirclePlusIcon size={16} />
<p class="ml-2 dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
</p>
</PrimaryButton>
</Button>
</div>
{/if}
</QuestionContainer>
@@ -7,14 +7,13 @@
import { STATUS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { SubmissionIdData } from '$lib/utils/types/submission';
import Preview from './Preview.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
// import { useCompletion } from 'ai/svelte';
// import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -231,16 +230,17 @@
});
</script>
<Modal
modalHeading={data.title}
<Dialog.Root
bind:open
{onClose}
width="w-4/5 h-[90%]"
containerClass="flex items-start !max-h-full h-[85%] py-0 px-4"
headerClass="py-2"
labelClass="text-base font-semibold"
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<div class="mt-2 h-full w-full">
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4">
<Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header>
<div class="mt-2 h-full w-full">
{#if openDeletePrompt}
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
<h1 class="text-lg dark:text-white">
@@ -248,20 +248,12 @@
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (openDeletePrompt = false)}
isDisabled={isDeleting}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.yes')}
onClick={handleDeleteSubmission}
isLoading={isDeleting}
/>
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
{:else}
@@ -383,7 +375,7 @@
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextArea
<TextareaField
bgColor="bg-gray-100 dark:bg-neutral-700"
className="font-semibold"
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
@@ -392,22 +384,22 @@
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<PrimaryButton onClick={gradeWithAI} variant={VARIANTS.OUTLINED} className="space-x-3 py-3 px-8 w-full ">
<Button variant="outline" onclick={gradeWithAI} class="w-full">
<img src="/ai.svg" alt="ai" />
<p class="text-sm font-semibold">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p>
</PrimaryButton>
<PrimaryButton
onClick={() => {
</Button>
<Button
onclick={() => {
handleSave(data);
// onClose();
}}
isLoading={isSaving}
label={$t('course.navItem.submissions.grading_modal.submit_grades')}
variant={VARIANTS.CONTAINED}
className="py-3 px-8 w-full"
/>
loading={isSaving}
class="w-full"
>
{$t('course.navItem.submissions.grading_modal.submit_grades')}
</Button>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Teacher</p>
@@ -415,7 +407,8 @@
</div> -->
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
<style>
.badge {
@@ -6,7 +6,7 @@
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
import { flip } from 'svelte/animate';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { questionnaireOrder, questionnaire } from '../store/exercise';
import { filterOutDeleted } from './functions';
@@ -60,13 +60,17 @@
});
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open={$questionnaireOrder.open}
width="w-96"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.order_questions')}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<section
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
</Dialog.Header>
<section
use:dndzone={{
items,
flipDurationMs,
@@ -82,7 +86,7 @@
{#each items as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="border-primary-600 flex items-center rounded-md border p-4"
class="ui:border-primary flex items-center rounded-md border p-4"
>
{#if item.type === 1}
<SquareCheckIcon size={16} />
@@ -95,7 +99,8 @@
</div>
{/each}
</section>
</Modal>
</Dialog.Content>
</Dialog.Root>
<style>
section {
@@ -62,7 +62,7 @@
{#each tabs as tab}
<button
class="relative {currentTab === tab.value
? 'text-primary-700'
? 'ui:text-primary'
: 'dark:bg-gray-500 dark:text-white'} mr-4 w-fit px-2 py-3 text-center font-semibold focus:outline-none dark:bg-transparent"
onclick={onChange(tab.value)}
>
@@ -2,9 +2,8 @@
import { questionnaire } from '../store/exercise';
import { t } from '$lib/utils/functions/translations';
import DateTime from '$lib/components/Form/DateTime.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
interface Props {
@@ -13,9 +12,9 @@
let { preview }: Props = $props();
function getTotalPossibleGrade(questions: { points: string }[]) {
function getTotalPossibleGrade(questions: { points: number | string }[]) {
return questions.reduce((acc, question) => {
acc += parseFloat(question.points, 10);
acc += typeof question.points === 'string' ? parseFloat(question.points) : question.points;
return acc;
}, 0);
}
@@ -24,18 +23,18 @@
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
<QuestionContainer isTitle={true}>
{#if !preview}
<TextField
<InputField
placeholder={$t('course.navItem.lessons.exercises.all_exercises.description.title')}
bind:value={$questionnaire.title}
className="mb-2"
onChange={() => ($questionnaire.is_title_dirty = true)}
onchange={() => ($questionnaire.is_title_dirty = true)}
/>
<DateTime
<InputField
label={$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}
className="w-50"
value={$questionnaire.due_by}
onInput={(e) => {
$questionnaire.due_by = e.target.value;
type="datetime-local"
bind:value={$questionnaire.due_by}
onchange={() => {
$questionnaire.is_due_by_dirty = true;
}}
/>
@@ -80,7 +79,7 @@
{/if}
</div>
<article class="preview prose prose-sm mt-3 p-2 sm:prose">
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
</article>
{/if}
@@ -8,10 +8,11 @@
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import Progress from '$lib/components/Progress/index.svelte';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { Empty } from '@cio/ui/custom/empty';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import { Progress } from '@cio/ui/base/progress';
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from './constants';
@@ -64,7 +65,7 @@
const submissionLink = `${baseUrl}/submissions`;
const content = `
<p>Hello ${teacherFullname},</p>
<p>A student ${student.profile.fullname} just submitted an exercise <a href=${exerciseLink}>${$questionnaire.title}</a>
<p>A student ${student.profile.fullname} just submitted an exercise <a href=${exerciseLink}>${$questionnaire.title}</a>
<p>You can get started grading by clicking "Open Submissions"</p>
<div>
<a class="button" href=${submissionLink}>Open Submissions</a>
@@ -259,15 +260,18 @@
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Box>
<img src="/images/empty-exercise-icon.svg" alt="Exercise svg" class="my-2.5" />
<h2 class="my-1.5 text-xl">No question added for this exercise</h2>
<p class="px-44 text-center text-sm">
<RoleBasedSecurity allowedRoles={[1, 2]}>
Click the <span class="text-primary-700">Edit</span> button to add.
</RoleBasedSecurity>
</p>
</Box>
<Empty
title="No question added for this exercise"
description="Click the Edit button to add."
icon={FileQuestionIcon}
variant="page"
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="ui:text-primary text-center text-sm">
Click the <span class="font-semibold">Edit</span> button to add.
</p>
</RoleBasedSecurity>
</Empty>
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
<RoleBasedSecurity allowedRoles={[3]}>
<div>
@@ -296,7 +300,7 @@
{@html $questionnaire.description || 'No desription'}
</article>
<PrimaryButton onClick={handleStart} label="Start" className="my-5 float-right" type="button" />
<Button onclick={handleStart} type="button" class="float-right my-5">Start</Button>
</div>
</RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished}
@@ -1,5 +1,5 @@
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { toggleConfetti } from '$features/ui/confetti/store';
export const isAnswerCorrect = (options, answer) => {
return options.some((option) => option.is_correct && option.value === answer);
@@ -18,18 +18,14 @@
} from '../store/exercise';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { upsertExercise } from '$lib/utils/services/courses';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import EditMode from './EditMode.svelte';
import ViewMode from './ViewMode.svelte';
import { PageBody } from '$lib/components/Page';
import Analytics from './Submissions/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from './UpdateDescription.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
exerciseId?: string;
@@ -106,7 +102,7 @@
// });
</script>
<PageBody isPageNavHidden={$globalStore.isStudent} padding="px-4 overflow-x-hidden">
<div class="overflow-x-hidden px-4">
<div class="sticky top-0 z-10 mb-3 bg-gray-100 p-2 dark:bg-neutral-800">
<Breadcrumb.Root>
<Breadcrumb.List>
@@ -139,31 +135,20 @@
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex items-center">
<PrimaryButton
className="mr-2"
variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.save')}
onClick={handleSave}
isLoading={isSaving}
/>
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onClick={() => (preview = !preview)}
contained={preview}
toolTipProps={{
title: $t('course.navItem.lessons.exercises.all_exercises.preview'),
direction: 'bottom',
hotkeys: []
}}
onclick={() => (preview = !preview)}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.preview')}
tooltipSide="bottom"
>
<EyeIcon size={20} class={preview ? 'filled' : ''} />
</IconButton>
<IconButton
onClick={handleAddQuestion}
toolTipProps={{
title: $t('course.navItem.lessons.exercises.all_exercises.add_question'),
direction: 'bottom',
hotkeys: []
}}
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
@@ -205,4 +190,4 @@
{:else if selectedTab === 'submissions'}
<Analytics bind:exerciseId />
{/if}
</PageBody>
</div>
@@ -2,16 +2,14 @@
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import Modal from '$lib/components/Modal/index.svelte';
import Confetti from '$lib/components/Confetti/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Confetti, ComingSoon } from '$features/ui';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseTemplate } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { type GeneratedTemplates, getAllTemplates, TAGS } from '$lib/mocks';
interface Props {
@@ -126,17 +124,20 @@
// const note = $derived(browser ? getTextFromHTML(content) : '');
</script>
<Modal
onClose={handleCancelAddExercise}
<Dialog.Root
bind:open
modalHeading={$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}
maxWidth="max-w-2xl"
width="w-4/5"
onOpenChange={(isOpen) => {
if (!isOpen) handleCancelAddExercise();
}}
>
{#if !isLoading && isAIStarted}
<Confetti />
{/if}
{#if step === 0}
<Dialog.Content class="max-w-2xl w-4/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
</Dialog.Header>
{#if !isLoading && isAIStarted}
<Confetti />
{/if}
{#if step === 0}
<div>
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
@@ -171,11 +172,9 @@
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
onClick={handleNext}
/>
<Button onclick={handleNext}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
</Button>
</div>
</div>
{:else if step === 1}
@@ -185,7 +184,7 @@
<h2 class="my-5 text-2xl font-medium">
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
</h2>
<TextField
<InputField
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
@@ -193,17 +192,12 @@
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={handleBack}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
onClick={handleAddExercise}
/>
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button onclick={handleAddExercise}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
@@ -258,18 +252,13 @@
</div>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={handleBack}
/>
<PrimaryButton
isDisabled={!selectedTemplateId || !allTemplates}
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
isLoading={isTemplateFinishedLoading}
onClick={async () => {
<Button variant="outline" onclick={handleBack}>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={!selectedTemplateId || !allTemplates}
loading={isTemplateFinishedLoading}
onclick={async () => {
isTemplateFinishedLoading = true;
const template = allTemplates?.[selectedTag]?.find((t) => t.id === selectedTemplateId);
@@ -281,7 +270,9 @@
isTemplateFinishedLoading = true;
}}
/>
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div>
</div>
@@ -294,7 +285,7 @@
<p class="mb-4 text-sm">
{$t('course.navItem.lessons.exercises.new_exercise_modal.choose_questions')}
</p>
<TextField
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_questions')}
type="number"
bind:value={questionNumber}
@@ -302,7 +293,7 @@
className="mb-2"
isRequired
/>
<TextField
<InputField
label={$t('course.navItem.lessons.exercises.new_exercise_modal.how_many_options')}
type="number"
bind:value={optionNumber}
@@ -313,14 +304,14 @@
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.add_note')}</h3>
{/if}
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
onClick={callAI}
{isLoading}
isDisabled={isLoading || !note}
variant={VARIANTS.OUTLINED}
<Button
onclick={callAI}
loading={isLoading}
disabled={isLoading || !note}
variant="outline"
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.generate')}
</PrimaryButton>
</Button>
</div>
</div>
<div
@@ -340,20 +331,21 @@
</div>
</div>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
variant={VARIANTS.TEXT}
onClick={handleBack}
/>
<PrimaryButton
isDisabled={isLoading || !note}
className="px-6 py-3"
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
onClick={handleAddExercise}
/>
<Button
variant="ghost"
onclick={handleBack}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
</Button>
<Button
disabled={isLoading || !note}
onclick={handleAddExercise}
>
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
</Button>
</div>
</div> -->
{/if}
{/if}
</Modal>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -13,13 +13,11 @@
import { isQuestionnaireFetching, questionnaire } from '../store/exercise';
import { createExercise, createExerciseFromTemplate } from '$lib/utils/services/courses';
import { PageBody } from '$lib/components/Page';
import Exercise from '../Exercise/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Backdrop, RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { Button } from '@cio/ui/base/button';
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
interface Props {
@@ -175,7 +173,7 @@
bind:title={newExercise.title}
/>
<PageBody isPageNavHidden={$globalStore.isStudent}>
<div>
{#snippet header()}
<slot:fragment>
<Breadcrumb.Root class="my-2">
@@ -188,11 +186,9 @@
</Breadcrumb.List>
</Breadcrumb.Root>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton
className="mr-2 my-2"
label={$t('course.navItem.lessons.exercises.add_button')}
onClick={() => (open = !open)}
/>
<Button class="my-2 mr-2" onclick={() => (open = !open)}>
{$t('course.navItem.lessons.exercises.add_button')}
</Button>
</RoleBasedSecurity>
</slot:fragment>
{/snippet}
@@ -224,5 +220,5 @@
</Empty.Root>
{/each}
</div>
</PageBody>
</div>
{/if}
@@ -6,9 +6,9 @@
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { Chip } from '@cio/ui/custom/chip';
import { InputField } from '@cio/ui/custom/input-field';
import { RoleBasedSecurity } from '$features/ui';
import * as Empty from '@cio/ui/base/empty';
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
@@ -67,7 +67,7 @@
});
}
function getLessonOrder(id: string) {
function getLessonOrder(id: string): number | string {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
@@ -98,19 +98,14 @@
>
<!-- Number Chip -->
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
<Chip value={getLessonOrder(lesson.id)} />
</div>
<!-- Lesson Content -->
<div class="w-4/5">
<!-- Lesson Title -->
{#if lessonEditing === lesson.id}
<TextField bind:value={lesson.title} autoFocus={true} className="max-w-lg" errorMessage={errors?.title} />
<InputField bind:value={lesson.title} autoFocus={true} className="max-w-lg" errorMessage={errors?.title} />
{:else}
<h3 class="m-0 flex items-center text-lg dark:text-white">
<a
@@ -7,16 +7,14 @@
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Button } from '@cio/ui/base/button';
import TextField from '$lib/components/Form/TextField.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { IconButton } from '$lib/components/IconButton';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { InputField } from '@cio/ui/custom/input-field';
import { Chip } from '@cio/ui/custom/chip';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import {
lessonSections,
handleDelete,
@@ -27,7 +25,7 @@
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
type CrudParam = {
sectionId?: string;
@@ -200,7 +198,7 @@
class="mb-2 flex min-h-[50px] items-center justify-between rounded-tl-md rounded-tr-md border-b bg-gray-50 px-3 py-1 dark:bg-neutral-700"
>
{#if lessonEditing === section.id}
<TextField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
<InputField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
{:else}
<p class="w-4/6 font-semibold">{section.title}</p>
{/if}
@@ -208,20 +206,20 @@
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
<Button
variant="outline"
onclick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })}
/>
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ sectionId: section.id })}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<IconButton contained size="small" onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<IconButton onclick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<PlusIcon size={16} />
</IconButton>
<DropdownMenu.Root>
@@ -272,15 +270,10 @@
animate:flip={{ duration: flipDurationMs }}
>
{#if lessonEditing === lesson.id}
<TextField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
<InputField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
{:else}
<div class="flex w-4/5 items-center gap-2">
<TextChip
value={getLessonOrder(section.lessons, lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
<Chip value={getLessonOrder(section.lessons, lesson.id)} />
<div>
<a
href={$globalStore.isStudent && !lesson.is_unlocked
@@ -309,18 +302,18 @@
<div class="flex items-center gap-1">
<!-- IS EDITING -->
{#if lessonEditing === lesson.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
<Button
variant="outline"
onclick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)}
/>
>
{$t('course.navItem.lessons.add_lesson.cancel')}
</Button>
<Button onclick={() => onSave({ lessonId: lesson.id }, lesson)}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -3,8 +3,7 @@
import { createEventDispatcher } from 'svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import PlayIcon from '@lucide/svelte/icons/play';
import { fetchLesssonLanguageHistory } from '$lib/utils/services/courses';
import { diffLines } from 'diff';
@@ -12,7 +11,7 @@
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { TLocale } from '@cio/db/types';
interface LessonHistory {
@@ -172,14 +171,15 @@
<div class="panel bg-white dark:bg-black">
<div class="w-full p-10 pr-80">
<div class="flex items-start gap-x-10">
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleDrawerClose}>
<Button variant="outline" onclick={handleDrawerClose}>
<ArrowLeftIcon size={16} />
</PrimaryButton>
</Button>
{#if selectedVersionIndex != 0}
<div class="">
<PrimaryButton isLoading={contentRestoreLoading} onClick={restoreSelectedVersion}
>{$t('course.navItem.lessons.version_history.restore_version')}</PrimaryButton
<Button loading={contentRestoreLoading} onclick={restoreSelectedVersion}>
{$t('course.navItem.lessons.version_history.restore_version')}
</Button>
>
</div>
{/if}
@@ -230,8 +230,9 @@
</button>
{/each}
<div class="mt-2 flex h-10 items-center justify-start px-10">
<PrimaryButton className="h-full" isLoading={isMoreHistoryLoading} onClick={loadMoreHistory}
>{$t('course.navItem.lessons.version_history.fetch_more_versions')}</PrimaryButton
<Button loading={isMoreHistoryLoading} onclick={loadMoreHistory}>
{$t('course.navItem.lessons.version_history.fetch_more_versions')}
</Button>
>
</div>
</div>
@@ -1,16 +1,14 @@
<script lang="ts">
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { UpgradeBanner, CloseButton } from '$features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
let fileInput: HTMLInputElement | undefined = $state();
let selectedFile: File | null = $state(null);
@@ -246,12 +244,9 @@
class="hidden"
/>
<div class="flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
variant={VARIANTS.OUTLINED}
{isDisabled}
onClick={() => fileInput?.click()}
/>
<Button variant="outline" disabled={isDisabled} onclick={() => fileInput?.click()}>
{$t('course.navItem.lessons.materials.tabs.document.choose_file')}
</Button>
</div>
{/if}
</div>
@@ -277,11 +272,9 @@
></div>
</div>
<div class="mt-3 flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
</Button>
</div>
</div>
{/if}
@@ -289,16 +282,12 @@
<!-- Upload Button -->
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
variant={VARIANTS.OUTLINED}
onClick={removeSelectedFile}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
onClick={uploadDocument}
isLoading={$lessonDocUpload.isUploading}
/>
<Button variant="outline" onclick={removeSelectedFile}>
{$t('course.navItem.lessons.materials.tabs.document.cancel')}
</Button>
<Button onclick={uploadDocument} loading={$lessonDocUpload.isUploading} disabled={$lessonDocUpload.isUploading}>
{$t('course.navItem.lessons.materials.tabs.document.upload_document')}
</Button>
</div>
{/if}
@@ -1,6 +1,5 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import FileTextIcon from '@lucide/svelte/icons/file-text';
@@ -40,11 +39,9 @@
{#if mode === MODES.edit}
<div class="mb-4">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.add_document')}
onClick={openDocumentUploadModal}
className="mb-4"
/>
<Button onclick={openDocumentUploadModal} class="mb-4">
{$t('course.navItem.lessons.materials.tabs.document.add_document')}
</Button>
</div>
{#if !isEmpty(displayDocuments)}
@@ -115,10 +112,10 @@
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<PrimaryButton variant={VARIANTS.CONTAINED_DARK} onClick={() => handleViewPDF(document)} {isLoading}>
<Button onclick={() => handleViewPDF(document)} loading={isLoading}>
<EyeIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</PrimaryButton>
</Button>
{:else}
<a
href={isLoading ? '#' : document.link}
@@ -8,9 +8,9 @@
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { IconButton } from '$lib/components/IconButton';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let genericLinks = $state('');
let error = $state('');
@@ -56,20 +56,17 @@
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.embed_link')}
bind:value={genericLinks}
className="flex-1 text-left "
inputClassName="text-sm"
onChange={() => ($isLessonDirty = true)}
className="flex-1"
onchange={() => ($isLessonDirty = true)}
placeholder="https://www.videoplayer.com/"
errorMessage={error}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
className="rounded-md"
onClick={addVideo}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
@@ -84,10 +81,10 @@
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton value="copy-video" onClick={() => copyToClipboard(video.link)}>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -1,14 +1,13 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { preventDefault } from '$lib/utils/functions/svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { UpgradeBanner } from '$features/ui';
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
interface Props {
lessonId?: string;
@@ -144,11 +143,9 @@
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
<Button variant="outline" onclick={cancelUpload}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
</Button>
</div>
</div>
{:else}
@@ -187,7 +184,9 @@
{$t('course.navItem.lessons.materials.tabs.video.add_video.maximum_size')}
</p>
</span>
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.button')} onClick={tryAgain} />
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.button')}
</Button>
</div>
{:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
@@ -201,7 +200,9 @@
{$t('course.navItem.lessons.materials.tabs.video.add_video.format')}
</p>
</span>
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.try_again')} onClick={tryAgain} />
<Button onclick={tryAgain}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.try_again')}
</Button>
</div>
{:else}
<div class=" w-full rounded-md border px-8 py-3">
@@ -214,10 +215,9 @@
</video>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</div>
<PrimaryButton
label={$t('course.navItem.lessons.materials.button_done')}
onClick={() => ($lessonVideoUpload.isModalOpen = false)}
/>
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.button_done')}
</Button>
</div>
</div>
{/if}
@@ -4,13 +4,13 @@
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import type { LessonVideoType } from '$lib/utils/types';
import { copyToClipboard, getVideoUrls, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
let youtubeLinks = $state('');
let error = $state('');
@@ -45,20 +45,17 @@
</script>
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField
<InputField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
bind:value={youtubeLinks}
className="flex-1 text-left "
inputClassName="text-sm"
onChange={() => ($isLessonDirty = true)}
className="flex-1 text-left"
onchange={() => ($isLessonDirty = true)}
placeholder="https://www.youtube.com/watch?v="
errorMessage={error}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
className="rounded-md"
onClick={addVideo}
/>
<Button onclick={addVideo}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_video')}
</Button>
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
@@ -73,10 +70,10 @@
<Badge class="max-w-md truncate" variant="secondary">
{video.link}
</Badge>
<IconButton value="copy-video" onClick={() => copyToClipboard(video.link)}>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
@@ -6,18 +6,17 @@
import { group } from '$lib/components/Course/store';
import * as Avatar from '@cio/ui/base/avatar';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { GroupPerson, LessonComment, LessonCommentInsertPayload } from '$lib/utils/types';
const supabase = getSupabase();
@@ -63,7 +62,7 @@
id: 0,
comment: comment,
name: $t('course.navItem.lessons.comments.you'),
avatar: $profile.avatarUrl,
avatar: $profile.avatarUrl || 'AV',
commentAt: new Date(),
groupmember_id: groupmember.id
},
@@ -279,7 +278,7 @@
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full">
<TextArea
<TextareaField
label={$t('course.navItem.lessons.comments.text_area_title')}
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={comment}
@@ -288,12 +287,9 @@
</div>
<div class="mt-2 flex flex-row-reverse">
<PrimaryButton
label={$t('course.navItem.lessons.comments.comment_btn')}
onClick={handleSend}
isDisabled={!comment}
isLoading={isSaving}
/>
<Button onclick={handleSend} disabled={!comment} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
</div>
@@ -344,22 +340,17 @@
</div>
{#if editCommentId === commentItem.id}
<TextArea
<TextareaField
placeholder={$t('course.navItem.lessons.comments.placeholder')}
bind:value={commentItem.comment}
/>
<div class="mt-2 flex flex-row-reverse items-center gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.comments.cancel_btn')}
onClick={() => (editCommentId = null)}
/>
<PrimaryButton
label={$t('course.navItem.lessons.comments.comment_btn')}
onClick={() => handleUpdate(commentItem)}
isDisabled={!commentItem.comment}
isLoading={isSaving}
/>
<Button variant="outline" onclick={() => (editCommentId = null)}>
{$t('course.navItem.lessons.comments.cancel_btn')}
</Button>
<Button onclick={() => handleUpdate(commentItem)} disabled={!commentItem.comment} loading={isSaving}>
{$t('course.navItem.lessons.comments.comment_btn')}
</Button>
</div>
{:else}
<article class="prose sm:prose-sm max-w-[300px] dark:text-white">
@@ -372,12 +363,9 @@
</div>
{#if pagination.hasMore}
<div class="mt-2 flex items-center justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.comments.load_more_btn')}
variant={VARIANTS.OUTLINED}
onClick={() => fetchComments($group.people)}
isLoading={isFetching}
/>
<Button variant="outline" onclick={() => fetchComments($group.people)} loading={isFetching}>
{$t('course.navItem.lessons.comments.load_more_btn')}
</Button>
</div>
{/if}
</div>
@@ -1,9 +1,9 @@
<script lang="ts">
import { lesson, deleteLessonDocument } from '$lib/components/Course/components/Lesson/store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
import { CloseButton } from '$features/ui';
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import MODES from '$lib/utils/constants/mode';
import { IconButton } from '$lib/components/IconButton';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
@@ -12,7 +12,7 @@
import DocumentList from '../Document/DocumentList.svelte';
import { t } from '$lib/utils/functions/translations';
import type { LessonDocument } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
let { mode = MODES.view } = $props();
@@ -334,30 +334,18 @@
<!-- Navigation Controls -->
{#if !isLoading && !error}
<div class="flex items-center space-x-1">
<IconButton
onClick={prevPage}
disabled={pageNum <= 1}
toolTipProps={{ title: 'Previous page (←)', hotkeys: ['ArrowLeft'] }}
>
<IconButton onclick={prevPage} disabled={pageNum <= 1} tooltip="Previous page (←)">
<ChevronLeftIcon size={16} />
</IconButton>
<IconButton
onClick={nextPage}
disabled={pageNum >= pageCount}
toolTipProps={{ title: 'Next page (→)', hotkeys: ['ArrowRight'] }}
>
<IconButton onclick={nextPage} disabled={pageNum >= pageCount} tooltip="Next page (→)">
<ChevronRightIcon size={16} />
</IconButton>
</div>
<!-- Zoom Controls -->
<div class="flex items-center space-x-1">
<IconButton
onClick={zoomOut}
disabled={scale <= 0.5}
toolTipProps={{ title: 'Zoom out (-)', hotkeys: ['-'] }}
>
<IconButton onclick={zoomOut} disabled={scale <= 0.5} tooltip="Zoom out (-)">
<ZoomOutIcon size={16} />
</IconButton>
@@ -365,11 +353,7 @@
{Math.round(scale * 100)}%
</span>
<IconButton
onClick={zoomIn}
disabled={scale >= 3.0}
toolTipProps={{ title: 'Zoom in (+)', hotkeys: ['+'] }}
>
<IconButton onclick={zoomIn} disabled={scale >= 3.0} tooltip="Zoom in (+)">
<ZoomInIcon size={16} />
</IconButton>
</div>
@@ -18,7 +18,7 @@
import type { Content } from '@cio/ui/custom/editor';
import { course } from '$lib/components/Course/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { LessonPage } from '$lib/utils/types';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import {
@@ -30,23 +30,23 @@
lessonVideoUpload,
lessonDocUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { lessonFallbackNote, t } from '$lib/utils/functions/translations';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { Button } from '@cio/ui/base/button';
import { Chip } from '@cio/ui/custom/chip';
import Loader from './Loader.svelte';
import Box from '$lib/components/Box/index.svelte';
import Comments from './components/Comments.svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextField from '$lib/components/Form/TextField.svelte';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import ComponentNote from './components/ComponentNote.svelte';
import ComponentSlide from './components/ComponentSlide.svelte';
import ComponentVideo from './components/ComponentVideo.svelte';
import TabContent from '$lib/components/TabContent/index.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { TextEditor } from '$features/ui';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import ComponentDocument from './components/ComponentDocument.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
@@ -106,7 +106,7 @@
});
});
const componentsToRender = $derived(getComponentOrder(tabs));
let currentTab = $derived(tabs[0].value);
let currentTab = $state<string>('');
function onEditorChange(content: Content) {
console.log('content updated:', content);
@@ -124,11 +124,11 @@
$isLessonDirty = true;
}
const onChange = (tab) => {
return () => {
currentTab = tab;
};
};
$effect(() => {
if (tabs.length > 0 && !currentTab) {
currentTab = String(tabs[0].value);
}
});
const getValue = (label: string) => {
const tabValue = tabs.find((tab) => tab.label === label)?.value;
@@ -353,23 +353,33 @@
);
</script>
<Modal
{onClose}
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.video.add_video.title')}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<AddVideoToLesson {lessonId} />
</Modal>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
<AddVideoToLesson {lessonId} />
</Dialog.Content>
</Dialog.Root>
<Modal
onClose={onDocumentClose}
<Dialog.Root
bind:open={$lessonDocUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
onOpenChange={(isOpen) => {
if (!isOpen) onDocumentClose();
}}
>
<AddDocumentToLesson />
</Modal>
<Dialog.Content class="max-w-4/5 h-[80%] w-[90%] md:h-[566px]">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
</Dialog.Header>
<AddDocumentToLesson />
</Dialog.Content>
</Dialog.Root>
<HtmlRender className="m-auto text-center">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
@@ -380,151 +390,156 @@
{#if $lesson.isFetching}
<Loader />
{:else if mode === MODES.edit}
<Tabs {tabs} {currentTab} {onChange}>
{#snippet content()}
<slot:fragment>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.note.title')} index={currentTab}>
<div class="flex justify-end gap-1">
<!-- Update this when ai-sdk is updated -->
<div class="hidden flex-row-reverse">
<Popover.Root>
<Popover.Trigger>
<PrimaryButton
className="flex items-center relative"
onClick={() => {
openPopover = !openPopover;
}}
isLoading={$isLoading}
isDisabled={$isLoading}
variant={VARIANTS.OUTLINED}
disableScale
>
<WandSparklesIcon size={16} />
AI
</PrimaryButton>
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<div class="p-2">
<button class={aiButtonClass} onclick={() => callAI('outline')}>
<ListChecksIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
</button>
<button class={aiButtonClass} onclick={() => callAI('note')}>
<NotepadTextIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
</button>
<button class={aiButtonClass} onclick={() => callAI('activities')}>
<ListTodoIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
</button>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="mt-5 h-[60vh]">
<TextEditor
content={_editorValue}
onChange={(content) => onEditorChange(content)}
onReady={() =>
// editor
{
// editorInstance = editor;
// editorWindowRef = editor.view.dom.ownerDocument.defaultView;
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
<UnderlineTabs.Trigger value={String(tab.value)}>
{#if tab.icon}
<tab.icon size={16} />
{/if}
{$t(tab.label)}
{#if typeof tab.badgeValue === 'number' && tab.badgeValue > 0}
<Chip value={`${tab.badgeValue}`} className="ml-1" />
{/if}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.note.title') || '')}>
<div class="flex justify-end gap-1">
<!-- Update this when ai-sdk is updated -->
<div class="hidden flex-row-reverse">
<Popover.Root>
<Popover.Trigger>
<Button
variant="outline"
onclick={() => {
openPopover = !openPopover;
}}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
</TabContent>
loading={$isLoading}
disabled={$isLoading}
>
<WandSparklesIcon size={16} />
AI
</Button>
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<div class="p-2">
<button class={aiButtonClass} onclick={() => callAI('outline')}>
<ListChecksIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
</button>
<button class={aiButtonClass} onclick={() => callAI('note')}>
<NotepadTextIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
</button>
<button class={aiButtonClass} onclick={() => callAI('activities')}>
<ListTodoIcon size={16} />
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
</button>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.slide.title')} index={currentTab}>
{#if mode === MODES.edit}
<TextField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
bind:value={$lesson.materials.slide_url}
onInputChange={() => ($isLessonDirty = true)}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{/if}
</TabContent>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.video.title')} index={currentTab}>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.button')}
onClick={openAddVideoModal}
className="mb-2"
/>
{#if $lesson.materials.videos.length}
<div class="flex h-full w-full flex-col items-start">
{#each $lesson.materials.videos as video, index}
{#if mode === MODES.edit}
<div class="ml-auto">
<IconButton value="delete-video" contained={true} onClick={() => deleteLessonVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="flex h-full w-full flex-col gap-2 overflow-hidden">
{#key video.link}
<div class="mb-5">
{#if video.type === 'youtube'}
<iframe
width="100%"
height="569"
class="iframe"
src={formatYoutubeVideo(video.link, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.type === 'generic'}
<iframe
width="100%"
height="569"
class="iframe"
src={video.link}
title="Embeded Video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.metadata?.svid}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src="https://muse.ai/embed/{video.metadata
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
></iframe>
</div>
{:else}
<video
bind:this={player}
class="plyr-video-trigger"
style="max-height: 569px;"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
<div class="mt-5 h-[60vh]">
<TextEditor
content={_editorValue}
onChange={(content) => onEditorChange(content)}
onReady={() =>
// editor
{
// editorInstance = editor;
// editorWindowRef = editor.view.dom.ownerDocument.defaultView;
}}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.slide.title') || '')}>
{#if mode === MODES.edit}
<InputField
label={$t('course.navItem.lessons.materials.tabs.slide.slide_link')}
bind:value={$lesson.materials.slide_url}
onInputChange={() => ($isLessonDirty = true)}
helperMessage={$t('course.navItem.lessons.materials.tabs.slide.helper_message')}
/>
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.video.title') || '')}>
<Button onclick={openAddVideoModal} class="mb-2">
{$t('course.navItem.lessons.materials.tabs.video.button')}
</Button>
{#if $lesson.materials.videos.length}
<div class="flex h-full w-full flex-col items-start">
{#each $lesson.materials.videos as video, index}
{#if mode === MODES.edit}
<div class="ml-auto">
<IconButton onclick={() => deleteLessonVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="flex h-full w-full flex-col gap-2 overflow-hidden">
{#key video.link}
<div class="mb-5">
{#if video.type === 'youtube'}
<iframe
width="100%"
height="569"
class="iframe"
src={formatYoutubeVideo(video.link, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.type === 'generic'}
<iframe
width="100%"
height="569"
class="iframe"
src={video.link}
title="Embeded Video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
{:else if video.metadata?.svid}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src="https://muse.ai/embed/{video.metadata
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
></iframe>
</div>
{/key}
{:else}
<video
bind:this={player}
class="plyr-video-trigger"
style="max-height: 569px;"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/each}
{/key}
</div>
{/if}
</TabContent>
<TabContent value={getValue('course.navItem.lessons.materials.tabs.document.title')} index={currentTab}>
<ComponentDocument {mode} />
</TabContent>
</slot:fragment>
{/snippet}
</Tabs>
{/each}
</div>
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.document.title') || '')}>
<ComponentDocument {mode} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
{:else if !isMaterialsEmpty($lesson.materials, $lessonByTranslation[lessonId])}
{#key lessonId}
<div class="mb-20 flex w-full flex-col gap-6" in:fade={{ delay: 500 }} out:fade>
@@ -534,29 +549,28 @@
{#if $currentOrg.customization?.apps?.comments}
<hr class="my-5" />
{$currentOrg.customization}
<Comments {lessonId} />
{/if}
</div>
{/key}
{:else}
<Box className="text-center">
<img src="/no-video.svg" alt="Video not found" />
<h3 class="py-2 text-xl font-normal dark:text-white">
{$t('course.navItem.lessons.materials.body_heading')}
</h3>
<Empty
title={$t('course.navItem.lessons.materials.body_heading')}
description={$t('course.navItem.lessons.materials.body_content') +
' ' +
$t('course.navItem.lessons.materials.get_started') +
' ' +
$t('course.navItem.lessons.materials.button') +
'.'}
icon={VideoIcon}
variant="page"
class="text-center"
>
{#if !isStudent}
<p class="py-2 text-center text-sm font-normal">
{$t('course.navItem.lessons.materials.body_content')}
<strong>{$t('course.navItem.lessons.materials.get_started')}</strong>
{$t('course.navItem.lessons.materials.button')}.
</p>
<PrimaryButton
label={$t('course.navItem.lessons.materials.get_started')}
className="rounded-md"
onClick={toggleMode}
/>
<Button onclick={toggleMode}>
{$t('course.navItem.lessons.materials.get_started')}
</Button>
{/if}
</Box>
</Empty>
{/if}
@@ -1,9 +1,8 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
// import Select from '$lib/components/Form/Select.svelte';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import {
lessons,
lessonSections,
@@ -11,7 +10,7 @@
handleSaveLessonSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from './store';
import { t } from '$lib/utils/functions/translations';
@@ -107,35 +106,39 @@
}
</script>
<Modal
onClose={handleClose}
<Dialog.Root
bind:open={$handleAddLessonWidget.open}
width="w-[80%] md:w-[65%]"
maxWidth="max-w-2xl"
containerClass="overflow-hidden"
modalHeading={$t(
`course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}`
)}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<form
onsubmit={preventDefault(handleSave)}
class="relative m-auto mb-2 flex flex-wrap items-center px-2 py-2 md:mb-4 md:px-5 md:py-3 dark:bg-neutral-800"
>
<div class="w-full">
<TextField
label={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'lesson_section_title' : 'lesson_title'
}`
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{$t(
`course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}`
)}
bind:value={lesson.title}
autoFocus={true}
className="flex-1 min-w-lg max-w-lg"
isRequired={true}
errorMessage={errors.title}
/>
{#if $course.type == COURSE_TYPE.LIVE_CLASS}
<!-- <div
</Dialog.Title>
</Dialog.Header>
<form
onsubmit={preventDefault(handleSave)}
class="relative mb-2 flex w-full flex-wrap items-center px-2 py-2 md:mb-4 md:px-5 md:py-3"
>
<div class="w-full">
<InputField
label={$t(
`course.navItem.lessons.add_lesson.${
$handleAddLessonWidget.isSection ? 'lesson_section_title' : 'lesson_title'
}`
)}
bind:value={lesson.title}
autoFocus={true}
className="w-full"
isRequired={true}
errorMessage={errors.title}
/>
{#if $course.type == COURSE_TYPE.LIVE_CLASS}
<!-- <div
class="flex items-start justify-evenly gap-1 flex-col lg:flex-row lg:items-center mt-2 w-4/5"
>
<div class="lg:mb-0">
@@ -156,14 +159,17 @@
</div>
<div class="flex items-center mb-3 lg:mb-0">
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
<InputField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div> -->
{/if}
</div>
</form>
{/if}
</div>
</form>
<div class="flex flex-row-reverse">
<PrimaryButton label={$t('course.navItem.lessons.add_lesson.save')} onClick={handleSave} />
</div>
</Modal>
<div class="flex flex-row-reverse">
<Button onclick={handleSave}>
{$t('course.navItem.lessons.add_lesson.save')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -12,7 +12,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js';
import type { TLocale } from '@cio/db/types';
import type { Writable } from 'svelte/store';
import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import { writable } from 'svelte/store';
export const lessonVideoUpload = writable({
@@ -1,13 +0,0 @@
export const NAV_IDS = {
NEWS_FEED: 'NEWS_FEED',
LESSONS: 'LESSONS',
ATTENDANCE: 'ATTENDANCE',
SUBMISSIONS: 'SUBMISSIONS',
MARKS: 'MARKS',
CERTIFICATES: 'CERTIFICATES',
LANDING_PAGE: 'LANDING_PAGE',
PEOPLE: 'PEOPLE',
ANALYTICS: 'ANALYTICS',
SETTINGS: 'SETTINGS',
SECTION: 'SECTION'
};
@@ -1,405 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import LockIcon from '@lucide/svelte/icons/lock';
import * as Collapsible from '@cio/ui/base/collapsible';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import UsersIcon from '@lucide/svelte/icons/users';
import AwardIcon from '@lucide/svelte/icons/award';
import PanelTop from '@lucide/svelte/icons/panel-top';
import SettingsIcon from '@lucide/svelte/icons/settings';
import MegaphoneIcon from '@lucide/svelte/icons/megaphone';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import BookCheckIcon from '@lucide/svelte/icons/book-check';
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
import SquareLibrary from '@lucide/svelte/icons/square-library';
import BookOpenCheck from '@lucide/svelte/icons/book-open-check';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { NAV_IDS } from './constants';
import { profile } from '$lib/utils/store/user';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { handleAddLessonWidget } from '../Lesson/store';
import { getIsLessonComplete } from '../Lesson/functions';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import { lessons, lessonSections } from '../Lesson/store/lessons';
import { getNavItemRoute, getLessonsRoute, getLectureNo } from '$lib/components/Course/function';
import TextChip from '$lib/components/Chip/Text.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
interface Props {
path: string;
isStudent?: boolean;
}
let { path, isStudent = false }: Props = $props();
const navItems = $derived(
[
{
id: NAV_IDS.NEWS_FEED,
title: $t('course.navItems.nav_news_feed'),
url: getNavItemRoute($course.id),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id),
show() {
return isStudent ? $currentOrg.customization?.['course']?.['newsfeed'] : true;
},
icon: getNavIcon(NAV_IDS.NEWS_FEED)
},
{
id: NAV_IDS.LESSONS,
title: $t('course.navItems.nav_content'),
url: getLessonsRoute($course.id),
isActive:
(path || page.url.pathname).includes('/lessons') ||
(path || page.url.pathname) === getLessonsRoute($course.id),
isLesson: true,
icon: getNavIcon(NAV_IDS.LESSONS),
items:
$course.version === COURSE_VERSION.V1
? ($lessons || []).map((lesson, index) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
index,
isV1: true
}))
: $lessonSections.flatMap((section) =>
section.lessons.map((lesson) => ({
title: lesson.title,
url: getLessonsRoute($course.id, lesson.id),
lesson,
sectionTitle: section.title,
isV1: false
}))
)
},
{
id: NAV_IDS.ANALYTICS,
title: $t('course.navItems.nav_analytics'),
url: getNavItemRoute($course.id, 'analytics'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'analytics'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.ANALYTICS)
},
{
id: NAV_IDS.ATTENDANCE,
title: $t('course.navItems.nav_attendance'),
url: getNavItemRoute($course.id, 'attendance'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'attendance'),
show() {
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
},
icon: getNavIcon(NAV_IDS.ATTENDANCE)
},
{
id: NAV_IDS.SUBMISSIONS,
title: $t('course.navItems.nav_submissions'),
url: getNavItemRoute($course.id, 'submissions'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'submissions'),
show() {
if (isStudent) return false;
return true;
},
icon: getNavIcon(NAV_IDS.SUBMISSIONS)
},
{
id: NAV_IDS.MARKS,
title: $t('course.navItems.nav_marks'),
url: getNavItemRoute($course.id, 'marks'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'marks'),
show() {
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
return isStudent ? ($currentOrg.customization?.['course']?.['grading'] ?? false) : true;
}
return false;
},
icon: getNavIcon(NAV_IDS.MARKS)
},
{
id: NAV_IDS.CERTIFICATES,
title: $t('course.navItems.nav_certificates'),
url: getNavItemRoute($course.id, 'certificates'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'certificates'),
show() {
if (isStudent && $isFreePlan) {
return false;
}
return true;
},
icon: getNavIcon(NAV_IDS.CERTIFICATES)
},
{
id: NAV_IDS.LANDING_PAGE,
title: $t('course.navItems.nav_landing_page'),
url: getNavItemRoute($course.id, 'landingpage'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'landingpage'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.LANDING_PAGE)
},
{
id: NAV_IDS.PEOPLE,
title: $t('course.navItems.nav_people'),
url: getNavItemRoute($course.id, 'people'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'people'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.PEOPLE)
},
{
id: NAV_IDS.SETTINGS,
title: $t('course.navItems.nav_settings'),
url: getNavItemRoute($course.id, 'settings'),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'settings'),
show() {
return !isStudent;
},
icon: getNavIcon(NAV_IDS.SETTINGS)
}
].filter((item) => !item.show || item.show())
);
function addLesson(isSection: boolean, id: string) {
goto('/courses/' + $course.id + '/lessons');
$handleAddLessonWidget.open = true;
if ($course.version === COURSE_VERSION.V2) {
$handleAddLessonWidget.isSection = isSection ? false : true;
if (isSection) {
$handleAddLessonWidget.id = id;
}
}
}
function getNavIcon(id: string) {
if (!id) return null;
if (id === NAV_IDS.SECTION) {
return TableOfContentsIcon;
} else if (id === NAV_IDS.NEWS_FEED) {
return MegaphoneIcon;
} else if (id === NAV_IDS.LESSONS) {
return ScrollTextIcon;
} else if (id === NAV_IDS.ATTENDANCE) {
return BookOpenCheck;
} else if (id === NAV_IDS.SUBMISSIONS) {
return SquareLibrary;
} else if (id === NAV_IDS.MARKS) {
return BookCheckIcon;
} else if (id === NAV_IDS.PEOPLE) {
return UsersIcon;
} else if (id === NAV_IDS.ANALYTICS) {
return ChartLineIcon;
} else if (id === NAV_IDS.LANDING_PAGE) {
return PanelTop;
} else if (id === NAV_IDS.CERTIFICATES) {
return AwardIcon;
} else if (id === NAV_IDS.SETTINGS) {
return SettingsIcon;
}
return null;
}
function toggleSidebarOnMobile() {}
</script>
<Sidebar.Provider class="flex w-fit items-start gap-2">
<Sidebar.Root collapsible="icon">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Course Navigation</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each navItems as item (item.id)}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if item.isLesson && item.items}
<Collapsible.Trigger>
{#snippet child({ props })}
<a href={item.url}>
<Sidebar.MenuButton
{...props}
tooltipContent={item.title}
class="flex w-full items-center gap-4 py-2 pl-1.5"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(false, item.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuButton>
</a>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#if $course.version === COURSE_VERSION.V1}
{#each item.items as lessonItem}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={(path || page.url.pathname).includes(lessonItem.lesson.id)}
>
{#snippet child({ props })}
<a
href={isStudent && !lessonItem.lesson.is_unlocked
? page.url.pathname
: lessonItem.url}
onclick={toggleSidebarOnMobile}
aria-disabled={!lessonItem.lesson.is_unlocked}
title={lessonItem.lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lessonItem.lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
{#if lessonItem.isV1 && 'index' in lessonItem}
<TextChip
value={getLectureNo(lessonItem.index + 1)}
className="bg-primary-200 text-primary-600 text-xs"
size="sm"
shape="rounded-full"
/>
{/if}
<span class="flex-1 truncate">{lessonItem.lesson.title}</span>
{#if !lessonItem.lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lessonItem.lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
{:else}
{#each $lessonSections as section}
<Collapsible.Root open={true} class="group/section">
{#snippet child({ props })}
<Sidebar.MenuSubItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuSubButton
{...props}
class="flex w-full items-center gap-2 font-medium"
>
{@const Icon = getNavIcon(NAV_IDS.SECTION)}
<Icon size={14} />
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1 hover:bg-gray-200"
onclick={() => addLesson(true, section.id)}
/>
<ChevronRightIcon
size={20}
class="rounded-full p-1 transition-transform duration-200 hover:bg-gray-200 group-data-[state=open]/collapsible:rotate-90"
/>
</div>
</Sidebar.MenuSubButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub class="ml-2">
{#each section.lessons as lesson}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={(path || page.url.pathname).includes(lesson.id)}
>
{#snippet child({ props })}
<a
href={isStudent && !lesson.is_unlocked
? page.url.pathname
: getLessonsRoute($course.id, lesson.id)}
onclick={toggleSidebarOnMobile}
aria-disabled={!lesson.is_unlocked}
title={lesson.title}
class="flex w-full items-center gap-2 {isStudent && !lesson.is_unlocked
? 'cursor-not-allowed opacity-50'
: ''}"
{...props}
>
<span class="flex-1 truncate">{lesson.title}</span>
{#if !lesson.is_unlocked}
<LockIcon size={16} class="shrink-0" />
{:else if getIsLessonComplete(lesson.lesson_completion, $profile.id)}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuSubItem>
{/snippet}
</Collapsible.Root>
{/each}
{/if}
</Sidebar.MenuSub>
</Collapsible.Content>
{:else}
<a href={item.url} onclick={toggleSidebarOnMobile}>
<Sidebar.MenuButton
tooltipContent={item.title}
class="flex w-full cursor-pointer items-center gap-4 px-1.5 py-2 {item.isActive
? 'ui:bg-accent ui:text-accent-foreground'
: ''}"
>
{@const Icon = item.icon}
<Icon size={16} class="nav-icon group-hover:animate-{item.id}" />
{item.title}
</Sidebar.MenuButton>
</a>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<Sidebar.Trigger />
</Sidebar.Inset>
</Sidebar.Provider>
<style>
a {
text-decoration: none;
}
</style>
@@ -1,8 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
interface Props {
openDeleteModal?: boolean;
@@ -17,18 +15,27 @@
}
</script>
<Modal onClose={() => (openDeleteModal = false)} bind:open={openDeleteModal} width="w-96" modalHeading="Delete Feed">
<div>
<Dialog.Root
bind:open={openDeleteModal}
>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>Delete Feed</Dialog.Title>
</Dialog.Header>
<div>
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No"
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton className="px-6 py-3" variant={VARIANTS.OUTLINED} label="Yes" onClick={handleDelete} />
<Button
variant="outline"
onclick={() => (openDeleteModal = false)}
>
No
</Button>
<Button variant="outline" onclick={handleDelete}>
Yes
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -1,17 +1,16 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { Feed, Author } from '$lib/utils/types/feed';
import { createNewFeed } from '$lib/utils/services/newsfeed';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { createNewsfeedValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import Modal from '$lib/components/Modal/index.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextEditor } from '$features/ui';
interface Props {
author?: Author | any;
@@ -113,16 +112,21 @@
};
</script>
<Modal
onClose={resetEditor}
<Dialog.Root
bind:open={$isNewFeedModal.open}
width="w-4/5"
maxWidth="max-w-lg"
modalHeading={edit === true
? $t('course.navItem.news_feed.heading_button.edit_post')
: $t('course.navItem.news_feed.heading_button.make_a_post')}
onOpenChange={(isOpen) => {
if (!isOpen) resetEditor();
}}
>
<section class="w-2/ flex h-full flex-col rounded-xl pb-3">
<Dialog.Content class="w-4/5 max-w-lg">
<Dialog.Header>
<Dialog.Title>
{edit === true
? $t('course.navItem.news_feed.heading_button.edit_post')
: $t('course.navItem.news_feed.heading_button.make_a_post')}
</Dialog.Title>
</Dialog.Header>
<section class="w-2/ flex h-full flex-col rounded-xl pb-3">
<TextEditor
content={newPost}
onChange={(text) => {
@@ -136,13 +140,17 @@
{/if}
<div class="flex items-center justify-end py-2">
<div class="flex gap-2">
<PrimaryButton
label={$t('course.navItem.news_feed.heading_button.cancel')}
variant={VARIANTS.OUTLINED}
onClick={resetEditor}
/>
<PrimaryButton {isLoading} label={$t('course.navItem.news_feed.heading_button.post')} onClick={onPost} />
<Button
variant="outline"
onclick={resetEditor}
>
{$t('course.navItem.news_feed.heading_button.cancel')}
</Button>
<Button loading={isLoading} onclick={onPost}>
{$t('course.navItem.news_feed.heading_button.post')}
</Button>
</div>
</div>
</section>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -14,10 +14,10 @@
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import Chip from '$lib/components/Chip/index.svelte';
import { Chip } from '@cio/ui/custom/chip';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { RoleBasedSecurity } from '$features/ui';
interface Props {
feed: Feed;
@@ -102,7 +102,7 @@
const usersReacted = feed.reaction[reactionType] || [];
return usersReacted.includes(author.id)
? 'bg-primary-200 border-primary-600 pl-2'
? 'bg-primary-200 ui:border-primary pl-2'
: 'bg-gray-200 border-gray-600 pl-2';
};
@@ -1,8 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { deleteMemberModal } from './store';
@@ -14,32 +12,32 @@
}
</script>
<Modal
onClose={() => ($deleteMemberModal.open = false)}
<Dialog.Root
bind:open={$deleteMemberModal.open}
width="w-96"
containerClass="pt-3"
headerClass="py-2 px-5"
modalHeading={$t('course.navItem.people.delete_confirmation.title')}
>
<div>
<Dialog.Content class="w-96 pt-3">
<Dialog.Header class="py-2 px-5">
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.people.delete_confirmation.no')}
onClick={() => ($deleteMemberModal.open = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.people.delete_confirmation.yes')}
onClick={handleDelete}
/>
<Button
variant="outline"
onclick={() => ($deleteMemberModal.open = false)}
>
{$t('course.navItem.people.delete_confirmation.no')}
</Button>
<Button
variant="outline"
onclick={handleDelete}
>
{$t('course.navItem.people.delete_confirmation.yes')}
</Button>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
@@ -13,9 +13,9 @@
import { qrInviteNodeStore } from './store';
import { ROLE } from '@cio/utils/constants';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { preventDefault } from '$lib/utils/functions/svelte';
import { course, setCourse } from '$lib/components/Course/store';
@@ -25,8 +25,8 @@
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import ShareQrImage from './ShareQRImage.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
interface Tutor {
id: number;
@@ -176,112 +176,115 @@
});
</script>
<Modal
onClose={() => closeModal()}
<Dialog.Root
open={addPeopleParm === 'true'}
width="w-4/5 md:w-2/5"
maxWidth="max-w-lg"
modalHeading={$t('course.navItem.people.invite_modal.title')}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}
>
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
<div class="flex flex-wrap gap-1">
{#each selectedTutors as tutor}
<Badge variant="secondary" class="flex items-center gap-1">
{tutor.text}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
selectedIds = selectedIds.filter((id) => id !== tutor.id.toString());
}}
class="rounded-sm hover:bg-gray-200"
>
<XIcon size={14} />
</button>
</Badge>
{/each}
</div>
{:else}
{$t('course.navItem.people.invite_modal.select')}
{/if}
<Dialog.Content class="w-4/5 max-w-lg md:w-2/5">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
<div class="flex flex-wrap gap-1">
{#each selectedTutors as tutor}
<Badge variant="secondary" class="flex items-center gap-1">
{tutor.text}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
selectedIds = selectedIds.filter((id) => id !== tutor.id.toString());
}}
class="rounded-sm hover:bg-gray-200"
>
<XIcon size={14} />
</button>
</Badge>
{/each}
</div>
{:else}
{$t('course.navItem.people.invite_modal.select')}
{/if}
</div>
</Select.Trigger>
<Select.Content>
{#each tutors as tutor}
<Select.Item value={tutor.id.toString()}>
{tutor.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if orgApi.isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Loading...</span>
</div>
</Select.Trigger>
<Select.Content>
{#each tutors as tutor}
<Select.Item value={tutor.id.toString()}>
{tutor.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if orgApi.isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Loading...</span>
{:else}
<span class="mt-2 block text-sm">
{$t('course.navItem.people.invite_modal.to_add')}
<a href={`/org/${$currentOrg.siteName}/settings/teams`} class="ui:text-primary underline">
{$t('course.navItem.people.invite_modal.go_to')}
</a>
</span>
{/if}
</div>
<div class="mb-8 flex w-full items-center justify-between">
<div class="w-3/5">
<p class="mb-1 text-base font-semibold">
{$t('course.navItem.people.invite_modal.invite_students')}
</p>
<p class=" text-sm">{$t('course.navItem.people.invite_modal.via_link')}</p>
</div>
{:else}
<span class="mt-2 block text-sm">
{$t('course.navItem.people.invite_modal.to_add')}
<a href={`/org/${$currentOrg.siteName}/settings/teams`} class="text-primary-600 underline">
{$t('course.navItem.people.invite_modal.go_to')}
</a>
</span>
{/if}
</div>
<div class="mb-8 flex w-full items-center justify-between">
<div class="w-3/5">
<p class="mb-1 text-base font-semibold">
{$t('course.navItem.people.invite_modal.invite_students')}
</p>
<p class=" text-sm">{$t('course.navItem.people.invite_modal.via_link')}</p>
<Popover.Root>
<Popover.Trigger>
<button type="button" onclick={copyLink} class="text-primary-800 cursor-pointer capitalize underline">
{$t('course.navItem.people.invite_modal.copy_link')}
</button>
</Popover.Trigger>
<Popover.Content align="start" class="w-auto">
<div class="p-1 text-sm">{$t('course.navItem.people.invite_modal.success')}</div>
</Popover.Content>
</Popover.Root>
</div>
<Popover.Root>
<Popover.Trigger>
<button type="button" onclick={copyLink} class="text-primary-800 cursor-pointer capitalize underline">
{$t('course.navItem.people.invite_modal.copy_link')}
</button>
</Popover.Trigger>
<Popover.Content align="start" class="w-auto">
<div class="p-1 text-sm">{$t('course.navItem.people.invite_modal.success')}</div>
</Popover.Content>
</Popover.Root>
</div>
<div
class="flex w-full flex-col-reverse items-center justify-between gap-5 rounded-md border p-4 md:flex-row md:items-stretch"
>
<div class="flex flex-col items-center justify-between gap-3 md:items-start">
<span class="text-sm font-medium">
{$t('course.navItem.people.invite_modal.via_qr')}
</span>
<div
class="flex w-full flex-col-reverse items-center justify-between gap-5 rounded-md border p-4 md:flex-row md:items-stretch"
>
<div class="flex flex-col items-center justify-between gap-3 md:items-start">
<span class="text-sm font-medium">
{$t('course.navItem.people.invite_modal.via_qr')}
</span>
<Button variant="outline" loading={isLoadingQRDownload} onclick={handleQRDownload}>
{$t('course.navItem.people.invite_modal.download_qr')}
</Button>
</div>
<PrimaryButton
isLoading={isLoadingQRDownload}
onClick={handleQRDownload}
label={$t('course.navItem.people.invite_modal.download_qr')}
className="font-medium"
/>
<div class="w-full border-4 border-[#f7f7f7] p-1 md:w-28">
<img src={qrImage} alt="link qrcode" class="h-full w-full" />
</div>
</div>
<div class="w-full border-4 border-[#f7f7f7] p-1 md:w-28">
<img src={qrImage} alt="link qrcode" class="h-full w-full" />
<div class="w-160 absolute left-[-1000px]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
</div>
<div class="w-160 absolute left-[-1000px]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton className="px-6 py-3" label={$t('course.navItem.people.invite_modal.finish')} type="submit" />
</div>
</form>
</Modal>
<div class="mt-5 flex flex-row-reverse items-center">
<Button variant="secondary" type="submit">
{$t('course.navItem.people.invite_modal.finish')}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,5 +1,5 @@
<script lang="ts">
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import type { Course } from '$lib/utils/types';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
@@ -28,7 +28,7 @@
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
<img src={qrImage} alt="qrcode" />
<div class="pb-4 pt-1">
<p class="text-primary-600 mt-2 text-2xl">{course.title}</p>
<p class="ui:text-primary mt-2 text-2xl">{course.title}</p>
<p class="mt-1 text-lg font-semibold text-black">{currentOrg.name}</p>
</div>
</div>
@@ -1,46 +0,0 @@
<script>
import { flip } from 'svelte/animate';
import { settings } from './store';
import { dndzone } from 'svelte-dnd-action';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import { t } from '$lib/utils/functions/translations';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const flipDurationMs = 300;
function handleDndConsider(e) {
$settings.tabs = e.detail.items;
}
function handleDndFinalize(e) {
$settings.tabs = e.detail.items;
dispatch('change');
}
</script>
<section
use:dndzone={{
items: $settings.tabs,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
}
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="flex w-fit gap-1 p-1 md:gap-3"
>
{#each $settings.tabs as item (item.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="flex items-center justify-start gap-1 rounded-md bg-slate-100 p-2 text-center text-xs md:gap-2 md:text-base dark:bg-slate-700 dark:text-white"
>
<GripVerticalIcon size={16} />
{$t(item.name)}
</div>
{/each}
</section>
@@ -1,480 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import * as RadioGroup from '@cio/ui/base/radio-group';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import DragAndDrop from './DragAndDrop.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import { settings } from './store';
import Copy from '@lucide/svelte/icons/copy';
import type { Course } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
import { lessons } from '../Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$lib/components/Snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import { deleteCourse, updateCourse } from '$lib/utils/services/courses';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { currentOrg, currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
let isSaving = $state(false);
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
title: string | undefined;
description: string | undefined;
} = $state({
title: undefined,
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
function widgetControl() {
$handleOpenWidget.open = !$handleOpenWidget.open;
}
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
const downloadCourse = async () => {
alert('Coming soon');
return;
isLoading = true;
try {
const lessonsList = $lessons.map((lesson) => ({
lessonTitle: lesson.title,
lessonNumber: getLessonOrder(lesson.id),
lessonNote: lesson.note,
slideUrl: lesson.slide_url || '',
video: lesson.videos || ''
}));
const response = await fetch('/downloadCourse', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
courseTitle: $course.title,
orgName: $currentOrg.name,
orgTheme: $currentOrg.theme || '',
lessons: lessonsList
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const data = await response.blob();
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = $course.title + ' - ' + 'Course ';
a.href = fileURL;
a.click();
a.remove();
snackbar.success('snackbar.course_settings.success.download');
} catch (error) {
snackbar.error('snackbar.course_settings.error.not_right');
}
isLoading = false;
};
const deleteBannerImage = () => {
$settings.logo = '';
hasUnsavedChanges = true;
};
async function handleDeleteCourse() {
isDeleting = true;
try {
await deleteCourse($course.id);
goto($currentOrgPath + '/courses');
} catch (error) {
snackbar.error('snackbar.course_settings.error.went_wrong');
}
isDeleting = false;
}
const handleSave = async () => {
if (!$settings.course_title) {
errors.title = $t('snackbar.course_settings.error.title');
return;
}
if (!$settings.course_description) {
errors.description = $t('snackbar.course_settings.error.description');
return;
}
isSaving = true;
try {
const updatedCourse = {
title: $settings.course_title,
description: $settings.course_description,
type: $settings.type,
logo: $settings.logo,
is_published: $settings.is_published,
metadata: {
...(isObject($course.metadata) ? $course.metadata : {}),
lessonTabsOrder: $settings.tabs,
grading: $settings.grading,
lessonDownload: $settings.lesson_download,
allowNewStudent: $settings.allow_new_students
},
slug: $course.slug
};
await updateCourse($course.id, avatar, updatedCourse);
$course = {
...$course,
...updatedCourse
};
snackbar.success('snackbar.course_settings.success.saved');
hasUnsavedChanges = false;
} catch (error) {
snackbar.error();
}
isSaving = false;
};
const generateNewCourseLink = () => {
$course.slug = generateSlug($course.title);
hasUnsavedChanges = true;
};
async function setDefault(course: Course) {
if (!course || !Object.keys(course).length) return;
untrack(() => {
settings.set({
course_title: course.title,
type: course.type,
course_description: course.description,
logo: course.logo || '',
tabs: course.metadata.lessonTabsOrder || $settings.tabs,
grading: !!course.metadata.grading,
lesson_download: !!course.metadata.lessonDownload,
is_published: !!course.is_published,
allow_new_students: course.metadata.allowNewStudent
});
});
}
$effect(() => {
setDefault($course);
});
let courseLink = $derived(`${$currentOrgDomain}/course/${$course.slug}`);
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.cover_image')}</SectionTitle>
<p>
{$t('course.navItem.settings.optional_image')}
</p>
<span class="flex items-center justify-start">
<PrimaryButton
variant={VARIANTS.CONTAINED_DARK}
label={$t('course.navItem.settings.replace')}
className="mr-2"
onClick={widgetControl}
/>
<PrimaryButton variant={VARIANTS.OUTLINED} label={$t('ai.reset')} onClick={deleteBannerImage} />
</span>
{#if $handleOpenWidget.open}
<UploadWidget
bind:imageURL={$settings.logo}
onchange={() => {
hasUnsavedChanges = true;
}}
/>
{/if}
</Column>
<Column sm={8} md={8} lg={6}>
<div class="relative z-[20] w-fit">
<img
style="min-width:280px; min-height:200px"
alt="About us"
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
/>
</div>
</Column>
</Row>
<Row id="share" class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.course_details')}</SectionTitle>
</Column>
<Column sm={8} md={8} lg={8}>
<TextField
label={$t('course.navItem.settings.course_title')}
placeholder="Write the course title here"
className="w-full mb-5"
isRequired
bind:value={$settings.course_title}
errorMessage={errors?.title}
onInputChange={() => {
hasUnsavedChanges = true;
}}
/>
<TextArea
label={$t('course.navItem.settings.course_description')}
placeholder={$t('course.navItem.settings.placeholder')}
className="w-full mb-5"
isRequired
bind:value={$settings.course_description}
errorMessage={errors?.description}
onChange={() => {
hasUnsavedChanges = true;
}}
/>
<div id="share">
<p class="text-md mb-2 flex items-center gap-2">
{$t('course.navItem.settings.link')}
<IconButton contained={true} size="small" onClick={generateNewCourseLink}>
<RotateCcwIcon size={16} />
</IconButton>
<span class="grow"></span>
<IconButton contained={true} size="small" onClick={() => goto(courseLink)}>
<ArrowUpRightIcon size={16} />
</IconButton>
</p>
<div class="flex items-center justify-between rounded-md border p-3">
{#if $course.slug}
<p class="text-sm">{courseLink}</p>
<IconButton
contained={true}
size="small"
onClick={() => {
copyToClipboard(courseLink);
}}
>
<Copy size={16} />
</IconButton>
{:else}
<p class="text-sm">Setup landing page to get course link</p>
{/if}
</div>
</div>
</Column>
</Row>
<!-- <Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.grading')}</SectionTitle>
<p>{$t('course.navItem.settings.reports')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<Toggle size="sm" bind:toggled={$settings.grading}>
<span slot="labelA" style="color: gray">{$t('course.navItem.settings.disabled')}</span>
<span slot="labelB" style="color: gray">{$t('course.navItem.settings.enabled')}</span>
</Toggle>
</Column>
</Row> -->
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.order')}</SectionTitle>
<p>{$t('course.navItem.settings.drag')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<DragAndDrop
on:change={() => {
hasUnsavedChanges = true;
}}
/>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.lesson_download')}</SectionTitle>
<p>{$t('course.navItem.settings.available')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_lessons')}</UpgradeBanner>
{:else}
<div class="flex items-center space-x-2">
<Switch
id="lesson-download"
bind:checked={$settings.lesson_download}
onCheckedChange={() => {
hasUnsavedChanges = true;
}}
/>
<Label for="lesson-download" class="text-sm text-gray-500">
{$settings.lesson_download ? $t('course.navItem.settings.enabled') : $t('course.navItem.settings.disabled')}
</Label>
</div>
{/if}
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.course_download')}</SectionTitle>
<p>{$t('course.navItem.settings.course_avail')}</p>
</Column>
<Column sm={4} md={4} lg={4}>
{#if $isFreePlan}
<UpgradeBanner>{$t('upgrade.download_course')}</UpgradeBanner>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.settings.download')}
onClick={downloadCourse}
isDisabled={isLoading}
{isLoading}
/>
{/if}
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.type')}</SectionTitle>
<p>{$t('course.navItem.settings.course_type_desc')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<RadioGroup.Root
value={$settings.type}
onValueChange={(value) => {
$settings.type = value as COURSE_TYPE;
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.LIVE_CLASS} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={COURSE_TYPE.SELF_PACED} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
</RadioGroup.Root>
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.allow')}</SectionTitle>
<p>{$t('course.navItem.settings.access')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<div class="flex items-center space-x-2">
<Switch
id="allow-new-students"
bind:checked={$settings.allow_new_students}
onCheckedChange={() => {
hasUnsavedChanges = true;
}}
/>
<Label for="allow-new-students" class="text-sm text-gray-500">
{$settings.allow_new_students
? $t('course.navItem.settings.enabled')
: $t('course.navItem.settings.disabled')}
</Label>
</div>
</Column>
</Row>
<!-- Publish Course -->
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.publish')}</SectionTitle>
<p>{$t('course.navItem.settings.determines')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<div class="flex items-center space-x-2">
<Switch
id="is-published"
bind:checked={$settings.is_published}
onCheckedChange={(checked) => {
hasUnsavedChanges = true;
$settings.allow_new_students = checked;
if (!$course.slug) {
generateNewCourseLink();
}
}}
/>
<Label for="is-published" class="text-sm text-gray-500">
{$settings.is_published ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label>
</div>
</Column>
</Row>
<Row id="delete" class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.delete')}</SectionTitle>
<p>{$t('course.navItem.settings.delete_text')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<PrimaryButton
variant={VARIANTS.CONTAINED_DANGER}
label={$t('course.navItem.settings.delete')}
onClick={() => (openDeleteModal = true)}
isLoading={isDeleting}
isDisabled={isDeleting}
/>
</Column>
</Row>
<div class="flex w-full items-center justify-end p-5">
<PrimaryButton
label={$t('course.navItem.settings.save')}
isLoading={isSaving}
isDisabled={isSaving}
onClick={handleSave}
/>
</div>
</Grid>
@@ -1,19 +0,0 @@
import { COURSE_TYPE } from '$lib/utils/types';
import { writable } from 'svelte/store';
export const settings = writable({
logo: '',
course_title: '',
course_description: '',
grading: false,
type: COURSE_TYPE.LIVE_CLASS,
allow_new_students: false,
tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
lesson_download: false,
is_published: false
});
@@ -2,16 +2,12 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Spinner } from '@cio/ui/base/spinner';
import Navigation from '../Course/components/Navigation/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Backdrop, Confetti } from '$features/ui';
import { course, group, courseStore } from '../Course/store';
import Confetti from '../Confetti/index.svelte';
import { isMobile } from '$lib/utils/store/useMobile';
import { profile } from '$lib/utils/store/user';
import { globalStore } from '$lib/utils/store/app';
import Modal from '$lib/components/Modal/index.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { isOrgAdmin } from '$lib/utils/store/org';
import type { GroupPerson } from '$lib/utils/types';
@@ -28,10 +24,8 @@
let {
courseId,
path = '',
isExercisePage = false,
isFetching = $bindable(false),
containerClass = '',
onFetchingChange = (_v: boolean) => {},
children,
renderOnlyChildren = false
@@ -69,50 +63,38 @@
</Backdrop>
{/if}
<Modal open={!isPermitted} width="w-96" modalHeading={$t('course.not_permitted.header')}>
<div>
<p class="text-md text-center dark:text-white">
{$t('course.not_permitted.body')}
</p>
<Dialog.Root open={!isPermitted}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-md text-center dark:text-white">
{$t('course.not_permitted.body')}
</p>
<div class="mt-5 flex justify-center">
<PrimaryButton
className="px-6 py-3"
label={$t('course.not_permitted.button')}
onClick={() => {
goto('/org/*');
}}
/>
<div class="mt-5 flex justify-center">
<Button
onclick={() => {
goto('/org/*');
}}
>
{$t('course.not_permitted.button')}
</Button>
</div>
</div>
</div>
</Modal>
</Dialog.Content>
</Dialog.Root>
{#if renderOnlyChildren}
{@render children?.()}
{:else}
<div class="root">
<Navigation {path} isStudent={$globalStore.isStudent} />
<div class="rightBar {containerClass}" class:isMobile={$isMobile}>
{#if isExercisePage}
<Confetti />
{/if}
{#if isExercisePage}
<Confetti />
{/if}
<!-- Show only if permitted -->
{#if isPermitted}
{@render children?.()}
{/if}
</div>
</div>
<!-- Show only if permitted -->
{#if isPermitted}
{@render children?.()}
{/if}
{/if}
<style>
.root {
display: flex;
width: 100%;
}
.rightBar {
flex-grow: 1;
width: calc(100% - 360px);
}
</style>
@@ -1,71 +0,0 @@
<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 '$lib/components/UploadImage/index.svelte';
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>
@@ -1,42 +0,0 @@
<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>
@@ -1,42 +0,0 @@
<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 '$lib/components/TextEditor/index.svelte';
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>
@@ -1,42 +0,0 @@
<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 '$lib/components/TextEditor/index.svelte';
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>
@@ -1,47 +0,0 @@
<script lang="ts">
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { handleOpenWidget } from '../../store';
import { t } from '$lib/utils/functions/translations';
let { course = $bindable() } = $props();
function widgetControl() {
$handleOpenWidget.open = !$handleOpenWidget.open;
}
</script>
<TextField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.header_form.title')}
bind:value={course.title}
/>
<TextArea
label={$t('course.navItem.landing_page.editor.header_form.description')}
bind:value={course.description}
rows={6}
className="mt-5"
labelClassName="font-bold"
/>
<TextField
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>
<PrimaryButton
label={$t('course.navItem.landing_page.editor.header_form.replace')}
variant={VARIANTS.OUTLINED}
onClick={widgetControl}
/>
</div>
@@ -1,98 +0,0 @@
<script lang="ts">
import get from 'lodash/get';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.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>
<TextField
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}
/>
<TextField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.instructor_form.role')}
placeholder="e.g Software developer"
bind:value={role}
/>
<TextArea
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}
/>
<TextField
className="mt-5"
labelClassName="font-bold"
label={$t('course.navItem.landing_page.editor.instructor_form.total')}
type="number"
bind:value={courseNo}
/>
@@ -1,128 +0,0 @@
<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 TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
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>
<TextField
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)}
<TextField
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}
<TextField
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}
@@ -1,42 +0,0 @@
<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 '$lib/components/TextEditor/index.svelte';
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>
@@ -1,96 +0,0 @@
<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 '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
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} />
<TextField
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 -->
<TextArea
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 -->
<TextField
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}
onInputChange={(e) => {
review.rating = parseInt(e.target.value);
}}
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 contained={true} value="delete" onClick={deleteReviewData} size="large">
<TrashIcon size={16} />
</IconButton>
<IconButton contained={true} value="save" onClick={() => onExpand(review.id)} size="large">
<SaveIcon size={16} />
</IconButton>
</div>
</div>
@@ -1,131 +0,0 @@
<script lang="ts">
import get from 'lodash/get';
import z from 'zod';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { IconButton } from '$lib/components/IconButton';
import { isMobile } from '$lib/utils/store/useMobile';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ReviewFormEditor from './ReviewFormEditor.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 value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}>
<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 -->
<PrimaryButton
label={$t('course.navItem.landing_page.editor.reviews_form.add_reviews')}
variant={VARIANTS.CONTAINED}
onClick={addReviewForm}
className="w-5 rounded-md mt-8"
/>
</div>
</section>
@@ -1,291 +0,0 @@
<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 '$lib/components/IconButton';
import { CloseButton } from '$lib/components/Buttons/Close';
import HeaderForm from './HeaderForm.svelte';
import RequirementForm from './RequirementForm.svelte';
import DescriptionForm from './DescriptionForm.svelte';
import PricingForm from './PricingForm.svelte';
import GoalsForm from './GoalsForm.svelte';
import ReviewsForm from './ReviewsForm.svelte';
import CertificateForm from './CertificateForm.svelte';
import InstructorForm from './InstructorForm.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
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-[100%] md:absolute' : 'fixed z-[50] translate-x-0 md:relative'
}left-0 border-r-1 z-[50] h-full w-[90vw] min-w-[300px] max-w-[350px] border border-b-0 border-l-0 border-t-0 bg-gray-100 transition dark:bg-neutral-800`}
>
<div class="toggler absolute rounded-full shadow-lg">
<IconButton
value="toggle"
onClick={() => (show = !show)}
size={$isMobile ? 'large' : 'small'}
color="text-black"
toolTipProps={$isMobile
? undefined
: {
title: 'Toggle editor',
direction: 'right',
hotkeys: []
}}
>
{#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">
<PrimaryButton
label={$t('course.navItem.landing_page.editor.save')}
type="button"
className="mr-1"
variant={VARIANTS.OUTLINED}
onClick={handleSave}
isLoading={loading}
/>
<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>
@@ -1,19 +0,0 @@
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);
}
};
}
@@ -1,109 +0,0 @@
<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 Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
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>
<Modal onClose={() => (open = false)} bind:open width="w-96" modalHeading="Process course payment">
{#if step === STEPS.STEP_1}
<form onsubmit={preventDefault(onSubmit)}>
<TextField
label="Your Fullname"
bind:value={fields.fullname}
autoFocus={true}
placeholder="John Doe"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.fullname}
/>
<TextField
label="Your Email"
bind:value={fields.email}
placeholder="[email protected]"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.email}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton className="px-6 py-3" label={paymentLink ? 'Next' : 'Finish'} type="submit" />
</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}
</Modal>
@@ -1,270 +0,0 @@
<script lang="ts">
import get from 'lodash/get';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
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 './PaymentModal.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">
<PrimaryButton
label={isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
className="w-full sm:w-full h-[40px]"
onClick={handleJoinCourse}
isDisabled={!courseData.metadata.allowNewStudent}
/>
</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">
<PrimaryButton
label={isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
className="w-full sm:w-full py-3 mb-3"
onClick={handleJoinCourse}
isDisabled={!courseData.metadata.allowNewStudent}
/>
{#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>
@@ -1,50 +0,0 @@
<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}
<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>
@@ -1,41 +0,0 @@
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'
}
];
@@ -1,579 +0,0 @@
<script lang="ts">
import get from 'lodash/get';
import pluralize from 'pluralize';
import { page } from '$app/state';
import { fade } from 'svelte/transition';
import { onMount, onDestroy } from 'svelte';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '../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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
import { getExerciseCount, getLessonSections, getTotalLessons, filterNavItems } from './utils';
import Chip from '../Chip/index.svelte';
import Modal from '../Modal/index.svelte';
import ImageRenderer from '../Org/ImageRenderer.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/PricingSection.svelte';
import { PoweredBy } from '$lib/features/ui';
import { shortenName } from '$lib/utils/functions/string';
import SectionsDisplay from './components/SectionsDisplay.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { observeIntersection } from './components/IntersectionObserver';
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>
<PrimaryButton
label={$t('course.navItem.landing_page.start_course')}
className="px-6 py-5 mt-6 sm:w-fit hidden md:block"
onClick={() => {
if (editMode) return;
startCoursePayment = true;
}}
isDisabled={!allowNewStudent}
/>
{#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 -->
<nav
class="sticky top-0 flex items-center border-b border-gray-300 py-3 {!editMode &&
'lg:top-11'} bg-white dark:bg-neutral-800"
>
{#each navItems as navItem}
<a
href="{page.url.pathname}{navItem.key}"
class="{navItem.key === activeNav &&
'active text-primary-700'} z-0 mr-6 rounded-lg px-2 font-normal text-slate-700 hover:bg-gray-200 dark:text-white dark:hover:text-slate-900"
>
{$t(navItem.label)}
</a>
{/each}
</nav>
<!-- Sections - Requirement -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
<section
id="requirement"
transition:fade={{ delay: 250, duration: 300 }}
class="mt-8 border-b border-gray-300 pb-10"
>
<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>
</section>
{/if}
<!-- Sections - Course Description -->
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
<section
id="description"
transition:fade={{ delay: 250, duration: 300 }}
class="mt-8 border-b border-gray-300 pb-10"
>
<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>
</section>
{/if}
<!-- Sections - Goal -->
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
<section id="goals" transition:fade={{ delay: 250, duration: 300 }} class="mt-8 pb-10">
<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>
</section>
{/if}
<!-- Sections - Certificate -->
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
<section
id="certificate"
transition:fade={{ delay: 250, duration: 300 }}
class="mt-8 border-b border-gray-300 pb-10"
>
<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>
<ImageRenderer
src={certificate?.templateUrl}
alt="certificate template"
className="certificate-img max-h-[215px]"
/>
</section>
{/if}
<!-- Sections - Lessons -->
{#if courseData.version === COURSE_VERSION.V1}
<section id="lessons" class="mt-8 border-b border-gray-300 pb-10">
<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}
<div class="m-2 rounded border px-2 py-1">
<Chip value={getLectureNo(index + 1, '0')} className="bg-primary-100 text-primary-700 inline " />
<p class="ml-2 inline text-xs font-light dark:text-white">
{lesson.title}
</p>
</div>
{/each}
</div>
</section>
{:else if courseData.version === COURSE_VERSION.V2}
<section 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}
<SectionsDisplay
exerciseCount={getExerciseCount(section.lessons)}
lessonCount={section.lessons?.length}
lessons={section.lessons}
title={section.title}
/>
{/each}
</section>
{/if}
<!-- Sections - Reviews -->
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
<section id="reviews" transition:fade={{ delay: 250, duration: 300 }}>
<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}
{#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="text-primary-700 mt-2 font-normal underline"
onclick={() => toggleDescription(id)}>See More</button
>
{/if}
{#if expandDescription[id]}
<button
class="text-primary-700 mt-2 font-normal underline"
onclick={() => toggleDescription(id)}>See Less</button
>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{#if reviews.length > 4}
<PrimaryButton
label={$t('course.navItem.landing_page.see_all')}
className="w-3/12 p-4 mt-2"
variant={VARIANTS.OUTLINED}
onClick={() => ($reviewsModalStore.open = true)}
/>
{/if}
<!-- Reviews Modal -->
<Modal
onClose={() => ($reviewsModalStore.open = false)}
bind:open={$reviewsModalStore.open}
width="w-9/12"
modalHeading={$t('course.navItem.landing_page.reviews_modal.title')}
>
<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 -->
<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>
</Modal>
</section>
{/if}
<!-- Sections - Instructor -->
<section id="instructor" class="mt-8 pb-10">
<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.avatar_url || '/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>
</section>
</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>
@@ -1,65 +0,0 @@
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 let handleOpenWidget = writable({
open: false
});
@@ -1,45 +0,0 @@
import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
import type { Course, Review } from '$lib/utils/types';
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;
});
}
@@ -1,72 +0,0 @@
<script lang="ts">
interface Props {
options?: any;
classNames?: string;
isIcon?: boolean;
children?: import('svelte').Snippet;
}
let { options = [], classNames = 'relative', isIcon = false, children }: Props = $props();
let open = $state(false);
const notIconClass = 'border rounded-lg border-grey p-3 focus:outline-none focus:border-gray-400 focus:bg-gray-200';
function isOptionLast(index) {
return index === options.length - 1;
}
function onClick(option) {
return () => {
open = !open;
if (option && option.onClick) {
option.onClick();
}
};
}
function onBlur() {
// In case an option was clicked, let's wait for the on:click of that button before we close
setTimeout(() => (open = false), 100);
}
</script>
<div class="{classNames} z-10 {!options.length && 'hidden'}">
<button
class="flex flex-row items-center {!isIcon && notIconClass}"
onclick={(e) => {
e.preventDefault();
e.stopImmediatePropagation();
open = !open;
}}
onblur={onBlur}
>
{@render children?.()}
</button>
{#if open}
<div class="dropdown border-grey absolute mt-2 whitespace-nowrap rounded-lg border bg-white py-3 dark:bg-black">
{#each options as option, index}
<button
class="hover:bg-primary-100 block px-4 py-3 text-left {!isOptionLast(index) &&
'border-b'} w-full dark:text-white dark:hover:text-black"
onclick={onClick(option)}
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.dropdown {
box-shadow:
0px 5px 5px -3px rgb(0 0 0 / 20%),
0px 8px 10px 1px rgb(0 0 0 / 14%),
0px 3px 14px 2px rgb(0 0 0 / 12%);
right: 0px;
top: 20px;
left: unset;
}
</style>
@@ -1,5 +0,0 @@
<script lang="ts">
let { message } = $props();
</script>
<p class="text-sm text-red-500">{message}</p>
@@ -1,62 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import LinkIcon from '@lucide/svelte/icons/link';
interface Props {
id?: string;
title?: string;
titleClass?: string;
disableContainerPadding?: boolean;
rootClass?: string;
supportsLink?: boolean;
isExpanded?: boolean;
children?: import('svelte').Snippet;
}
let {
id = '',
title = '',
titleClass = '',
disableContainerPadding = false,
rootClass = '',
supportsLink = false,
isExpanded = $bindable(true),
children
}: Props = $props();
function handleClick(e) {
e.stopPropagation();
isExpanded = !isExpanded;
}
</script>
<div {id} class="w-full bg-white dark:bg-black {rootClass}">
<button
class="relative flex w-full items-center justify-between {!disableContainerPadding && 'p-5'}"
tabindex="0"
onclick={handleClick}
>
<p class="flex items-center dark:text-white {titleClass}">
{title}
{#if supportsLink}
<a class="ml-2" href="{page.url.pathname}#{id}" onclick={(e) => e.stopPropagation()}>
<LinkIcon size={16} />
</a>
{/if}
</p>
{#if isExpanded}
<ChevronUpIcon size={16} />
{:else}
<ChevronDownIcon size={16} />
{/if}
</button>
{#if isExpanded}
<div class="flex flex-col p-5">
{@render children?.()}
</div>
{/if}
</div>
@@ -1,17 +0,0 @@
<script>
</script>
<!-- markup (zero or more items) goes here -->
<div class="my-1">
<hr />
<div class="flex w-full justify-center">
<span class="py-3 text-sm text-gray-700"
>Created with love by
<a class="text-primary-700 underline" href="https://twitter.com/rotimi_best" target="_blank">Rotimi Best</a></span
>
</div>
</div>
<style>
/* your styles go here */
</style>
@@ -1,83 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
value: string | number | null;
placeholder?: string;
inputClassName?: string;
labelClassName?: string;
isDisabled?: boolean;
isRequired?: boolean;
bgColor?: string;
errorMessage?: string;
helperMessage?: string;
onInput?: any;
onKeyDown?: any;
onInputChange?: any;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
value,
placeholder = '',
inputClassName = '',
labelClassName = 'font-light',
isDisabled = false,
isRequired = false,
bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-800 dark:text-white',
errorMessage = '',
helperMessage = '',
onInput = (e) => {},
onKeyDown = (e) => {},
onInputChange = (e) => {},
onChange = () => {}
}: Props = $props();
let focusClass = $state('');
</script>
{#if label}
<p class="m-0 text-left text-sm dark:text-white {labelClassName}">
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</p>
{/if}
<span
class="form-input custom-placeholder form-input mt-2 block min-h-[50px] w-full resize overflow-hidden rounded-t-md border border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 p-3 pt-3.5 leading-5 outline-none focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600 dark:bg-white dark:text-black {inputClassName} {focusClass} {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} mt-1 block w-full p-3 dark:text-black {bgColor} {errorMessage
? 'border-red-600'
: ''}"
role="textbox"
tabindex={0}
contenteditable
oninput={onInput}
onkeydown={onKeyDown}
onchange={onInputChange}
onblur={(e) => {
if (focusClass.includes('border-primary-600')) {
focusClass = focusClass.replace('border-primary-600', '');
}
onChange(e);
}}
onfocus={() => (focusClass += ' border-primary-600')}
style="--placeholder: '{placeholder}'">{value}</span
>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{:else if helperMessage}
<p class="text-sm dark:text-white">
{helperMessage}
</p>
{/if}
<style>
.custom-placeholder:empty::before {
content: var(--placeholder, 'Default Placeholder');
}
.form-input {
box-shadow: none !important;
}
</style>
@@ -1,47 +0,0 @@
<script lang="ts">
import TextField from '$lib/components/Form/TextField.svelte';
interface Props {
label?: string | null;
value?: string;
checked?: boolean;
name?: string;
isEditable?: boolean;
disabled?: boolean;
className?: string;
onChange?: any;
children?: import('svelte').Snippet;
}
let {
label = $bindable(''),
value = '',
checked = $bindable(false),
name = '',
isEditable = false,
disabled = false,
className = '',
onChange = () => {},
children
}: Props = $props();
</script>
<div class="{className} group inline-flex w-full items-center {disabled ? 'cursor-not-allowed' : 'cursor-pointer'}">
<input
type="checkbox"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 focus:ring-offset-0"
{name}
{value}
disabled={disabled || isEditable}
bind:checked
/>
{#if isEditable}
<div class="w-2/4">
<TextField bind:value={label} placeholder="Your option" className="ml-1" type="text" {onChange} />
</div>
{:else}
<span class="ml-2 dark:text-white">{label}</span>
{/if}
{@render children?.()}
</div>
@@ -1,28 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
placeholder?: string;
value?: string;
className?: string;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
placeholder = '',
value = $bindable(''),
className = '',
onChange = (e?: Event) => {}
}: Props = $props();
</script>
<label class="block {className}">
<span class="text-gray-700 dark:bg-neutral-800 dark:text-white">{label}</span>
<input
bind:value
{placeholder}
type="date"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-800 dark:text-white"
onblur={onChange}
/>
</label>
@@ -1,35 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
placeholder?: string;
value?: string | Date;
className?: string;
onInput?: any;
isRequired?: boolean;
}
let {
label = '',
placeholder = '',
value = $bindable(''),
className = '',
onInput = () => {},
isRequired = false
}: Props = $props();
</script>
<label class="block {className}">
<span class="text-gray-700 dark:text-white"
>{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</span>
<input
bind:value
oninput={onInput}
{placeholder}
type="datetime-local"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-800 dark:text-white"
/>
</label>
@@ -1,40 +0,0 @@
<script lang="ts">
import TextField from '$lib/components/Form/TextField.svelte';
interface Props {
label?: string | null;
value?: string;
checked?: boolean;
name?: string;
isEditable?: boolean;
disabled?: boolean;
className?: string;
onChange?: any; // This is to know if element is 'dirty'
children?: import('svelte').Snippet;
}
let {
label = $bindable(''),
value = '',
checked = false,
name = '',
isEditable = false,
disabled = false,
className = '',
onChange = () => {},
children
}: Props = $props();
</script>
<div class="{className} group inline-flex w-full items-center {disabled ? 'cursor-not-allowed' : 'cursor-pointer'}">
<input class="form-radio" type="radio" {checked} {name} {value} disabled={disabled || isEditable} />
{#if isEditable}
<div class="w-2/4">
<TextField bind:value={label} placeholder="Your option" className="ml-1" type="text" {onChange} />
</div>
{:else}
<span class="ml-2 dark:text-white">{label}</span>
{/if}
{@render children?.()}
</div>
@@ -1,61 +0,0 @@
<script lang="ts">
interface Props {
label?: string;
value: any;
options?: any;
labelKey?: string;
isRequired?: boolean;
className?: string;
selectClassName?: string;
onChange?: any; // This is to know if element is 'dirty'
}
let {
label = '',
value = $bindable(),
options = [],
labelKey = 'label',
isRequired = false,
className = '',
selectClassName = '',
onChange = () => {}
}: Props = $props();
// onMount(() => {
// value = options[0];
// });
</script>
<label class="block {className}">
{#if label}
<span class="mr-2 text-gray-700 dark:text-white">{label}</span>
{/if}
<select
bind:value
class="form-select mt-1 block w-auto rounded-md border-2 px-2 py-1.5 dark:bg-neutral-700 dark:text-white {selectClassName}"
required={isRequired}
onblur={() => {}}
onchange={onChange}
class:customColor={!!selectClassName}
>
{#each options as option}
<option class="bg-white text-black" value={option}>
{option[labelKey]}
</option>
{/each}
</select>
</label>
<style>
select.customColor {
font-weight: 700;
cursor: pointer;
border-radius: 4px;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' class='text-white' viewBox='0 0 20 20'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
select.customColor option {
background-color: #fff;
color: #000;
}
</style>
@@ -1,105 +0,0 @@
<script lang="ts">
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
import { Popover as PopoverPrimitive } from 'bits-ui';
import { t } from '$lib/utils/functions/translations';
interface Props {
label?: string;
disabled?: boolean;
placeholder?: any;
value?: string;
rows?: number;
// export let maxRows = 3;
className?: string;
labelClassName?: string;
bgColor?: string;
helperMessage?: string;
errorMessage?: string;
isRequired?: boolean;
onChange?: any; // This is to know if element is 'dirty'
ref?: HTMLTextAreaElement | null;
isAIEnabled?: boolean;
initAIPrompt?: string;
aiAlignPopover?: PopoverPrimitive.ContentProps['align'];
iconbutton?: import('svelte').Snippet;
}
let {
label = '',
disabled = false,
placeholder = $t('course.navItem.lessons.exercises.all_exercises.write_your_answer'),
value = $bindable(),
rows = 3,
className = '',
labelClassName = '',
bgColor = 'bg-gray-50 focus:bg-primary-50 dark:bg-neutral-700 dark:text-white',
helperMessage = '',
errorMessage = '',
isRequired = false,
onChange = () => {},
ref = $bindable(null),
// isAIEnabled = false,
initAIPrompt = $bindable(''),
// aiAlignPopover = 'left',
iconbutton
}: Props = $props();
// $: maxHeight = maxRows ? `${1 + maxRows * 1.2}em` : `auto`;
</script>
<label for="text-field" class="relative block w-full {className}">
{#if label}
<p class="flex w-full items-center justify-between text-left dark:text-white {labelClassName}">
<span>
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</span>
<!-- {#if isAIEnabled}
<CustomPromptBtn
bind:defaultPrompt={initAIPrompt}
alignPopover={aiAlignPopover}
handleInsert={(v) => {
value = v;
}}
/>
{/if} -->
</p>
{/if}
<!-- <pre
aria-hidden="true"
style="min-height: {minHeight}; max-height: {maxHeight}">
{value + '\n'}
</pre> -->
<textarea
{disabled}
{rows}
{placeholder}
bind:value
bind:this={ref}
required={isRequired}
class="form-textarea focus:border-primary-600 mt-1 block w-full rounded-t-md border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 p-3 focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600
{bgColor} {errorMessage ? 'border-red-500' : 'border-gray-300'}"
onchange={onChange}
></textarea>
{#if !!errorMessage}
<p class="text-sm text-red-500">
{errorMessage}
</p>
{:else if helperMessage}
<p class="text-sm text-gray-500 dark:text-white">
{helperMessage}
</p>
{/if}
{@render iconbutton?.()}
</label>
<style>
.form-textarea {
box-shadow: none !important;
}
</style>
@@ -1,156 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { IconButton } from '$lib/components/IconButton';
import PasswordEye from '../Icons/PasswordEye.svelte';
interface Props {
label?: string;
placeholder?: string;
value?: string | number | null;
name?: string;
onKeyDown?: any;
className?: string;
inputClassName?: string;
labelClassName?: string;
bgColor?: string;
type?: string;
isPassword?: boolean;
autoFocus?: boolean;
isRequired?: boolean;
isDisabled?: boolean;
min?: string | number | null | undefined;
max?: string | number | null | undefined;
errorMessage?: string;
helperMessage?: string;
autoComplete?: boolean;
onChange?: any; // This is to know if element is 'dirty'
onInputChange?: any;
}
let {
label = '',
placeholder = '',
value = $bindable(),
name = '',
onKeyDown = (_e) => {},
className = '',
inputClassName = '',
labelClassName = 'font-light',
bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-700 dark:text-white',
type = $bindable('text'),
isPassword = $bindable(false),
autoFocus = false,
isRequired = false,
isDisabled = false,
min = undefined,
max = undefined,
errorMessage = '',
helperMessage = '',
autoComplete = true,
onChange = () => {},
onInputChange = () => {}
}: Props = $props();
let ref: HTMLInputElement | undefined = $state(undefined);
let fieldNode: HTMLInputElement | undefined = undefined;
let focusClass = $state('');
function typeAction(node: HTMLInputElement | undefined) {
if (!node) return;
node.type = type || 'text';
if (isRequired) {
node.required = true;
}
fieldNode = node;
}
function handlePasswordEye() {
type = type === 'password' ? 'text' : 'password';
typeAction(fieldNode);
}
onMount(() => {
if (autoFocus && ref) {
ref.focus();
}
if (type === 'password') {
isPassword = true;
}
});
</script>
<label class="relative block {className}">
{#if label}
<label for="text-field" class="m-0 text-left text-sm dark:text-white {labelClassName}">
{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</label>
{/if}
<input
use:typeAction
class="form-input rounded-t-md border-b-2 border-l-0 border-r-0 border-t-0 border-gray-200 focus:border-b-2 focus:border-l-0 focus:border-r-0 focus:border-t-0 dark:border-neutral-600 {inputClassName} {focusClass} {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} mt-1 block w-full p-3 dark:text-black {bgColor} {errorMessage
? 'border-red-600'
: ''}"
onkeydown={onKeyDown}
onchange={onInputChange}
{placeholder}
bind:value
bind:this={ref}
required={isRequired}
disabled={isDisabled}
autocomplete={autoComplete ? 'on' : 'off'}
onblur={(e) => {
if (focusClass.includes('border-primary-600')) {
focusClass = focusClass.replace('border-primary-600', '');
}
onChange(e);
}}
onfocus={() => (focusClass += ' border-primary-600')}
{name}
{min}
{max}
/>
{#if isPassword}
<span class="password-eye">
<IconButton value="write-code" onClick={handlePasswordEye}>
<PasswordEye isClosed={type === 'password' ? true : false} />
</IconButton>
</span>
{/if}
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{:else if helperMessage}
<p class="text-sm opacity-70 dark:text-white">
{helperMessage}
</p>
{/if}
</label>
<style>
.form-input {
box-shadow: none !important;
}
.password-eye {
position: absolute;
top: 30px;
right: 6px;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
/* input[type='number'] {
-moz-appearance: textfield;
} */
</style>
@@ -1,9 +0,0 @@
<script lang="ts">
let { tags = [] } = $props();
</script>
<div class="break-all">
{#each tags as tag}
<a class="text-primary-700 dark:text-primary-500 mr-5 text-sm" href="?filter={tag}">#{tag.toLowerCase()}</a>
{/each}
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
let hovering;
function enter() {
hovering = true;
}
function leave() {
hovering = false;
}
</script>
<div onmouseenter={enter} onmouseleave={leave}>
{@render children?.()}
</div>
@@ -1,100 +0,0 @@
<script lang="ts">
import ToolTip from '../ToolTip/index.svelte';
export interface Props {
onClick?: any;
stopPropagation?: boolean;
disabled?: boolean;
selected?: boolean;
buttonClassName?: string;
contained?: boolean;
bordered?: boolean;
value?: string;
type?: 'button' | 'submit' | 'reset' | null | undefined;
size?: 'small' | 'large';
color?: string;
toolTipProps?: {
title: string;
hotkeys: string[];
direction?: string;
};
children?: import('svelte').Snippet;
}
let {
onClick = (_v: string) => {},
stopPropagation = false,
disabled = false,
selected = false,
buttonClassName = '',
contained = false,
bordered = false,
value = '',
type = 'button',
size = 'large',
color = '',
toolTipProps = {
title: '',
hotkeys: [],
direction: ''
},
children
}: Props = $props();
function handleClick(e: Event) {
if (stopPropagation) {
e.stopPropagation();
}
onClick(value);
}
</script>
<ToolTip title={toolTipProps.title} hotkeys={disabled ? [] : toolTipProps.hotkeys} direction={toolTipProps.direction}>
<button
class="root {color} {selected && 'active'} {disabled && 'disabled'} {size} {contained &&
'contained dark:bg-neutral-700'} {bordered && 'border'} {buttonClassName}"
{disabled}
{type}
onclick={handleClick}
>
{@render children?.()}
</button>
</ToolTip>
<style lang="scss">
.root {
flex: 0 0 auto;
padding: 12px;
font-size: 1.5rem;
text-align: center;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
border-radius: 50%;
cursor: pointer;
margin: 0;
display: flex;
outline: 0;
align-items: center;
justify-content: center;
text-decoration: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
width: fit-content;
&.disabled {
cursor: not-allowed;
color: rgba(0, 0, 0, 0.07);
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
.root.small {
padding: 5px;
}
.contained {
background-color: rgba(0, 0, 0, 0.04);
}
</style>
@@ -1 +0,0 @@
export { default as IconButton } from './index.svelte';
@@ -1,17 +0,0 @@
<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>
@@ -1,27 +0,0 @@
<svg width="121" height="120" viewBox="0 0 121 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="79.7922" cy="32.4026" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M69.7402 40.7794L74.2444 37.7766C77.6914 35.4786 82.0846 35.1362 85.8461 36.8722L90.6818 39.1041"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="79.4199" cy="28.68" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<circle cx="106.597" cy="62.5588" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M96.5455 70.9357L101.05 67.9329C104.497 65.6349 108.89 65.2924 112.651 67.0285L117.487 69.2603"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="106.225" cy="58.8363" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<circle cx="71.4156" cy="79.3128" r="12.5649" stroke="#0F62FE" stroke-width="1.67532" />
<path
d="M61.3636 87.6896L65.8678 84.6868C69.3148 82.3888 73.708 82.0463 77.4695 83.7824L82.3052 86.0143"
stroke="#0F62FE"
stroke-width="1.67532"
/>
<circle cx="71.0433" cy="75.5902" r="3.81602" stroke="#0F62FE" stroke-width="1.67532" />
<rect x="5.5" y="49.5" width="24.5" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="61.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="73.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
<rect x="5.5" y="85.5" width="44" height="5" rx="2.5" stroke="#1339A6" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,53 +0,0 @@
<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>

Some files were not shown because too many files have changed in this diff Show More