feat: setup drizzle (#594)
* chore: migration to SvelteKit 2 * stage 1: migrate to svelte 5 and fix rerendering issues * stage 2: fix more $: reactivity across dashboard * fix merge conflicts * chore: migrate code in main to svelte 5 * chore: update preventDefault * chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte * chore: enforce .ts across dashboard * rename from classroomio-com to website * stage-3: Replace carbon icon svelte with lucide icon * update lock file * chore: finish icon replacement * fix: settings and setup page multiple rerender * stage 3: More icon and rerendering fixes * feat: setup drizzle and better auth * fix: onboarding validation error showing translation key #586 (#593) * fix: onboarding validation error showing translation key #586 * fix: remove debug log for profile update errors * chore: add a clean command * fix: remove message from email verification response
This commit is contained in:
+25
-24
@@ -1,32 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" />
|
||||
<script
|
||||
defer
|
||||
src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
|
||||
></script>
|
||||
|
||||
<script defer src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
|
||||
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
|
||||
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -115,22 +115,28 @@ code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
[aria-current] :global(svg.carbon-icon),
|
||||
/* [aria-current] :global(svg.carbon-icon),
|
||||
.active :global(svg.carbon-icon) {
|
||||
fill: var(--main-primary-color);
|
||||
}
|
||||
} */
|
||||
|
||||
.prose a {
|
||||
color: var(--main-primary-color);
|
||||
}
|
||||
|
||||
.success :global(svg.carbon-icon) {
|
||||
svg.lucide-icon:not(.custom) {
|
||||
color: #666666;
|
||||
}
|
||||
.dark svg.lucide-icon:not(.custom) {
|
||||
color: #fff;
|
||||
}
|
||||
/* .success :global(svg.carbon-icon) {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
:global(svg.carbon-icon.error) {
|
||||
fill: red;
|
||||
}
|
||||
} */
|
||||
|
||||
.org-root {
|
||||
height: calc(100vh - 48px);
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
<img src="/logo-512.png" class="logo" alt="ClassroomIO - Logo" />
|
||||
<h2>Something unexpected occured.</h2>
|
||||
<p>
|
||||
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error
|
||||
notification and will push a fix ASAP. In the meantime, take a short break and come back a bit
|
||||
later.
|
||||
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and
|
||||
will push a fix ASAP. In the meantime, take a short break and come back a bit later.
|
||||
</p>
|
||||
<a href="mailto:support@classroomio.com">Contact Us</a>
|
||||
</div>
|
||||
|
||||
@@ -38,12 +38,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Unauthenticated user') {
|
||||
return new Response(
|
||||
JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }),
|
||||
{
|
||||
status: 401
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import { Popover } from 'carbon-components-svelte';
|
||||
import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
|
||||
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
// import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
// import { writable } from 'svelte/store';
|
||||
// // import { useCompletion } from 'ai/svelte';
|
||||
// import { Popover } from 'carbon-components-svelte';
|
||||
// import type { PopoverProps } from 'carbon-components-svelte/types/Popover/Popover.svelte';
|
||||
// import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
|
||||
// import { IconButton } from '$lib/components/IconButton';
|
||||
// import { Loading } from 'carbon-components-svelte';
|
||||
// import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
// import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let className = '';
|
||||
export let handleInsert = (v: string) => {};
|
||||
export let defaultPrompt = '';
|
||||
export let alignPopover: PopoverProps['align'] = 'left';
|
||||
export let isHTML = false;
|
||||
// interface Props {
|
||||
// className?: string;
|
||||
// handleInsert?: any;
|
||||
// defaultPrompt?: string;
|
||||
// alignPopover?: PopoverProps['align'];
|
||||
// isHTML?: boolean;
|
||||
// }
|
||||
|
||||
let openPopover = false;
|
||||
let plainText = '';
|
||||
let textRef: HTMLParagraphElement;
|
||||
let prompt: string = defaultPrompt;
|
||||
// let {
|
||||
// className = '',
|
||||
// handleInsert = (_v: string) => {},
|
||||
// defaultPrompt = '',
|
||||
// alignPopover = 'left',
|
||||
// isHTML = false
|
||||
// }: Props = $props();
|
||||
|
||||
let isSubmitted = false;
|
||||
// let openPopover = $state(false);
|
||||
// let plainText = $state('');
|
||||
// let textRef: HTMLParagraphElement | undefined = $state();
|
||||
// let prompt: string = $state(defaultPrompt);
|
||||
|
||||
const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
api: '/api/completion/customprompt'
|
||||
});
|
||||
// let isSubmitted = $state(false);
|
||||
|
||||
function restart() {
|
||||
isSubmitted = false;
|
||||
plainText = '';
|
||||
}
|
||||
// let isLoading = writable(false);
|
||||
// // const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
// // api: '/api/completion/customprompt'
|
||||
// // });
|
||||
|
||||
function onCompletion(completion: string) {
|
||||
plainText = completion;
|
||||
if (textRef) {
|
||||
textRef.scrollTop = textRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
// function restart() {
|
||||
// isSubmitted = false;
|
||||
// plainText = '';
|
||||
// }
|
||||
|
||||
function callAI(prompt: string, rephrase: boolean) {
|
||||
isSubmitted = true;
|
||||
// function onCompletion(completion: string) {
|
||||
// plainText = completion;
|
||||
// if (textRef) {
|
||||
// textRef.scrollTop = textRef.scrollHeight;
|
||||
// }
|
||||
// }
|
||||
|
||||
$input = rephrase ? `${$t('ai.can_you')}: ${plainText}` : prompt;
|
||||
// // function callAI(prompt: string, rephrase: boolean) {
|
||||
// // isSubmitted = true;
|
||||
|
||||
setTimeout(() => {
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
}
|
||||
// // $input = rephrase ? `${$t('ai.can_you')}: ${plainText}` : prompt;
|
||||
|
||||
$: onCompletion($completion);
|
||||
$: prompt = defaultPrompt;
|
||||
// // setTimeout(() => {
|
||||
// // handleSubmit({ preventDefault: () => {} });
|
||||
// // }, 500);
|
||||
// // }
|
||||
|
||||
// $effect(() => {
|
||||
// onCompletion($completion);
|
||||
// });
|
||||
|
||||
// $effect(() => {
|
||||
// prompt = defaultPrompt;
|
||||
// });
|
||||
</script>
|
||||
|
||||
<div></div>
|
||||
<!--
|
||||
<div class="{className} relative z-[100]">
|
||||
<IconButton
|
||||
contained={true}
|
||||
@@ -58,31 +78,27 @@
|
||||
size="large"
|
||||
onClick={() => (openPopover = !openPopover)}
|
||||
>
|
||||
<MagicWandFilled size={16} class="carbon-icon" />
|
||||
<MagicWandFilled />
|
||||
<Popover caret align={alignPopover} bind:open={openPopover}>
|
||||
<button
|
||||
class="p-2 w-[300px] h-[220px] text-start"
|
||||
type="button"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="h-[220px] w-[300px] p-2 text-start">
|
||||
{#if isSubmitted}
|
||||
<div class="h-[200px]">
|
||||
<div
|
||||
bind:this={textRef}
|
||||
class="h-[82%] bg-white dark:bg-neutral-700 p-2 overflow-y-auto text-sm text-start w-full my-1"
|
||||
class="my-1 h-[82%] w-full overflow-y-auto bg-white p-2 text-start text-sm dark:bg-neutral-700"
|
||||
>
|
||||
{#if isHTML}
|
||||
<HtmlRender content={plainText} />
|
||||
<HtmlRender>{plainText}</HtmlRender>
|
||||
{:else}
|
||||
<p class="font-normal">
|
||||
{plainText}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-5 ml-2">
|
||||
<div class="ml-2 flex gap-5">
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
|
||||
on:click={() => {
|
||||
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
|
||||
onclick={() => {
|
||||
handleInsert(plainText);
|
||||
openPopover = false;
|
||||
}}
|
||||
@@ -96,9 +112,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium {$isLoading &&
|
||||
'opacity-25 cursor-not-allowed'}"
|
||||
on:click|preventDefault={() => callAI(prompt, true)}
|
||||
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium {$isLoading &&
|
||||
'cursor-not-allowed opacity-25'}"
|
||||
onclick={preventDefault(() => callAI(prompt, true))}
|
||||
type="button"
|
||||
disabled={$isLoading}
|
||||
>
|
||||
@@ -106,8 +122,8 @@
|
||||
</button>
|
||||
{#if !$isLoading}
|
||||
<button
|
||||
class="text-xs px-4 py-2 border-[1px] cursor-pointer rounded-md font-medium"
|
||||
on:click|preventDefault={restart}
|
||||
class="cursor-pointer rounded-md border-[1px] px-4 py-2 text-xs font-medium"
|
||||
onclick={preventDefault(restart)}
|
||||
type="button"
|
||||
>
|
||||
{$t('ai.reset')}
|
||||
@@ -117,22 +133,22 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full">
|
||||
<p class="text-sm font-medium mb-2 ml-1">{$t('ai.help_me')}</p>
|
||||
<p class="mb-2 ml-1 text-sm font-medium">{$t('ai.help_me')}</p>
|
||||
<textarea
|
||||
bind:value={prompt}
|
||||
class="w-full border-0 rounded-lg h-[65%] text-sm m-0 dark:text-black"
|
||||
class="m-0 h-[65%] w-full rounded-lg border-0 text-sm dark:text-black"
|
||||
placeholder={$t('ai.placeholder')}
|
||||
/>
|
||||
></textarea>
|
||||
<button
|
||||
class="flex text-xs px-4 py-2 m-0 border rounded-md font-medium"
|
||||
on:click|preventDefault={() => callAI(prompt, false)}
|
||||
class="m-0 flex rounded-md border px-4 py-2 text-xs font-medium"
|
||||
onclick={preventDefault(() => callAI(prompt, false))}
|
||||
type="button"
|
||||
>
|
||||
{$t('ai.text')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
@@ -7,18 +7,22 @@
|
||||
hidePercentage?: boolean;
|
||||
}
|
||||
|
||||
export let activity: Activity;
|
||||
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 dark:border-neutral-600 md:flex-row md:p-5"
|
||||
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">
|
||||
<svelte:component this={activity.icon} size={24} />
|
||||
<activity.icon />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-center text-sm font-medium text-gray-600 dark:text-gray-200 md:text-left">
|
||||
<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 font-bold md:text-left">
|
||||
@@ -27,7 +31,7 @@
|
||||
%
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-center text-xs text-gray-500 dark:text-gray-300 md:text-left md:text-sm">
|
||||
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
|
||||
{activity.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,28 +3,32 @@
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
import Alarm from 'carbon-icons-svelte/lib/Alarm.svelte';
|
||||
import Email from 'carbon-icons-svelte/lib/Email.svelte';
|
||||
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
|
||||
export let user: UserAnalytics['user'];
|
||||
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 src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" />
|
||||
<div class="flex flex-col space-y-2">
|
||||
<p class="text-center text-2xl font-bold dark:text-white md:text-left">
|
||||
<p class="text-center text-2xl font-bold 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">
|
||||
<Email />
|
||||
<MailIcon size={16} />
|
||||
{user.email}
|
||||
</p>
|
||||
|
||||
<p class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
|
||||
<Alarm />
|
||||
<AlarmClockIcon size={16} />
|
||||
{$t('analytics.last_seen')}:
|
||||
<span class="italic">
|
||||
{user.lastSeen ? calDateDiff(user.lastSeen) : $t('analytics.a_while_ago')}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<script>
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import { PageNav } from '$lib/components/Page';
|
||||
|
||||
export let handleClose;
|
||||
</script>
|
||||
|
||||
<PageNav title="Note" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script>
|
||||
export let name;
|
||||
export let src;
|
||||
export let className;
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="w-10 max-h-10 rounded-full bg-primary-100 {className}" />
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import DateTime from '$lib/components/Form/DateTime.svelte';
|
||||
import type { PollType } from '../types';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { Add, TrashCan } from 'carbon-icons-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let title = 'Poll';
|
||||
export let isSaving = false;
|
||||
export let onSubmit = (p: PollType) => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let poll: PollType = {
|
||||
id: new Date().getTime().toString(),
|
||||
courseId: $course.id || '',
|
||||
question: '',
|
||||
expiration: new Date().toDateString(),
|
||||
isPublic: false,
|
||||
status: $t('course.navItem.lessons.poll.draft'),
|
||||
author: {
|
||||
id: $profile.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatarUrl: $profile.avatar_url || ''
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
selectedBy: []
|
||||
}
|
||||
]
|
||||
};
|
||||
let isCreateDisabled = false;
|
||||
|
||||
function handleAddOptions() {
|
||||
poll = {
|
||||
...poll,
|
||||
options: [
|
||||
...poll.options,
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
selectedBy: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function handleRemoveOptions(index: string | number) {
|
||||
return () => {
|
||||
poll.options = poll.options.filter((_, i) => i !== index);
|
||||
};
|
||||
}
|
||||
|
||||
function finishPoll() {
|
||||
onSubmit(poll);
|
||||
}
|
||||
|
||||
$: isCreateDisabled = poll.question === '' || poll.options.length < 2;
|
||||
</script>
|
||||
|
||||
<div class="border rounded-md">
|
||||
<div class="border-b-2 p-4 text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<TextField
|
||||
label={$t('course.navItem.lessons.poll.question')}
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.question}
|
||||
placeholder={$t('course.navItem.lessons.poll.poll_question')}
|
||||
isRequired={true}
|
||||
/>
|
||||
<DateTime
|
||||
label={$t('course.navItem.lessons.poll.expiration')}
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.expiration}
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="dark:text-white p-0 m-0 mb-1 text-md flex items-center gap-2">
|
||||
{$t('course.navItem.lessons.poll.options')}
|
||||
|
||||
<IconButton onClick={handleAddOptions} contained={true} size="small">
|
||||
<Add size={16} />
|
||||
</IconButton>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#each poll.options as option, index}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<TextField
|
||||
label=""
|
||||
className="w-full"
|
||||
bind:value={option.label}
|
||||
placeholder={$t('course.navItem.lessons.poll.option_label')}
|
||||
isRequired={true}
|
||||
/>
|
||||
<IconButton onClick={handleRemoveOptions(index)} contained={true} size="small">
|
||||
<TrashCan size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center gap-2 mb-3">
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.poll.create_poll')}
|
||||
onClick={finishPoll}
|
||||
isLoading={isSaving}
|
||||
isDisabled={isCreateDisabled}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.poll.cancel')}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<p
|
||||
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -1,215 +0,0 @@
|
||||
<script script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Label from './Label.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import type { PollType } from '../types';
|
||||
import { updatePollStatus } from '../service';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Dashboard } from 'carbon-icons-svelte';
|
||||
|
||||
export let poll: PollType;
|
||||
export let onSelect: (a: string) => void;
|
||||
export let handlePollDelete = () => {};
|
||||
export let currentUserId: number | string;
|
||||
|
||||
let viewResult = false;
|
||||
let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
|
||||
let isAuthor = false;
|
||||
let totalVotes = 0;
|
||||
let isThereAnyVote = false;
|
||||
|
||||
function getTotalVotes(options: PollType['options']) {
|
||||
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
|
||||
}
|
||||
|
||||
function calculatePercent(
|
||||
options: PollType['options'],
|
||||
currentOption: { selectedBy: string | PollType['options'][0]['selectedBy'] }
|
||||
) {
|
||||
const totalVoteOfAllOptions = getTotalVotes(options);
|
||||
const totalVotesOfCurrentOption = currentOption.selectedBy.length;
|
||||
|
||||
return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0;
|
||||
}
|
||||
|
||||
function didUserSelect(
|
||||
option: { selectedBy: PollType['options'][0]['selectedBy'] },
|
||||
userId: number | string
|
||||
) {
|
||||
return option.selectedBy.find((user) => user.id === userId);
|
||||
}
|
||||
|
||||
let prevStatus = poll.status;
|
||||
function onStatusChange(status: string) {
|
||||
if (status === prevStatus || !status) return;
|
||||
|
||||
updatePollStatus(poll.id, status);
|
||||
prevStatus = status;
|
||||
}
|
||||
|
||||
$: onStatusChange(poll.status);
|
||||
$: isAuthor = currentUserId === poll.author.id;
|
||||
$: totalVotes = getTotalVotes(poll.options);
|
||||
$: {
|
||||
isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 flex-wrap bg-white dark:bg-black border-2 rounded-md mb-3 w-full">
|
||||
{#if selectedOptionToView}
|
||||
<div>
|
||||
<div class="flex items-center mb-5">
|
||||
<button class="text-xl w-10 px-3" on:click={() => (selectedOptionToView = null)}>
|
||||
{`<`}
|
||||
</button>
|
||||
<div class="flex-grow text-center">
|
||||
<h3 class="dark:text-white font-bold">{$t('course.navItem.lessons.poll.voted')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{#each selectedOptionToView.selectedBy as user}
|
||||
<div class="flex items-center mb-2">
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
<p class="dark:text-white">
|
||||
{user.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2 mb-3 justify-between">
|
||||
<div>
|
||||
<h3 class="dark:text-white font-bold">
|
||||
{poll.question}
|
||||
</h3>
|
||||
<p class="dark:text-white text-sm italic">
|
||||
{$t('course.navItem.lessons.poll.created_by')}
|
||||
<span class="text-primary-500">@{poll.author.username}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="flex mb-3 justify-end items-center">
|
||||
<label class="mr-2 font-bold" for="user-select"
|
||||
>{$t('course.navItem.lessons.poll.status')}:</label
|
||||
>
|
||||
|
||||
<select
|
||||
name="users"
|
||||
id="user-select"
|
||||
class="dark:text-white dark:bg-black"
|
||||
bind:value={poll.status}
|
||||
>
|
||||
<option value="draft">{$t('course.navItem.lessons.poll.draft')}</option>
|
||||
<option value="published">{$t('course.navItem.lessons.poll.publish')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if viewResult}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-5">
|
||||
<Label>{$t('course.navItem.lessons.poll.expiration')}</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{poll.expiration || $t('dashboard.today')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<Label>{$t('course.navItem.lessons.poll.total_voted')}</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>{$t('course.navItem.lessons.poll.responses')}</Label>
|
||||
{#each poll.options as option}
|
||||
<div class="border-b p-5">
|
||||
<p class="dark:text-white">
|
||||
{option.label}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-4/5 h-2 bg-gray-200 relative">
|
||||
<span
|
||||
class="absolute top-0 left-0 bg-primary-700 h-full"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
</div>
|
||||
{#if option.selectedBy.length}
|
||||
<button class="text-xl px-3 ml-5" on:click={() => (selectedOptionToView = option)}>
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#each option.selectedBy.slice(0, 3) as user}
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each poll.options as option, index}
|
||||
<button
|
||||
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 dark:border-neutral-600 h-[50px] {didUserSelect(
|
||||
option,
|
||||
currentUserId
|
||||
) &&
|
||||
'focus:border-primary-700 border-primary-700'} text-black dark:text-white p-2 w-full mb-3 text-left relative"
|
||||
on:click={() => onSelect(option.id)}
|
||||
>
|
||||
{#if isThereAnyVote}
|
||||
<span
|
||||
class="progress absolute top-0 text-black left-0 {didUserSelect(option, currentUserId)
|
||||
? 'bg-primary-700'
|
||||
: 'bg-gray-300'} h-full bg-opacity-25"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
{/if}
|
||||
<p
|
||||
class="absolute top-3 {didUserSelect(option, currentUserId)
|
||||
? 'text-white'
|
||||
: 'dark:text-white text-black'} left-2"
|
||||
>
|
||||
{option.label}
|
||||
({calculatePercent(poll.options, option)}%)
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
{$t('course.navItem.lessons.poll.expires')}: {poll.expiration || $t('dashboard.today')}
|
||||
</p>
|
||||
<span class="mx-2">|</span>
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
{$t('course.navItem.lessons.poll.voted')}: {totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="w-full flex items-center mt-3">
|
||||
<PrimaryButton
|
||||
label={viewResult
|
||||
? $t('course.navItem.lessons.poll.back')
|
||||
: $t('course.navItem.lessons.poll.result')}
|
||||
onClick={() => (viewResult = !viewResult)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.poll.delete')}
|
||||
variant={VARIANTS.TEXT_DANGER}
|
||||
onClick={handlePollDelete}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
transition: width 0.5s linear;
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import RoleBaseSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { Add } from 'carbon-icons-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import type { TabsType } from '../types';
|
||||
|
||||
export let onCreate: () => void;
|
||||
export let tabs: TabsType = [];
|
||||
|
||||
export let selectedTab: number;
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
|
||||
? 'border-b-4 border-primary-600 focus:border-primary-600'
|
||||
: 'border-b-0'}"
|
||||
on:click={() => (selectedTab = tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
<Chip bind:value={tab.number} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<RoleBaseSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton onClick={onCreate} contained={true} size="large">
|
||||
<Add size={16} />
|
||||
</IconButton>
|
||||
</RoleBaseSecurity>
|
||||
</div>
|
||||
@@ -1,290 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import { course, group } from '$lib/components/Course/store';
|
||||
import { PageNav } from '$lib/components/Page';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Groupmember } from '$lib/utils/types';
|
||||
import type {
|
||||
PostgrestSingleResponse,
|
||||
RealtimeChannel,
|
||||
RealtimePostgresChangesPayload
|
||||
} from '@supabase/supabase-js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import CreatePollForm from './components/CreatePollForm.svelte';
|
||||
import Poll from './components/Poll.svelte';
|
||||
import Tabs from './components/Tabs.svelte';
|
||||
import { fetchPolls, handleVote } from './service';
|
||||
import { polls } from './store';
|
||||
import type { PollOptionsSubmissionType, PollType, TabsType } from './types';
|
||||
import { getPollsData } from './utils';
|
||||
|
||||
export let handleClose = () => {};
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
let selectedTab = 0;
|
||||
let currentGroupMember: Groupmember | undefined;
|
||||
let openCreatePollForm = false;
|
||||
let isCreating = false;
|
||||
let isLoading = false;
|
||||
let author: PollType['author'] = {
|
||||
id: '',
|
||||
username: '',
|
||||
fullname: '',
|
||||
avatarUrl: ''
|
||||
};
|
||||
let pollSubmissionsChannel: RealtimeChannel;
|
||||
|
||||
let tabs: TabsType = [];
|
||||
let activePolls: PollType[] = [];
|
||||
let expiredPolls: PollType[] = [];
|
||||
|
||||
function setCoursePolls() {
|
||||
$course.polls = $polls.map((p) => ({ status: p.status }));
|
||||
}
|
||||
|
||||
async function createPoll(poll: PollType) {
|
||||
if (!currentGroupMember || !$course.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('apps_poll')
|
||||
.insert({
|
||||
question: poll.question,
|
||||
authorId: currentGroupMember.id,
|
||||
status: poll.status,
|
||||
expiration: poll.expiration,
|
||||
courseId: $course.id
|
||||
})
|
||||
.select('id');
|
||||
|
||||
if (error || !data) {
|
||||
console.log(error);
|
||||
snackbar.error('snackbar.poll.error.creating_poll');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollId = data[0].id;
|
||||
const appsPollOptions = poll.options.map((option) => ({
|
||||
poll_id: pollId,
|
||||
label: option.label
|
||||
}));
|
||||
const { data: appsPollOptionsData, error: appsPollOptionsError } = await supabase
|
||||
.from('apps_poll_option')
|
||||
.insert(appsPollOptions)
|
||||
.select();
|
||||
|
||||
if (appsPollOptionsError || !appsPollOptionsData) {
|
||||
console.log({ appsPollOptionsError });
|
||||
snackbar.error('snackbar.poll.error.creating_poll');
|
||||
return;
|
||||
}
|
||||
|
||||
$polls = [
|
||||
{
|
||||
...poll,
|
||||
id: pollId,
|
||||
courseId: $course.id,
|
||||
author,
|
||||
options: appsPollOptionsData.map((option) => ({
|
||||
...option,
|
||||
id: option.id,
|
||||
selectedBy: []
|
||||
}))
|
||||
},
|
||||
...$polls
|
||||
];
|
||||
|
||||
setCoursePolls();
|
||||
}
|
||||
|
||||
async function handleInsert(payload: RealtimePostgresChangesPayload<PollOptionsSubmissionType>) {
|
||||
const newVote = payload.new as PollOptionsSubmissionType;
|
||||
|
||||
const {
|
||||
data,
|
||||
error
|
||||
}: PostgrestSingleResponse<{
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}> = await supabase
|
||||
.from('groupmember')
|
||||
.select('profile:profile_id(username, fullname, avatar_url)')
|
||||
.eq('id', newVote.selected_by_id)
|
||||
.single();
|
||||
|
||||
console.log('newVote => data', data);
|
||||
console.log('newVote => error', error);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$polls = $polls.map((poll) => {
|
||||
if (poll.id === newVote.poll_id) {
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map((option) => {
|
||||
if (option.id === newVote.poll_option_id) {
|
||||
if (option.selectedBy.some((s) => s.id === newVote.selected_by_id)) {
|
||||
return option;
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
selectedBy: [
|
||||
...option.selectedBy,
|
||||
{
|
||||
id: newVote.selected_by_id,
|
||||
username: data.profile.username,
|
||||
fullname: data.profile.fullname,
|
||||
avatarUrl: data.profile.avatar_url
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return poll;
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePollCreate(poll: PollType) {
|
||||
if (!currentGroupMember) return;
|
||||
|
||||
isCreating = true;
|
||||
await createPoll(poll);
|
||||
isCreating = false;
|
||||
|
||||
openCreatePollForm = false;
|
||||
}
|
||||
|
||||
function handlePollDelete(pollId: string) {
|
||||
return async () => {
|
||||
await supabase.from('apps_poll_submission').delete().match({ poll_id: pollId });
|
||||
await supabase.from('apps_poll_option').delete().match({ poll_id: pollId });
|
||||
await supabase.from('apps_poll').delete().match({ id: pollId });
|
||||
|
||||
$polls = [...$polls.filter((p, i) => p.id !== pollId)];
|
||||
|
||||
setCoursePolls();
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$course.id) return;
|
||||
|
||||
isLoading = true;
|
||||
const { data, error } = await fetchPolls($course.id);
|
||||
|
||||
if (!data || error) {
|
||||
console.log(error);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
polls.set(getPollsData(data, $globalStore.isStudent));
|
||||
|
||||
setCoursePolls();
|
||||
|
||||
isLoading = false;
|
||||
|
||||
pollSubmissionsChannel = supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'apps_poll_submission' },
|
||||
handleInsert
|
||||
)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
supabase.removeChannel(pollSubmissionsChannel);
|
||||
});
|
||||
|
||||
$: currentGroupMember = $group.people.find(
|
||||
(person) => person.profile_id === $profile.id
|
||||
) as Groupmember;
|
||||
|
||||
$: author = {
|
||||
id: currentGroupMember?.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatarUrl: $profile.avatar_url || ''
|
||||
};
|
||||
|
||||
$: {
|
||||
activePolls = $polls.filter(
|
||||
(poll) => new Date(poll.expiration).getTime() >= new Date().getTime()
|
||||
);
|
||||
expiredPolls = $polls.filter(
|
||||
(poll) => new Date(poll.expiration).getTime() <= new Date().getTime()
|
||||
);
|
||||
tabs = [
|
||||
{
|
||||
label: $t('course.navItem.lessons.polls.active_polls'),
|
||||
value: 0,
|
||||
number: activePolls.length
|
||||
},
|
||||
{
|
||||
label: $t('course.navItem.lessons.polls.expired_polls'),
|
||||
value: 1,
|
||||
number: expiredPolls.length
|
||||
}
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav
|
||||
title={$t('course.navItem.lessons.polls.title')}
|
||||
overidableStyle="padding: 0 10px"
|
||||
paddingClass="w-full"
|
||||
>
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div class="overlow-y-auto w-full p-2 md:min-w-[340px] md:max-w-[350px]">
|
||||
{#if openCreatePollForm}
|
||||
<CreatePollForm
|
||||
onSubmit={handlePollCreate}
|
||||
onCancel={() => (openCreatePollForm = !openCreatePollForm)}
|
||||
bind:isSaving={isCreating}
|
||||
/>
|
||||
{:else if currentGroupMember}
|
||||
<div>
|
||||
<Tabs {tabs} bind:selectedTab onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
|
||||
|
||||
{#if isLoading}
|
||||
{$t('course.navItem.lessons.polls.loading')}...
|
||||
{:else}
|
||||
{#each selectedTab === tabs[0].value ? activePolls : expiredPolls as poll}
|
||||
<Poll
|
||||
bind:poll
|
||||
onSelect={handleVote(poll.id, currentGroupMember?.id || '', author)}
|
||||
handlePollDelete={handlePollDelete(poll.id)}
|
||||
bind:currentUserId={currentGroupMember.id}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold">{$t('course.navItem.lessons.polls.no_polls')}</h2>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,141 +0,0 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { PollType, PollOptionType, FetchPollsResponse } from './types';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { polls } from './store';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
export async function fetchPolls(courseId: string) {
|
||||
return await supabase
|
||||
.from('apps_poll')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
created_at,
|
||||
author:groupmember(
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
),
|
||||
options: apps_poll_option (
|
||||
id,
|
||||
label,
|
||||
submissions:apps_poll_submission(
|
||||
selectedBy:groupmember(
|
||||
id,
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({ courseId })
|
||||
.order('created_at', { ascending: false })
|
||||
.returns<FetchPollsResponse>();
|
||||
}
|
||||
|
||||
export const updatePollStatus = async (pollId: string, status: PollType['status']) => {
|
||||
const { error } = await supabase.from('apps_poll').update({ status }).match({ id: pollId });
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('snackbar.poll.error.updating_poll');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const togglePollSubmission = async (
|
||||
pollId: PollType['id'],
|
||||
pollOptionId: PollOptionType['id'],
|
||||
groupmemberId: string,
|
||||
add: boolean
|
||||
) => {
|
||||
if (add) {
|
||||
const { error } = await supabase.from('apps_poll_submission').insert({
|
||||
poll_id: pollId,
|
||||
poll_option_id: pollOptionId,
|
||||
selected_by_id: groupmemberId
|
||||
});
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('snackbar.poll.error.submitting_poll');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase.from('apps_poll_submission').delete().match({
|
||||
poll_id: pollId,
|
||||
poll_option_id: pollOptionId,
|
||||
selected_by_id: groupmemberId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('snackbar.poll.error.submitting_poll');
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isOptionSelectedByCurrentUser(option: PollOptionType, groupmemberId: string) {
|
||||
return option.selectedBy.some((gmember) => gmember.id === groupmemberId);
|
||||
}
|
||||
|
||||
export function handleVote(pollId: string, groupmemberId: string, author: PollType['author']) {
|
||||
return (optionId: string) => {
|
||||
// we have the pollId and the optionId
|
||||
polls.update((_polls) => {
|
||||
return [
|
||||
..._polls.map((poll) => {
|
||||
// Prevent user from voting on their own poll
|
||||
// if (poll.author.id === currentGroupMember?.id) return poll;
|
||||
|
||||
// Prevent user from voting on a poll that has expired
|
||||
if (poll.expiration && new Date(poll.expiration) < new Date()) return poll;
|
||||
|
||||
if (poll.id === pollId) {
|
||||
// Prevent user from voting on a poll that the status is not published
|
||||
if (poll.status !== 'published') {
|
||||
snackbar.info('snackbar.poll.info.not_published');
|
||||
return poll;
|
||||
}
|
||||
|
||||
// Prevent user from voting twice
|
||||
if (
|
||||
poll.options.some((option) => isOptionSelectedByCurrentUser(option, groupmemberId))
|
||||
) {
|
||||
snackbar.info('snackbar.poll.info.vote_once');
|
||||
return poll;
|
||||
}
|
||||
|
||||
poll.options = poll.options.map((option) => {
|
||||
const isSelected = isOptionSelectedByCurrentUser(option, groupmemberId);
|
||||
|
||||
// If user has not voted on this option, add their vote
|
||||
if (option.id === optionId && !isSelected) {
|
||||
option.selectedBy = [...option.selectedBy, author];
|
||||
|
||||
togglePollSubmission(pollId, optionId, groupmemberId, true);
|
||||
}
|
||||
// else if (isSelected) {
|
||||
// // If user has voted on this option, remove their vote
|
||||
// option.selectedBy = option.selectedBy.filter(
|
||||
// (gmember) => gmember.id !== groupmemberId
|
||||
// );
|
||||
// }
|
||||
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
return poll;
|
||||
})
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { PollType } from './types';
|
||||
|
||||
export const polls: Writable<PollType[]> = writable([]);
|
||||
@@ -1,69 +0,0 @@
|
||||
export type PollOptionType = {
|
||||
id: string;
|
||||
label: string;
|
||||
selectedBy: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type PollType = {
|
||||
courseId: string;
|
||||
id: string;
|
||||
question: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
expiration: string | Date;
|
||||
options: PollOptionType[];
|
||||
};
|
||||
|
||||
export type TabsType = {
|
||||
label: string;
|
||||
value: number;
|
||||
number: number;
|
||||
}[];
|
||||
|
||||
export type PollOptionsSubmissionType = {
|
||||
poll_id: string;
|
||||
poll_option_id: string;
|
||||
selected_by_id: string;
|
||||
};
|
||||
|
||||
export type FetchPollsResponse = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
expiration: string;
|
||||
authorId: string;
|
||||
status: string;
|
||||
question: string;
|
||||
created_at: string;
|
||||
author: {
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
options: {
|
||||
id: string;
|
||||
label: string;
|
||||
submissions: {
|
||||
selectedBy: {
|
||||
id: string;
|
||||
profile: {
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { PollType, FetchPollsResponse } from './types';
|
||||
|
||||
export const getPollsData = (data: FetchPollsResponse, isStudent?: boolean) => {
|
||||
const pollsData: PollType[] = data.map((appsPollItem) => ({
|
||||
id: appsPollItem.id,
|
||||
courseId: appsPollItem.courseId,
|
||||
expiration: appsPollItem.expiration,
|
||||
status: appsPollItem.status,
|
||||
question: appsPollItem.question,
|
||||
author: {
|
||||
id: appsPollItem.authorId,
|
||||
username: appsPollItem.author.profile.username,
|
||||
fullname: appsPollItem.author.profile.fullname,
|
||||
avatarUrl: appsPollItem.author.profile.avatar_url
|
||||
},
|
||||
options: appsPollItem.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
selectedBy: option.submissions.map((submission) => {
|
||||
return {
|
||||
id: submission.selectedBy.id,
|
||||
username: submission.selectedBy.profile.username,
|
||||
fullname: submission.selectedBy.profile.fullname,
|
||||
avatarUrl: submission.selectedBy.profile.avatar_url
|
||||
};
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
return pollsData.filter((p) => (isStudent ? p.status === 'published' : true));
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
<script>
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { PageNav } from '$lib/components/Page';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
|
||||
export let handleClose;
|
||||
let bodyRef;
|
||||
let question;
|
||||
|
||||
let questions = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Evgeniy',
|
||||
question: "I'am looking at this and it looks great",
|
||||
vote: 1,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sergey Semko',
|
||||
question:
|
||||
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
|
||||
vote: 4,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Vitalii Marushko',
|
||||
question: 'Всем привет',
|
||||
vote: 20,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Natasha',
|
||||
question: 'Привет Виталий',
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
questions = [
|
||||
...questions,
|
||||
{
|
||||
id: questions.length,
|
||||
name: 'You',
|
||||
question,
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
question = null;
|
||||
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleMarkAnswered(id) {
|
||||
return () => {
|
||||
questions = questions
|
||||
.map((question) => {
|
||||
if (question.id === id) {
|
||||
question.answered = !question.answered;
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
.sort((a, b) => Number(a.answered) - Number(b.answered));
|
||||
};
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Q/A" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="body">
|
||||
{#each questions as question}
|
||||
<div class="mb-2 ml-2 mt-2 flex items-start">
|
||||
<Vote bind:value={question.vote} />
|
||||
<div class="ml-2 flex-grow">
|
||||
<p class="text-sm font-bold dark:text-white">{question.name}</p>
|
||||
<article class="prose prose-sm sm:prose">
|
||||
{question.question}
|
||||
</article>
|
||||
</div>
|
||||
<button class={`${question.answered && 'active'}`} on:click={handleMarkAnswered(question.id)}>
|
||||
<CheckmarkOutline size={20} class="carbon-icon dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField placeholder="Say something" bind:value={question} onKeyDown={handleKeyDown} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.body {
|
||||
overflow: auto;
|
||||
height: 80%;
|
||||
|
||||
& button {
|
||||
display: none;
|
||||
}
|
||||
& div:hover button,
|
||||
& button.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
export default {
|
||||
APPS: {
|
||||
QANDA: 'QANDA',
|
||||
NOTES: 'NOTES',
|
||||
COMMENTS: 'COMMENTS',
|
||||
POLL: 'POLL'
|
||||
}
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
|
||||
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { apps } from './store';
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import Notes from './components/Notes/index.svelte';
|
||||
import Poll from './components/Poll/index.svelte';
|
||||
import QandA from './components/QandA/index.svelte';
|
||||
import APPS_CONSTANTS from './constants';
|
||||
|
||||
let appBarRef: HTMLDivElement;
|
||||
let appContentRef: HTMLDivElement;
|
||||
let topPadding = $globalStore.isStudent ? '48px' : '109px';
|
||||
|
||||
const getResizableSidebar = () => {
|
||||
if (!browser) return;
|
||||
|
||||
return window.innerWidth >= 1024;
|
||||
};
|
||||
|
||||
function handleClose() {
|
||||
$apps.selectedApp = undefined;
|
||||
if ($globalStore.isStudent) {
|
||||
$apps.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppClick(appName?: string) {
|
||||
if (appName === $apps.selectedApp) {
|
||||
handleClose();
|
||||
} else {
|
||||
$apps.selectedApp = appName;
|
||||
}
|
||||
$apps.dropdown = !$apps.dropdown;
|
||||
}
|
||||
|
||||
function updateTopPadding(isStudent?: boolean) {
|
||||
topPadding = isStudent ? '48px' : '109px';
|
||||
}
|
||||
|
||||
function getAppClass(appName: string, selected: string | undefined) {
|
||||
return `transition duration-500 delay-150 ease-in-out border-t-0 border-b-0 border-r-0 ${
|
||||
appName === selected ? 'border-l-4 border-primary-600 focus:border-primary-600' : 'border-l-0'
|
||||
}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTopPadding($globalStore.isStudent);
|
||||
});
|
||||
|
||||
$: updateTopPadding($globalStore.isStudent);
|
||||
$: getResizableSidebar();
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={`--top-padding:${topPadding}`}
|
||||
class={`${$apps.open ? 'open ' : 'close'} root bg-white dark:bg-black`}
|
||||
bind:this={appBarRef}
|
||||
>
|
||||
<div class="apps">
|
||||
<div class="mb-2 lg:hidden">
|
||||
<IconButton
|
||||
buttonClassName="lg:hidden"
|
||||
toolTipProps={{ title: 'Settings', hotkeys: [] }}
|
||||
onClick={handleAppClick}
|
||||
>
|
||||
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}">
|
||||
<IconButton
|
||||
toolTipProps={{
|
||||
title: `${
|
||||
$globalStore.isOrgSite && !$currentOrg.customization.apps.poll
|
||||
? $t('course.navItem.lessons.disabled')
|
||||
: $t('course.navItem.lessons.polls.title')
|
||||
} `,
|
||||
hotkeys: ['Ctrl/Command', '2']
|
||||
}}
|
||||
value={APPS_CONSTANTS.APPS.POLL}
|
||||
onClick={handleAppClick}
|
||||
disabled={$globalStore.isOrgSite && !$currentOrg.customization.apps.poll}
|
||||
>
|
||||
<Chip
|
||||
value={$course.polls.length}
|
||||
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
|
||||
/>
|
||||
<ChartPie size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- <div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'QandA', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.QANDA}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.QANDA === $apps.selectedApp}
|
||||
>
|
||||
<Forum size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Notes', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.NOTES}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.NOTES === $apps.selectedApp}
|
||||
>
|
||||
<AlignBoxTopLeft size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
{#if !!$apps.selectedApp}
|
||||
<div
|
||||
class="app max-w-[350px] transition delay-150 duration-100 ease-in-out"
|
||||
bind:this={appContentRef}
|
||||
>
|
||||
{#key $apps.selectedApp}
|
||||
<div class="h-full w-full" transition:fade={{ duration: 200 }}>
|
||||
{#if $apps.selectedApp === APPS_CONSTANTS.APPS.QANDA}
|
||||
<QandA {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.POLL}
|
||||
<Poll {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.NOTES}
|
||||
<Notes {handleClose} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1023px) {
|
||||
.app {
|
||||
width: 50vw;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.app {
|
||||
width: 100vw;
|
||||
min-width: calc(100vw - 60px);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.root {
|
||||
position: absolute;
|
||||
height: calc(100vh - var(--top-padding));
|
||||
top: var(--top-padding);
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
.open {
|
||||
position: absolute;
|
||||
transform: translateX(0);
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
transform: translateX(100%);
|
||||
display: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const apps = writable<{
|
||||
open: boolean;
|
||||
dropdown: boolean;
|
||||
selectedApp: string | undefined;
|
||||
}>({
|
||||
open: false,
|
||||
dropdown: false,
|
||||
selectedApp: undefined
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import { page } from '$app/state';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -8,15 +10,31 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
|
||||
|
||||
export let supabase: SupabaseClient;
|
||||
export let handleSubmit = () => {};
|
||||
export let isLogin = true;
|
||||
export let showOnlyContent = false;
|
||||
export let isLoading = false;
|
||||
export let showLogo = false;
|
||||
export let formRef;
|
||||
export let hideGoogleAuth = false;
|
||||
export let redirectPathname = '';
|
||||
interface Props {
|
||||
supabase: SupabaseClient;
|
||||
handleSubmit?: any;
|
||||
isLogin?: boolean;
|
||||
showOnlyContent?: boolean;
|
||||
isLoading?: boolean;
|
||||
showLogo?: boolean;
|
||||
formRef: any;
|
||||
hideGoogleAuth?: boolean;
|
||||
redirectPathname?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
supabase,
|
||||
handleSubmit = () => {},
|
||||
isLogin = true,
|
||||
showOnlyContent = false,
|
||||
isLoading = false,
|
||||
showLogo = false,
|
||||
formRef = $bindable(),
|
||||
hideGoogleAuth = false,
|
||||
redirectPathname = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (isLoading) {
|
||||
@@ -70,12 +88,8 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<form
|
||||
bind:this={formRef}
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
class="flex w-10/12 flex-col items-center"
|
||||
>
|
||||
<slot />
|
||||
<form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
|
||||
{@render children?.()}
|
||||
</form>
|
||||
{#if !showOnlyContent && !hideGoogleAuth}
|
||||
<div class="mb-3 w-10/12">
|
||||
@@ -98,14 +112,10 @@
|
||||
<div class="border-grey w-full border-t p-6 text-center">
|
||||
{#if isLogin}
|
||||
{$t('login.not_registered_yet')}
|
||||
<a class="text-primary-700 hover:underline" href="/signup{$page.url.search}"
|
||||
>{$t('login.signup')}</a
|
||||
>
|
||||
<a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
|
||||
{:else}
|
||||
{$t('login.already_have_account')}
|
||||
<a class="text-primary-700 hover:underline" href="/login{$page.url.search}"
|
||||
>{$t('login.login')}</a
|
||||
>
|
||||
<a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
<script>
|
||||
export let name = '';
|
||||
export let src = '';
|
||||
export let className = '';
|
||||
export let shape = 'rounded-full';
|
||||
export let width = 'w-10';
|
||||
export let height = 'h-10';
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string;
|
||||
src?: string;
|
||||
className?: string;
|
||||
shape?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name = '',
|
||||
src = '',
|
||||
className = '',
|
||||
shape = 'rounded-full',
|
||||
width = 'w-10',
|
||||
height = 'h-10'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="{className} {width} {height} {shape}" />
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
export let disableCenteredContent = false;
|
||||
export let className = '';
|
||||
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}"
|
||||
>
|
||||
<slot />
|
||||
<div class="root {!className.includes('opacity') && 'opacity-100'} {!disableCenteredContent && 'center'} {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script>
|
||||
export let className = '';
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{className} w-full h-[517px] flex items-center justify-center flex-col border rounded border-gray-200 dark:border-neutral-600"
|
||||
class="{className} flex h-[517px] w-full flex-col items-center justify-center rounded border border-gray-200 dark:border-neutral-600"
|
||||
>
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script>
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick = () => {};
|
||||
export let contained = false;
|
||||
export let color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500';
|
||||
export let size = 'small';
|
||||
interface Props {
|
||||
onClick?: IconButtonProps['onClick'];
|
||||
contained?: boolean;
|
||||
color?: string;
|
||||
size?: IconButtonProps['size'];
|
||||
toolTipProps?: IconButtonProps['toolTipProps'];
|
||||
}
|
||||
|
||||
let {
|
||||
onClick = () => {},
|
||||
contained = false,
|
||||
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
|
||||
size = 'small'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<IconButton {onClick} {color} {contained} {size}>
|
||||
{#if size === 'large'}
|
||||
<CloseIcon size={24} class="carbon-icon dark:text-black" title="Delete" />
|
||||
{:else}
|
||||
<CloseIcon size={20} class="carbon-icon dark:text-black" title="Delete" />
|
||||
{/if}
|
||||
<XIcon size={size === 'large' ? 24 : 16} class="carbon-icon dark:text-black" />
|
||||
</IconButton>
|
||||
<!-- color="text-primary-700 bg-primary-200 dark:bg-gray-200 dark:text-black" -->
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CloseButton } from './index.svelte';
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import Close from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
|
||||
export let onClick = () => {};
|
||||
let { onClick = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<Close size={24} class="carbon-icon dark:text-white" title="Close" />
|
||||
<XIcon size={16} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import SendFilled from 'carbon-icons-svelte/lib/SendFilled.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
<script lang="ts">
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
|
||||
export let onClick;
|
||||
let { onClick } = $props();
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<SendFilled size={24} class="carbon-icon dark:text-white" />
|
||||
<SendHorizontalIcon size={16} class="filled" />
|
||||
</IconButton>
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
export let className = '';
|
||||
export let isLMS = false;
|
||||
interface Props {
|
||||
className?: string;
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
$: href = isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain;
|
||||
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">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script>
|
||||
export let name = '';
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
let { name = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="w-fit text-xs border font-semibold px-1.5 rounded-md text-center text-blue-700 border-blue-700"
|
||||
>
|
||||
<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,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let className = '';
|
||||
export let value: string | number = '';
|
||||
export let size = '';
|
||||
export let shape = 'rounded-md';
|
||||
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' ? 'py-1 px-1' : 'py-2 px-2'} org-avatar {className.includes(
|
||||
'bg-'
|
||||
)
|
||||
class="{className} {size === 'sm' ? 'px-1 py-1' : 'px-2 py-2'} org-avatar {className.includes('bg-')
|
||||
? ''
|
||||
: 'bg-gray-200 dark:text-black'} {shape} text-center"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script>
|
||||
export let value = 0;
|
||||
export let className = '';
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script>
|
||||
export let code = '';
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
let { code = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-200 dark:bg-gray-500 p-5 rounded-md mb-3">
|
||||
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
|
||||
{@html code}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
export let label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon');
|
||||
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="org-avatar text-xs bg-primary-200 text-primary-700 font-medium py-1 px-2 rounded-md text-center"
|
||||
>
|
||||
<p class="org-avatar bg-primary-200 text-primary-700 rounded-md px-2 py-1 text-center text-xs font-medium">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
import { confettiStore } from './store';
|
||||
|
||||
let myCanvas;
|
||||
let myCanvas = $state();
|
||||
let myConfetti;
|
||||
|
||||
function showConfetti(show) {
|
||||
@@ -29,13 +29,13 @@
|
||||
myConfetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
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 },
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
@@ -44,14 +44,16 @@
|
||||
onMount(() => {
|
||||
myConfetti = confetti.create(myCanvas, {
|
||||
resize: true,
|
||||
useWorker: true,
|
||||
useWorker: true
|
||||
});
|
||||
});
|
||||
|
||||
$: showConfetti($confettiStore.show);
|
||||
$effect(() => {
|
||||
showConfetti($confettiStore.show);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={myCanvas} />
|
||||
<canvas bind:this={myCanvas}></canvas>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
|
||||
+16
-12
@@ -1,27 +1,31 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let value: string | number;
|
||||
export let subtitle: string = '';
|
||||
export let icon: any;
|
||||
export let iconColor: string = 'blue';
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: any;
|
||||
iconColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
$: colorClasses = {
|
||||
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
|
||||
|
||||
let colorClasses = $derived({
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||
};
|
||||
});
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
|
||||
<svelte:component this={icon} className={colorClasses[iconColor]} />
|
||||
<SvelteComponent className={colorClasses[iconColor]} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
|
||||
+59
-70
@@ -5,53 +5,58 @@
|
||||
import { ScaleTypes } from '@carbon/charts-svelte';
|
||||
import '@carbon/charts-svelte/styles.css';
|
||||
|
||||
export let courseAnalytics: CourseAnalytics | null = null;
|
||||
interface Props {
|
||||
courseAnalytics?: CourseAnalytics | null;
|
||||
}
|
||||
|
||||
let BarChartSimple: any;
|
||||
let { courseAnalytics = null }: Props = $props();
|
||||
|
||||
let BarChartSimple: any = $state();
|
||||
|
||||
// Transform data for progress distribution chart
|
||||
$: progressChartData = courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '80%+ Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
|
||||
},
|
||||
{
|
||||
group: '60-79% Progress',
|
||||
value: courseAnalytics.students.filter(
|
||||
(s) => s.progressPercentage >= 60 && s.progressPercentage < 80
|
||||
).length
|
||||
},
|
||||
{
|
||||
group: 'Below 60% Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
|
||||
}
|
||||
]
|
||||
: [];
|
||||
let progressChartData = $derived(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '80%+ Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
|
||||
},
|
||||
{
|
||||
group: '60-79% Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 60 && s.progressPercentage < 80)
|
||||
.length
|
||||
},
|
||||
{
|
||||
group: 'Below 60% Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Transform data for grade distribution chart
|
||||
$: gradeChartData = courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '90%+ Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
|
||||
},
|
||||
{
|
||||
group: '80-89% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90)
|
||||
.length
|
||||
},
|
||||
{
|
||||
group: '70-79% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80)
|
||||
.length
|
||||
},
|
||||
{
|
||||
group: 'Below 70% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
|
||||
}
|
||||
]
|
||||
: [];
|
||||
let gradeChartData = $derived(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '90%+ Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
|
||||
},
|
||||
{
|
||||
group: '80-89% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90).length
|
||||
},
|
||||
{
|
||||
group: '70-79% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80).length
|
||||
},
|
||||
{
|
||||
group: 'Below 70% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
@@ -61,7 +66,7 @@
|
||||
});
|
||||
|
||||
// Chart options for progress distribution
|
||||
$: progressChartOptions = {
|
||||
let progressChartOptions = $derived({
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value',
|
||||
@@ -84,10 +89,10 @@
|
||||
'Below 60% Progress': '#ef4444' // red
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Chart options for grade distribution
|
||||
$: gradeChartOptions = {
|
||||
let gradeChartOptions = $derived({
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value',
|
||||
@@ -111,24 +116,16 @@
|
||||
'Below 70% Grade': '#ef4444' // red
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if browser && BarChartSimple}
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<!-- Progress Distribution Chart -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Student Progress Distribution
|
||||
</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Student Progress Distribution</h3>
|
||||
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
|
||||
<svelte:component
|
||||
this={BarChartSimple}
|
||||
data={progressChartData}
|
||||
options={progressChartOptions}
|
||||
/>
|
||||
<BarChartSimple data={progressChartData} options={progressChartOptions} />
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No progress data available
|
||||
@@ -137,12 +134,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution Chart -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
|
||||
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
|
||||
<svelte:component this={BarChartSimple} data={gradeChartData} options={gradeChartOptions} />
|
||||
<BarChartSimple data={gradeChartData} options={gradeChartOptions} />
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No grade data available
|
||||
@@ -153,19 +148,13 @@
|
||||
{:else}
|
||||
<!-- Loading state -->
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Student Progress Distribution
|
||||
</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Student Progress Distribution</h3>
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
|
||||
|
||||
+3
-9
@@ -8,9 +8,7 @@
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _}
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<SkeletonPlaceholder style="width: 48px; height: 48px;" />
|
||||
<div class="flex-1">
|
||||
@@ -26,9 +24,7 @@
|
||||
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<SkeletonPlaceholder style="width: 200px; height: 24px; margin-bottom: 24px;" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
|
||||
@@ -38,9 +34,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Students Table Skeleton -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<SkeletonPlaceholder style="width: 200px; height: 24px;" />
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { ChartBar } from 'carbon-icons-svelte';
|
||||
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
|
||||
|
||||
export let title: string = 'No data available';
|
||||
export let description: string = 'There is no data to display at this time.';
|
||||
export let icon: any = ChartBar;
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'No data available',
|
||||
description = 'There is no data to display at this time.',
|
||||
icon = ChartBarIcon,
|
||||
className = ''
|
||||
}: Props = $props();
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
|
||||
<div
|
||||
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
>
|
||||
<svelte:component this={icon} size="24" class="text-gray-400 dark:text-gray-500" />
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800">
|
||||
<SvelteComponent size="24" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
|
||||
@@ -3,27 +3,32 @@
|
||||
import { Tag, Pagination } from 'carbon-components-svelte';
|
||||
import type { StudentOverview } from '$lib/utils/types/analytics';
|
||||
import EmptyState from './EmptyState.svelte';
|
||||
import { User } from 'carbon-icons-svelte';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { course } from '../../store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let students: StudentOverview[] = [];
|
||||
interface Props {
|
||||
students?: StudentOverview[];
|
||||
}
|
||||
|
||||
let { students = [] }: Props = $props();
|
||||
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// Computed values
|
||||
$: totalPages = Math.ceil(students.length / pageSize);
|
||||
$: startIndex = (currentPage - 1) * pageSize;
|
||||
$: endIndex = startIndex + pageSize;
|
||||
$: paginatedStudents = students.slice(startIndex, endIndex);
|
||||
$: startItem = startIndex + 1;
|
||||
$: endItem = Math.min(endIndex, students.length);
|
||||
let totalPages = $derived(Math.ceil(students.length / pageSize));
|
||||
let startIndex = $derived((currentPage - 1) * pageSize);
|
||||
let endIndex = $derived(startIndex + pageSize);
|
||||
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
||||
let startItem = $derived(startIndex + 1);
|
||||
let endItem = $derived(Math.min(endIndex, students.length));
|
||||
|
||||
const GotoFullProfile = (student: StudentOverview) => {
|
||||
if (!student) return;
|
||||
|
||||
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
|
||||
};
|
||||
|
||||
@@ -32,16 +37,18 @@
|
||||
}
|
||||
|
||||
// Reset to first page when students array changes
|
||||
$: if (students.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
$effect(() => {
|
||||
if (students.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if students.length === 0}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_students_found')}
|
||||
description={$t('analytics.no_students_description')}
|
||||
icon={User}
|
||||
icon={UserIcon}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Responsive Table -->
|
||||
@@ -91,12 +98,7 @@
|
||||
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={student.profile.avatar_url}
|
||||
name={student.profile.fullname}
|
||||
width="w-8"
|
||||
height="h-8"
|
||||
/>
|
||||
<Avatar src={student.profile.avatar_url} name={student.profile.fullname} width="w-8" height="h-8" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{student.profile.fullname}
|
||||
@@ -115,10 +117,7 @@
|
||||
</span>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-500"
|
||||
style="width: {student.progressPercentage}%"
|
||||
></div>
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,11 +129,7 @@
|
||||
</td>
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<Tag
|
||||
type={student.averageGrade >= 80
|
||||
? 'green'
|
||||
: student.averageGrade >= 60
|
||||
? 'blue'
|
||||
: 'red'}
|
||||
type={student.averageGrade >= 80 ? 'green' : student.averageGrade >= 60 ? 'blue' : 'red'}
|
||||
size="sm"
|
||||
>
|
||||
{student.averageGrade}%
|
||||
@@ -148,7 +143,7 @@
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<button
|
||||
class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
on:click={() => GotoFullProfile(student)}
|
||||
onclick={() => GotoFullProfile(student)}
|
||||
>
|
||||
{$t('analytics.view_details')}
|
||||
</button>
|
||||
@@ -162,9 +157,7 @@
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{#if students.length > pageSize}
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600"
|
||||
>
|
||||
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{$t('analytics.showing_students', {
|
||||
start: startItem,
|
||||
@@ -172,12 +165,7 @@
|
||||
total: students.length
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalItems={students.length}
|
||||
{pageSize}
|
||||
on:change={handlePageChange}
|
||||
/>
|
||||
<Pagination page={currentPage} totalItems={students.length} {pageSize} on:change={handlePageChange} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
+6
-4
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
@@ -10,7 +14,5 @@
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z"
|
||||
/>
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { RadioButtonGroup, RadioButton, Toggle } from 'carbon-components-svelte';
|
||||
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { goto } from '$app/navigation';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -30,11 +30,14 @@
|
||||
'blueBadgePattern'
|
||||
];
|
||||
|
||||
let isSaving = false;
|
||||
let errors = {
|
||||
let isSaving = $state(false);
|
||||
let errors = $state({
|
||||
description: ''
|
||||
};
|
||||
let helperText = '';
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${$course.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
const saveCertificate = async () => {
|
||||
isSaving = true;
|
||||
@@ -47,8 +50,7 @@
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
errors.description =
|
||||
$t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
@@ -70,15 +72,11 @@
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: helperText = `${$course.description?.length || 0}/200 ${$t(
|
||||
'course.navItem.certificates.characters'
|
||||
)}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="true" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
@@ -96,20 +94,12 @@
|
||||
<p class="my-4 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.theme')}
|
||||
</p>
|
||||
<RadioButtonGroup
|
||||
bind:selected={$course.certificate_theme}
|
||||
class="mb-10"
|
||||
disabled={$isFreePlan}
|
||||
>
|
||||
<RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10" disabled={$isFreePlan}>
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each themes as theme}
|
||||
<div class="mr-3 flex">
|
||||
<RadioButton value={theme} />
|
||||
<img
|
||||
src={`/images/certificate_theme_${theme}.png`}
|
||||
alt="themes"
|
||||
class="h-[82px] w-[110px]"
|
||||
/>
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -159,9 +149,7 @@
|
||||
</Toggle>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800"
|
||||
>
|
||||
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
|
||||
<div class="certificate-container flex items-center justify-center">
|
||||
{#if $course.certificate_theme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
@@ -188,7 +176,7 @@
|
||||
isDisabled={$isFreePlan}
|
||||
>
|
||||
{#if $isFreePlan}
|
||||
<FlashFilled size={16} class="text-blue-700" />
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</PrimaryButton>
|
||||
|
||||
+9
-15
@@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import { Toggle } from 'carbon-components-svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -33,16 +35,13 @@
|
||||
<span slot="labelA" style="color: #161616">Automatic</span>
|
||||
<span slot="labelB" style="color: green">Automatic</span>
|
||||
</Toggle>
|
||||
<p class="text-sm font-medium my-4">
|
||||
If you set this as automatic, certificates will be issued after the
|
||||
learner completes the course.
|
||||
<p class="my-4 text-sm font-medium">
|
||||
If you set this as automatic, certificates will be issued after the learner completes the course.
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs font-normal text-gray-500 my-4">
|
||||
or send a personalised/ custom certificate below:
|
||||
</p>
|
||||
<form on:submit|preventDefault={issueCertificate}>
|
||||
<div class="flex flex-col md:flex-row gap-2 w-full">
|
||||
<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
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
@@ -65,18 +64,13 @@
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
rows={2}
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<PrimaryButton className="px-6 py-3 rounded-md text-sm font-medium" label="Issue certificate" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -11,15 +11,13 @@
|
||||
Pagination
|
||||
} from 'carbon-components-svelte';
|
||||
|
||||
let searchValue = '';
|
||||
let checked = false;
|
||||
let searchValue = $state('');
|
||||
let checked = $state(false);
|
||||
</script>
|
||||
|
||||
<section class="flex flex-wrap items-start gap-2 mb-10 -ml-4 -mr-4">
|
||||
<section class="-ml-4 -mr-4 mb-10 flex flex-wrap items-start gap-2">
|
||||
{#each CertificateReportData as data (data.id)}
|
||||
<div
|
||||
class="box flex flex-col rounded border border-gray-200 dark:border-neutral-600 justify-center px-2 mb-5"
|
||||
>
|
||||
<div class="box mb-5 flex flex-col justify-center rounded border border-gray-200 px-2 dark:border-neutral-600">
|
||||
<p class="text-xs font-normal">{data.title}</p>
|
||||
<bold class="text-2xl font-medium">{data.value}</bold>
|
||||
</div>
|
||||
@@ -28,12 +26,8 @@
|
||||
|
||||
<section>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<p class="text-base font-semibold w-full">Certificate issued</p>
|
||||
<Search
|
||||
placeholder="search students"
|
||||
bind:value={searchValue}
|
||||
searchClass="border-b border-transparent"
|
||||
/>
|
||||
<p class="w-full text-base font-semibold">Certificate issued</p>
|
||||
<Search placeholder="search students" bind:value={searchValue} searchClass="border-b border-transparent" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -42,16 +36,14 @@
|
||||
<StructuredListHead class="bg-primary-50 py-0">
|
||||
<StructuredListRow head class="py-0">
|
||||
<StructuredListCell head class="py-0">
|
||||
<div
|
||||
class="flex-3 flex flex-row items-center justify-start w-full text-primary-600 text-sm font-medium"
|
||||
>
|
||||
<div class="flex-3 text-primary-600 flex w-full flex-row items-center justify-start text-sm font-medium">
|
||||
<Checkbox bind:checked />
|
||||
<p class="text-primary-900 text-start w-full text-sm font-medium">Name of student</p>
|
||||
<p class="text-primary-900 w-full text-start text-sm font-medium">Name of student</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
<StructuredListCell head class="py-0">
|
||||
<div class="flex-2 items-center w-full text-primary-900 text-sm font-medium">
|
||||
<p class="align-middle text-sm font-medium pt-1">Date issued</p>
|
||||
<div class="flex-2 text-primary-900 w-full items-center text-sm font-medium">
|
||||
<p class="pt-1 align-middle text-sm font-medium">Date issued</p>
|
||||
<p class="opacity-0">personalized</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
@@ -61,19 +53,17 @@
|
||||
{#each students as item (item.id)}
|
||||
<StructuredListRow label for="row-{item}">
|
||||
<StructuredListCell>
|
||||
<div
|
||||
class="flex-3 flex flex-row items-center justify-start w-full text-sm font-medium pt-4"
|
||||
>
|
||||
<div class="flex-3 flex w-full flex-row items-center justify-start pt-4 text-sm font-medium">
|
||||
<Checkbox />
|
||||
<p class="text-start w-full text-sm font-normal">{item.name}</p>
|
||||
<p class="w-full text-start text-sm font-normal">{item.name}</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
<StructuredListCell>
|
||||
<div class="flex-2 flex flex-row items-center gap-10 w-full">
|
||||
<p class="text-sm font-normal align-middle">{item.issued}</p>
|
||||
<div class="flex-2 flex w-full flex-row items-center gap-10">
|
||||
<p class="align-middle text-sm font-normal">{item.issued}</p>
|
||||
{#if item.peronalized == true}
|
||||
<span class="text-center bg-primary-100 px-3 py-2 rounded-lg">
|
||||
<p class="opacity-100 text-xs text-primary-600">personalized</p>
|
||||
<span class="bg-primary-100 rounded-lg px-3 py-2 text-center">
|
||||
<p class="text-primary-600 text-xs opacity-100">personalized</p>
|
||||
</span>
|
||||
{:else}
|
||||
<p class="opacity-0">personalized</p>
|
||||
|
||||
+3
-3
@@ -4,12 +4,12 @@
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-2">
|
||||
<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-xl font-normal text-center">
|
||||
<p class="text-center text-xl font-normal">
|
||||
{$t('course.navItem.certificates.locked_certificate')}
|
||||
</p>
|
||||
<p class="text-sm font-normal text-center">
|
||||
<p class="text-center text-sm font-normal">
|
||||
{$t('course.navItem.certificates.instructor_unlock')} <br />
|
||||
{$t('course.navItem.certificates.contact_instructor')}
|
||||
</p>
|
||||
|
||||
+15
-12
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Download from 'carbon-icons-svelte/lib/Download.svelte';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
@@ -15,8 +14,8 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = false;
|
||||
let isCourseComplete = false;
|
||||
let isLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let progress: ProfileCourseProgress | undefined;
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
@@ -73,12 +72,16 @@
|
||||
hasUserCompletedCourse();
|
||||
});
|
||||
|
||||
$: title = isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title';
|
||||
$: subtitle = isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle';
|
||||
let title = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title'
|
||||
);
|
||||
let subtitle = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
@@ -94,10 +97,10 @@
|
||||
className="flex items-center gap-2"
|
||||
onClick={downLoadCertificate}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
isDisabled={!env.PUBLIC_SERVER_URL || !isCourseComplete}
|
||||
isDisabled={!isCourseComplete}
|
||||
{isLoading}
|
||||
>
|
||||
<Download size={16} />
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
+22
-26
@@ -1,60 +1,56 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full relative dark:text-black">
|
||||
<img
|
||||
src="/images/blue-badge-pattern-background.svg"
|
||||
alt=""
|
||||
class="w-full h-full absolute z-0 object-cover"
|
||||
/>
|
||||
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm font-bold">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="font-bold mt-16">
|
||||
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
|
||||
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
<div class="mt-16 font-bold">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="font-bold mt-3 w-[90%]">
|
||||
<div class="mt-3 w-[90%] font-bold">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="text-lg m-0">
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center my-8 text-center">
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black font-bold text-[7px]">Signature</p>
|
||||
<p class="border-t border-black text-[7px] font-bold">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="text-[8px] font-bold w-[90%] desc">
|
||||
<footer class="desc w-[90%] text-[8px] font-bold">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden flex justify-center items-center">
|
||||
<img
|
||||
src="/images/blue-badge-pattern-badge.svg"
|
||||
alt="A badge"
|
||||
class="max-w-none w-[8rem] md:w-[10rem]"
|
||||
/>
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+19
-15
@@ -1,45 +1,49 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="p-3 w-full dark:text-black bg-[#2D499F]">
|
||||
<div class="w-full bg-white pb-5 flex">
|
||||
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex justify-start items-center gap-1 text-sm font-bold">
|
||||
<header class="flex items-center justify-start gap-1 text-sm font-bold">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="font-bold mt-7">
|
||||
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
|
||||
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
<div class="mt-7 font-bold">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="font-bold mt-3 w-[90%]">
|
||||
<div class="mt-3 w-[90%] font-bold">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="text-lg m-0">
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-8 mb-5 text-center">
|
||||
<div class="mb-5 mt-8 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black font-bold text-[7px]">Signature</p>
|
||||
<p class="border-t border-black text-[7px] font-bold">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="text-[8px] font-bold w-[90%] desc">
|
||||
<footer class="desc w-[90%] text-[8px] font-bold">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
+14
-16
@@ -1,36 +1,34 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-4';
|
||||
const bordercolor = 'border-pink-700';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="certificate-bg w-full flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
|
||||
>
|
||||
<div class="flex items-center gap-1 my-2">
|
||||
<img
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
|
||||
alt="logo"
|
||||
class=" w-10 h-10 rounded-md"
|
||||
/>
|
||||
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class=" h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="text-sm text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full">
|
||||
<p class="my-2 w-full text-center text-sm font-normal uppercase tracking-widest text-gray-500">
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="border-b border-gray-500 mb-4">
|
||||
<p class="text-xs text-black font-medium">This is to certify that</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs text-black font-medium">has successfully completed the course</p>
|
||||
<p class="text-xs font-medium text-black">has successfully completed the course</p>
|
||||
<div>
|
||||
<p class="clamp bg-transparent text-base font-semibold text-pink-700 text-center uppercase">
|
||||
<p class="clamp bg-transparent text-center text-base font-semibold uppercase text-pink-700">
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+14
-20
@@ -1,44 +1,38 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-l-8';
|
||||
const bordercolor = 'border-primary-800';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="certificate-bg w-full flex items-center justify-center border border-dashed border-primary-400"
|
||||
>
|
||||
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
|
||||
<div class="{borderwidth} {bordercolor} w-full">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="text-sm font-normal my-2 uppercase text-gray-500">
|
||||
<p class="my-2 text-sm font-normal uppercase text-gray-500">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="border-b border-gray-500 mb-4">
|
||||
<p class="text-xs text-black font-medium">This is to certify that</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs text-black font-medium">
|
||||
has successfully completed introductory courses in
|
||||
</p>
|
||||
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
|
||||
<div>
|
||||
<p
|
||||
class="clamp bg-transparent text-base font-semibold text-primary-800 text-center uppercase my-2"
|
||||
>
|
||||
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{$course.description}</p>
|
||||
<div class="flex items-center gap-1 my-2">
|
||||
<img
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
|
||||
alt="logo"
|
||||
class="w-10 h-10 rounded-md"
|
||||
/>
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+22
-26
@@ -1,60 +1,56 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full relative dark:text-black">
|
||||
<img
|
||||
src="/images/purple-badge-pattern-background.svg"
|
||||
alt=""
|
||||
class="w-full h-full absolute z-0 object-cover"
|
||||
/>
|
||||
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-[17rem]">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm font-bold">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="font-bold mt-16">
|
||||
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
|
||||
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#914397]">{studentName}</h1>
|
||||
<div class="mt-16 font-bold">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="font-bold mt-3 w-[90%]">
|
||||
<div class="mt-3 w-[90%] font-bold">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="text-lg m-0">
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center my-8 text-center">
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black font-bold text-[7px]">Signature</p>
|
||||
<p class="border-t border-black text-[7px] font-bold">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="text-[8px] font-bold w-[90%] desc">
|
||||
<footer class="desc w-[90%] text-[8px] font-bold">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden flex justify-center items-center">
|
||||
<img
|
||||
src="/images/purple-badge-pattern-badge.svg"
|
||||
alt="A badge"
|
||||
class="max-w-none w-[8rem] md:w-[10rem]"
|
||||
/>
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+19
-15
@@ -1,45 +1,49 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="p-3 w-full dark:text-black bg-[#95449A]">
|
||||
<div class="w-full bg-white pb-5 flex">
|
||||
<div class="w-full bg-[#95449A] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex justify-start items-center gap-1 text-sm font-bold">
|
||||
<header class="flex items-center justify-start gap-1 text-sm font-bold">
|
||||
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="font-bold mt-7">
|
||||
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
|
||||
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#95449A]">{studentName}</h1>
|
||||
<div class="mt-7 font-bold">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="font-bold mt-3 w-[90%]">
|
||||
<div class="mt-3 w-[90%] font-bold">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="text-lg m-0">
|
||||
<h2 class="m-0 text-lg">
|
||||
{$course.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-8 mb-5 text-center">
|
||||
<div class="mb-5 mt-8 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px] font-bold">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black font-bold text-[7px]">Signature</p>
|
||||
<p class="border-t border-black text-[7px] font-bold">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="text-[8px] font-bold w-[90%] desc">
|
||||
<footer class="desc w-[90%] text-[8px] font-bold">
|
||||
{$course.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
+8
-4
@@ -7,9 +7,13 @@
|
||||
import { course } from '../../store';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export let open = false;
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let isActivating = false;
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
let isActivating = $state(false);
|
||||
|
||||
const activate = async () => {
|
||||
isActivating = true;
|
||||
@@ -37,12 +41,12 @@
|
||||
maxWidth="max-w-xl"
|
||||
modalHeading={$t(`course.navItem.lessons.section_prompt.header`)}
|
||||
>
|
||||
<div class="flex flex-col w-full items-center">
|
||||
<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')}
|
||||
</h3>
|
||||
<p class="text-center max-w-md">
|
||||
<p class="max-w-md text-center">
|
||||
{$t('course.navItem.lessons.section_prompt.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -4,8 +4,12 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let openDeleteModal = false;
|
||||
export let deleteLesson = () => {};
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteLesson?: any;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteLesson = () => {} }: Props = $props();
|
||||
|
||||
async function handleDelete() {
|
||||
deleteLesson();
|
||||
@@ -21,7 +25,7 @@
|
||||
modalHeading={$t('delete_modal.label')}
|
||||
>
|
||||
<div>
|
||||
<h1 class="dark:text-white text-lg">
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}?
|
||||
</h1>
|
||||
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -6,8 +6,7 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { deleteConfirmation } from '../store/exercise';
|
||||
|
||||
export let onDelete = () => {};
|
||||
export let onCancel = () => {};
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
|
||||
+147
-168
@@ -1,4 +1,6 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import {
|
||||
questionnaire,
|
||||
deleteConfirmation,
|
||||
@@ -12,17 +14,16 @@
|
||||
} from '../store/exercise';
|
||||
import { Select, SelectItem } from 'carbon-components-svelte';
|
||||
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import AddFilledIcon from 'carbon-icons-svelte/lib/AddFilled.svelte';
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
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 IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
@@ -30,21 +31,23 @@
|
||||
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
|
||||
import OrderModal from './OrderModal.svelte';
|
||||
import { QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
import { filterOutDeleted } from './functions';
|
||||
import { deleteExercise } from '$lib/utils/services/courses';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
const initialQuestionsLength = $questionnaire.questions.length;
|
||||
|
||||
export let shouldDeleteExercise = false;
|
||||
export let exerciseId;
|
||||
export let goBack = () => {};
|
||||
interface Props {
|
||||
shouldDeleteExercise?: boolean;
|
||||
exerciseId: any;
|
||||
goBack?: any;
|
||||
}
|
||||
|
||||
let errors = {};
|
||||
let questionIdToDelete = null;
|
||||
let questions = [];
|
||||
let isDeleting = false;
|
||||
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
|
||||
function shouldScrollToLast(questionId, questions) {
|
||||
const [lastQuestion] = questions.slice(-1);
|
||||
@@ -70,25 +73,21 @@
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
console.log('delete');
|
||||
isDeleting = true;
|
||||
await deleteExercise(questions, exerciseId);
|
||||
|
||||
const qToDelete = $questionnaire.questions.map((q) => ({ id: `${q.id}` }));
|
||||
await deleteExercise(qToDelete, exerciseId);
|
||||
|
||||
lesson.update((_lesson) => ({
|
||||
..._lesson,
|
||||
exercises: _lesson.exercises.filter((exercise) => exercise.id !== exerciseId)
|
||||
}));
|
||||
|
||||
goBack();
|
||||
}
|
||||
|
||||
$: errors = $questionnaireValidation;
|
||||
$: questions = filterOutDeleted($questionnaire.questions);
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
onCancel={() => (questionIdToDelete = null)}
|
||||
onDelete={onFinalDeleteClicked}
|
||||
/>
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
|
||||
<OrderModal />
|
||||
|
||||
@@ -98,8 +97,8 @@
|
||||
width="w-2/4"
|
||||
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}
|
||||
>
|
||||
<form on:submit|preventDefault>
|
||||
<h1 class="dark:text-white text-xl">
|
||||
<form onsubmit={preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
@@ -124,157 +123,137 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<div class="w-full mb-20">
|
||||
{#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>
|
||||
<TrashIcon size={18} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
value={`${option.id}`}
|
||||
onClick={handleAnswerSelect(question.id, option.id)}
|
||||
buttonClassName={option.is_correct ? 'success' : ''}
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<CircleCheckIcon size={18} filled={option.is_correct} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="w-full flex justify-center mb-4">
|
||||
<ErrorMessage
|
||||
message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}
|
||||
/>
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
<ErrorMessage message={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="questions pt-6 px-6">
|
||||
{#each questions as question (question.id)}
|
||||
<!-- {#key question.id} -->
|
||||
<QuestionContainer
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
onPointsChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="mr-5 w-3/5">
|
||||
<TextField
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
|
||||
bind:value={question.title}
|
||||
isRequired={true}
|
||||
onChange={() => {
|
||||
<div class="questions px-6 pt-6">
|
||||
{#each $questionnaire.questions as question, index}
|
||||
{#if !question.deleted_at}
|
||||
<QuestionContainer
|
||||
key={question.deleted_at}
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
onPointsChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-5 w-3/5">
|
||||
<TextField
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.question')}
|
||||
bind:value={question.title}
|
||||
isRequired={true}
|
||||
onChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
size="xl"
|
||||
class="w-[50px]"
|
||||
selected={question?.question_type?.id}
|
||||
on:change={(e) => {
|
||||
const id = (e.target as HTMLSelectElement)?.value;
|
||||
if (!id) return;
|
||||
|
||||
question.question_type = QUESTION_TYPES.find((q) => q.id === parseInt(id)) ?? QUESTION_TYPES[0];
|
||||
question.question_type_id = question.question_type.id;
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
size="xl"
|
||||
class="w-[50px]"
|
||||
bind:selected={question.question_type.id}
|
||||
on:change={(e) => {
|
||||
const id = parseInt(e.target?.value);
|
||||
question.question_type = QUESTION_TYPES.find((q) => q.id === id);
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
>
|
||||
{#each QUESTION_TYPES as type}
|
||||
<SelectItem value={type.id} text={$t(type.label)} />
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="flex justify-between items-center my-3 w-3/5">
|
||||
<TextArea
|
||||
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)}>
|
||||
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col mt-2">
|
||||
{#if QUESTION_TYPE.RADIO === question.question_type.id}
|
||||
{#each filterOutDeleted(question.options) as option}
|
||||
<RadioItem
|
||||
isEditable={true}
|
||||
name={question.title || 'radio-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
<div slot="iconbutton" class="flex items-center">
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleRemoveOption(question.id, option.id)}
|
||||
>
|
||||
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleAnswerSelect(question.id, option.id)}
|
||||
buttonClassName={option.is_correct && 'success'}
|
||||
>
|
||||
{#if option.is_correct}
|
||||
<CheckmarkFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</RadioItem>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
|
||||
{#each filterOutDeleted(question.options) as option}
|
||||
<Checkbox
|
||||
isEditable={true}
|
||||
name={question || 'checkbox-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
<div slot="iconbutton" class="flex items-center">
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleRemoveOption(question.id, option.id)}
|
||||
>
|
||||
<TrashCanIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleAnswerSelect(question.id, option.id)}
|
||||
buttonClassName={option.is_correct && 'success'}
|
||||
>
|
||||
{#if option.is_correct}
|
||||
<CheckmarkFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Checkbox>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
|
||||
<TextArea bind:value={question.value} disabled={true} />
|
||||
{/if}
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
|
||||
<div class="flex items-center mt-3">
|
||||
<PrimaryButton
|
||||
disablePadding={true}
|
||||
className="p-2"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleAddOption(question.id)}
|
||||
>
|
||||
<AddFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
<p class="dark:text-white ml-2">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
|
||||
</p>
|
||||
</PrimaryButton>
|
||||
{#each QUESTION_TYPES as type}
|
||||
<SelectItem value={type.id} text={$t(type.label)} />
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
|
||||
<!-- {/key} -->
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="my-3 flex w-3/5 items-center justify-between">
|
||||
<TextArea
|
||||
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)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex flex-col">
|
||||
{#each $questionnaire.questions[index]?.options || [] as option}
|
||||
{#if !option.deleted_at}
|
||||
{#if QUESTION_TYPE.RADIO === question.question_type.id}
|
||||
<RadioItem
|
||||
isEditable={true}
|
||||
name={question.title || 'radio-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
{@render optionActions(question, option)}
|
||||
</RadioItem>
|
||||
{:else if QUESTION_TYPE.CHECKBOX === question.question_type.id}
|
||||
<Checkbox
|
||||
isEditable={true}
|
||||
name={question?.name || 'checkbox-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
{@render optionActions(question, option)}
|
||||
</Checkbox>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
|
||||
<TextArea bind:value={question.value} disabled={true} />
|
||||
{/if}
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<ErrorMessage message={getQuestionErrorMsg(errors, question, 'option')} />
|
||||
{/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)}
|
||||
>
|
||||
<CirclePlusIcon size={16} />
|
||||
<p class="ml-2 dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.option')}
|
||||
</p>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+144
-152
@@ -7,25 +7,34 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { Dropdown, Tag } from 'carbon-components-svelte';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
// import { useCompletion } from 'ai/svelte';
|
||||
// import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
||||
import type { SubmissionIdData } from '$lib/utils/types/submission';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
export let open = false;
|
||||
export let onClose = () => {};
|
||||
export let handleSave = (d: SubmissionIdData) => {};
|
||||
export let isGradeWithAI = false;
|
||||
export let data: SubmissionIdData;
|
||||
export let deleteSubmission = async (id: string, statusId: number) => {};
|
||||
export let updateStatus = (arg: {
|
||||
submissionId: string;
|
||||
prevStatusId: number;
|
||||
nextStatusId: number;
|
||||
total: number;
|
||||
}) => {};
|
||||
export let isSaving = false;
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onClose?: any;
|
||||
handleSave?: any;
|
||||
isGradeWithAI?: boolean;
|
||||
data: SubmissionIdData;
|
||||
deleteSubmission?: any;
|
||||
updateStatus?: any;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onClose = () => {},
|
||||
handleSave = (_d: SubmissionIdData) => {},
|
||||
isGradeWithAI = $bindable(false),
|
||||
data = $bindable(),
|
||||
deleteSubmission = async (_id: string, _statusId: number) => {},
|
||||
updateStatus = (_arg: { submissionId: string; prevStatusId: number; nextStatusId: number; total: number }) => {},
|
||||
isSaving = false
|
||||
}: Props = $props();
|
||||
|
||||
const SELECTABLE_STATUS = [
|
||||
{
|
||||
@@ -43,14 +52,15 @@
|
||||
];
|
||||
|
||||
let status = SELECTABLE_STATUS[0];
|
||||
let selectedId = status.id;
|
||||
let reasons = {};
|
||||
let isLoading = false;
|
||||
let total = 0;
|
||||
let maxPoints = 0;
|
||||
let openMenu = false;
|
||||
let openDeletePrompt = false;
|
||||
let isDeleting = false;
|
||||
let selectedId = $state(status.id);
|
||||
let reasons = $state({});
|
||||
let isLoading = $state(false);
|
||||
let openMenu = $state(false);
|
||||
let openDeletePrompt = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const total = calculateTotal(data.questionAnswerByPoint);
|
||||
const maxPoints = $derived(getMaxPoints(data.questions));
|
||||
|
||||
function getMaxPoints(questions) {
|
||||
return (questions || []).reduce((acc, question) => acc + question.points, 0);
|
||||
@@ -80,41 +90,43 @@
|
||||
}
|
||||
|
||||
function setStatus(data: SubmissionIdData) {
|
||||
const statusBySelectedId = SELECTABLE_STATUS.find((status) => status.id === data.statusId);
|
||||
untrack(() => {
|
||||
const statusBySelectedId = SELECTABLE_STATUS.find((status) => status.id === data.statusId);
|
||||
|
||||
if (!statusBySelectedId) {
|
||||
return;
|
||||
}
|
||||
if (!statusBySelectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
status = statusBySelectedId;
|
||||
status = statusBySelectedId;
|
||||
|
||||
if (data.statusId !== selectedId) {
|
||||
selectedId = data.statusId;
|
||||
}
|
||||
if (data.statusId !== selectedId) {
|
||||
selectedId = data.statusId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getMultipleAnswersGrade = (options, answer, points) => {
|
||||
const correctOptions = options
|
||||
.filter((option) => option.is_correct)
|
||||
.map((option) => option.value);
|
||||
// const getMultipleAnswersGrade = (options, answer, points) => {
|
||||
// const correctOptions = options
|
||||
// .filter((option) => option.is_correct)
|
||||
// .map((option) => option.value);
|
||||
|
||||
const correctSelections = answer.answers.filter((answer) =>
|
||||
correctOptions.includes(answer)
|
||||
).length;
|
||||
// const correctSelections = answer.answers.filter((answer) =>
|
||||
// correctOptions.includes(answer)
|
||||
// ).length;
|
||||
|
||||
const incorrectSelections = answer.answers.length - correctSelections;
|
||||
// const incorrectSelections = answer.answers.length - correctSelections;
|
||||
|
||||
const pointsEarned = (correctSelections / correctOptions.length) * points;
|
||||
const deduction = (incorrectSelections * points) / correctOptions.length;
|
||||
const finalPoints = Math.max(pointsEarned - deduction, 0);
|
||||
// const pointsEarned = (correctSelections / correctOptions.length) * points;
|
||||
// const deduction = (incorrectSelections * points) / correctOptions.length;
|
||||
// const finalPoints = Math.max(pointsEarned - deduction, 0);
|
||||
|
||||
return {
|
||||
finalPoints,
|
||||
correctOptions,
|
||||
correctSelections,
|
||||
incorrectSelections
|
||||
};
|
||||
};
|
||||
// return {
|
||||
// finalPoints,
|
||||
// correctOptions,
|
||||
// correctSelections,
|
||||
// incorrectSelections
|
||||
// };
|
||||
// };
|
||||
|
||||
async function handleDeleteSubmission() {
|
||||
isDeleting = true;
|
||||
@@ -122,97 +134,96 @@
|
||||
isDeleting = false;
|
||||
}
|
||||
|
||||
const { input, handleSubmit, completion } = useCompletion({
|
||||
api: '/api/completion/gradingprompt',
|
||||
onFinish: async () => {
|
||||
try {
|
||||
const responseData = $completion.replace('```json', '').replace('```', '');
|
||||
// const { input, handleSubmit, completion } = useCompletion({
|
||||
// api: '/api/completion/gradingprompt',
|
||||
// onFinish: async () => {
|
||||
// try {
|
||||
// const responseData = $completion.replace('```json', '').replace('```', '');
|
||||
|
||||
let aiResponses: {
|
||||
id: number;
|
||||
score: number;
|
||||
explanation: string;
|
||||
}[] = [];
|
||||
try {
|
||||
// Parse the modified response data as JSON
|
||||
aiResponses = JSON.parse(responseData);
|
||||
} catch (error) {
|
||||
console.error('Error parsing AI response', error);
|
||||
}
|
||||
// let aiResponses: {
|
||||
// id: number;
|
||||
// score: number;
|
||||
// explanation: string;
|
||||
// }[] = [];
|
||||
// try {
|
||||
// // Parse the modified response data as JSON
|
||||
// aiResponses = JSON.parse(responseData);
|
||||
// } catch (error) {
|
||||
// console.error('Error parsing AI response', error);
|
||||
// }
|
||||
|
||||
data?.questions.forEach((question) => {
|
||||
const { id, points, question_type_id, options } = question;
|
||||
if (question_type_id == QUESTION_TYPE.RADIO) {
|
||||
const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
// data?.questions.forEach((question) => {
|
||||
// const { id, points, question_type_id, options } = question;
|
||||
// if (question_type_id == QUESTION_TYPE.RADIO) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
const answerPoints = answer?.answers?.length ?? 0;
|
||||
// const answerPoints = answer?.answers?.length ?? 0;
|
||||
|
||||
reasons = {
|
||||
...reasons,
|
||||
[id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
|
||||
'course.navItem.submissions.grading_modal.total_try'
|
||||
)} ${answerPoints} `
|
||||
};
|
||||
data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
|
||||
} else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
|
||||
const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.total_try'
|
||||
// )} ${answerPoints} `
|
||||
// };
|
||||
// data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
|
||||
// } else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
|
||||
// const answer = data.questionAnswers.find((q) => q.question_id === id);
|
||||
|
||||
const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
|
||||
getMultipleAnswersGrade(options, answer, points);
|
||||
// const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
|
||||
// getMultipleAnswersGrade(options, answer, points);
|
||||
|
||||
reasons = {
|
||||
...reasons,
|
||||
[id]: `${$t(
|
||||
'course.navItem.submissions.grading_modal.allocated'
|
||||
)} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
|
||||
correctOptions.length
|
||||
} ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
|
||||
'course.navItem.submissions.grading_modal.options_wrong'
|
||||
)} ${incorrectSelections} `
|
||||
};
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${$t(
|
||||
// 'course.navItem.submissions.grading_modal.allocated'
|
||||
// )} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
|
||||
// correctOptions.length
|
||||
// } ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
|
||||
// 'course.navItem.submissions.grading_modal.options_wrong'
|
||||
// )} ${incorrectSelections} `
|
||||
// };
|
||||
|
||||
data.questionAnswerByPoint[id] = `${finalPoints}`;
|
||||
} else if (aiResponses.length) {
|
||||
const graded = aiResponses.find((res) => res.id === id);
|
||||
// data.questionAnswerByPoint[id] = `${finalPoints}`;
|
||||
// } else if (aiResponses.length) {
|
||||
// const graded = aiResponses.find((res) => res.id === id);
|
||||
|
||||
reasons = {
|
||||
...reasons,
|
||||
[id]: `${graded?.explanation}`
|
||||
};
|
||||
// reasons = {
|
||||
// ...reasons,
|
||||
// [id]: `${graded?.explanation}`
|
||||
// };
|
||||
|
||||
data.questionAnswerByPoint[id] = `${graded?.score}`;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
// data.questionAnswerByPoint[id] = `${graded?.score}`;
|
||||
// }
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Error', error);
|
||||
// } finally {
|
||||
// isLoading = false;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
function gradeWithAI() {
|
||||
isGradeWithAI = true;
|
||||
isLoading = true;
|
||||
const paragraphAiInput = data.questions
|
||||
.filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
|
||||
.map((q) => {
|
||||
const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
|
||||
return {
|
||||
id: q.id,
|
||||
question: q.title,
|
||||
answer: answer?.open_answer,
|
||||
point: q.points
|
||||
};
|
||||
});
|
||||
|
||||
$input = JSON.stringify(paragraphAiInput);
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
// isGradeWithAI = true;
|
||||
// isLoading = true;
|
||||
// const paragraphAiInput = data.questions
|
||||
// .filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
|
||||
// .map((q) => {
|
||||
// const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
|
||||
// return {
|
||||
// id: q.id,
|
||||
// question: q.title,
|
||||
// answer: answer?.open_answer,
|
||||
// point: q.points
|
||||
// };
|
||||
// });
|
||||
// $input = JSON.stringify(paragraphAiInput);
|
||||
// handleSubmit({ preventDefault: () => {} });
|
||||
}
|
||||
|
||||
$: total = calculateTotal(data.questionAnswerByPoint);
|
||||
$: maxPoints = getMaxPoints(data.questions);
|
||||
$: setStatus(data);
|
||||
$effect(() => {
|
||||
setStatus(data);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
@@ -250,9 +261,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Preview
|
||||
questions={Array.isArray(data.questions)
|
||||
? data.questions.sort((a, b) => a.order - b.order)
|
||||
: []}
|
||||
questions={Array.isArray(data.questions) ? data.questions.sort((a, b) => a.order - b.order) : []}
|
||||
questionnaireMetaData={{
|
||||
answers: data.answers || {},
|
||||
isFinished: true
|
||||
@@ -300,9 +309,7 @@
|
||||
{$t('course.navItem.submissions.grading_modal.total_grade')}:
|
||||
</p>
|
||||
|
||||
<Tag
|
||||
class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
<Tag class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{total}/{maxPoints}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -318,14 +325,8 @@
|
||||
{$t('course.navItem.submissions.grading_modal.student')}:
|
||||
</p>
|
||||
{#if data.student}
|
||||
<div
|
||||
class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700"
|
||||
>
|
||||
<img
|
||||
alt="Student avatar"
|
||||
class="flex h-5 w-5 rounded-full"
|
||||
src={data.student.avatar_url}
|
||||
/>
|
||||
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
|
||||
<img alt="Student avatar" class="flex h-5 w-5 rounded-full" src={data.student.avatar_url} />
|
||||
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
|
||||
{data.student.fullname}
|
||||
</p>
|
||||
@@ -344,12 +345,7 @@
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.status')}:
|
||||
</p>
|
||||
<Dropdown
|
||||
bind:selectedId
|
||||
items={SELECTABLE_STATUS}
|
||||
class="w-full"
|
||||
on:select={handleStatusChange}
|
||||
/>
|
||||
<Dropdown bind:selectedId items={SELECTABLE_STATUS} class="w-full" on:select={handleStatusChange} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
@@ -365,11 +361,7 @@
|
||||
</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 "
|
||||
>
|
||||
<PrimaryButton onClick={gradeWithAI} variant={VARIANTS.OUTLINED} className="space-x-3 py-3 px-8 w-full ">
|
||||
<img src="/ai.svg" alt="ai" />
|
||||
<p class="text-sm font-semibold">
|
||||
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
|
||||
|
||||
+24
-15
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import CheckboxCheckedFilledIcon from 'carbon-icons-svelte/lib/CheckboxCheckedFilled.svelte';
|
||||
import RadioButtonCheckedIcon from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
|
||||
import TextAlignJustifyIcon from 'carbon-icons-svelte/lib/TextAlignJustify.svelte';
|
||||
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TablePropertiesIcon from '@lucide/svelte/icons/table-properties';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
@@ -19,7 +20,17 @@
|
||||
type: number;
|
||||
}
|
||||
|
||||
let items: Array<Question> = [];
|
||||
let items: Array<Question> = $state([]);
|
||||
|
||||
function setItemsFromQuestions(questions: typeof $questionnaire.questions) {
|
||||
untrack(() => {
|
||||
items = filterOutDeleted(questions).map((q) => ({
|
||||
id: q.id,
|
||||
name: q.title,
|
||||
type: q.question_type.id
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleDndConsider(e) {
|
||||
items = e.detail.items;
|
||||
@@ -44,11 +55,9 @@
|
||||
$questionnaireOrder.open = false;
|
||||
}
|
||||
|
||||
$: items = filterOutDeleted($questionnaire.questions).map((q) => ({
|
||||
id: q.id,
|
||||
name: q.title,
|
||||
type: q.question_type.id
|
||||
}));
|
||||
$effect(() => {
|
||||
setItemsFromQuestions($questionnaire.questions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
@@ -66,21 +75,21 @@
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="flex items-center rounded-md p-4 border border-primary-600"
|
||||
class="border-primary-600 flex items-center rounded-md border p-4"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<RadioButtonCheckedIcon size={16} class="carbon-icon active" />
|
||||
<SquareCheckIcon size={16} />
|
||||
{:else if item.type === 2}
|
||||
<CheckboxCheckedFilledIcon size={16} class="carbon-icon active" />
|
||||
<CircleDotIcon size={16} />
|
||||
{:else}
|
||||
<TextAlignJustifyIcon size={16} class="carbon-icon active" />
|
||||
<TablePropertiesIcon size={16} />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
|
||||
+24
-10
@@ -1,19 +1,33 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
|
||||
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
|
||||
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { getPropsForQuestion } from './functions';
|
||||
|
||||
export let questions = [];
|
||||
export let questionnaireMetaData = {};
|
||||
export let grades = {};
|
||||
export let onSubmit = () => {};
|
||||
export let onPrevious = () => {};
|
||||
export let disableGrading = true;
|
||||
export let isGradeWithAI = false;
|
||||
export let isLoading = false;
|
||||
export let reasons = {};
|
||||
interface Props {
|
||||
questions?: any;
|
||||
questionnaireMetaData?: any;
|
||||
grades?: any;
|
||||
onSubmit?: any;
|
||||
onPrevious?: any;
|
||||
disableGrading?: boolean;
|
||||
isGradeWithAI?: boolean;
|
||||
isLoading?: boolean;
|
||||
reasons?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
questions = [],
|
||||
questionnaireMetaData = {},
|
||||
grades = $bindable({}),
|
||||
onSubmit = () => {},
|
||||
onPrevious = () => {},
|
||||
disableGrading = true,
|
||||
isGradeWithAI = $bindable(false),
|
||||
isLoading = $bindable(false),
|
||||
reasons = $bindable({})
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each questions as currentQuestion, currentQuestionIndex}
|
||||
|
||||
+17
-20
@@ -5,17 +5,18 @@
|
||||
import { submissions } from './store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let isLoading = false;
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(false) }: Props = $props();
|
||||
|
||||
const defaultImg =
|
||||
'https://images.unsplash.com/photo-1599566150163-29194dcaad36?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8YXZhdGFyc3xlbnwwfHwwfHx8Mg%3D%3D';
|
||||
|
||||
let studentSelected = 0;
|
||||
let studentSelected = $state(0);
|
||||
|
||||
const isSelected = (
|
||||
student: ExerciseSubmissions,
|
||||
option: { question_id: number; value: string[] }
|
||||
) => {
|
||||
const isSelected = (student: ExerciseSubmissions, option: { question_id: number; value: string[] }) => {
|
||||
if ($submissions) {
|
||||
let submittedAnswer = student?.answers;
|
||||
let filteredAnswer = submittedAnswer.filter(
|
||||
@@ -42,9 +43,7 @@
|
||||
};
|
||||
const getOpenAnswer = (student: ExerciseSubmissions, q: never) => {
|
||||
let submittedAnswer = student?.answers;
|
||||
let filteredAnswer = submittedAnswer.filter(
|
||||
(ans: { question_id: number }) => ans.question_id === q.id
|
||||
);
|
||||
let filteredAnswer = submittedAnswer.filter((ans: { question_id: number }) => ans.question_id === q.id);
|
||||
if (filteredAnswer.some((ans: { open_answer: string }) => ans.open_answer == '')) {
|
||||
return $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no');
|
||||
} else {
|
||||
@@ -56,20 +55,18 @@
|
||||
{#if isLoading}
|
||||
<Loading withOverlay={true} />
|
||||
{:else if $submissions?.length}
|
||||
<div class="flex gap-1 w-full overflow-auto mt-2 mb-5">
|
||||
<div class="mb-5 mt-2 flex w-full gap-1 overflow-auto">
|
||||
{#each $submissions as student, i}
|
||||
<button on:click={() => (studentSelected = i)} class="flex flex-col items-center w-20">
|
||||
<button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center">
|
||||
<div
|
||||
class={`flex items-center justify-center w-12 h-12 rounded-full ${
|
||||
studentSelected == i ? 'border-[3px] border-primary-700' : ''
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${
|
||||
studentSelected == i ? 'border-primary-700 border-[3px]' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={student.submitted_by.profile.avatar_url
|
||||
? student.submitted_by.profile.avatar_url
|
||||
: defaultImg}
|
||||
src={student.submitted_by.profile.avatar_url ? student.submitted_by.profile.avatar_url : defaultImg}
|
||||
alt="student"
|
||||
class={`w-10 max-h-10 rounded-full bg-white m-1`}
|
||||
class="m-1 max-h-10 w-10 rounded-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="line-clamp-2 w-20 whitespace-pre-wrap leading-4">
|
||||
@@ -79,7 +76,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="font-medium mb-2">
|
||||
<p class="mb-2 font-medium">
|
||||
{$submissions[studentSelected].submitted_by.profile.fullname}'s {$t(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers'
|
||||
)}
|
||||
@@ -92,7 +89,7 @@
|
||||
{#if q.question_type_id !== 3}
|
||||
{#each q.options as option, i}
|
||||
<div
|
||||
class={`flex gap-2 items-center my-2 border-2 border-gray-300 rounded-md p-2 ${isCorrect(
|
||||
class={`my-2 flex items-center gap-2 rounded-md border-2 border-gray-300 p-2 ${isCorrect(
|
||||
$submissions[studentSelected],
|
||||
option
|
||||
)}`}
|
||||
@@ -108,7 +105,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="rounded bg-slate-100 dark:bg-slate-300 p-2 my-1 w-full">
|
||||
<div class="my-1 w-full rounded bg-slate-100 p-2 dark:bg-slate-300">
|
||||
<span class="text-base font-medium text-black">
|
||||
{getOpenAnswer($submissions[studentSelected], q)}
|
||||
</span>
|
||||
|
||||
+29
-34
@@ -1,22 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
import { questionnaire } from '../../store/exercise';
|
||||
import { replaceHTMLTag } from '$lib/utils/functions/course';
|
||||
import type {
|
||||
BarChartOptions,
|
||||
PieChartOptions,
|
||||
PieChart,
|
||||
BarChartSimple
|
||||
} from '@carbon/charts-svelte';
|
||||
import type { BarChartOptions, PieChartOptions, PieChart, BarChartSimple } from '@carbon/charts-svelte';
|
||||
import { getChartOptions } from './functions';
|
||||
import { submissions } from './store';
|
||||
import '@carbon/charts-svelte/styles.css';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let isLoading = true;
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(true) }: Props = $props();
|
||||
|
||||
type TranformedQuestionChartData = {
|
||||
group: string;
|
||||
@@ -29,11 +28,11 @@
|
||||
chartData: TranformedQuestionChartData[];
|
||||
}
|
||||
|
||||
let transformedQuestions: TranformedQuestion[] = [];
|
||||
let barChart: typeof BarChartSimple;
|
||||
let pieChart: typeof PieChart;
|
||||
let pieOptions: PieChartOptions;
|
||||
let barOptions: BarChartOptions;
|
||||
let transformedQuestions: TranformedQuestion[] = $state([]);
|
||||
let barChart: typeof BarChartSimple | undefined = $state();
|
||||
let pieChart: typeof PieChart | undefined = $state();
|
||||
let pieOptions: PieChartOptions | undefined = $derived(getChartOptions(isLoading).pieOptions);
|
||||
let barOptions: BarChartOptions | undefined = $derived(getChartOptions(isLoading).barOptions);
|
||||
|
||||
function getAnswerToQuestionOfStudent(
|
||||
questionId: number,
|
||||
@@ -99,11 +98,7 @@
|
||||
};
|
||||
|
||||
$submissions.forEach((studentSubmission) => {
|
||||
const studentAnswer = getAnswerToQuestionOfStudent(
|
||||
question.id,
|
||||
false,
|
||||
studentSubmission
|
||||
);
|
||||
const studentAnswer = getAnswerToQuestionOfStudent(question.id, false, studentSubmission);
|
||||
|
||||
if (studentAnswer.includes(value)) {
|
||||
chartData.value += 1;
|
||||
@@ -117,7 +112,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
transformedQuestions = [..._transformedQuestions];
|
||||
untrack(() => {
|
||||
transformedQuestions = [..._transformedQuestions];
|
||||
});
|
||||
|
||||
console.log({ transformedQuestions });
|
||||
};
|
||||
@@ -128,37 +125,35 @@
|
||||
pieChart = charts.PieChart;
|
||||
});
|
||||
|
||||
$: browser && $submissions?.length && getTransformedData($submissions);
|
||||
|
||||
$: {
|
||||
let chartOptions = getChartOptions(isLoading);
|
||||
barOptions = chartOptions.barOptions;
|
||||
pieOptions = chartOptions.pieOptions;
|
||||
|
||||
console.log({ chartOptions });
|
||||
}
|
||||
$effect(() => {
|
||||
if ($submissions?.length) {
|
||||
getTransformedData($submissions);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Loading withOverlay={true} />
|
||||
{:else if browser}
|
||||
<div>
|
||||
<p class="text-2xl mb-3">
|
||||
<p class="mb-3 text-2xl">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
|
||||
</p>
|
||||
{#each transformedQuestions as q}
|
||||
<div class="mb-4">
|
||||
<p>{q.title}</p>
|
||||
{#if q.type === 1}
|
||||
<svelte:component this={pieChart} data={q.chartData} options={pieOptions} />
|
||||
{:else if q.type === 2}
|
||||
<svelte:component this={barChart} data={q.chartData} options={barOptions} />
|
||||
{#if q.type === 1 && pieOptions}
|
||||
{@const SvelteComponent = pieChart}
|
||||
<SvelteComponent data={q.chartData} options={pieOptions} />
|
||||
{:else if q.type === 2 && barOptions}
|
||||
{@const SvelteComponent_1 = barChart}
|
||||
<SvelteComponent_1 data={q.chartData} options={barOptions} />
|
||||
{:else}
|
||||
<div class="max-h-[250px] overflow-auto">
|
||||
<ul>
|
||||
{#each q.chartData as answer (answer)}
|
||||
{#if answer.group}
|
||||
<div class="rounded bg-slate-100 dark:bg-slate-300 p-2 my-1 w-full">
|
||||
<div class="my-1 w-full rounded bg-slate-100 p-2 dark:bg-slate-300">
|
||||
<li class="text-base font-medium text-black">{answer.group}</li>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+31
-23
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import { browser } from '$app/environment';
|
||||
import Summary from './Summary.svelte';
|
||||
import Individual from './Individual.svelte';
|
||||
import { submissions } from './store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let exerciseId: string;
|
||||
interface Props {
|
||||
exerciseId: string;
|
||||
}
|
||||
|
||||
let { exerciseId = $bindable('') }: Props = $props();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -18,32 +22,36 @@
|
||||
value: 'individual'
|
||||
}
|
||||
];
|
||||
let currentTab = tabs[0].value;
|
||||
let currentTab = $state(tabs[0].value);
|
||||
|
||||
let isLoading = true;
|
||||
let isLoading = $state(true);
|
||||
|
||||
const onChange = (tabValue: string) => () => (currentTab = tabValue);
|
||||
|
||||
async function fetchSubmissions(id: string | undefined) {
|
||||
if (!id) return;
|
||||
isLoading = true;
|
||||
function fetchSubmissions(id: string | undefined) {
|
||||
untrack(async () => {
|
||||
if (!id) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await fetchSubmission({
|
||||
exerciseId: id
|
||||
const { data } = await fetchSubmission({
|
||||
exerciseId: id
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
submissions.set(data);
|
||||
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
submissions.set(data);
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: browser && fetchSubmissions(exerciseId);
|
||||
$effect(() => {
|
||||
fetchSubmissions(exerciseId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="border rounded-md mb-2 px-4">
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="mb-2 rounded-md border px-4">
|
||||
<div class="mt-4">
|
||||
<p class="text-lg">
|
||||
{$submissions.length}
|
||||
@@ -55,18 +63,18 @@
|
||||
<button
|
||||
class="relative {currentTab === tab.value
|
||||
? 'text-primary-700'
|
||||
: 'dark:bg-gray-500 dark:text-white'} dark:bg-transparent font-semibold focus:outline-none w-fit mr-4 text-center py-3 px-2"
|
||||
on:click={onChange(tab.value)}
|
||||
: '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)}
|
||||
>
|
||||
<div class="flex items-center justify-center w-full text-center">
|
||||
<div class="flex w-full items-center justify-center text-center">
|
||||
{tab.label}
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-0 left-0 h-[2px] bg-primary-700 transition-all ease-in-out duration-500 {currentTab ===
|
||||
class="bg-primary-700 absolute bottom-0 left-0 h-[2px] transition-all duration-500 ease-in-out {currentTab ===
|
||||
tab.value
|
||||
? 'w-full'
|
||||
: 'w-0'}"
|
||||
/>
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
+11
-8
@@ -6,7 +6,11 @@
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let preview: boolean;
|
||||
interface Props {
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
let { preview }: Props = $props();
|
||||
|
||||
function getTotalPossibleGrade(questions: { points: string }[]) {
|
||||
return questions.reduce((acc, question) => {
|
||||
@@ -52,31 +56,30 @@
|
||||
{:else if preview}
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$questionnaire.questions.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}
|
||||
</p>
|
||||
{#if $questionnaire.due_by}
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
|
||||
{new Date($questionnaire.due_by).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="mt-3 preview prose prose-sm sm:prose p-2">
|
||||
{@html $questionnaire.description ||
|
||||
$t('course.navItem.lessons.exercises.all_exercises.description.no')}
|
||||
<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}
|
||||
</QuestionContainer>
|
||||
|
||||
+55
-63
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { group, course } from '$lib/components/Course/store';
|
||||
import { questionnaire } from '../store/exercise';
|
||||
@@ -20,25 +21,23 @@
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import {
|
||||
NOTIFICATION_NAME,
|
||||
triggerSendEmail
|
||||
} from '$lib/utils/services/notification/notification';
|
||||
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { browser } from '$app/environment';
|
||||
import { COURSE_TYPE } from '$lib/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let preview: boolean = false;
|
||||
export let exerciseId = '';
|
||||
export let isFetchingExercise = false;
|
||||
interface Props {
|
||||
preview?: boolean;
|
||||
exerciseId?: string;
|
||||
isFetchingExercise?: boolean;
|
||||
}
|
||||
|
||||
let { preview = false, exerciseId = '', isFetchingExercise = false }: Props = $props();
|
||||
|
||||
let currentQuestion = {};
|
||||
let renderProps = {};
|
||||
let submission;
|
||||
let hasSubmission = false;
|
||||
let isLoadingAutoSavedData = false;
|
||||
let alreadyCheckedAutoSavedData = false;
|
||||
let isLoadingAutoSavedData = $state(false);
|
||||
let alreadyCheckedAutoSavedData = $state(false);
|
||||
let submissionResponse;
|
||||
|
||||
function handleStart() {
|
||||
@@ -85,8 +84,7 @@
|
||||
const { questions } = $questionnaire;
|
||||
const prevAnswer = answers[id] || [];
|
||||
|
||||
const formattedAnswer =
|
||||
typeof value === 'string' ? value : removeDuplicate([...prevAnswer, ...(value || [])]);
|
||||
const formattedAnswer = typeof value === 'string' ? value : removeDuplicate([...prevAnswer, ...(value || [])]);
|
||||
|
||||
$questionnaireMetaData.answers = {
|
||||
...answers,
|
||||
@@ -98,26 +96,18 @@
|
||||
|
||||
const isFinished = !questions[$questionnaireMetaData.currentQuestionIndex];
|
||||
console.log(`isFinished`, isFinished);
|
||||
console.log(
|
||||
`$questionnaireMetaData.currentQuestionIndex`,
|
||||
$questionnaireMetaData.currentQuestionIndex
|
||||
);
|
||||
console.log(`$questionnaireMetaData.currentQuestionIndex`, $questionnaireMetaData.currentQuestionIndex);
|
||||
|
||||
if (isCorrect) {
|
||||
setTimeout(async () => {
|
||||
$questionnaireMetaData.currentQuestionIndex += 1;
|
||||
localStorage.setItem(
|
||||
`autosave-exercise-${exerciseId}`,
|
||||
JSON.stringify($questionnaireMetaData)
|
||||
);
|
||||
localStorage.setItem(`autosave-exercise-${exerciseId}`, JSON.stringify($questionnaireMetaData));
|
||||
|
||||
// If last question send to server
|
||||
if (isFinished) {
|
||||
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||
$questionnaireMetaData.status = 1;
|
||||
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade(
|
||||
$questionnaire.questions
|
||||
);
|
||||
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
$questionnaireMetaData.grades = {};
|
||||
|
||||
$questionnaireMetaData.comment = '';
|
||||
@@ -161,7 +151,7 @@
|
||||
|
||||
function getTotalPossibleGrade(questions) {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc += parseFloat(question.points, 10);
|
||||
acc += parseFloat(question.points);
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
@@ -173,13 +163,14 @@
|
||||
|
||||
if (hasSubmission) return;
|
||||
|
||||
hasSubmission = true;
|
||||
|
||||
const args = {
|
||||
exerciseId,
|
||||
courseId,
|
||||
submittedBy: getGroupMemberId(people, profileId)
|
||||
};
|
||||
const { data } = await fetchSubmission(args);
|
||||
hasSubmission = true;
|
||||
|
||||
if (Array.isArray(data) && data.length) {
|
||||
submission = data[0];
|
||||
@@ -208,9 +199,7 @@
|
||||
function getAutoSavedData() {
|
||||
isLoadingAutoSavedData = true;
|
||||
|
||||
const stringifiedQuestionnaireMetaData = localStorage.getItem(
|
||||
`autosave-exercise-${exerciseId}`
|
||||
);
|
||||
const stringifiedQuestionnaireMetaData = localStorage.getItem(`autosave-exercise-${exerciseId}`);
|
||||
|
||||
if (stringifiedQuestionnaireMetaData) {
|
||||
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
|
||||
@@ -222,32 +211,43 @@
|
||||
alreadyCheckedAutoSavedData = true;
|
||||
}
|
||||
|
||||
$: browser && !alreadyCheckedAutoSavedData && getAutoSavedData();
|
||||
|
||||
// Reactive code
|
||||
$: if (alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0) {
|
||||
currentQuestion = $questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1];
|
||||
if ($questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion) {
|
||||
$questionnaireMetaData.isFinished = true;
|
||||
$effect(() => {
|
||||
if (!alreadyCheckedAutoSavedData) {
|
||||
getAutoSavedData();
|
||||
}
|
||||
});
|
||||
|
||||
if (currentQuestion) {
|
||||
renderProps = getPropsForQuestion(
|
||||
$questionnaire.questions,
|
||||
currentQuestion,
|
||||
$questionnaireMetaData,
|
||||
$questionnaireMetaData.currentQuestionIndex,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
preview
|
||||
);
|
||||
}
|
||||
$questionnaireMetaData.progressValue = getProgressValue(
|
||||
$questionnaireMetaData.currentQuestionIndex
|
||||
);
|
||||
}
|
||||
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
|
||||
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
|
||||
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
|
||||
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
|
||||
const renderProps = $derived(
|
||||
getPropsForQuestion(
|
||||
$questionnaire.questions,
|
||||
currentQuestion,
|
||||
$questionnaireMetaData,
|
||||
$questionnaireMetaData.currentQuestionIndex,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
preview
|
||||
)
|
||||
);
|
||||
|
||||
$: !isFetchingExercise && checkForSubmission($group.people, $profile.id, $course.id);
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
$questionnaireMetaData.isFinished = isFinished;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
$questionnaireMetaData.progressValue = progressValue;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
!isFetchingExercise && checkForSubmission($group.people, $profile.id, $course.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
@@ -256,10 +256,7 @@
|
||||
|
||||
{#if preview}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Preview
|
||||
questions={filterOutDeleted($questionnaire.questions)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
/>
|
||||
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
|
||||
</RoleBasedSecurity>
|
||||
{:else if !$questionnaire.questions.length}
|
||||
<Box>
|
||||
@@ -299,12 +296,7 @@
|
||||
{@html $questionnaire.description || 'No desription'}
|
||||
</article>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleStart}
|
||||
label="Start"
|
||||
className="my-5 float-right"
|
||||
type="button"
|
||||
/>
|
||||
<PrimaryButton onClick={handleStart} label="Start" className="my-5 float-right" type="button" />
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
{:else if $questionnaireMetaData.isFinished}
|
||||
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export const STATUS = {
|
||||
SUBMITTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
@@ -11,7 +11,7 @@ export const isAnswerCorrect = (options, answer) => {
|
||||
Single = only one should be correct
|
||||
Multiple = all should be correct
|
||||
*/
|
||||
export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished) {
|
||||
export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished?: boolean) {
|
||||
if (currentQuestion.question_type.id === QUESTION_TYPE.TEXTAREA) {
|
||||
return true;
|
||||
}
|
||||
@@ -29,9 +29,7 @@ export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished) {
|
||||
.every((option) => formattedAnswers?.includes(option.value));
|
||||
} else if (currentQuestion.question_type.id === QUESTION_TYPE.RADIO) {
|
||||
// At least one correct answer should be selected
|
||||
isCorrect = formattedAnswers?.some((answer) =>
|
||||
isAnswerCorrect(currentQuestion.options, answer)
|
||||
);
|
||||
isCorrect = formattedAnswers?.some((answer) => isAnswerCorrect(currentQuestion.options, answer));
|
||||
}
|
||||
|
||||
if (isCorrect && !isFinished) {
|
||||
@@ -60,11 +58,11 @@ export function getPropsForQuestion(
|
||||
if (!isCorrect && document && document.getElementById('question')) {
|
||||
const questionElement = document.getElementById('question');
|
||||
// Shake if wrong answer was selected
|
||||
questionElement.classList.toggle('shake');
|
||||
questionElement?.classList.toggle('shake');
|
||||
|
||||
setTimeout(() => {
|
||||
// Remove so we can trigger animation again
|
||||
questionElement.classList.toggle('shake');
|
||||
questionElement?.classList.toggle('shake');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { PageBody } from '$lib/components/Page';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -13,15 +13,14 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
ContentSwitcher,
|
||||
// ContentSwitcher,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
Switch
|
||||
OverflowMenuItem
|
||||
// Switch
|
||||
} from 'carbon-components-svelte';
|
||||
import AddAltIcon from 'carbon-icons-svelte/lib/AddAlt.svelte';
|
||||
import ViewIcon from 'carbon-icons-svelte/lib/View.svelte';
|
||||
import ViewFilledIcon from 'carbon-icons-svelte/lib/ViewFilled.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import {
|
||||
handleAddQuestion,
|
||||
questionnaire,
|
||||
@@ -34,15 +33,19 @@
|
||||
import UpdateDescription from './UpdateDescription.svelte';
|
||||
import ViewMode from './ViewMode.svelte';
|
||||
|
||||
export let exerciseId = '';
|
||||
export let path = '';
|
||||
export let goBack = () => {};
|
||||
export let isFetching = false;
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
path?: string;
|
||||
goBack?: any;
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
let preview: boolean = false;
|
||||
let shouldDeleteExercise = false;
|
||||
let isSaving = false;
|
||||
let selectedIndex = 0;
|
||||
let { exerciseId = $bindable(''), path = '', goBack = () => {}, isFetching = false }: Props = $props();
|
||||
|
||||
let preview: boolean = $state(false);
|
||||
let shouldDeleteExercise = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
async function handleSave() {
|
||||
if ($globalStore.isStudent) return;
|
||||
@@ -51,9 +54,11 @@
|
||||
if (Object.values(errors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
reset();
|
||||
|
||||
try {
|
||||
const updatedQuestions = await upsertExercise($questionnaire, exerciseId);
|
||||
|
||||
@@ -79,9 +84,10 @@
|
||||
reset();
|
||||
});
|
||||
|
||||
$: onSelectedIndexChange(selectedIndex);
|
||||
function onSelectedIndexChange(index: number) {
|
||||
goto($page.url.pathname + '?tabIndex=' + index);
|
||||
untrack(() => {
|
||||
goto($page.url.pathname + '?tabIndex=' + index);
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -91,10 +97,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
$: $questionnaire?.questions?.length < 1 && handleAddQuestion();
|
||||
$effect(() => {
|
||||
onSelectedIndexChange(selectedIndex);
|
||||
});
|
||||
// $effect(() => {
|
||||
// const addNewQ = $questionnaire?.questions?.length < 1;
|
||||
// console.log('addNewQ', addNewQ);
|
||||
|
||||
// if (addNewQ) {
|
||||
// untrack(() => {
|
||||
// handleAddQuestion();
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
</script>
|
||||
|
||||
<PageBody bind:isPageNavHidden={$globalStore.isStudent} padding="px-4 overflow-x-hidden">
|
||||
<PageBody isPageNavHidden={$globalStore.isStudent} padding="px-4 overflow-x-hidden">
|
||||
<div class="sticky top-0 z-10 mb-3 bg-gray-100 p-2 dark:bg-neutral-800">
|
||||
<Breadcrumb noTrailingSlash>
|
||||
<BreadcrumbItem href={path}>
|
||||
@@ -106,7 +124,7 @@
|
||||
</Breadcrumb>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<ContentSwitcher bind:selectedIndex class="mb-2">
|
||||
<!-- <ContentSwitcher bind:selectedIndex class="mb-2">
|
||||
<Switch
|
||||
text="{$t('course.navItem.lessons.exercises.all_exercises.questions')} ({$questionnaire
|
||||
.questions.length})"
|
||||
@@ -116,7 +134,7 @@
|
||||
'course.navItem.lessons.exercises.all_exercises.submissions'
|
||||
)} ({$questionnaire.totalSubmissions})"
|
||||
/>
|
||||
</ContentSwitcher>
|
||||
</ContentSwitcher> -->
|
||||
|
||||
{#if selectedIndex === 0}
|
||||
<div class="right-0 flex w-full items-center justify-end">
|
||||
@@ -137,14 +155,17 @@
|
||||
hotkeys: []
|
||||
}}
|
||||
>
|
||||
{#if preview}
|
||||
<ViewFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ViewIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
<EyeIcon size={20} class={preview ? 'filled' : ''} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleAddQuestion()} size="small">
|
||||
<AddAltIcon size={24} class="carbon-icon dark:text-white" />
|
||||
<IconButton
|
||||
onClick={handleAddQuestion}
|
||||
toolTipProps={{
|
||||
title: $t('course.navItem.lessons.exercises.all_exercises.add_question'),
|
||||
direction: 'bottom',
|
||||
hotkeys: []
|
||||
}}
|
||||
>
|
||||
<CirclePlusIcon size={20} />
|
||||
</IconButton>
|
||||
<OverflowMenu flipped>
|
||||
<OverflowMenuItem
|
||||
|
||||
+124
-120
@@ -1,90 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
// import { browser } from '$app/environment';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import { Tag } from 'carbon-components-svelte';
|
||||
import { type GeneratedTemplates, getAllTemplates, TAGS } from '$lib/mocks';
|
||||
import type { ExerciseTemplate } from '$lib/utils/types';
|
||||
import { lesson, lessonByTranslation } from '../store/lessons';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
// import { lesson, lessonByTranslation } from '../store/lessons';
|
||||
// import { useCompletion } from 'ai/svelte';
|
||||
import Confetti from '$lib/components/Confetti/index.svelte';
|
||||
import { toggleConfetti } from '$lib/components/Confetti/store';
|
||||
// import { toggleConfetti } from '$lib/components/Confetti/store';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
|
||||
import { writable } from 'svelte/store';
|
||||
import { Circle3 } from 'svelte-loading-spinners';
|
||||
// import { getTextFromHTML } from '$lib/utils/functions/toHtml';
|
||||
// import { Circle3 } from 'svelte-loading-spinners';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { lessonFallbackNote } from '$lib/utils/functions/translations';
|
||||
// import { lessonFallbackNote } from '$lib/utils/functions/translations';
|
||||
|
||||
export let open = false;
|
||||
export let handleAddExercise = () => {};
|
||||
export let handleCancelAddExercise = () => {};
|
||||
export let handleTemplateCreate: (template: ExerciseTemplate) => Promise<void>;
|
||||
export let title = '';
|
||||
|
||||
enum Type {
|
||||
SCRATCH = 0,
|
||||
TEMPLATE = 1,
|
||||
AI = 2
|
||||
interface Props {
|
||||
open: boolean;
|
||||
handleAddExercise: () => void;
|
||||
handleCancelAddExercise: () => void;
|
||||
handleTemplateCreate: (template: ExerciseTemplate) => Promise<void>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
let type: Type = Type.SCRATCH;
|
||||
let questionNumber = 5;
|
||||
let optionNumber = 5;
|
||||
let isLoading = writable(false);
|
||||
let isAIStarted = false;
|
||||
let note = '';
|
||||
let allTemplates: GeneratedTemplates;
|
||||
let {
|
||||
open = $bindable(false),
|
||||
handleAddExercise = () => {},
|
||||
handleCancelAddExercise = () => {},
|
||||
handleTemplateCreate,
|
||||
title = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
const Type = {
|
||||
SCRATCH: 0,
|
||||
TEMPLATE: 1,
|
||||
AI: 2
|
||||
} as const;
|
||||
|
||||
const options = [
|
||||
{
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch'),
|
||||
subtitle: $t(
|
||||
'course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch_subtitle'
|
||||
),
|
||||
subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.from_scratch_subtitle'),
|
||||
type: Type.SCRATCH,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template'),
|
||||
subtitle: $t(
|
||||
'course.navItem.lessons.exercises.new_exercise_modal.options.use_template_subtitle'
|
||||
),
|
||||
subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_template_subtitle'),
|
||||
type: Type.TEMPLATE,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai'),
|
||||
subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai_subtitle'),
|
||||
type: Type.AI,
|
||||
isDisabled: false
|
||||
}
|
||||
// TODO: Come back to fix ai implementation
|
||||
// {
|
||||
// title: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai'),
|
||||
// subtitle: $t('course.navItem.lessons.exercises.new_exercise_modal.options.use_ai_subtitle'),
|
||||
// type: Type.AI,
|
||||
// isDisabled: false
|
||||
// }
|
||||
];
|
||||
|
||||
const tags = Object.values(TAGS);
|
||||
let selectedTag = tags[0];
|
||||
let selectedTemplateId = '';
|
||||
let isTemplateFinishedLoading = false;
|
||||
|
||||
const { input, handleSubmit, completion } = useCompletion({
|
||||
api: '/api/completion/exerciseprompt',
|
||||
onFinish: async (prompt: string, completion: string) => {
|
||||
if (!$lesson.id) return;
|
||||
let step = $state(0);
|
||||
let type: number = $state(Type.SCRATCH);
|
||||
// let questionNumber = $state(5);
|
||||
// let optionNumber = $state(5);
|
||||
let isLoading = $state(false);
|
||||
let isAIStarted = $state(false);
|
||||
let allTemplates: GeneratedTemplates | undefined = $state();
|
||||
|
||||
toggleConfetti();
|
||||
const responseData = $completion.replace('```json', '').replace('```', '');
|
||||
const template: ExerciseTemplate = JSON.parse(responseData);
|
||||
await handleTemplateCreate(template);
|
||||
toggleConfetti();
|
||||
$isLoading = false;
|
||||
}
|
||||
});
|
||||
let selectedTag = $state(tags[0]);
|
||||
let selectedTemplateId = $state('');
|
||||
let isTemplateFinishedLoading = $state(false);
|
||||
|
||||
// const {
|
||||
// input,
|
||||
// handleSubmit
|
||||
// completion
|
||||
// } = useCompletion({
|
||||
// api: '/api/completion/exerciseprompt',
|
||||
// onFinish: async (/**prompt: string, completion: string*/) => {
|
||||
// if (!$lesson.id) return;
|
||||
|
||||
// toggleConfetti();
|
||||
// const responseData = completion. $completion.replace('```json', '').replace('```', '');
|
||||
// const template: ExerciseTemplate = JSON.parse(responseData);
|
||||
// await handleTemplateCreate(template);
|
||||
// toggleConfetti();
|
||||
// isLoading = false;
|
||||
// }
|
||||
// });
|
||||
|
||||
function handleNext() {
|
||||
step = step + 1;
|
||||
@@ -94,86 +104,79 @@
|
||||
step = step - 1;
|
||||
}
|
||||
|
||||
function callAI() {
|
||||
$input = JSON.stringify({
|
||||
questionNumber,
|
||||
optionNumber,
|
||||
lessonNote: note
|
||||
});
|
||||
// function callAI() {
|
||||
// $input = JSON.stringify({
|
||||
// questionNumber,
|
||||
// optionNumber,
|
||||
// lessonNote: note
|
||||
// });
|
||||
|
||||
setTimeout(() => {
|
||||
isAIStarted = true;
|
||||
$isLoading = true;
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
}
|
||||
// setTimeout(() => {
|
||||
// isAIStarted = true;
|
||||
// isLoading = true;
|
||||
// handleSubmit({ preventDefault: () => {} });
|
||||
// }, 500);
|
||||
// }
|
||||
|
||||
onMount(async () => {
|
||||
allTemplates = await getAllTemplates();
|
||||
});
|
||||
|
||||
$: content = lessonFallbackNote(
|
||||
$lesson.materials.note,
|
||||
$lessonByTranslation[$lesson.id || ''],
|
||||
$lesson.locale
|
||||
);
|
||||
$: note = browser ? getTextFromHTML(content) : '';
|
||||
// const content = $derived(
|
||||
// lessonFallbackNote(
|
||||
// $lesson.materials.note,
|
||||
// $lessonByTranslation[$lesson.id || ''],
|
||||
// $lesson.locale
|
||||
// )
|
||||
// );
|
||||
// const note = $derived(browser ? getTextFromHTML(content) : '');
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={handleCancelAddExercise}
|
||||
bind:open
|
||||
modalHeading={$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}
|
||||
maxWidth="max-w-4xl"
|
||||
maxWidth="max-w-2xl"
|
||||
width="w-4/5"
|
||||
>
|
||||
{#if !$isLoading && isAIStarted}
|
||||
{#if !isLoading && isAIStarted}
|
||||
<Confetti />
|
||||
{/if}
|
||||
{#if step === 0}
|
||||
<div>
|
||||
<h2 class="text-2xl font-medium my-5">
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
|
||||
</h2>
|
||||
|
||||
<div class="flex gap-2 justify-between my-8">
|
||||
<div class="my-8 flex justify-between gap-2">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="w-[261px] h-[240px] p-5 rounded-md dark:bg-neutral-700 border-2 {option.type ===
|
||||
type
|
||||
class="h-[240px] w-[261px] rounded-md border-2 p-5 dark:bg-neutral-700 {option.type === type
|
||||
? 'border-primary-400'
|
||||
: `border-gray-200 dark:border-neutral-600 ${
|
||||
!option.isDisabled && 'hover:scale-95'
|
||||
}`} flex flex-col {option.isDisabled &&
|
||||
'cursor-not-allowed opacity-60'} transition-all ease-in-out"
|
||||
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
|
||||
type="button"
|
||||
on:click={!option.isDisabled ? () => (type = option.type) : undefined}
|
||||
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
|
||||
>
|
||||
<div class="w-full flex flex-row-reverse h-[70%]">
|
||||
{#if option.type === type}
|
||||
<CheckmarkFilledIcon
|
||||
size={16}
|
||||
class="carbon-icon text-primary-600 dark:text-primary-200"
|
||||
/>
|
||||
{:else if !option.isDisabled}
|
||||
<CheckmarkOutlineIcon size={16} class="carbon-icon" />
|
||||
{/if}
|
||||
<div class="flex h-[70%] w-full flex-row-reverse">
|
||||
<CircleCheckIcon size={16} filled={option.type === type} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-bold text-start flex items-center">
|
||||
<p class="flex items-center text-start font-bold">
|
||||
<span class="mr-2 text-sm">{option.title}</span>
|
||||
{#if option.isDisabled}
|
||||
<ComingSoon />
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs font-light text-start">{option.subtitle}</p>
|
||||
<p class="text-start text-xs font-light">{option.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center flex-row-reverse">
|
||||
<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')}
|
||||
@@ -183,17 +186,15 @@
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
{#if type === Type.SCRATCH}
|
||||
<div class="flex items-center justify-center w-96 m-auto min-h-[300px]">
|
||||
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
|
||||
<div class="w-full">
|
||||
<h2 class="text-2xl font-medium my-5">
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
|
||||
</h2>
|
||||
<TextField
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t(
|
||||
'course.navItem.lessons.exercises.new_exercise_modal.title_placeholder'
|
||||
)}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
@@ -214,7 +215,7 @@
|
||||
</div>
|
||||
{:else if type === Type.TEMPLATE}
|
||||
<div>
|
||||
<h2 class="text-2xl font-medium mb-2 m-0">
|
||||
<h2 class="m-0 mb-2 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
|
||||
</h2>
|
||||
|
||||
@@ -237,15 +238,15 @@
|
||||
{#if allTemplates}
|
||||
{#each allTemplates[selectedTag] as template}
|
||||
<button
|
||||
class="w-[161px] h-[140px] hover:scale-95 p-5 rounded-md dark:bg-neutral-700 border-2 {template.id ===
|
||||
class="h-[140px] w-[161px] rounded-md border-2 p-5 hover:scale-95 dark:bg-neutral-700 {template.id ===
|
||||
selectedTemplateId
|
||||
? 'border-primary-400'
|
||||
: `border-gray-200 dark:border-neutral-600 `} flex flex-col transition-all ease-in-out"
|
||||
type="button"
|
||||
on:click={() => (selectedTemplateId = template.id)}
|
||||
onclick={() => (selectedTemplateId = template.id)}
|
||||
>
|
||||
<div class="flex flex-col justify-evenly h-full">
|
||||
<p class="font-bold text-sm text-start flex items-center">
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<p class="flex items-center text-start text-sm font-bold">
|
||||
{template.title}
|
||||
</p>
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
@@ -253,7 +254,7 @@
|
||||
{template.questions}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
|
||||
</p>
|
||||
<p class="text-xs font-normal text-start">
|
||||
<p class="text-start text-xs font-normal">
|
||||
{template.points}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
</p>
|
||||
@@ -278,24 +279,27 @@
|
||||
isLoading={isTemplateFinishedLoading}
|
||||
onClick={async () => {
|
||||
isTemplateFinishedLoading = true;
|
||||
const template = allTemplates[selectedTag].find((t) => t.id === selectedTemplateId);
|
||||
const template = allTemplates?.[selectedTag]?.find((t) => t.id === selectedTemplateId);
|
||||
|
||||
if (!template) return;
|
||||
|
||||
console.log('Selected template', template);
|
||||
|
||||
await handleTemplateCreate(template.data);
|
||||
|
||||
isTemplateFinishedLoading = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === Type.AI}
|
||||
<div>
|
||||
<div class="flex flex-row justify-between max-h-[500px]">
|
||||
<div class="w-[60%] mr-1 border px-3 py-2 rounded-md">
|
||||
<!-- {:else if type === Type.AI} -->
|
||||
<!-- <div>
|
||||
<div class="flex max-h-[500px] flex-row justify-between">
|
||||
<div class="mr-1 w-[60%] rounded-md border px-3 py-2">
|
||||
{#if note.length}
|
||||
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.create_exercises')}</h3>
|
||||
<p class="text-sm mb-4">
|
||||
<p class="mb-4 text-sm">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.choose_questions')}
|
||||
</p>
|
||||
<TextField
|
||||
@@ -316,11 +320,11 @@
|
||||
{:else}
|
||||
<h3>{$t('course.navItem.lessons.exercises.new_exercise_modal.add_note')}</h3>
|
||||
{/if}
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<div class="mt-5 flex flex-row-reverse items-center">
|
||||
<PrimaryButton
|
||||
onClick={callAI}
|
||||
isLoading={$isLoading}
|
||||
isDisabled={$isLoading || !note}
|
||||
{isLoading}
|
||||
isDisabled={isLoading || !note}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.generate')}
|
||||
@@ -328,16 +332,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-[40%] px-5 py-3 border rounded-md overflow-y-auto flex justify-center items-center"
|
||||
class="flex w-[40%] items-center justify-center overflow-y-auto rounded-md border px-5 py-3"
|
||||
>
|
||||
{#if $isLoading}
|
||||
{#if isLoading}
|
||||
<Circle3 size="60" unit="px" duration="1s" />
|
||||
{:else if isAIStarted}
|
||||
<p class="max-h-[200px] leading-7 text-sm">
|
||||
<p class="max-h-[200px] text-sm leading-7">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.completion')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="max-h-[200px] leading-7 text-sm">
|
||||
<p class="max-h-[200px] text-sm leading-7">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.click_generate')}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -351,13 +355,13 @@
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<PrimaryButton
|
||||
isDisabled={$isLoading || !note}
|
||||
isDisabled={isLoading || !note}
|
||||
className="px-6 py-3"
|
||||
label={$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
|
||||
onClick={handleAddExercise}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
@@ -14,26 +14,31 @@
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import type { ExerciseTemplate } from '$lib/utils/types';
|
||||
import { Breadcrumb, BreadcrumbItem } from 'carbon-components-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import Exercise from '../Exercise/index.svelte';
|
||||
import { isQuestionnaireFetching, questionnaire } from '../store/exercise';
|
||||
import { lesson } from '../store/lessons';
|
||||
import type { Exercise as ExerciseType } from '$lib/utils/types';
|
||||
|
||||
export let path = '';
|
||||
export let exerciseId = '';
|
||||
export let lessonId = '';
|
||||
interface Props {
|
||||
path?: string;
|
||||
exerciseId?: string;
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let open = false;
|
||||
let isFetching = false;
|
||||
let newExercise = {
|
||||
let { path = $bindable(''), exerciseId = '', lessonId = '' }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let isFetching = $state(false);
|
||||
let newExercise = $state({
|
||||
id: 1,
|
||||
title: '',
|
||||
description: ''
|
||||
};
|
||||
});
|
||||
|
||||
async function handleTemplateCreate(template: ExerciseTemplate): Promise<void> {
|
||||
const newExercise = await createExerciseFromTemplate(lessonId, template);
|
||||
const newExercise = (await createExerciseFromTemplate(lessonId, template)) as ExerciseType;
|
||||
console.log('newExercise', newExercise);
|
||||
if (newExercise) {
|
||||
lesson.update((_lesson) => ({
|
||||
@@ -52,10 +57,13 @@
|
||||
lesson_id: lessonId
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
console.log(`data, error `, data, error);
|
||||
const insertedExercise = data?.[0] as ExerciseType;
|
||||
lesson.update((_lesson) => ({
|
||||
..._lesson,
|
||||
exercises: [..._lesson.exercises, data[0]]
|
||||
exercises: [..._lesson.exercises, insertedExercise]
|
||||
}));
|
||||
|
||||
handleCancelAddExercise();
|
||||
@@ -70,27 +78,31 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function getExercises() {
|
||||
if (!lessonId) return;
|
||||
isFetching = true;
|
||||
const exercisesData = await supabase
|
||||
.from('exercise')
|
||||
.select(`id, title, created_at`)
|
||||
.match({ lesson_id: lessonId });
|
||||
function getExercises(lId: string | undefined) {
|
||||
if (!lId) return;
|
||||
|
||||
$lesson.exercises = exercisesData.data;
|
||||
isFetching = false;
|
||||
untrack(async () => {
|
||||
isFetching = true;
|
||||
|
||||
const exercisesData = await supabase.from('exercise').select(`id, title, created_at`).match({ lesson_id: lId });
|
||||
|
||||
$lesson.exercises = exercisesData.data as ExerciseType[];
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function getExercise(exerciseId: string | undefined) {
|
||||
if (!exerciseId) return;
|
||||
isFetching = true;
|
||||
isQuestionnaireFetching.update(() => true);
|
||||
|
||||
let { data, error } = await supabase
|
||||
.from('exercise')
|
||||
.select(
|
||||
`
|
||||
untrack(async () => {
|
||||
isFetching = true;
|
||||
|
||||
isQuestionnaireFetching.update(() => true);
|
||||
|
||||
let { data, error } = await supabase
|
||||
.from('exercise')
|
||||
.select(
|
||||
`
|
||||
id, title, description, due_by,
|
||||
totalSubmissions:submission(count),
|
||||
questions:question(
|
||||
@@ -99,38 +111,37 @@
|
||||
question_type:question_type_id(id, label)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('id', exerciseId)
|
||||
.single();
|
||||
)
|
||||
.eq('id', exerciseId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data.questions)) {
|
||||
// Need to set the question type inorder for the select in the questionnaire builder to match
|
||||
data.questions.forEach((question) => {
|
||||
question.question_type = QUESTION_TYPES.find(
|
||||
(type) => type.id === question.question_type.id
|
||||
);
|
||||
return question;
|
||||
if (data && Array.isArray(data.questions)) {
|
||||
// Need to set the question type inorder for the select in the questionnaire builder to match
|
||||
data.questions.forEach((question) => {
|
||||
question.question_type = QUESTION_TYPES.find((type) => type.id === question.question_type.id);
|
||||
return question;
|
||||
});
|
||||
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
||||
} else if (data) {
|
||||
data.questions = [];
|
||||
}
|
||||
|
||||
questionnaire.set({
|
||||
...(data || {}),
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
is_due_by_dirty: false,
|
||||
questions: Array.isArray(data?.questions) ? data?.questions : [],
|
||||
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
|
||||
});
|
||||
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
||||
} else if (data) {
|
||||
data.questions = [];
|
||||
}
|
||||
|
||||
questionnaire.set({
|
||||
...(data || {}),
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
is_due_by_dirty: false,
|
||||
questions: Array.isArray(data.questions) ? data.questions : [],
|
||||
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
|
||||
isQuestionnaireFetching.update(() => false);
|
||||
isFetching = false;
|
||||
});
|
||||
|
||||
isQuestionnaireFetching.update(() => false);
|
||||
isFetching = false;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -138,10 +149,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getExercises();
|
||||
getExercises(lessonId);
|
||||
});
|
||||
|
||||
$: getExercise(exerciseId);
|
||||
$effect(() => {
|
||||
getExercise(exerciseId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isFetching}
|
||||
@@ -151,7 +164,7 @@
|
||||
{/if}
|
||||
|
||||
{#if exerciseId}
|
||||
<Exercise {exerciseId} {goBack} bind:path {isFetching} />
|
||||
<Exercise {exerciseId} {goBack} {path} {isFetching} />
|
||||
{:else}
|
||||
<NewExerciseModal
|
||||
bind:open
|
||||
@@ -161,29 +174,27 @@
|
||||
bind:title={newExercise.title}
|
||||
/>
|
||||
|
||||
<PageBody bind:isPageNavHidden={$globalStore.isStudent}>
|
||||
<slot:fragment slot="header">
|
||||
<Breadcrumb class="my-2">
|
||||
<BreadcrumbItem href={path}>{$t('course.navItem.lessons.exercises.heading')}</BreadcrumbItem
|
||||
>
|
||||
</Breadcrumb>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton
|
||||
className="mr-2 my-2"
|
||||
label={$t('course.navItem.lessons.exercises.add_button')}
|
||||
onClick={() => (open = !open)}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
<PageBody isPageNavHidden={$globalStore.isStudent}>
|
||||
{#snippet header()}
|
||||
<slot:fragment>
|
||||
<Breadcrumb class="my-2">
|
||||
<BreadcrumbItem href={path}>{$t('course.navItem.lessons.exercises.heading')}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton
|
||||
className="mr-2 my-2"
|
||||
label={$t('course.navItem.lessons.exercises.add_button')}
|
||||
onClick={() => (open = !open)}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-wrap mt-5">
|
||||
<div class="mt-5 flex flex-wrap">
|
||||
{#each $lesson.exercises as exercise}
|
||||
<a
|
||||
class="w-52 bg-gray-100 dark:bg-neutral-800 px-4 py-7 mr-4 mb-4 rounded-lg"
|
||||
href="{path}/{exercise.id}"
|
||||
>
|
||||
<h3 class="dark:text-white text-xl">{exercise.title}</h3>
|
||||
<p class="dark:text-white mt-4 text-sm">{formatDate(exercise.created_at)}</p>
|
||||
<a class="mb-4 mr-4 w-52 rounded-lg bg-gray-100 px-4 py-7 dark:bg-neutral-800" href="{path}/{exercise.id}">
|
||||
<h3 class="text-xl dark:text-white">{exercise.title}</h3>
|
||||
<p class="mt-4 text-sm dark:text-white">{formatDate(exercise.created_at)}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<Box className="mt-3 text-center">
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import ScreenMap from 'carbon-icons-svelte/lib/ScreenMap.svelte';
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
lessons,
|
||||
handleSaveLesson
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { updateLesson } from '$lib/utils/services/courses';
|
||||
import type { Course, Lesson } from '$lib/utils/types';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
export let reorder = false;
|
||||
export let lessonEditing: string | undefined;
|
||||
export let lessonToDelete: Lesson | undefined;
|
||||
export let openDeleteModal = false;
|
||||
interface Props {
|
||||
reorder?: boolean;
|
||||
lessonEditing: string | undefined;
|
||||
lessonToDelete: Lesson | undefined;
|
||||
openDeleteModal?: boolean;
|
||||
}
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let {
|
||||
reorder = false,
|
||||
lessonEditing = $bindable(),
|
||||
lessonToDelete = $bindable(),
|
||||
openDeleteModal = $bindable(false)
|
||||
}: Props = $props();
|
||||
|
||||
let errors: Record<string, string> = $state({});
|
||||
|
||||
async function saveLesson(lesson: Lesson, courseId: Course['id']) {
|
||||
const validationRes = await handleSaveLesson(lesson, courseId);
|
||||
@@ -52,9 +58,7 @@
|
||||
|
||||
if (order !== prevLessonsByOrder[item.id]) {
|
||||
$lessons[index].order = order;
|
||||
updateLesson({ order }, item.id).then((update) =>
|
||||
console.log(`updated lesson order`, update)
|
||||
);
|
||||
updateLesson({ order }, item.id).then((update) => console.log(`updated lesson order`, update));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,12 +85,12 @@
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
>
|
||||
{#each $lessons as lesson (lesson.id)}
|
||||
<div
|
||||
class={`relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 border-gray-200 dark:border-neutral-600 p-5 dark:bg-neutral-800`}
|
||||
class="relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-800"
|
||||
>
|
||||
<!-- Number Chip -->
|
||||
<div class="mr-5">
|
||||
@@ -102,17 +106,12 @@
|
||||
<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}
|
||||
/>
|
||||
<TextField 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
|
||||
href={$globalStore.isStudent && !lesson.is_unlocked
|
||||
? $page.url.pathname
|
||||
? page.url.pathname
|
||||
: `/courses/${$course.id}/lessons/${lesson.id}`}
|
||||
class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent &&
|
||||
!lesson.is_unlocked
|
||||
@@ -124,12 +123,10 @@
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center"
|
||||
>
|
||||
<div class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center">
|
||||
<!-- Lesson Length -->
|
||||
<div class="mb-3 flex items-center lg:mb-0">
|
||||
<ScreenMap size={20} class="carbon-icon dark:text-white" />
|
||||
<ListChecksIcon size={16} />
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
|
||||
{$t('exercises.heading')}
|
||||
|
||||
+29
-36
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { Add, ScreenMap } from 'carbon-icons-svelte';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -33,12 +34,16 @@
|
||||
lessonTitle?: string;
|
||||
};
|
||||
|
||||
export let reorder = false;
|
||||
export let lessonEditing: string | undefined;
|
||||
interface Props {
|
||||
reorder?: boolean;
|
||||
lessonEditing: string | undefined;
|
||||
}
|
||||
|
||||
let prevTitle: string | undefined;
|
||||
let errors: Record<string, string>;
|
||||
let openDeleteModal = false;
|
||||
let { reorder = false, lessonEditing = $bindable() }: Props = $props();
|
||||
|
||||
let prevTitle: string | undefined = $state();
|
||||
let errors: Record<string, string> = $state({});
|
||||
let openDeleteModal = $state(false);
|
||||
let deletingData = {
|
||||
id: '',
|
||||
isSection: false
|
||||
@@ -126,9 +131,7 @@
|
||||
|
||||
if (order !== prevSectionsOrder[section.id]) {
|
||||
$lessonSections[index].order = order;
|
||||
updateLessonSection({ order }, section.id).then((update) =>
|
||||
console.log(`updated section order`, update)
|
||||
);
|
||||
updateLessonSection({ order }, section.id).then((update) => console.log(`updated section order`, update));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -156,9 +159,7 @@
|
||||
order,
|
||||
section_id: $lessonSections[colIdx].id
|
||||
};
|
||||
updateLesson(lessonUpdate, lesson.id).then((update) =>
|
||||
console.log(`updated lesson order`, update)
|
||||
);
|
||||
updateLesson(lessonUpdate, lesson.id).then((update) => console.log(`updated lesson order`, update));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -188,15 +189,13 @@
|
||||
},
|
||||
dragDisabled: !reorder
|
||||
}}
|
||||
on:consider={handleDndConsiderColumns}
|
||||
on:finalize={handleDndFinalizeColumns}
|
||||
onconsider={handleDndConsiderColumns}
|
||||
onfinalize={handleDndFinalizeColumns}
|
||||
>
|
||||
{#each $lessonSections as section (section.id)}
|
||||
<div
|
||||
class="mb-3 m-auto max-w-xl border-2 border-gray-200 dark:border-neutral-600 dark:bg-neutral-800 rounded-md"
|
||||
>
|
||||
<div class="m-auto mb-3 max-w-xl rounded-md border-2 border-gray-200 dark:border-neutral-600 dark:bg-neutral-800">
|
||||
<div
|
||||
class="mb-2 px-3 py-1 min-h-[50px] border-b bg-gray-50 dark:bg-neutral-700 rounded-tl-md rounded-tr-md flex justify-between items-center"
|
||||
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} />
|
||||
@@ -220,13 +219,8 @@
|
||||
onClick={() => onSave({ sectionId: section.id })}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton
|
||||
contained
|
||||
size="small"
|
||||
onClick={() => handleAddLesson(section.id)}
|
||||
disabled={!!lessonEditing}
|
||||
>
|
||||
<Add size={20} />
|
||||
<IconButton contained size="small" onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
<OverflowMenu size="xl" flipped>
|
||||
<OverflowMenuItem
|
||||
@@ -256,18 +250,18 @@
|
||||
},
|
||||
dragDisabled: !reorder
|
||||
}}
|
||||
on:consider={(e) => handleDndConsiderCards(section.id, e)}
|
||||
on:finalize={(e) => handleDndFinalizeCards(section.id, e)}
|
||||
onconsider={(e) => handleDndConsiderCards(section.id, e)}
|
||||
onfinalize={(e) => handleDndFinalizeCards(section.id, e)}
|
||||
>
|
||||
{#each section.lessons as lesson (lesson.id)}
|
||||
<div
|
||||
class="border max-w-xl border-gray-200 px-3 py-1 min-h-[50px] rounded-md mb-2 flex items-center justify-between"
|
||||
class="mb-2 flex min-h-[50px] max-w-xl items-center justify-between rounded-md border border-gray-200 px-3 py-1"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
{#if lessonEditing === lesson.id}
|
||||
<TextField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
|
||||
{:else}
|
||||
<div class="w-4/5 flex items-center gap-2">
|
||||
<div class="flex w-4/5 items-center gap-2">
|
||||
<TextChip
|
||||
value={getLessonOrder(section.lessons, lesson.id)}
|
||||
size="sm"
|
||||
@@ -277,10 +271,9 @@
|
||||
<div>
|
||||
<a
|
||||
href={$globalStore.isStudent && !lesson.is_unlocked
|
||||
? $page.url.pathname
|
||||
? page.url.pathname
|
||||
: `/courses/${$course.id}/lessons/${lesson.id}`}
|
||||
class=" text-black dark:text-white {$globalStore.isStudent &&
|
||||
!lesson.is_unlocked
|
||||
class=" text-black dark:text-white {$globalStore.isStudent && !lesson.is_unlocked
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
data-sveltekit-preload-data="off"
|
||||
@@ -288,8 +281,8 @@
|
||||
{lesson.title}
|
||||
</a>
|
||||
|
||||
<div class="mt-1 mb-3 flex items-center lg:mb-0">
|
||||
<ScreenMap size={16} class="carbon-icon dark:text-white" />
|
||||
<div class="mb-3 mt-1 flex items-center lg:mb-0">
|
||||
<ListChecksIcon size={16} />
|
||||
<p class="ml-2 text-xs text-gray-500 dark:text-white">
|
||||
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
|
||||
{$t('exercises.heading')}
|
||||
|
||||
+61
-63
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import ArrowLeft from 'carbon-icons-svelte/lib/ArrowLeft.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 CaretRight from 'carbon-icons-svelte/lib/CaretRight.svelte';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import { fetchLesssonLanguageHistory } from '$lib/utils/services/courses';
|
||||
import { diffLines } from 'diff';
|
||||
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
@@ -23,10 +23,12 @@
|
||||
lesson_id: string;
|
||||
}
|
||||
|
||||
export let open = false;
|
||||
let lessonTitle: string = '';
|
||||
let lessonId: string = '';
|
||||
let lessonHistory: LessonHistory[] = [];
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { open = false }: Props = $props();
|
||||
let lessonHistory: LessonHistory[] = $state([]);
|
||||
let content = '';
|
||||
let selectedVersion: LessonHistory = {
|
||||
new_content: '',
|
||||
@@ -35,13 +37,16 @@
|
||||
locale: LOCALE.EN,
|
||||
lesson_id: ''
|
||||
};
|
||||
let selectedVersionIndex = 0;
|
||||
let contentRestoreLoading = false;
|
||||
let versionsToFetch = 9;
|
||||
let isMoreHistoryLoading = false;
|
||||
let selectedVersionIndex = $state(0);
|
||||
let contentRestoreLoading = $state(false);
|
||||
let versionsToFetch = $state(9);
|
||||
let isMoreHistoryLoading = $state(false);
|
||||
|
||||
let mounted = false;
|
||||
|
||||
const lessonId = $derived($lesson.id || '');
|
||||
const lessonTitle = $derived($lessons.find((les) => les.id === lessonId)?.title || '');
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function scrollLock(open) {
|
||||
@@ -75,36 +80,33 @@
|
||||
|
||||
function removeDuplicate(history: LessonHistory[]) {
|
||||
return history.filter(
|
||||
(obj1, i, arr) =>
|
||||
arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
|
||||
(obj1, i, arr) => arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
|
||||
try {
|
||||
isMoreHistoryLoading = true;
|
||||
const { data, error } = await fetchLesssonLanguageHistory(lessonId, locale, endRange);
|
||||
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
|
||||
untrack(async () => {
|
||||
try {
|
||||
isMoreHistoryLoading = true;
|
||||
const { data, error } = await fetchLesssonLanguageHistory(lessonId, locale, endRange);
|
||||
|
||||
if (!data) {
|
||||
throw error;
|
||||
if (!data) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Filter out duplicates based on timestamp
|
||||
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
|
||||
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp).getMinutes()));
|
||||
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
|
||||
|
||||
updateContentVersion(lessonHistory[0], 0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error('Failed to fetch history');
|
||||
} finally {
|
||||
isMoreHistoryLoading = false;
|
||||
}
|
||||
|
||||
// Filter out duplicates based on timestamp
|
||||
const existingTimestamps = new Set(
|
||||
lessonHistory.map((item) => new Date(item.timestamp).getMinutes())
|
||||
);
|
||||
const newEntries = data.filter(
|
||||
(item) => !existingTimestamps.has(new Date(item.timestamp).getMinutes())
|
||||
);
|
||||
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
|
||||
|
||||
updateContentVersion(lessonHistory[0], 0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error('Failed to fetch history');
|
||||
} finally {
|
||||
isMoreHistoryLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -158,18 +160,20 @@
|
||||
versionsToFetch += 10;
|
||||
}
|
||||
|
||||
$: lessonTitle = $lessons.find((les) => les.id === $lesson.id)?.title || '';
|
||||
$: lessonId = $lesson.id || '';
|
||||
$: scrollLock(open);
|
||||
$: fetchLessonHistory(lessonId, $lesson.locale, versionsToFetch);
|
||||
$effect(() => {
|
||||
scrollLock(open);
|
||||
});
|
||||
$effect(() => {
|
||||
fetchLessonHistory(lessonId, $lesson.locale, versionsToFetch);
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
|
||||
<div class="panel bg-white dark:bg-black">
|
||||
<div class="p-10 w-full pr-80">
|
||||
<div class="w-full p-10 pr-80">
|
||||
<div class="flex items-start gap-x-10">
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleDrawerClose}>
|
||||
<ArrowLeft />
|
||||
<ArrowLeftIcon size={16} />
|
||||
</PrimaryButton>
|
||||
|
||||
{#if selectedVersionIndex != 0}
|
||||
@@ -180,22 +184,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full h-full flex flex-col items-start">
|
||||
<div class="flex h-full w-full flex-col items-start">
|
||||
<HtmlRender className="m-auto text-center mt-6 flex items-center justify-center">
|
||||
<svelte:fragment slot="content">
|
||||
<h1 class="text-2xl md:text-4xl mt-0 capitalize">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</svelte:fragment>
|
||||
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</HtmlRender>
|
||||
|
||||
{#key lessonId}
|
||||
<HtmlRender id="display" className="m-auto">
|
||||
<svelte:fragment slot="content">
|
||||
<div class="amen">
|
||||
{@html content}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="amen">
|
||||
{@html content}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{/key}
|
||||
</div>
|
||||
@@ -203,35 +203,33 @@
|
||||
</div>
|
||||
<div
|
||||
id="scroll-container"
|
||||
class="w-80 fixed right-0 top-0 min-h-screen h-full z-10 overflow-x-auto overflow-y-scroll bg-gray-100 dark:bg-neutral-800 py-10 space-y-6"
|
||||
class="fixed right-0 top-0 z-10 h-full min-h-screen w-80 space-y-6 overflow-x-auto overflow-y-scroll bg-gray-100 py-10 dark:bg-neutral-800"
|
||||
>
|
||||
<p class="font-medium text-xl text-left flex items-start justify-start px-10">
|
||||
<p class="flex items-start justify-start px-10 text-left text-xl font-medium">
|
||||
{$t('course.navItem.lessons.version_history.title')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{#each lessonHistory as version, index}
|
||||
<button
|
||||
on:click={() => updateContentVersion(version, index)}
|
||||
class="hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer p-4 w-full px-10 flex items-start {index ==
|
||||
onclick={() => updateContentVersion(version, index)}
|
||||
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
|
||||
selectedVersionIndex
|
||||
? 'bg-gray-200 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<CaretRight class="mt-1"></CaretRight>
|
||||
<PlayIcon size={16} class="mt-1" />
|
||||
<div>
|
||||
<span class="inline-block font-medium text-base"
|
||||
>{formatTimestamp(version.timestamp)}</span
|
||||
>
|
||||
<span class="inline-block text-base font-medium">{formatTimestamp(version.timestamp)}</span>
|
||||
{#if index == 0}
|
||||
<span class="block italic text-xs text-start"
|
||||
<span class="block text-start text-xs italic"
|
||||
>{$t('course.navItem.lessons.version_history.current_version')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center justify-start px-10 mt-2 h-10">
|
||||
<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
|
||||
>
|
||||
|
||||
+33
-43
@@ -4,22 +4,19 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
|
||||
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import { DocumentUploader } from '$lib/utils/services/courses/presign';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { lesson } from '../../store/lessons';
|
||||
import { CloseButton } from '$lib/components/Buttons/Close';
|
||||
|
||||
export const lessonId = '';
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let selectedFile: File | null = null;
|
||||
let dragOver = false;
|
||||
let errorTimeout: NodeJS.Timeout | null = null;
|
||||
let isDisabled = false;
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let selectedFile: File | null = $state(null);
|
||||
let dragOver = $state(false);
|
||||
let errorTimeout: NodeJS.Timeout | null = $state(null);
|
||||
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
@@ -33,8 +30,7 @@
|
||||
|
||||
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
|
||||
if (file.type === 'application/pdf') return 'pdf';
|
||||
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
return 'docx';
|
||||
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx';
|
||||
if (file.type === 'application/msword') return 'doc';
|
||||
return 'pdf'; // fallback
|
||||
}
|
||||
@@ -152,9 +148,7 @@
|
||||
console.error('Upload error:', error);
|
||||
|
||||
if ($lessonDocUpload.isCancelled) {
|
||||
$lessonDocUpload.error = $t(
|
||||
'course.navItem.lessons.materials.tabs.document.upload_cancelled'
|
||||
);
|
||||
$lessonDocUpload.error = $t('course.navItem.lessons.materials.tabs.document.upload_cancelled');
|
||||
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
|
||||
} else {
|
||||
$lessonDocUpload.error = error instanceof Error ? error.message : 'Upload failed';
|
||||
@@ -178,29 +172,29 @@
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
function getFileIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'application/pdf':
|
||||
return PdfIcon;
|
||||
default:
|
||||
return DocumentIcon;
|
||||
function autoClearError(error: string | null) {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
|
||||
errorTimeout = setTimeout(() => {
|
||||
untrack(() => {
|
||||
$lessonDocUpload.error = null;
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
$: if ($lessonDocUpload.error) {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
$lessonDocUpload.error = null;
|
||||
}, 5000);
|
||||
}
|
||||
$effect(() => {
|
||||
autoClearError($lessonDocUpload.error);
|
||||
});
|
||||
|
||||
// Clear error timeout when component is destroyed
|
||||
onDestroy(() => {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
});
|
||||
|
||||
$: isDisabled = $lessonDocUpload.isUploading || $isFreePlan;
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
|
||||
@@ -218,29 +212,25 @@
|
||||
</div>
|
||||
|
||||
<!-- File Upload Area -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {isDisabled &&
|
||||
'opacity-50 hover:cursor-not-allowed'} {dragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'hover:border-gray-400'}"
|
||||
on:drop={handleDrop}
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={handleDragLeave}
|
||||
'opacity-50 hover:cursor-not-allowed'} {dragOver ? 'border-blue-500 bg-blue-50' : 'hover:border-gray-400'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
>
|
||||
{#if selectedFile}
|
||||
<div class="mb-4 flex items-center justify-center space-x-3">
|
||||
<svelte:component this={getFileIcon(selectedFile.type)} size={32} class="text-blue-600" />
|
||||
<FileTextIcon size={16} />
|
||||
<div class="text-left">
|
||||
<p class="font-medium text-gray-900">{selectedFile.name}</p>
|
||||
<p class="text-sm text-gray-500">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<button on:click={removeSelectedFile} class="rounded-full p-1 hover:bg-gray-200">
|
||||
<CloseIcon size={20} class="text-gray-500" />
|
||||
</button>
|
||||
<CloseButton onClick={removeSelectedFile} />
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentIcon size={32} class="mx-auto mb-4 text-gray-400" />
|
||||
<FileTextIcon size={16} />
|
||||
<p class="mb-2 text-gray-600">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
|
||||
</p>
|
||||
@@ -252,7 +242,7 @@
|
||||
disabled={isDisabled}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
on:change={handleFileSelect}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
/>
|
||||
<div class="flex justify-center">
|
||||
|
||||
+35
-60
@@ -3,33 +3,36 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
|
||||
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
|
||||
import EyeIcon from 'carbon-icons-svelte/lib/View.svelte';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { LessonDocument } from '$lib/utils/types';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let mode = MODES.view;
|
||||
export let displayDocuments: LessonDocument[] = [];
|
||||
export let downloadingDocuments: Set<string>;
|
||||
export let formatFileSize: (bytes: number) => string;
|
||||
export let openDocumentUploadModal: () => void;
|
||||
export let deleteDocument: (index: number) => void;
|
||||
export let downloadDocument: (doc: LessonDocument) => Promise<void>;
|
||||
export let isLoading = false;
|
||||
|
||||
function getFileIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return PdfIcon;
|
||||
default:
|
||||
return DocumentIcon;
|
||||
}
|
||||
interface Props {
|
||||
mode?: any;
|
||||
displayDocuments?: LessonDocument[];
|
||||
downloadingDocuments: Set<string>;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
openDocumentUploadModal: () => void;
|
||||
deleteDocument: (index: number) => void;
|
||||
downloadDocument: (doc: LessonDocument) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = MODES.view,
|
||||
displayDocuments = [],
|
||||
downloadingDocuments,
|
||||
formatFileSize,
|
||||
openDocumentUploadModal,
|
||||
deleteDocument,
|
||||
downloadDocument,
|
||||
isLoading = false
|
||||
}: Props = $props();
|
||||
|
||||
function handleViewPDF(document: LessonDocument) {
|
||||
dispatch('viewPDF', document);
|
||||
}
|
||||
@@ -49,47 +52,35 @@
|
||||
{#each displayDocuments as document, index}
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svelte:component
|
||||
this={getFileIcon(document.type)}
|
||||
size={32}
|
||||
class="text-primary-600 w-[10%]"
|
||||
/>
|
||||
<FileTextIcon size={16} />
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
|
||||
<div
|
||||
class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<div class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}
|
||||
</span>
|
||||
{#if document.size}
|
||||
<span>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
|
||||
document.size
|
||||
)}
|
||||
{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(document.size)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
{#if document.type === 'pdf'}
|
||||
<button
|
||||
on:click={() => handleViewPDF(document)}
|
||||
onclick={() => handleViewPDF(document)}
|
||||
class="text-sm text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<EyeIcon size={16} class="mr-1 inline" />
|
||||
<EyeIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href={document.link}
|
||||
target="_blank"
|
||||
class="text-sm text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<a href={document.link} target="_blank" class="text-sm text-blue-600 underline hover:text-blue-800">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => downloadDocument(document)}
|
||||
onclick={() => downloadDocument(document)}
|
||||
disabled={downloadingDocuments.has(document.name)}
|
||||
class="cursor-pointer text-sm text-green-600 underline hover:text-green-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
@@ -97,10 +88,7 @@
|
||||
? $t('course.navItem.lessons.materials.tabs.document.downloading')
|
||||
: $t('course.navItem.lessons.materials.tabs.document.download')}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => deleteDocument(index)}
|
||||
class="text-sm text-red-600 underline hover:text-red-800"
|
||||
>
|
||||
<button onclick={() => deleteDocument(index)} class="text-sm text-red-600 underline hover:text-red-800">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.delete')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -115,33 +103,20 @@
|
||||
{#each displayDocuments as document}
|
||||
<div class="rounded-md border border-gray-200 p-4 dark:border-neutral-600">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svelte:component
|
||||
this={getFileIcon(document.type)}
|
||||
size={32}
|
||||
class="text-primary-600 w-[10%]"
|
||||
/>
|
||||
<FileTextIcon size={16} />
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
|
||||
<div class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
>{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}</span
|
||||
>
|
||||
<span>{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}</span>
|
||||
{#if document.size}
|
||||
<span
|
||||
>{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
|
||||
document.size
|
||||
)}</span
|
||||
<span>{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(document.size)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
{#if document.type === 'pdf'}
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
onClick={() => handleViewPDF(document)}
|
||||
{isLoading}
|
||||
>
|
||||
<EyeIcon size={16} class="mr-1 inline" />
|
||||
<PrimaryButton variant={VARIANTS.CONTAINED_DARK} onClick={() => handleViewPDF(document)} {isLoading}>
|
||||
<EyeIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
|
||||
</PrimaryButton>
|
||||
{:else}
|
||||
|
||||
+11
-11
@@ -1,16 +1,20 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import YoutubeVideo from './YoutubeVideo.svelte';
|
||||
import UploadVideo from './UploadVideo.svelte';
|
||||
import EmbedLink from './EmbedLink.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
|
||||
export let lessonId = '';
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
const tabs = CONSTANTS.videoTabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let currentTab = $state(tabs[0].value);
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
</script>
|
||||
@@ -22,7 +26,7 @@
|
||||
</p>
|
||||
{#each tabs as item (item.value)}
|
||||
<button
|
||||
on:click={onChange(item.value)}
|
||||
onclick={onChange(item.value)}
|
||||
class={`my-1 w-full border px-4 py-3 ${
|
||||
currentTab === item.value
|
||||
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
|
||||
@@ -30,13 +34,9 @@
|
||||
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
|
||||
>
|
||||
{#if $isFreePlan && item.value === 3}
|
||||
<FlashFilled size={20} class="text-blue-700" />
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{:else}
|
||||
<svelte:component
|
||||
this={item.icon}
|
||||
size={20}
|
||||
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
|
||||
/>
|
||||
<item.icon color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`} />
|
||||
{/if}
|
||||
<p>{$t(item.title)}</p>
|
||||
</button>
|
||||
|
||||
+6
-6
@@ -2,14 +2,14 @@
|
||||
import type { LessonVideoType } from '$lib/utils/types';
|
||||
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { CopyButton, Tag } from 'carbon-components-svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
let genericLinks = '';
|
||||
let error = '';
|
||||
let genericLinks = $state('');
|
||||
let error = $state('');
|
||||
|
||||
function getVideoUrls(urls = '') {
|
||||
return (urls || '').split(',').filter((url) => !!url.trim());
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
function removeVideo(index = 0) {
|
||||
$lesson.materials.videos = $lesson.materials.videos.filter((v, i) => i !== index);
|
||||
$lesson.materials.videos = $lesson.materials.videos.filter((_v, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</Tag>
|
||||
<CopyButton text={video.link} feedback="Copied to clipboard" />
|
||||
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
|
||||
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+11
-11
@@ -1,16 +1,20 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import YoutubeVideo from './YoutubeVideo.svelte';
|
||||
import UploadVideo from './UploadVideo.svelte';
|
||||
import EmbedLink from './EmbedLink.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
|
||||
export let lessonId = '';
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
const tabs = CONSTANTS.videoTabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let currentTab = $state(tabs[0].value);
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
</script>
|
||||
@@ -22,7 +26,7 @@
|
||||
</p>
|
||||
{#each tabs as item (item.value)}
|
||||
<button
|
||||
on:click={onChange(item.value)}
|
||||
onclick={onChange(item.value)}
|
||||
class={`my-1 w-full border px-4 py-3 ${
|
||||
currentTab === item.value
|
||||
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
|
||||
@@ -30,13 +34,9 @@
|
||||
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
|
||||
>
|
||||
{#if $isFreePlan && item.value === 3}
|
||||
<FlashFilled size={20} class="text-blue-700" />
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{:else}
|
||||
<svelte:component
|
||||
this={item.icon}
|
||||
size={20}
|
||||
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
|
||||
/>
|
||||
<item.icon color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`} />
|
||||
{/if}
|
||||
<p>{$t(item.title)}</p>
|
||||
</button>
|
||||
|
||||
+32
-30
@@ -1,11 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import {
|
||||
lesson,
|
||||
lessonVideoUpload,
|
||||
cancelVideoUpload
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { ProgressBar } from 'carbon-components-svelte';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
@@ -14,16 +11,27 @@
|
||||
|
||||
import { VideoUploader } from '$lib/utils/services/courses/presign';
|
||||
|
||||
export let lessonId = '';
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
let formRes;
|
||||
let isLoaded = false;
|
||||
let fileInput;
|
||||
let submit;
|
||||
let fileName = '';
|
||||
let fileSize: number | undefined = $state();
|
||||
|
||||
let formRes: {
|
||||
url?: string;
|
||||
fileKey?: string;
|
||||
status?: number;
|
||||
type?: string;
|
||||
message?: string;
|
||||
} | null = $state(null);
|
||||
let isLoaded = $state(false);
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
let submit: HTMLInputElement | null = $state(null);
|
||||
let fileName = $state('');
|
||||
|
||||
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
|
||||
|
||||
const videoUploader = new VideoUploader();
|
||||
|
||||
@@ -32,7 +40,9 @@
|
||||
|
||||
videoUploader.initUpload();
|
||||
|
||||
const videoFile = fileInput.files[0];
|
||||
const videoFile = fileInput.files?.[0];
|
||||
if (!videoFile) return;
|
||||
|
||||
fileSize = videoFile?.size / (1024 * 1024);
|
||||
fileName = videoFile?.name;
|
||||
|
||||
@@ -56,7 +66,7 @@
|
||||
...$lesson.materials.videos,
|
||||
{
|
||||
type: 'upload',
|
||||
link: formRes.url,
|
||||
link: formRes.url!,
|
||||
key: formRes?.fileKey
|
||||
}
|
||||
];
|
||||
@@ -109,9 +119,7 @@
|
||||
$lessonVideoUpload.uploadProgress = 0;
|
||||
}
|
||||
|
||||
$: helperText = $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize) + 'MB';
|
||||
|
||||
$: isDisabled = $lessonVideoUpload.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
@@ -121,13 +129,13 @@
|
||||
{#if !isLoaded}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
|
||||
onclick={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<form
|
||||
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
|
||||
on:submit|preventDefault={onUpload}
|
||||
onsubmit={preventDefault(onUpload)}
|
||||
>
|
||||
{#if $lessonVideoUpload.isUploading}
|
||||
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
|
||||
@@ -166,7 +174,7 @@
|
||||
type="file"
|
||||
accept="video/*"
|
||||
name="videoFile"
|
||||
on:change={() => submit.click()}
|
||||
onchange={() => submit?.click()}
|
||||
bind:this={fileInput}
|
||||
/>
|
||||
<input type="text" name="lessonId" value={lessonId} style="display: none;" />
|
||||
@@ -185,10 +193,7 @@
|
||||
{$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}
|
||||
/>
|
||||
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.button')} onClick={tryAgain} />
|
||||
</div>
|
||||
{:else if formRes?.status !== 200}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
|
||||
@@ -202,10 +207,7 @@
|
||||
{$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}
|
||||
/>
|
||||
<PrimaryButton label={$t('course.navItem.lessons.materials.tabs.video.add_video.try_again')} onClick={tryAgain} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" w-full rounded-md border px-8 py-3">
|
||||
|
||||
+8
-10
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { CopyButton, Tag } from 'carbon-components-svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LessonVideoType } from '$lib/utils/types';
|
||||
|
||||
let youtubeLinks = '';
|
||||
let error = '';
|
||||
let youtubeLinks = $state('');
|
||||
let error = $state('');
|
||||
|
||||
function getVideoUrls(urls = '') {
|
||||
return (urls || '').split(',').filter((url) => !!url.trim());
|
||||
@@ -38,14 +38,13 @@
|
||||
}
|
||||
|
||||
function isValidYouTubeLink(link = '') {
|
||||
const youtubeRegex =
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
return youtubeRegex.test(link.trim());
|
||||
}
|
||||
|
||||
function removeVideo(index = 0) {
|
||||
$lesson.materials.videos = $lesson.materials.videos.filter((v, i) => i !== index);
|
||||
$lesson.materials.videos = $lesson.materials.videos.filter((_v, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,8 +67,7 @@
|
||||
<p class="mt-4 pl-2 text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
|
||||
<strong>
|
||||
{$lesson?.materials?.videos.filter((v) => v.type === 'youtube' && isValidYouTubeLink(v.link))
|
||||
.length || 0}
|
||||
{$lesson?.materials?.videos.filter((v) => v.type === 'youtube' && isValidYouTubeLink(v.link)).length || 0}
|
||||
</strong>
|
||||
</p>
|
||||
<div class="">
|
||||
@@ -81,7 +79,7 @@
|
||||
</Tag>
|
||||
<CopyButton text={video.link} feedback="Copied to clipboard" />
|
||||
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
|
||||
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+6
-7
@@ -1,22 +1,21 @@
|
||||
import Link from 'carbon-icons-svelte/lib/Link.svelte';
|
||||
import Video from 'carbon-icons-svelte/lib/Video.svelte';
|
||||
|
||||
import DirectLink from 'carbon-icons-svelte/lib/DirectLink.svelte';
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
|
||||
export const videoTabs = [
|
||||
{
|
||||
value: 1,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.add_video.youtube_link',
|
||||
icon: Link
|
||||
icon: YoutubeIcon
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.embed_link',
|
||||
icon: DirectLink
|
||||
icon: FileSymlinkIcon
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.upload',
|
||||
icon: Video
|
||||
icon: VideoIcon
|
||||
}
|
||||
];
|
||||
|
||||
+20
-35
@@ -1,9 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import {
|
||||
lesson,
|
||||
lessonCommentsChannel
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
||||
@@ -15,27 +12,27 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { GroupPerson, LessonComment, LessonCommentInsertPayload } from '$lib/utils/types';
|
||||
import type {
|
||||
PostgrestSingleResponse,
|
||||
RealtimeChannel,
|
||||
RealtimePostgresChangesPayload
|
||||
} from '@supabase/supabase-js';
|
||||
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
|
||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
const supabase = getSupabase();
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export let lessonId = '';
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
}
|
||||
|
||||
let comment = '';
|
||||
let comments: LessonComment[] = [];
|
||||
let groupmember: GroupPerson | undefined;
|
||||
let isSaving: boolean = false;
|
||||
let isFetching = false;
|
||||
let openDeleteModal = false;
|
||||
let deleteCommentId: number | null = null;
|
||||
let editCommentId: number | null = null;
|
||||
let { lessonId = '' }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let comments: LessonComment[] = $state([]);
|
||||
let groupmember: GroupPerson | undefined = $state();
|
||||
let isSaving: boolean = $state(false);
|
||||
let isFetching = $state(false);
|
||||
let openDeleteModal = $state(false);
|
||||
let deleteCommentId: number | null = $state(null);
|
||||
let editCommentId: number | null = $state(null);
|
||||
|
||||
interface FetchComments {
|
||||
id: number;
|
||||
@@ -87,9 +84,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
comments = comments.map((comment) =>
|
||||
comment.id === 0 ? { ...comment, id: data.id } : comment
|
||||
);
|
||||
comments = comments.map((comment) => (comment.id === 0 ? { ...comment, id: data.id } : comment));
|
||||
isSaving = false;
|
||||
comment = '';
|
||||
});
|
||||
@@ -177,11 +172,11 @@
|
||||
pagination.count = comments.length;
|
||||
}
|
||||
|
||||
let pagination = {
|
||||
let pagination = $state({
|
||||
hasMore: true,
|
||||
count: 0,
|
||||
page: 0
|
||||
};
|
||||
});
|
||||
|
||||
async function fetchComments(people: GroupPerson[]) {
|
||||
if (!pagination.hasMore) return;
|
||||
@@ -250,11 +245,7 @@
|
||||
lessonCommentsChannel.set(
|
||||
supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'lesson_comment' },
|
||||
handleInsert
|
||||
)
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'lesson_comment' }, handleInsert)
|
||||
.subscribe()
|
||||
);
|
||||
}
|
||||
@@ -276,13 +267,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex h-full items-start gap-3">
|
||||
<Avatar
|
||||
src={$profile.avatar_url}
|
||||
name={$profile.fullname}
|
||||
width="w-8"
|
||||
height="h-8"
|
||||
className="mt-2"
|
||||
/>
|
||||
<Avatar src={$profile.avatar_url} name={$profile.fullname} width="w-8" height="h-8" className="mt-2" />
|
||||
<div class="h-full w-full">
|
||||
<TextArea
|
||||
label={$t('course.navItem.lessons.comments.text_area_title')}
|
||||
|
||||
+27
-41
@@ -1,34 +1,31 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
lesson,
|
||||
deleteLessonDocument
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lesson, deleteLessonDocument } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { CloseButton } from '$lib/components/Buttons/Close';
|
||||
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import ZoomInIcon from 'carbon-icons-svelte/lib/ZoomIn.svelte';
|
||||
import ZoomOutIcon from 'carbon-icons-svelte/lib/ZoomOut.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
||||
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
|
||||
export let mode = MODES.view;
|
||||
let { mode = MODES.view } = $props();
|
||||
|
||||
let downloadingDocuments = new Set<string>();
|
||||
let viewingPDF: any = null;
|
||||
let pdfViewerOpen = false;
|
||||
let pdfCanvas: HTMLCanvasElement;
|
||||
let downloadingDocuments = $state(new Set<string>());
|
||||
let viewingPDF: any = $state(null);
|
||||
let pdfViewerOpen = $state(false);
|
||||
let pdfCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let pdfDoc: any = null;
|
||||
let pageNum = 1;
|
||||
let pageCount = 0;
|
||||
let scale = 1.0;
|
||||
let isLoading = false;
|
||||
let error: string | null = null;
|
||||
let pageNum = $state(1);
|
||||
let pageCount = $state(0);
|
||||
let scale = $state(1.0);
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let pdfjsLib: any = null;
|
||||
let renderTimeout: any = null;
|
||||
let currentRenderTask: any = null;
|
||||
@@ -299,7 +296,7 @@
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
$: displayDocuments = $lesson?.materials?.documents || [];
|
||||
let displayDocuments = $derived($lesson?.materials?.documents || []);
|
||||
</script>
|
||||
|
||||
<DocumentList
|
||||
@@ -318,9 +315,7 @@
|
||||
{#if pdfViewerOpen}
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-800">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
{viewingPDF?.name}
|
||||
@@ -344,7 +339,7 @@
|
||||
disabled={pageNum <= 1}
|
||||
toolTipProps={{ title: 'Previous page (←)', hotkeys: ['ArrowLeft'] }}
|
||||
>
|
||||
<ChevronLeftIcon size={20} class="carbon-icon" />
|
||||
<ChevronLeftIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
@@ -352,7 +347,7 @@
|
||||
disabled={pageNum >= pageCount}
|
||||
toolTipProps={{ title: 'Next page (→)', hotkeys: ['ArrowRight'] }}
|
||||
>
|
||||
<ChevronRightIcon size={20} class="carbon-icon" />
|
||||
<ChevronRightIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -363,7 +358,7 @@
|
||||
disabled={scale <= 0.5}
|
||||
toolTipProps={{ title: 'Zoom out (-)', hotkeys: ['-'] }}
|
||||
>
|
||||
<ZoomOutIcon size={20} class="carbon-icon" />
|
||||
<ZoomOutIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<span class="min-w-[3rem] text-center text-sm text-gray-600">
|
||||
@@ -375,18 +370,12 @@
|
||||
disabled={scale >= 3.0}
|
||||
toolTipProps={{ title: 'Zoom in (+)', hotkeys: ['+'] }}
|
||||
>
|
||||
<ZoomInIcon size={20} class="carbon-icon" />
|
||||
<ZoomInIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Close Button -->
|
||||
<IconButton
|
||||
onClick={closePDFViewer}
|
||||
toolTipProps={{ title: 'Close (Esc)', hotkeys: ['Esc'] }}
|
||||
>
|
||||
<CloseIcon size={20} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<CloseButton onClick={closePDFViewer} toolTipProps={{ title: 'Close (Esc)', hotkeys: ['Esc'] }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,10 +406,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-2 text-red-600">{$t(error)}</p>
|
||||
<button
|
||||
on:click={() => viewPDF(viewingPDF)}
|
||||
class="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<button onclick={() => viewPDF(viewingPDF)} class="text-blue-600 underline hover:text-blue-800">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -429,8 +415,8 @@
|
||||
<div class="flex justify-center">
|
||||
<canvas
|
||||
bind:this={pdfCanvas}
|
||||
on:contextmenu={handleContextMenu}
|
||||
on:dragstart={handleDragStart}
|
||||
oncontextmenu={handleContextMenu}
|
||||
ondragstart={handleDragStart}
|
||||
class="shadow-lg"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
+13
-18
@@ -1,34 +1,29 @@
|
||||
<script lang="ts">
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import {
|
||||
lesson,
|
||||
lessonByTranslation
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lesson, lessonByTranslation } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { lessonFallbackNote } from '$lib/utils/functions/translations';
|
||||
|
||||
export let lessonId: string;
|
||||
interface Props {
|
||||
lessonId: string;
|
||||
}
|
||||
|
||||
$: content = lessonFallbackNote(
|
||||
$lesson.materials.note,
|
||||
$lessonByTranslation[lessonId],
|
||||
$lesson.locale
|
||||
);
|
||||
$: hasAtLeastOneTranslation = Object.values($lessonByTranslation[lessonId] || {}).some(
|
||||
(content) => {
|
||||
let { lessonId }: Props = $props();
|
||||
|
||||
let content = $derived(lessonFallbackNote($lesson.materials.note, $lessonByTranslation[lessonId], $lesson.locale));
|
||||
let hasAtLeastOneTranslation = $derived(
|
||||
Object.values($lessonByTranslation[lessonId] || {}).some((content) => {
|
||||
return content && !!content.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !isHtmlValueEmpty(content)}
|
||||
<HtmlRender className="m-auto">
|
||||
<svelte:fragment slot="content">
|
||||
<div>
|
||||
{@html content}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div>
|
||||
{@html content}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{:else if hasAtLeastOneTranslation}
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
|
||||
+3
-7
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
let url = '';
|
||||
let url = $derived(getUrl($lesson.materials.slide_url));
|
||||
|
||||
function canvaHandler(_url): string {
|
||||
if (_url.includes('?embed')) return _url;
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
return _url;
|
||||
}
|
||||
|
||||
$: url = getUrl($lesson.materials.slide_url);
|
||||
</script>
|
||||
|
||||
{#if url}
|
||||
@@ -31,8 +29,6 @@
|
||||
width="100%"
|
||||
height="569"
|
||||
class="iframe my-3"
|
||||
allowfullscreen="true"
|
||||
mozallowfullscreen="true"
|
||||
webkitallowfullscreen="true"
|
||||
/>
|
||||
allowfullscreen={true}
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
+7
-12
@@ -4,7 +4,10 @@
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let videoElements: (HTMLVideoElement | null)[] = [];
|
||||
let videoElements: (HTMLVideoElement | null)[] = $derived.by(() => {
|
||||
let elements = videoElements.slice(0, $lesson.materials.videos.length);
|
||||
return [...elements, ...Array($lesson.materials.videos.length - elements.length).fill(null)];
|
||||
});
|
||||
|
||||
function initPlyr() {
|
||||
if (typeof (window as any).Plyr === 'undefined') {
|
||||
@@ -19,14 +22,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
videoElements = videoElements.slice(0, $lesson.materials.videos.length);
|
||||
videoElements = [
|
||||
...videoElements,
|
||||
...Array($lesson.materials.videos.length - videoElements.length).fill(null)
|
||||
];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initPlyr();
|
||||
});
|
||||
@@ -46,7 +41,7 @@
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
></iframe>
|
||||
{:else if video.type === 'generic'}
|
||||
<iframe
|
||||
width="100%"
|
||||
@@ -57,7 +52,7 @@
|
||||
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
|
||||
@@ -67,7 +62,7 @@
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
title="Muse AI Video Embed"
|
||||
/>
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import type { Component } from 'svelte';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import PresentationIcon from '@lucide/svelte/icons/presentation';
|
||||
import type { Tabs } from '$lib/utils/types';
|
||||
import { Notebook, PresentationFile, Video, Document } from 'carbon-icons-svelte';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
interface MaterialTab {
|
||||
label: string;
|
||||
icon: any;
|
||||
icon: Component;
|
||||
value: number;
|
||||
badgeValue?: number;
|
||||
}
|
||||
|
||||
export let tabs: MaterialTab[] = [
|
||||
export const tabs: MaterialTab[] = [
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.note.title',
|
||||
icon: Notebook,
|
||||
icon: BookOpenIcon,
|
||||
value: 1,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.slide.title',
|
||||
icon: PresentationFile,
|
||||
icon: PresentationIcon,
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: Video,
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.document.title',
|
||||
icon: Document,
|
||||
icon: FileTextIcon,
|
||||
value: 4,
|
||||
badgeValue: 0
|
||||
}
|
||||
@@ -37,6 +41,7 @@ export let tabs: MaterialTab[] = [
|
||||
|
||||
export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
|
||||
if (!Array.isArray(settingTabs)) return tabs;
|
||||
|
||||
const reorderedTabs: MaterialTab[] = [];
|
||||
|
||||
const tabMap = new Map(tabs.map((tab) => [tab.value, tab]));
|
||||
@@ -49,5 +54,6 @@ export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) {
|
||||
});
|
||||
|
||||
const filteredReorderedTabs = reorderedTabs.filter((tab) => tab !== undefined);
|
||||
|
||||
return filteredReorderedTabs;
|
||||
}
|
||||
|
||||
+276
-279
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import Box from '$lib/components/Box/index.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';
|
||||
@@ -8,14 +10,13 @@
|
||||
isLessonDirty,
|
||||
lesson,
|
||||
lessonByTranslation,
|
||||
lessons,
|
||||
lessonVideoUpload,
|
||||
lessonDocUpload
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -30,13 +31,13 @@
|
||||
import { lessonFallbackNote, t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import type { LessonPage, LOCALE } from '$lib/utils/types';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
// import { useCompletion } from 'ai/svelte';
|
||||
import { Popover } from 'carbon-components-svelte';
|
||||
import AlignBoxTopLeftIcon from 'carbon-icons-svelte/lib/AlignBoxTopLeft.svelte';
|
||||
import IbmWatsonKnowledgeStudioIcon from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeStudio.svelte';
|
||||
import ListIcon from 'carbon-icons-svelte/lib/List.svelte';
|
||||
import MagicWandFilled from 'carbon-icons-svelte/lib/MagicWandFilled.svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import NotepadTextIcon from '@lucide/svelte/icons/notepad-text';
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Comments from './components/Comments.svelte';
|
||||
@@ -48,27 +49,62 @@
|
||||
import { orderedTabs } from './constants';
|
||||
import Loader from './Loader.svelte';
|
||||
|
||||
export let mode = MODES.view;
|
||||
export let prevMode = '';
|
||||
export let lessonId = '';
|
||||
export let isSaving = false;
|
||||
export let isStudent = false;
|
||||
export let toggleMode = () => {};
|
||||
interface Props {
|
||||
mode?: any;
|
||||
prevMode?: string;
|
||||
lessonId?: string;
|
||||
isSaving?: boolean;
|
||||
isStudent?: boolean;
|
||||
toggleMode?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = MODES.view,
|
||||
prevMode = '',
|
||||
lessonId = '',
|
||||
isSaving = $bindable(false),
|
||||
isStudent = false,
|
||||
toggleMode = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let localeExists: Record<string, boolean> = {};
|
||||
let lessonTitle = '';
|
||||
let prevContent = '';
|
||||
// let prevContent = '';
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let errors: Record<string, string> = {};
|
||||
let editorWindowRef: Window;
|
||||
let aiButtonRef: HTMLDivElement;
|
||||
let openPopover = false;
|
||||
let player: HTMLVideoElement;
|
||||
let componentsToRender = getComponentOrder(tabs);
|
||||
let editorWindowRef: Window | undefined = $state();
|
||||
let aiButtonRef: HTMLDivElement | undefined = $state();
|
||||
let openPopover = $state(false);
|
||||
let player: HTMLVideoElement | undefined = $state();
|
||||
let aiButtonClass =
|
||||
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
|
||||
'flex items-center gap-2 px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
|
||||
|
||||
const lessonTitle = $derived($lesson.title);
|
||||
|
||||
const tabs = $derived.by(() => {
|
||||
const ordered = orderedTabs(CONSTANTS.tabs, $course.metadata?.lessonTabsOrder);
|
||||
const content = $lessonByTranslation[lessonId]?.[$lesson.locale] || '';
|
||||
|
||||
const { slide_url, videos, note, documents } = $lesson.materials;
|
||||
|
||||
return ordered.map((tab) => {
|
||||
let badgeValue = 0;
|
||||
|
||||
if (tab.value === 1 && (!isHtmlValueEmpty(note) || !isHtmlValueEmpty(content))) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 2 && !!slide_url) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 3 && !isEmpty(videos)) {
|
||||
badgeValue = videos.length;
|
||||
} else if (tab.value === 4 && !isEmpty(documents)) {
|
||||
badgeValue = documents.length;
|
||||
}
|
||||
tab.badgeValue = badgeValue;
|
||||
return tab;
|
||||
});
|
||||
});
|
||||
const componentsToRender = $derived(getComponentOrder(tabs));
|
||||
|
||||
let currentTab = $derived(tabs[0].value);
|
||||
|
||||
const onChange = (tab) => {
|
||||
return () => {
|
||||
@@ -124,7 +160,7 @@
|
||||
}
|
||||
|
||||
async function saveLesson(materials?: LessonPage['materials']) {
|
||||
const _lesson = !!materials
|
||||
const _lesson = materials
|
||||
? {
|
||||
...$lesson,
|
||||
materials
|
||||
@@ -140,10 +176,7 @@
|
||||
return lessonRes;
|
||||
}
|
||||
|
||||
function isMaterialsEmpty(
|
||||
materials: LessonPage['materials'],
|
||||
translation: Record<LOCALE, string>
|
||||
) {
|
||||
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<LOCALE, string>) {
|
||||
const { slide_url, videos, note, documents } = materials;
|
||||
|
||||
return (
|
||||
@@ -156,28 +189,10 @@
|
||||
}
|
||||
|
||||
function handleSave(prevMode: string) {
|
||||
if (prevMode === MODES.edit) {
|
||||
saveLesson();
|
||||
}
|
||||
}
|
||||
|
||||
function addBadgeValueToTab(materials: LessonPage['materials'], localeContent: string) {
|
||||
const { slide_url, videos, note, documents } = materials;
|
||||
|
||||
tabs = tabs.map((tab) => {
|
||||
let badgeValue = 0;
|
||||
|
||||
if (tab.value === 1 && (!isHtmlValueEmpty(note) || !isHtmlValueEmpty(localeContent))) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 2 && !!slide_url) {
|
||||
badgeValue = 1;
|
||||
} else if (tab.value === 3 && !isEmpty(videos)) {
|
||||
badgeValue = videos.length;
|
||||
} else if (tab.value === 4 && !isEmpty(documents)) {
|
||||
badgeValue = documents.length;
|
||||
untrack(() => {
|
||||
if (prevMode === MODES.edit) {
|
||||
saveLesson();
|
||||
}
|
||||
tab.badgeValue = badgeValue;
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,41 +200,40 @@
|
||||
$lessonVideoUpload.isModalOpen = true;
|
||||
};
|
||||
|
||||
const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
api: '/api/completion'
|
||||
});
|
||||
let isLoading = writable(false);
|
||||
// const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
// api: '/api/completion'
|
||||
// });
|
||||
|
||||
function updateNoteByCompletion(completion: string) {
|
||||
if (!completion) return;
|
||||
// function updateNoteByCompletion(completion: string) {
|
||||
// if (!completion) return;
|
||||
|
||||
if ($lessonByTranslation[lessonId]) {
|
||||
$lessonByTranslation[lessonId][$lesson.locale] = `${prevContent}${completion}`;
|
||||
}
|
||||
// if ($lessonByTranslation[lessonId]) {
|
||||
// $lessonByTranslation[lessonId][$lesson.locale] = `${prevContent}${completion}`;
|
||||
// }
|
||||
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], false, lessonId);
|
||||
// autoSave($lesson.materials, $lessonByTranslation[lessonId], false, lessonId);
|
||||
|
||||
if (editorWindowRef) {
|
||||
const tmceBody = editorWindowRef?.document?.querySelector('body');
|
||||
if (typeof tmceBody?.scrollHeight === 'number') {
|
||||
editorWindowRef?.scrollTo(0, tmceBody.scrollHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (editorWindowRef) {
|
||||
// const tmceBody = editorWindowRef?.document?.querySelector('body');
|
||||
// if (typeof tmceBody?.scrollHeight === 'number') {
|
||||
// editorWindowRef?.scrollTo(0, tmceBody.scrollHeight);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
function callAI(type = '') {
|
||||
prevContent = $lessonByTranslation[lessonId]?.[$lesson.locale] || '';
|
||||
|
||||
const _lesson = $lessons.find((les) => les.id === $lesson.id);
|
||||
$input = JSON.stringify({
|
||||
type,
|
||||
lessonTitle: _lesson?.title || '',
|
||||
courseTitle: $course.title,
|
||||
locale: $lesson.locale
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
handleSubmit({ preventDefault: () => {} });
|
||||
}, 500);
|
||||
function callAI(_type = '') {
|
||||
// prevContent = $lessonByTranslation[lessonId]?.[$lesson.locale] || '';
|
||||
// const _lesson = $lessons.find((les) => les.id === $lesson.id);
|
||||
// $input = JSON.stringify({
|
||||
// type,
|
||||
// lessonTitle: _lesson?.title || '',
|
||||
// courseTitle: $course.title,
|
||||
// locale: $lesson.locale
|
||||
// });
|
||||
// setTimeout(() => {
|
||||
// handleSubmit({ preventDefault: () => {} });
|
||||
// }, 500);
|
||||
}
|
||||
|
||||
function initPlyr(_player: any, _video: LessonPage['materials']['videos']) {
|
||||
@@ -236,32 +250,26 @@
|
||||
|
||||
function autoSave(
|
||||
updatedMaterials: LessonPage['materials'],
|
||||
translation: Record<LOCALE, string>,
|
||||
_translation: Record<LOCALE, string>,
|
||||
_isLoading?: boolean,
|
||||
lessonId?: string
|
||||
_lessonId?: string
|
||||
) {
|
||||
if (mode === MODES.view) return;
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
const { error } = await saveLesson(updatedMaterials);
|
||||
untrack(() => {
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
const { error } = await saveLesson(updatedMaterials);
|
||||
|
||||
if (error) {
|
||||
console.error('error saving lesson', error);
|
||||
snackbar.error('snackbar.materials.apology');
|
||||
}
|
||||
isSaving = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function onLessonIdChange(_lid: string) {
|
||||
isSaving = false;
|
||||
|
||||
tabs = orderedTabs(tabs, $course.metadata?.lessonTabsOrder);
|
||||
currentTab = tabs[0].value;
|
||||
componentsToRender = getComponentOrder(tabs);
|
||||
if (error) {
|
||||
console.error('error saving lesson', error);
|
||||
snackbar.error('snackbar.materials.apology');
|
||||
}
|
||||
isSaving = false;
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
@@ -298,24 +306,26 @@
|
||||
return componentNames;
|
||||
}
|
||||
|
||||
$: autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
$effect(() => {
|
||||
console.log('autoSaving...');
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
});
|
||||
|
||||
$: onLessonIdChange(lessonId);
|
||||
$effect(() => {
|
||||
console.log('handleSave...');
|
||||
handleSave(prevMode);
|
||||
});
|
||||
|
||||
$: handleSave(prevMode);
|
||||
// $effect(() => {
|
||||
// updateNoteByCompletion($completion);
|
||||
// });
|
||||
|
||||
$: addBadgeValueToTab($lesson.materials, $lessonByTranslation[lessonId]?.[$lesson.locale] || '');
|
||||
$effect(() => {
|
||||
initPlyr(player, $lesson.materials.videos);
|
||||
});
|
||||
|
||||
$: updateNoteByCompletion($completion);
|
||||
|
||||
$: initPlyr(player, $lesson.materials.videos);
|
||||
|
||||
$: lessonTitle = $lesson.title;
|
||||
|
||||
$: editorValue = lessonFallbackNote(
|
||||
$lesson.materials.note,
|
||||
$lessonByTranslation[lessonId],
|
||||
$lesson.locale
|
||||
let editorValue = $derived(
|
||||
lessonFallbackNote($lesson.materials.note, $lessonByTranslation[lessonId], $lesson.locale)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -334,190 +344,177 @@
|
||||
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
|
||||
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
|
||||
>
|
||||
<AddDocumentToLesson {lessonId} />
|
||||
<AddDocumentToLesson />
|
||||
</Modal>
|
||||
|
||||
<HtmlRender className="m-auto text-center">
|
||||
<svelte:fragment slot="content">
|
||||
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</svelte:fragment>
|
||||
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</HtmlRender>
|
||||
|
||||
{#if $lesson.isFetching}
|
||||
<Loader />
|
||||
{:else if mode === MODES.edit}
|
||||
<Tabs {tabs} {currentTab} {onChange}>
|
||||
<slot:fragment slot="content">
|
||||
<TabContent
|
||||
value={getValue('course.navItem.lessons.materials.tabs.note.title')}
|
||||
index={currentTab}
|
||||
>
|
||||
<div class="flex justify-end gap-1">
|
||||
<div bind:this={aiButtonRef} class="flex flex-row-reverse">
|
||||
<PrimaryButton
|
||||
className="flex items-center relative"
|
||||
onClick={() => {
|
||||
openPopover = !openPopover;
|
||||
}}
|
||||
isLoading={$isLoading}
|
||||
isDisabled={$isLoading}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
disableScale
|
||||
>
|
||||
<MagicWandFilled size={20} class="carbon-icon mr-3" />
|
||||
AI
|
||||
<Popover
|
||||
caret
|
||||
align="left"
|
||||
bind:open={openPopover}
|
||||
on:click:outside={({ detail }) => {
|
||||
openPopover = aiButtonRef?.contains(detail.target);
|
||||
{#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 bind:this={aiButtonRef} class="hidden flex-row-reverse">
|
||||
<PrimaryButton
|
||||
className="flex items-center relative"
|
||||
onClick={() => {
|
||||
openPopover = !openPopover;
|
||||
}}
|
||||
isLoading={$isLoading}
|
||||
isDisabled={$isLoading}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
disableScale
|
||||
>
|
||||
<div class="p-2">
|
||||
<button class={aiButtonClass} on:click={() => callAI('outline')}>
|
||||
<ListIcon class="carbon-icon mr-2" />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.outline')}
|
||||
</button>
|
||||
<button class={aiButtonClass} on:click={() => callAI('note')}>
|
||||
<AlignBoxTopLeftIcon class="carbon-icon mr-2" />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.note')}
|
||||
</button>
|
||||
<button class={aiButtonClass} on:click={() => callAI('activities')}>
|
||||
<IbmWatsonKnowledgeStudioIcon class="carbon-icon mr-2" />
|
||||
{$t('course.navItem.lessons.materials.tabs.note.ai.activities')}
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 h-[60vh]">
|
||||
<TextEditor
|
||||
id={lessonId}
|
||||
bind:editorWindowRef
|
||||
value={editorValue}
|
||||
onChange={(html) => {
|
||||
if (mode === MODES.view) return;
|
||||
$lessonByTranslation[lessonId][$lesson.locale] = html;
|
||||
try {
|
||||
// Backup locale of lesson content
|
||||
localStorage.setItem(`lesson-${lessonId}-${$lesson.locale}`, html);
|
||||
} catch (error) {}
|
||||
$isLessonDirty = true;
|
||||
}}
|
||||
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
<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)}
|
||||
>
|
||||
<TrashCanIcon size={20} class="carbon-icon dark:text-white" />
|
||||
</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
|
||||
/>
|
||||
{: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
|
||||
/>
|
||||
{: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"
|
||||
/>
|
||||
</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}
|
||||
<WandSparklesIcon size={16} />
|
||||
AI
|
||||
<Popover
|
||||
caret
|
||||
align="left"
|
||||
bind:open={openPopover}
|
||||
on:click:outside={({ detail }) => {
|
||||
openPopover = aiButtonRef?.contains(detail.target) || false;
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{/key}
|
||||
</div>
|
||||
{/each}
|
||||
</Popover>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</TabContent>
|
||||
<TabContent
|
||||
value={getValue('course.navItem.lessons.materials.tabs.document.title')}
|
||||
index={currentTab}
|
||||
>
|
||||
<ComponentDocument {mode} />
|
||||
</TabContent>
|
||||
</slot:fragment>
|
||||
|
||||
<div class="mt-5 h-[60vh]">
|
||||
<TextEditor
|
||||
id={lessonId}
|
||||
bind:editorWindowRef
|
||||
value={editorValue}
|
||||
onChange={(html) => {
|
||||
if (mode === MODES.view) return;
|
||||
|
||||
$lessonByTranslation[lessonId][$lesson.locale] = html;
|
||||
|
||||
try {
|
||||
// Backup locale of lesson content
|
||||
localStorage.setItem(`lesson-${lessonId}-${$lesson.locale}`, html);
|
||||
} catch (error) {}
|
||||
$isLessonDirty = true;
|
||||
}}
|
||||
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
<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>
|
||||
{/key}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</TabContent>
|
||||
<TabContent value={getValue('course.navItem.lessons.materials.tabs.document.title')} index={currentTab}>
|
||||
<ComponentDocument {mode} />
|
||||
</TabContent>
|
||||
</slot:fragment>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
{: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>
|
||||
{#each componentsToRender as Component}
|
||||
<svelte:component this={Component} {lessonId} />
|
||||
<Component {lessonId} />
|
||||
{/each}
|
||||
|
||||
{#if $currentOrg.customization.apps.comments}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<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';
|
||||
@@ -16,10 +18,10 @@
|
||||
import { COURSE_TYPE } from '$lib/utils/types';
|
||||
import type { Lesson } from '$lib/utils/types';
|
||||
|
||||
let errors = {
|
||||
let errors = $state({
|
||||
title: ''
|
||||
};
|
||||
let lesson: Lesson = {
|
||||
});
|
||||
let lesson: Lesson = $state({
|
||||
id: '',
|
||||
course_id: $course.id || '',
|
||||
title: '',
|
||||
@@ -29,7 +31,7 @@
|
||||
is_unlocked: true,
|
||||
lesson_completion: [],
|
||||
created_at: ''
|
||||
};
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!lesson.title.trim()) {
|
||||
@@ -112,14 +114,12 @@
|
||||
maxWidth="max-w-2xl"
|
||||
containerClass="overflow-hidden"
|
||||
modalHeading={$t(
|
||||
`course.navItem.lessons.add_lesson.${
|
||||
$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'
|
||||
}`
|
||||
`course.navItem.lessons.add_lesson.${$handleAddLessonWidget.isSection ? 'modal_heading_section' : 'modal_heading'}`
|
||||
)}
|
||||
>
|
||||
<form
|
||||
on:submit|preventDefault={handleSave}
|
||||
class="relative m-auto py-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap items-center dark:bg-neutral-800"
|
||||
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
|
||||
|
||||
@@ -24,10 +24,7 @@ export function generateQuestion(questions) {
|
||||
return generatedQuestions;
|
||||
}
|
||||
|
||||
export function getIsLessonComplete(
|
||||
completions: LessonCompletion[],
|
||||
profileId: string | undefined
|
||||
): boolean {
|
||||
export function getIsLessonComplete(completions: LessonCompletion[], profileId: string | undefined): boolean {
|
||||
if (!Array.isArray(completions)) return false;
|
||||
return completions.some((c) => {
|
||||
return c.is_complete && c.profile_id === profileId;
|
||||
|
||||
+40
-53
@@ -1,7 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { QUESTION_TEMPLATE, QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { questionnaireMetaData } from './answers';
|
||||
import { QUESTION_TEMPLATE, QUESTION_TYPE, QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
|
||||
import type { Question } from '$lib/components/Course/types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
import { questionnaireMetaData } from './answers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isQuestionnaireFetching = writable(false);
|
||||
export const deleteConfirmation = writable({ open: false });
|
||||
@@ -10,7 +13,16 @@ export const questionnaireOrder = writable({ open: false });
|
||||
// {'question-id': { option: '', title: '', ... }}
|
||||
export const questionnaireValidation = writable({});
|
||||
|
||||
export const questionnaire = writable({
|
||||
export const questionnaire: Writable<{
|
||||
title?: string;
|
||||
due_by?: string;
|
||||
is_due_by_dirty?: boolean;
|
||||
is_title_dirty?: boolean;
|
||||
description?: string;
|
||||
is_description_dirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
}> = writable({
|
||||
title: '',
|
||||
due_by: '',
|
||||
is_due_by_dirty: false,
|
||||
@@ -50,9 +62,7 @@ export function validateQuestionnaire(questions) {
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnswer = question.options
|
||||
.filter((o) => !o.deleted_at)
|
||||
.some((option) => option.is_correct);
|
||||
const hasAnswer = question.options.filter((o) => !o.deleted_at).some((option) => option.is_correct);
|
||||
|
||||
if (!hasAnswer) {
|
||||
qErrors.option = 'Please mark an option as the correct answer';
|
||||
@@ -79,9 +89,8 @@ export function handleAddQuestion() {
|
||||
value: '',
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
question_type: {
|
||||
id: QUESTION_TYPE.RADIO
|
||||
},
|
||||
question_type: QUESTION_TYPES[0],
|
||||
question_type_id: QUESTION_TYPES[0].id,
|
||||
options: [
|
||||
{
|
||||
id: '1-form',
|
||||
@@ -109,7 +118,7 @@ export function handleAddOption(questionId) {
|
||||
options: [
|
||||
...question.options,
|
||||
{
|
||||
id: `${question.options.length + 1}-form`,
|
||||
id: `${new Date().getTime()}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
is_correct: false
|
||||
@@ -128,26 +137,17 @@ export function handleAddOption(questionId) {
|
||||
export function handleRemoveOption(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
option.deleted_at = new Date();
|
||||
}
|
||||
return option;
|
||||
})
|
||||
};
|
||||
}
|
||||
const optionsIndex = q.questions[questionIndex].options.findIndex((oItem) => oItem.id === optionId);
|
||||
if (optionsIndex === -1) return q;
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
q.questions[questionIndex].options[optionsIndex].deleted_at = new Date().toString();
|
||||
q.questions[questionIndex].options[optionsIndex].is_dirty = true;
|
||||
q.questions[questionIndex].is_dirty = true; // Mark as dirty if needed
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -155,38 +155,25 @@ export function handleRemoveOption(questionId, optionId) {
|
||||
export function handleRemoveQuestion(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((q) => {
|
||||
if (q.id === questionId) {
|
||||
q.deleted_at = new Date();
|
||||
}
|
||||
return q;
|
||||
})
|
||||
};
|
||||
q.questions[questionIndex].deleted_at = new Date().toString();
|
||||
|
||||
return q;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleCode(questionId, shouldAdd = true) {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
const questionIndex = q.questions.findIndex((qItem) => qItem.id === questionId);
|
||||
if (questionIndex === -1) return q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
code: shouldAdd ? question.code || '' : undefined
|
||||
};
|
||||
}
|
||||
const question = q.questions[questionIndex];
|
||||
q.questions[questionIndex].code = shouldAdd ? question.code || '' : undefined;
|
||||
|
||||
return question;
|
||||
})
|
||||
};
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,7 +220,7 @@ export function addDynamicValue(questionId, optionId) {
|
||||
if (option.id === optionId) {
|
||||
const label = e.target.value;
|
||||
|
||||
if (!isUUID(option.value)) {
|
||||
if (!isUUID(option.value || '')) {
|
||||
option.value = label.split(' ').join('-');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Course, Lesson, LessonComment, LessonPage, LessonSection } from '$lib/utils/types';
|
||||
import type { Course, Lesson, LessonComment, LessonCompletion, LessonPage, LessonSection } from '$lib/utils/types';
|
||||
import type { Updater, Writable } from 'svelte/store';
|
||||
import {
|
||||
createLesson,
|
||||
@@ -28,7 +28,7 @@ export const lessonDocUpload = writable({
|
||||
isCancelled: false,
|
||||
isModalOpen: false,
|
||||
isUploading: false,
|
||||
uploadedDocument: null as any,
|
||||
uploadedDocument: null,
|
||||
uploadProgress: 0
|
||||
});
|
||||
|
||||
@@ -64,20 +64,74 @@ export const lessonComments = writable<LessonComment[]>([]);
|
||||
|
||||
export const isLessonDirty = writable(false);
|
||||
|
||||
export function setLesson({
|
||||
id,
|
||||
lessonData,
|
||||
locale,
|
||||
totalExercises,
|
||||
totalComments
|
||||
}: {
|
||||
id: string;
|
||||
lessonData: Lesson & { lesson_language: { locale: LOCALE; content: string }[] };
|
||||
locale: LOCALE;
|
||||
totalExercises: number;
|
||||
totalComments: number;
|
||||
}) {
|
||||
if (!lessonData) return;
|
||||
|
||||
let lesson_completion: LessonCompletion[] = [];
|
||||
|
||||
if (Array.isArray(lessonData.lesson_completion)) {
|
||||
lesson_completion = [...lessonData.lesson_completion];
|
||||
}
|
||||
|
||||
lesson.update((l) => ({
|
||||
...l,
|
||||
id: id,
|
||||
title: lessonData.title,
|
||||
totalExercises,
|
||||
totalComments: totalComments,
|
||||
materials: {
|
||||
videos: lessonData.videos || [],
|
||||
note: lessonData.note,
|
||||
slide_url: lessonData.slide_url,
|
||||
documents: lessonData.documents || []
|
||||
},
|
||||
lesson_completion,
|
||||
exercises: [],
|
||||
locale: locale
|
||||
}));
|
||||
|
||||
if (Array.isArray(lessonData.lesson_language)) {
|
||||
lessonByTranslation.update((lessLocales) => {
|
||||
return {
|
||||
...lessLocales,
|
||||
[id]: lessonData.lesson_language.reduce(
|
||||
(acc, cur) => {
|
||||
acc[cur.locale] = cur.content;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<LOCALE, string>
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAddLesson() {
|
||||
lessons.update(((_lessons) => {
|
||||
return [
|
||||
..._lessons,
|
||||
{
|
||||
id: null,
|
||||
title: 'Untitled lesson',
|
||||
// profile: undefined,
|
||||
call_url: undefined,
|
||||
lesson_at: new Date(),
|
||||
is_unlocked: false
|
||||
}
|
||||
];
|
||||
}) as Updater<any>);
|
||||
lessons.update((_lessons) => {
|
||||
const newLesson = {
|
||||
id: '',
|
||||
title: 'Untitled lesson',
|
||||
lesson_at: new Date().toDateString(),
|
||||
is_unlocked: false,
|
||||
course_id: '',
|
||||
created_at: new Date().toDateString(),
|
||||
lesson_completion: []
|
||||
} as Lesson;
|
||||
|
||||
return [..._lessons, newLesson];
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDelete(lessonId: Lesson['id'] | undefined) {
|
||||
@@ -143,7 +197,7 @@ export async function handleSaveLesson(lesson: Lesson, courseId: Course['id']) {
|
||||
section_id: lesson.section_id
|
||||
};
|
||||
|
||||
let newLessonData: any[] | null = null;
|
||||
let newLessonData: Lesson | null | undefined = null;
|
||||
|
||||
if (lesson.id) {
|
||||
// No need to get the result of update cause we have all in local state
|
||||
@@ -151,14 +205,11 @@ export async function handleSaveLesson(lesson: Lesson, courseId: Course['id']) {
|
||||
} else {
|
||||
const { data } = await createLesson(newLesson);
|
||||
|
||||
newLessonData = data;
|
||||
newLessonData = data?.[0];
|
||||
}
|
||||
return newLessonData;
|
||||
}
|
||||
export async function handleSaveLessonSection(
|
||||
section: Partial<LessonSection>,
|
||||
courseId: Course['id']
|
||||
) {
|
||||
export async function handleSaveLessonSection(section: Partial<LessonSection>, courseId: Course['id']) {
|
||||
const result = lessonValidation(section);
|
||||
|
||||
if (Object.keys(result).length) {
|
||||
|
||||
+60
-34
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { ChevronDown, ChevronUp, Add, FlashFilled } from 'carbon-icons-svelte';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { SkeletonText } from 'carbon-components-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { NavClasses } from '$lib/utils/constants/reusableClass';
|
||||
import { handleAddLessonWidget } from '../Lesson/store';
|
||||
import NavIcons from './NavIcons.svelte';
|
||||
@@ -11,20 +14,41 @@
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { COURSE_VERSION } from '$lib/utils/types';
|
||||
|
||||
export let handleClick = () => {};
|
||||
export let id = '';
|
||||
export let name = '';
|
||||
export let label = '';
|
||||
export let isGroupActive = false;
|
||||
export let isExpanded: boolean | undefined;
|
||||
export let total = 0;
|
||||
export let isLoading = true;
|
||||
export let isLesson = false;
|
||||
export let isSection = false;
|
||||
export let isStudent = true;
|
||||
export let isPaidFeature = false;
|
||||
export let className = '';
|
||||
export let btnPadding = 'py-3 px-4';
|
||||
interface Props {
|
||||
handleClick?: any;
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
isGroupActive?: boolean;
|
||||
isExpanded: boolean | undefined;
|
||||
total?: number;
|
||||
isLoading?: boolean;
|
||||
isLesson?: boolean;
|
||||
isSection?: boolean;
|
||||
isStudent?: boolean;
|
||||
isPaidFeature?: boolean;
|
||||
className?: string;
|
||||
btnPadding?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
handleClick = () => {},
|
||||
id = '',
|
||||
name = '',
|
||||
label = '',
|
||||
isGroupActive = false,
|
||||
isExpanded = $bindable(),
|
||||
total = 0,
|
||||
isLoading = true,
|
||||
isLesson = false,
|
||||
isSection = false,
|
||||
isStudent = true,
|
||||
isPaidFeature = false,
|
||||
className = '',
|
||||
btnPadding = 'py-3 px-4',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
function addLesson() {
|
||||
goto('/courses/' + $course.id + '/lessons');
|
||||
@@ -55,45 +79,47 @@
|
||||
|
||||
<div class={className}>
|
||||
<button
|
||||
class="item relative flex items-center {btnPadding} ml-2 mb-1 {NavClasses.item} {isGroupActive &&
|
||||
class="item relative flex items-center {btnPadding} mb-1 ml-2 {NavClasses.item} {isGroupActive &&
|
||||
!isLoading &&
|
||||
NavClasses.active} w-[95%]"
|
||||
tabindex="0"
|
||||
on:click={onClick}
|
||||
onclick={onClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<NavIcons {name} />
|
||||
{#if isLoading}
|
||||
<div class="w-11/12 mx-auto">
|
||||
<SkeletonText class="rounded-md" style="margin: 0px; height: 30px;" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-bold text-md text-start leading-4 line-clamp-2">{label}</span>
|
||||
{#if total}
|
||||
<span class="ml-1">({total})</span>
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<NavIcons {name} />
|
||||
{#if isLoading}
|
||||
<div class="mx-auto w-11/12">
|
||||
<SkeletonText class="rounded-md" style="margin: 0px; height: 20px;" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-md line-clamp-2 text-start font-bold leading-4">{label}</span>
|
||||
{#if total}
|
||||
<span class="ml-1">({total})</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="grow" />
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if isPaidFeature && $isFreePlan}
|
||||
<FlashFilled size={20} class="text-blue-700" />
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{#if (isLesson || isSection) && !isLoading && !isStudent}
|
||||
<IconButton stopPropagation={true} onClick={addLesson} size="small">
|
||||
<Add />
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton size="small" stopPropagation={true} onClick={toggleIsExpanded}>
|
||||
{#if isExpanded}
|
||||
<ChevronUp class="carbon-icon dark:text-white" />
|
||||
<ChevronUpIcon size={16} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ChevronDown class="carbon-icon dark:text-white" />
|
||||
<ChevronDownIcon size={16} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isExpanded && !isLoading}
|
||||
<div in:slide out:slide class="flex flex-col">
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user