feat: lint codebase and run precommit hook
@@ -1,31 +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="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" />
|
||||
<script
|
||||
nonce="%sveltekit.nonce%"
|
||||
defer
|
||||
src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
|
||||
></script>
|
||||
|
||||
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
|
||||
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
|
||||
<script nonce="%sveltekit.nonce%" 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" class="theme-container">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<Skeleton class="h-[16px] w-[80px] mb-2" />
|
||||
<Skeleton class="h-[24px] w-[40px] mb-1" />
|
||||
<Skeleton class="mb-2 h-[16px] w-[80px]" />
|
||||
<Skeleton class="mb-1 h-[24px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,10 +26,10 @@
|
||||
<!-- 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">
|
||||
<Skeleton class="h-[24px] w-[200px] mb-6" />
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Skeleton class="w-full h-[300px]" />
|
||||
<Skeleton class="w-full h-[300px]" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
<!-- Students Table Skeleton -->
|
||||
<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">
|
||||
<Skeleton class="h-[24px] w-[200px] mb-6" />
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[32px] w-[32px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[16px] w-[150px] mb-1" />
|
||||
<Skeleton class="mb-1 h-[16px] w-[150px]" />
|
||||
<Skeleton class="h-[12px] w-[200px]" />
|
||||
</div>
|
||||
<Skeleton class="h-[16px] w-[80px]" />
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.description = 'Certificate customization is only available on paid plans';
|
||||
throw new Error(errors.description);
|
||||
|
||||
@@ -28,47 +28,47 @@
|
||||
<Dialog.Title>Send Certificate</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<main>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center space-x-2">
|
||||
<Switch id="auto-certificate" bind:checked={isAutomatic} />
|
||||
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
|
||||
</div>
|
||||
<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="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">
|
||||
<InputField
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="email,comma seperated"
|
||||
bind:value={$issueCertificateModal.email}
|
||||
/>
|
||||
<InputField
|
||||
label="Schedule date"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="12/06/2023"
|
||||
bind:value={$issueCertificateModal.date}
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center space-x-2">
|
||||
<Switch id="auto-certificate" bind:checked={isAutomatic} />
|
||||
<Label for="auto-certificate" class="text-sm font-medium">Automatic</Label>
|
||||
</div>
|
||||
<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="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">
|
||||
<InputField
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="email,comma seperated"
|
||||
bind:value={$issueCertificateModal.email}
|
||||
/>
|
||||
<InputField
|
||||
label="Schedule date"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
placeholder="12/06/2023"
|
||||
bind:value={$issueCertificateModal.date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextareaField
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows={2}
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
<TextareaField
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows={2}
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex w-full items-end justify-end">
|
||||
<Button type="submit">Issue certificate</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
<div class="mt-5 flex w-full items-end justify-end">
|
||||
<Button type="submit">Issue certificate</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -39,34 +39,28 @@
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[80%] md:w-[65%] max-w-xl">
|
||||
<Dialog.Content class="w-[80%] max-w-xl md:w-[65%]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t(`course.navItem.lessons.section_prompt.header`)}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<div class="mb-8">
|
||||
<h3 class="text-center text-2xl">
|
||||
{$t('course.navItem.lessons.section_prompt.title')}
|
||||
</h3>
|
||||
<p class="max-w-md text-center">
|
||||
{$t('course.navItem.lessons.section_prompt.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h3 class="text-center text-2xl">
|
||||
{$t('course.navItem.lessons.section_prompt.title')}
|
||||
</h3>
|
||||
<p class="max-w-md text-center">
|
||||
{$t('course.navItem.lessons.section_prompt.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleClose}
|
||||
>
|
||||
{$t('course.navItem.lessons.section_prompt.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={activate}
|
||||
loading={isActivating}
|
||||
>
|
||||
{$t('course.navItem.lessons.section_prompt.activate')}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
{$t('course.navItem.lessons.section_prompt.cancel')}
|
||||
</Button>
|
||||
<Button onclick={activate} loading={isActivating}>
|
||||
{$t('course.navItem.lessons.section_prompt.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -22,29 +22,23 @@
|
||||
if (!isOpen) openDeleteModal = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96 px-6 pt-2 pb-6">
|
||||
<Dialog.Content class="w-96 px-6 pb-6 pt-2">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}?
|
||||
</h1>
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}?
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (openDeleteModal = false)}
|
||||
>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -7,37 +7,35 @@
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$deleteConfirmation.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$deleteConfirmation.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
</p>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
$deleteConfirmation.open = false;
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
$deleteConfirmation.open = false;
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -236,108 +236,108 @@
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-4/5 h-[90%] flex items-start !max-h-full h-[85%] py-0 px-4">
|
||||
<Dialog.Content class="flex h-[85%] h-[90%] !max-h-full w-4/5 items-start px-4 py-0">
|
||||
<Dialog.Header class="py-2">
|
||||
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="mt-2 h-full w-full">
|
||||
{#if openDeletePrompt}
|
||||
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</h1>
|
||||
{#if openDeletePrompt}
|
||||
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
|
||||
<h1 class="text-lg dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Preview
|
||||
questions={sortedQuestions}
|
||||
questionnaireMetaData={{
|
||||
answers: data.answers || {},
|
||||
isFinished: true
|
||||
}}
|
||||
bind:grades={data.questionAnswerByPoint}
|
||||
bind:reasons
|
||||
bind:isGradeWithAI
|
||||
bind:isLoading
|
||||
disableGrading={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sticky top-0 ml-4 mt-2 w-2/5">
|
||||
<div class="rounded-md border border-gray-300">
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 border-b border-l-0 border-r-0 border-t-0 border-gray-300 p-3"
|
||||
>
|
||||
<p class="text-base dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.details')}
|
||||
{#if data.isEarly}
|
||||
<span class="badge ml-2 rounded-sm bg-green-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.early')}</span
|
||||
{:else}
|
||||
<Preview
|
||||
questions={sortedQuestions}
|
||||
questionnaireMetaData={{
|
||||
answers: data.answers || {},
|
||||
isFinished: true
|
||||
}}
|
||||
bind:grades={data.questionAnswerByPoint}
|
||||
bind:reasons
|
||||
bind:isGradeWithAI
|
||||
bind:isLoading
|
||||
disableGrading={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sticky top-0 ml-4 mt-2 w-2/5">
|
||||
<div class="rounded-md border border-gray-300">
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 border-b border-l-0 border-r-0 border-t-0 border-gray-300 p-3"
|
||||
>
|
||||
<p class="text-base dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.details')}
|
||||
{#if data.isEarly}
|
||||
<span class="badge ml-2 rounded-sm bg-green-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.early')}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="badge ml-2 rounded-sm bg-red-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.late')}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{:else}
|
||||
<span class="badge ml-2 rounded-sm bg-red-500 px-2 text-sm text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.late')}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={() => {
|
||||
openDeletePrompt = true;
|
||||
}}
|
||||
>
|
||||
{$t('delete_modal.label')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={() => {
|
||||
openDeletePrompt = true;
|
||||
}}
|
||||
>
|
||||
{$t('delete_modal.label')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.total_grade')}:
|
||||
</p>
|
||||
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.total_grade')}:
|
||||
</p>
|
||||
|
||||
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{total}/{maxPoints}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{total}/{maxPoints}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Status</p>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
|
||||
<p class="dark:text-white">Grading</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$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} />
|
||||
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
|
||||
{data.student.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
|
||||
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
|
||||
<p class="text-sm font-semibold text-gray-500 dark:text-white">
|
||||
{$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} />
|
||||
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
|
||||
{data.student.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
|
||||
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
|
||||
<Tag
|
||||
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
|
||||
@@ -345,68 +345,68 @@
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.status')}:
|
||||
</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={status.id.toString()}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleStatusChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{status.text}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each SELECTABLE_STATUS as statusOption}
|
||||
<Select.Item value={statusOption.id.toString()}>
|
||||
{statusOption.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.add_comment')}:
|
||||
</p>
|
||||
<TextareaField
|
||||
bgColor="bg-gray-100 dark:bg-neutral-700"
|
||||
className="font-semibold"
|
||||
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
|
||||
bind:value={data.feedback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col space-y-3 px-3 py-2">
|
||||
<Button variant="outline" onclick={gradeWithAI} class="w-full">
|
||||
<img src="/ai.svg" alt="ai" />
|
||||
<p class="text-sm font-semibold">
|
||||
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.status')}:
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
handleSave(data);
|
||||
// onClose();
|
||||
}}
|
||||
loading={isSaving}
|
||||
class="w-full"
|
||||
>
|
||||
{$t('course.navItem.submissions.grading_modal.submit_grades')}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={status.id.toString()}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleStatusChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{status.text}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each SELECTABLE_STATUS as statusOption}
|
||||
<Select.Item value={statusOption.id.toString()}>
|
||||
{statusOption.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start px-3 py-2 text-sm">
|
||||
<p class="font-semibold text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.submissions.grading_modal.add_comment')}:
|
||||
</p>
|
||||
<TextareaField
|
||||
bgColor="bg-gray-100 dark:bg-neutral-700"
|
||||
className="font-semibold"
|
||||
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
|
||||
bind:value={data.feedback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col space-y-3 px-3 py-2">
|
||||
<Button variant="outline" onclick={gradeWithAI} class="w-full">
|
||||
<img src="/ai.svg" alt="ai" />
|
||||
<p class="text-sm font-semibold">
|
||||
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
handleSave(data);
|
||||
// onClose();
|
||||
}}
|
||||
loading={isSaving}
|
||||
class="w-full"
|
||||
>
|
||||
{$t('course.navItem.submissions.grading_modal.submit_grades')}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white w-1/2">Teacher</p>
|
||||
<p class="dark:text-white">rotimi-best</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -71,34 +71,34 @@
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.order_questions')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="ui:border-primary flex items-center rounded-md border p-4"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<SquareCheckIcon size={16} />
|
||||
{:else if item.type === 2}
|
||||
<CircleDotIcon size={16} />
|
||||
{:else}
|
||||
<TablePropertiesIcon size={16} />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="ui:border-primary flex items-center rounded-md border p-4"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<SquareCheckIcon size={16} />
|
||||
{:else if item.type === 2}
|
||||
<CircleDotIcon size={16} />
|
||||
{:else}
|
||||
<TablePropertiesIcon size={16} />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
if (!isOpen) handleCancelAddExercise();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-2xl w-4/5">
|
||||
<Dialog.Content class="w-4/5 max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.new_exercise_modal.heading')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -161,147 +161,147 @@
|
||||
<Confetti />
|
||||
{/if}
|
||||
{#if step === 0}
|
||||
<div>
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
|
||||
</h2>
|
||||
|
||||
<div class="my-8 flex justify-between gap-2">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="h-60 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"
|
||||
type="button"
|
||||
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
|
||||
>
|
||||
<div class="flex h-[70%] w-full flex-row-reverse">
|
||||
<CircleCheckIcon size={16} filled={option.type === type} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="flex items-center text-start">
|
||||
<span class="mr-2 text-sm">{option.title}</span>
|
||||
{#if option.isDisabled}
|
||||
<ComingSoon />
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-start text-xs font-light">{option.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-row-reverse items-center">
|
||||
<Button onclick={handleNext}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
{#if type === Type.SCRATCH}
|
||||
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
|
||||
<div class="w-full">
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
|
||||
</h2>
|
||||
<InputField
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={handleBack}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
|
||||
</Button>
|
||||
<Button onclick={handleAddExercise}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === Type.TEMPLATE}
|
||||
<div>
|
||||
<h2 class="m-0 mb-2 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.how')}?
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div class="mb-5 flex items-center gap-2">
|
||||
{#each tags as tag}
|
||||
<Badge class={selectedTag === tag ? 'bg-primary-400' : ''} onclick={() => handleTagSelection(tag)}
|
||||
>{tag}</Badge
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if exerciseTemplateApi.isLoading}
|
||||
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each Array(16) as _}
|
||||
<div
|
||||
class="h-[140px] w-full rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-700"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<Skeleton class="h-4 w-3/4" />
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if exerciseTemplateApi.templates.length > 0}
|
||||
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each exerciseTemplateApi.templates as template}
|
||||
<button
|
||||
class="h-[140px] w-full 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"
|
||||
onclick={() => (selectedTemplateId = template.id)}
|
||||
>
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<p class="flex items-center text-start text-sm">
|
||||
{template.title}
|
||||
</p>
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<p class="text-xs font-normal">
|
||||
{template.questions}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
|
||||
</p>
|
||||
<p class="text-start text-xs font-normal">
|
||||
{template.points}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={handleBack}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedTemplateId || exerciseTemplateApi.templates.length === 0}
|
||||
loading={isTemplateFinishedLoading}
|
||||
onclick={handleTemplateSelection}
|
||||
<div class="my-8 flex justify-between gap-2">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="h-60 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"
|
||||
type="button"
|
||||
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex h-[70%] w-full flex-row-reverse">
|
||||
<CircleCheckIcon size={16} filled={option.type === type} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="flex items-center text-start">
|
||||
<span class="mr-2 text-sm">{option.title}</span>
|
||||
{#if option.isDisabled}
|
||||
<ComingSoon />
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-start text-xs font-light">{option.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-row-reverse items-center">
|
||||
<Button onclick={handleNext}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- {:else if type === Type.AI} -->
|
||||
<!-- <div>
|
||||
{:else if step === 1}
|
||||
{#if type === Type.SCRATCH}
|
||||
<div class="m-auto flex min-h-[300px] w-96 items-center justify-center">
|
||||
<div class="w-full">
|
||||
<h2 class="my-5 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.title')}
|
||||
</h2>
|
||||
<InputField
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={handleBack}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
|
||||
</Button>
|
||||
<Button onclick={handleAddExercise}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === Type.TEMPLATE}
|
||||
<div>
|
||||
<h2 class="m-0 mb-2 text-2xl font-medium">
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.select_template')}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div class="mb-5 flex items-center gap-2">
|
||||
{#each tags as tag}
|
||||
<Badge class={selectedTag === tag ? 'bg-primary-400' : ''} onclick={() => handleTagSelection(tag)}
|
||||
>{tag}</Badge
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if exerciseTemplateApi.isLoading}
|
||||
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each Array(16) as _}
|
||||
<div
|
||||
class="h-[140px] w-full rounded-md border-2 border-gray-200 p-5 dark:border-neutral-600 dark:bg-neutral-700"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<Skeleton class="h-4 w-3/4" />
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if exerciseTemplateApi.templates.length > 0}
|
||||
<div class="grid grid-cols-2 items-start gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each exerciseTemplateApi.templates as template}
|
||||
<button
|
||||
class="h-[140px] w-full 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"
|
||||
onclick={() => (selectedTemplateId = template.id)}
|
||||
>
|
||||
<div class="flex h-full flex-col justify-evenly">
|
||||
<p class="flex items-center text-start text-sm">
|
||||
{template.title}
|
||||
</p>
|
||||
<div class="flex flex-col items-start justify-between gap-1">
|
||||
<p class="text-xs font-normal">
|
||||
{template.questions}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.questions')}
|
||||
</p>
|
||||
<p class="text-start text-xs font-normal">
|
||||
{template.points}
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={handleBack}>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.back')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedTemplateId || exerciseTemplateApi.templates.length === 0}
|
||||
loading={isTemplateFinishedLoading}
|
||||
onclick={handleTemplateSelection}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.new_exercise_modal.finish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- {: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}
|
||||
@@ -369,7 +369,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div> -->
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -101,14 +101,14 @@
|
||||
|
||||
async function uploadDocument() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
$lessonDocUpload.error = $t('upgrade.required');
|
||||
snackbar.error($lessonDocUpload.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$lessonDocUpload.isUploading = true;
|
||||
|
||||
try {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
formRes = {
|
||||
type: 'AUTHORIZATION_ERROR',
|
||||
type: 'AUTHORIZATION_ERROR',
|
||||
status: 403,
|
||||
message: $t('upgrade.required')
|
||||
};
|
||||
|
||||
@@ -15,27 +15,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={openDeleteModal}
|
||||
>
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Feed</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
|
||||
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (openDeleteModal = false)}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
Yes
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>Yes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -127,30 +127,27 @@
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section class="w-2/ flex h-full flex-col rounded-xl pb-3">
|
||||
<TextEditor
|
||||
content={newPost}
|
||||
onChange={(text) => {
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('course.navItem.news_feed.heading_button.placeholder')}
|
||||
/>
|
||||
{#if errors.newPost}
|
||||
<p class="text-sm text-red-500">{errors.newPost}</p>
|
||||
{/if}
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={resetEditor}
|
||||
>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('course.navItem.news_feed.heading_button.post')}
|
||||
</Button>
|
||||
<TextEditor
|
||||
content={newPost}
|
||||
onChange={(text) => {
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('course.navItem.news_feed.heading_button.placeholder')}
|
||||
/>
|
||||
{#if errors.newPost}
|
||||
<p class="text-sm text-red-500">{errors.newPost}</p>
|
||||
{/if}
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('course.navItem.news_feed.heading_button.post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -12,32 +12,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$deleteMemberModal.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$deleteMemberModal.open}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
<Dialog.Header class="py-2 px-5">
|
||||
<Dialog.Header class="px-5 py-2">
|
||||
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
|
||||
</p>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.people.delete_confirmation.sure')} <strong>{email}</strong>?
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => ($deleteMemberModal.open = false)}
|
||||
>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => ($deleteMemberModal.open = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -20,37 +20,28 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$t('community.delete.title')} {isQuestion
|
||||
? $t('community.delete.question')
|
||||
: $t('community.delete.comment')}
|
||||
{$t('community.delete.title')}
|
||||
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('community.delete.sure')}
|
||||
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}?
|
||||
</p>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('community.delete.sure')}
|
||||
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}?
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={onCancel}
|
||||
>
|
||||
{$t('community.delete.no')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={onDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{$t('community.delete.yes')}
|
||||
</Button>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={onCancel}>
|
||||
{$t('community.delete.no')}
|
||||
</Button>
|
||||
<Button onclick={onDelete} loading={isDeleting}>
|
||||
{$t('community.delete.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -7,4 +7,3 @@ export interface CurrentCommunityQuestion {
|
||||
export const currentCommunityQuestion = writable<CurrentCommunityQuestion>({
|
||||
title: null
|
||||
});
|
||||
|
||||
|
||||
@@ -20,43 +20,41 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$copyCourseModal.open}
|
||||
>
|
||||
<Dialog.Root bind:open={$copyCourseModal.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('courses.copy_course.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createCourse)}>
|
||||
<InputField
|
||||
label={$t('courses.copy_course.course_name_label')}
|
||||
bind:value={$copyCourseModal.title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('courses.copy_course.course_name_placeholder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
autoComplete={false}
|
||||
errorMessage={courseCloneApi.errors.title}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('courses.copy_course.course_name_label')}
|
||||
bind:value={$copyCourseModal.title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('courses.copy_course.course_name_placeholder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
autoComplete={false}
|
||||
errorMessage={courseCloneApi.errors.title}
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
label={$t('courses.copy_course.course_description_label')}
|
||||
bind:value={$copyCourseModal.description}
|
||||
placeholder={$t('courses.copy_course.course_description_placeholder')}
|
||||
className="mb-4"
|
||||
rows={4}
|
||||
errorMessage={courseCloneApi.errors.description}
|
||||
/>
|
||||
<TextareaField
|
||||
label={$t('courses.copy_course.course_description_label')}
|
||||
bind:value={$copyCourseModal.description}
|
||||
placeholder={$t('courses.copy_course.course_description_placeholder')}
|
||||
className="mb-4"
|
||||
rows={4}
|
||||
errorMessage={courseCloneApi.errors.description}
|
||||
/>
|
||||
|
||||
{#if courseCloneApi.errors.general}
|
||||
<div class="mb-4 text-sm text-red-600">{courseCloneApi.errors.general}</div>
|
||||
{/if}
|
||||
{#if courseCloneApi.errors.general}
|
||||
<div class="mb-4 text-sm text-red-600">{courseCloneApi.errors.general}</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center">
|
||||
<Button type="submit" loading={$copyCourseModal.isSaving}>
|
||||
{$t('courses.copy_course.create_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-5 flex flex-row-reverse items-center">
|
||||
<Button type="submit" loading={$copyCourseModal.isSaving}>
|
||||
{$t('courses.copy_course.create_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -233,4 +233,3 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -109,4 +109,3 @@
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -55,4 +55,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface DOMProtectionCleanup {
|
||||
protectionInterval?: ReturnType<typeof setInterval>;
|
||||
domObserver?: MutationObserver;
|
||||
cleanup: () => void;
|
||||
protectionInterval?: ReturnType<typeof setInterval>;
|
||||
domObserver?: MutationObserver;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,192 +14,190 @@ export interface DOMProtectionCleanup {
|
||||
* @param isEmailVerified - A readable store or getter function that returns whether email is verified
|
||||
* @returns Cleanup object with intervals, observers, and a cleanup function
|
||||
*/
|
||||
export function setupDOMProtection(
|
||||
isEmailVerified: Readable<boolean> | (() => boolean | null)
|
||||
): DOMProtectionCleanup {
|
||||
if (!browser) {
|
||||
return {
|
||||
cleanup: () => {}
|
||||
};
|
||||
}
|
||||
export function setupDOMProtection(isEmailVerified: Readable<boolean> | (() => boolean | null)): DOMProtectionCleanup {
|
||||
if (!browser) {
|
||||
return {
|
||||
cleanup: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
let protectionInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let domObserver: MutationObserver | undefined;
|
||||
let protectionInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let domObserver: MutationObserver | undefined;
|
||||
|
||||
// Helper to get current verification status
|
||||
const getIsVerified = () => {
|
||||
if (typeof isEmailVerified === 'function') {
|
||||
return isEmailVerified() ?? false;
|
||||
}
|
||||
// For stores, we need to subscribe temporarily - this is a limitation
|
||||
// In practice, the component should pass a getter function
|
||||
let value = false;
|
||||
const unsubscribe = isEmailVerified.subscribe((v) => {
|
||||
value = v ?? false;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
};
|
||||
// Helper to get current verification status
|
||||
const getIsVerified = () => {
|
||||
if (typeof isEmailVerified === 'function') {
|
||||
return isEmailVerified() ?? false;
|
||||
}
|
||||
// For stores, we need to subscribe temporarily - this is a limitation
|
||||
// In practice, the component should pass a getter function
|
||||
let value = false;
|
||||
const unsubscribe = isEmailVerified.subscribe((v) => {
|
||||
value = v ?? false;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
};
|
||||
|
||||
// Function to disable all interactive elements
|
||||
function disableInteractions() {
|
||||
if (getIsVerified()) return;
|
||||
// Function to disable all interactive elements
|
||||
function disableInteractions() {
|
||||
if (getIsVerified()) return;
|
||||
|
||||
// Disable all buttons except verification-related ones
|
||||
const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]');
|
||||
buttons.forEach((button) => {
|
||||
const el = button as HTMLElement;
|
||||
// Disable all buttons except verification-related ones
|
||||
const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]');
|
||||
buttons.forEach((button) => {
|
||||
const el = button as HTMLElement;
|
||||
|
||||
// Skip if already protected or is verification-related
|
||||
if (
|
||||
el.dataset.verificationProtected === 'true' ||
|
||||
el.closest('[data-verification-modal]') ||
|
||||
el.textContent?.toLowerCase().includes('verify') ||
|
||||
el.textContent?.toLowerCase().includes('resend')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Skip if already protected or is verification-related
|
||||
if (
|
||||
el.dataset.verificationProtected === 'true' ||
|
||||
el.closest('[data-verification-modal]') ||
|
||||
el.textContent?.toLowerCase().includes('verify') ||
|
||||
el.textContent?.toLowerCase().includes('resend')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.cursor = 'not-allowed';
|
||||
el.setAttribute('title', 'Email verification required');
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.cursor = 'not-allowed';
|
||||
el.setAttribute('title', 'Email verification required');
|
||||
|
||||
// Add click prevention
|
||||
el.addEventListener('click', preventInteraction, true);
|
||||
el.addEventListener('mousedown', preventInteraction, true);
|
||||
});
|
||||
// Add click prevention
|
||||
el.addEventListener('click', preventInteraction, true);
|
||||
el.addEventListener('mousedown', preventInteraction, true);
|
||||
});
|
||||
|
||||
// Disable all form inputs
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach((input) => {
|
||||
const el = input as HTMLInputElement;
|
||||
// Disable all form inputs
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach((input) => {
|
||||
const el = input as HTMLInputElement;
|
||||
|
||||
// Skip if in verification modal
|
||||
if (el.closest('[data-verification-modal]')) return;
|
||||
// Skip if in verification modal
|
||||
if (el.closest('[data-verification-modal]')) return;
|
||||
|
||||
if (!el.dataset.verificationProtected) {
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
|
||||
el.disabled = true;
|
||||
el.style.opacity = '0.5';
|
||||
el.addEventListener('focus', preventInteraction, true);
|
||||
el.addEventListener('input', preventInteraction, true);
|
||||
}
|
||||
});
|
||||
if (!el.dataset.verificationProtected) {
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
|
||||
el.disabled = true;
|
||||
el.style.opacity = '0.5';
|
||||
el.addEventListener('focus', preventInteraction, true);
|
||||
el.addEventListener('input', preventInteraction, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable contenteditable elements
|
||||
const editables = document.querySelectorAll('[contenteditable="true"]');
|
||||
editables.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
if (!element.dataset.verificationProtected) {
|
||||
element.dataset.verificationProtected = 'true';
|
||||
element.contentEditable = 'false';
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Disable contenteditable elements
|
||||
const editables = document.querySelectorAll('[contenteditable="true"]');
|
||||
editables.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
if (!element.dataset.verificationProtected) {
|
||||
element.dataset.verificationProtected = 'true';
|
||||
element.contentEditable = 'false';
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function preventInteraction(event: Event) {
|
||||
if (!getIsVerified()) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
function preventInteraction(event: Event) {
|
||||
if (!getIsVerified()) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
// Show warning
|
||||
if (event.target) {
|
||||
const target = event.target as HTMLElement;
|
||||
target.setAttribute('title', 'Please verify your email to use this feature');
|
||||
}
|
||||
// Show warning
|
||||
if (event.target) {
|
||||
const target = event.target as HTMLElement;
|
||||
target.setAttribute('title', 'Please verify your email to use this feature');
|
||||
}
|
||||
|
||||
// Optional: Show alert
|
||||
console.warn('🔒 Email verification required to interact with this element');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Optional: Show alert
|
||||
console.warn('🔒 Email verification required to interact with this element');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to restore interactions when verified
|
||||
function enableInteractions() {
|
||||
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
|
||||
protectedElements.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
// Function to restore interactions when verified
|
||||
function enableInteractions() {
|
||||
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
|
||||
protectedElements.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
|
||||
// Restore styles
|
||||
if (element.dataset.originalPointerEvents) {
|
||||
element.style.pointerEvents = element.dataset.originalPointerEvents;
|
||||
} else {
|
||||
element.style.pointerEvents = '';
|
||||
}
|
||||
element.style.opacity = '';
|
||||
element.style.cursor = '';
|
||||
element.removeAttribute('title');
|
||||
// Restore styles
|
||||
if (element.dataset.originalPointerEvents) {
|
||||
element.style.pointerEvents = element.dataset.originalPointerEvents;
|
||||
} else {
|
||||
element.style.pointerEvents = '';
|
||||
}
|
||||
element.style.opacity = '';
|
||||
element.style.cursor = '';
|
||||
element.removeAttribute('title');
|
||||
|
||||
// Restore functionality for inputs
|
||||
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
|
||||
const input = element as HTMLInputElement;
|
||||
input.disabled = input.dataset.originalDisabled === 'true';
|
||||
}
|
||||
// Restore functionality for inputs
|
||||
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
|
||||
const input = element as HTMLInputElement;
|
||||
input.disabled = input.dataset.originalDisabled === 'true';
|
||||
}
|
||||
|
||||
// Restore contenteditable
|
||||
if (element.hasAttribute('contenteditable')) {
|
||||
element.contentEditable = 'true';
|
||||
}
|
||||
// Restore contenteditable
|
||||
if (element.hasAttribute('contenteditable')) {
|
||||
element.contentEditable = 'true';
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
element.removeEventListener('click', preventInteraction, true);
|
||||
element.removeEventListener('mousedown', preventInteraction, true);
|
||||
element.removeEventListener('focus', preventInteraction, true);
|
||||
element.removeEventListener('input', preventInteraction, true);
|
||||
// Remove event listeners
|
||||
element.removeEventListener('click', preventInteraction, true);
|
||||
element.removeEventListener('mousedown', preventInteraction, true);
|
||||
element.removeEventListener('focus', preventInteraction, true);
|
||||
element.removeEventListener('input', preventInteraction, true);
|
||||
|
||||
// Clean up data attributes
|
||||
delete element.dataset.verificationProtected;
|
||||
delete element.dataset.originalPointerEvents;
|
||||
delete element.dataset.originalDisabled;
|
||||
});
|
||||
}
|
||||
// Clean up data attributes
|
||||
delete element.dataset.verificationProtected;
|
||||
delete element.dataset.originalPointerEvents;
|
||||
delete element.dataset.originalDisabled;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up mutation observer to protect new elements
|
||||
if (window.MutationObserver) {
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldReapply = false;
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
shouldReapply = true;
|
||||
}
|
||||
});
|
||||
// Set up mutation observer to protect new elements
|
||||
if (window.MutationObserver) {
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldReapply = false;
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
shouldReapply = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReapply && !getIsVerified()) {
|
||||
setTimeout(disableInteractions, 100);
|
||||
}
|
||||
});
|
||||
if (shouldReapply && !getIsVerified()) {
|
||||
setTimeout(disableInteractions, 100);
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Run protection every second
|
||||
protectionInterval = setInterval(() => {
|
||||
if (getIsVerified()) {
|
||||
enableInteractions();
|
||||
cleanup();
|
||||
} else {
|
||||
disableInteractions();
|
||||
}
|
||||
}, 1000);
|
||||
// Run protection every second
|
||||
protectionInterval = setInterval(() => {
|
||||
if (getIsVerified()) {
|
||||
enableInteractions();
|
||||
cleanup();
|
||||
} else {
|
||||
disableInteractions();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Initial protection
|
||||
if (!getIsVerified()) {
|
||||
disableInteractions();
|
||||
}
|
||||
// Initial protection
|
||||
if (!getIsVerified()) {
|
||||
disableInteractions();
|
||||
}
|
||||
|
||||
// Add global CSS to prevent any missed interactions
|
||||
if (document) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'email-verification-protection';
|
||||
style.textContent = `
|
||||
// Add global CSS to prevent any missed interactions
|
||||
if (document) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'email-verification-protection';
|
||||
style.textContent = `
|
||||
body:not(.email-verified) button:not([data-verification-modal] button),
|
||||
body:not(.email-verified) a:not([data-verification-modal] a),
|
||||
body:not(.email-verified) input:not([data-verification-modal] input),
|
||||
@@ -210,45 +208,44 @@ export function setupDOMProtection(
|
||||
user-select: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (protectionInterval) {
|
||||
clearInterval(protectionInterval);
|
||||
protectionInterval = undefined;
|
||||
}
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = undefined;
|
||||
}
|
||||
function cleanup() {
|
||||
if (protectionInterval) {
|
||||
clearInterval(protectionInterval);
|
||||
protectionInterval = undefined;
|
||||
}
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = undefined;
|
||||
}
|
||||
|
||||
// Clean up protection styles
|
||||
if (browser) {
|
||||
const style = document?.getElementById('email-verification-protection');
|
||||
if (style) style.remove();
|
||||
}
|
||||
}
|
||||
// Clean up protection styles
|
||||
if (browser) {
|
||||
const style = document?.getElementById('email-verification-protection');
|
||||
if (style) style.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
protectionInterval,
|
||||
domObserver,
|
||||
cleanup
|
||||
};
|
||||
return {
|
||||
protectionInterval,
|
||||
domObserver,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the body class based on email verification status
|
||||
*/
|
||||
export function updateBodyClass(isEmailVerified: boolean | null) {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmailVerified) {
|
||||
document.body.classList.add('email-verified');
|
||||
} else {
|
||||
document.body.classList.remove('email-verified');
|
||||
}
|
||||
if (isEmailVerified) {
|
||||
document.body.classList.add('email-verified');
|
||||
} else {
|
||||
document.body.classList.remove('email-verified');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,30 +41,29 @@
|
||||
<Dialog.Title>{$t('add_org.create_org')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createNewOrg)} class="px-2">
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.name')}</Field.Label>
|
||||
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
|
||||
{#if orgApi.errors.name || orgApi.errors.orgName}
|
||||
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.name')}</Field.Label>
|
||||
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
|
||||
{#if orgApi.errors.name || orgApi.errors.orgName}
|
||||
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
{#if orgApi.errors.siteName || orgApi.errors.general}
|
||||
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
{#if orgApi.errors.siteName || orgApi.errors.general}
|
||||
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
|
||||
{$t('add_org.create')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
|
||||
{$t('add_org.create')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@
|
||||
<p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
|
||||
@@ -25,4 +25,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -24,4 +24,3 @@
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ import { writable } from 'svelte/store';
|
||||
export const newOrgModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
|
||||
@@ -43,29 +43,29 @@
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="my-10 flex w-full flex-col items-center">
|
||||
<h1 class="text-center text-lg dark:text-white">
|
||||
{#if $deleteModal.isQuestion}
|
||||
{$t('components.quiz.delete_question')}
|
||||
{:else}
|
||||
{$t('components.quiz.delete_quiz')}
|
||||
{/if}
|
||||
{$t('components.quiz.lose_content')}
|
||||
</h1>
|
||||
<h1 class="text-center text-lg dark:text-white">
|
||||
{#if $deleteModal.isQuestion}
|
||||
{$t('components.quiz.delete_question')}
|
||||
{:else}
|
||||
{$t('components.quiz.delete_quiz')}
|
||||
{/if}
|
||||
{$t('components.quiz.lose_content')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
onclick={() => {
|
||||
if ($deleteModal.isQuestion) {
|
||||
onDelete();
|
||||
$deleteModal.open = false;
|
||||
} else {
|
||||
deleteQuiz();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('components.quiz.delete')}
|
||||
</Button>
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
onclick={() => {
|
||||
if ($deleteModal.isQuestion) {
|
||||
onDelete();
|
||||
$deleteModal.open = false;
|
||||
} else {
|
||||
deleteQuiz();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('components.quiz.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -114,28 +114,27 @@
|
||||
<Dialog.Content class="w-4/5 md:w-2/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')} {$t(
|
||||
'components.quiz.a_quiz'
|
||||
)}
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')}
|
||||
{$t('components.quiz.a_quiz')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createQuiz)}>
|
||||
<InputField
|
||||
label={$t('components.quiz.quiz_title')}
|
||||
bind:value={$createQuizModal.title}
|
||||
autofocus={true}
|
||||
placeholder={$t('components.quiz.placehoolder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
autoComplete={false}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('components.quiz.quiz_title')}
|
||||
bind:value={$createQuizModal.title}
|
||||
autofocus={true}
|
||||
placeholder={$t('components.quiz.placehoolder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
autoComplete={false}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end">
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.save') : $t('components.quiz.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-5 flex items-center justify-end">
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.save') : $t('components.quiz.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -53,10 +53,7 @@
|
||||
errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')}
|
||||
/>
|
||||
<div class="absolute right-2 top-2 flex justify-end">
|
||||
<IconButton
|
||||
onclick={() => (option.isCorrect = !option.isCorrect)}
|
||||
class={option.isCorrect ? 'success' : ''}
|
||||
>
|
||||
<IconButton onclick={() => (option.isCorrect = !option.isCorrect)} class={option.isCorrect ? 'success' : ''}>
|
||||
<CircleCheckIcon size={16} filled={option.isCorrect} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -114,4 +114,3 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
show: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -38,4 +38,3 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,4 +44,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
<Skeleton class="h-40 w-[270px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,27 +23,18 @@
|
||||
<Dialog.Title>{$t('delete_modal.label')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-center text-xl dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</p>
|
||||
<p class="text-center text-xl dark:text-white">
|
||||
{$t('delete_modal.content')}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={onDelete}
|
||||
loading={isLoading}
|
||||
>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isLoading}>
|
||||
{$t('delete_modal.no')}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={onDelete} loading={isLoading}>
|
||||
{$t('delete_modal.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
<line x1="17" y1="7" x2="7" y2="17" /><polyline points="8 7 17 7 17 16" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@
|
||||
fill: none; /* Ensure the checkmark path itself is not filled */
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -41,4 +41,3 @@
|
||||
stroke-linejoin="bevel"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -15,4 +15,3 @@
|
||||
stroke-width="5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -183,4 +183,3 @@
|
||||
fill="#E4E4E4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -183,4 +183,3 @@
|
||||
fill="#5C6065"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -24,4 +24,3 @@
|
||||
fill="#EB4132"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -15,4 +15,3 @@
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -46,12 +46,12 @@ export interface BreadcrumbConfig {
|
||||
* Can be a string or a function that extracts the prefix dynamically
|
||||
*/
|
||||
pathPrefix: string | ((pathname: string) => string);
|
||||
|
||||
|
||||
/**
|
||||
* Base path for generating hrefs (e.g., '/lms', '/org/myorg')
|
||||
*/
|
||||
basePath: string | ((pathname: string) => string);
|
||||
|
||||
|
||||
/**
|
||||
* Resolver for dynamic segment titles
|
||||
* Can resolve from page.data, stores, or API calls
|
||||
@@ -62,12 +62,12 @@ export interface BreadcrumbConfig {
|
||||
fullPathname: string,
|
||||
context?: unknown
|
||||
) => string | Promise<string> | null | undefined;
|
||||
|
||||
|
||||
/**
|
||||
* Optional: Cache breadcrumbs for better performance
|
||||
*/
|
||||
enableCache?: boolean;
|
||||
|
||||
|
||||
/**
|
||||
* Optional: Maximum breadcrumb depth (prevents infinite recursion)
|
||||
*/
|
||||
@@ -107,7 +107,7 @@ function getCachedOrGenerate(
|
||||
}
|
||||
|
||||
clearExpiredCache();
|
||||
|
||||
|
||||
const cached = breadcrumbCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -122,31 +122,25 @@ function getCachedOrGenerate(
|
||||
/**
|
||||
* Extract path prefix from pathname
|
||||
*/
|
||||
function extractPathPrefix(
|
||||
pathname: string,
|
||||
prefixConfig: string | ((pathname: string) => string)
|
||||
): string {
|
||||
function extractPathPrefix(pathname: string, prefixConfig: string | ((pathname: string) => string)): string {
|
||||
if (typeof prefixConfig === 'function') {
|
||||
return prefixConfig(pathname);
|
||||
}
|
||||
|
||||
|
||||
// Handle dynamic prefixes like '/org/[slug]'
|
||||
if (prefixConfig.includes('[')) {
|
||||
const pattern = prefixConfig.replace(/\[.*?\]/g, '[^/]+');
|
||||
const match = pathname.match(new RegExp(`^${pattern}`));
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
|
||||
return pathname.startsWith(prefixConfig) ? prefixConfig : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base path for href generation
|
||||
*/
|
||||
function getBasePath(
|
||||
pathname: string,
|
||||
basePathConfig: string | ((pathname: string) => string)
|
||||
): string {
|
||||
function getBasePath(pathname: string, basePathConfig: string | ((pathname: string) => string)): string {
|
||||
if (typeof basePathConfig === 'function') {
|
||||
return basePathConfig(pathname);
|
||||
}
|
||||
@@ -228,19 +222,16 @@ async function resolveDynamicTitle(
|
||||
/**
|
||||
* Extract parameters from pathname (e.g., { slug: 'my-post' })
|
||||
*/
|
||||
function extractParamsFromPath(
|
||||
pathname: string,
|
||||
parentPath: string
|
||||
): Record<string, string> {
|
||||
function extractParamsFromPath(pathname: string, parentPath: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
|
||||
|
||||
// Simple extraction - can be enhanced for more complex patterns
|
||||
segments.forEach((segment, index) => {
|
||||
params[`param${index}`] = segment;
|
||||
});
|
||||
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -272,7 +263,7 @@ async function generateNestedBreadcrumbs(
|
||||
// Check for static nested route
|
||||
if (navItem.nestedRoutes) {
|
||||
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
|
||||
|
||||
|
||||
if (nestedRoute) {
|
||||
breadcrumbs.push({
|
||||
label: nestedRoute.titleKey,
|
||||
@@ -300,16 +291,11 @@ async function generateNestedBreadcrumbs(
|
||||
}
|
||||
|
||||
// Handle dynamic segment
|
||||
if (navItem.supportsDynamicSegment ||
|
||||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) {
|
||||
const dynamicTitle = await resolveDynamicTitle(
|
||||
currentSegment,
|
||||
currentPath,
|
||||
fullPathname,
|
||||
navItem,
|
||||
config,
|
||||
context
|
||||
);
|
||||
if (
|
||||
navItem.supportsDynamicSegment ||
|
||||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)
|
||||
) {
|
||||
const dynamicTitle = await resolveDynamicTitle(currentSegment, currentPath, fullPathname, navItem, config, context);
|
||||
|
||||
if (dynamicTitle) {
|
||||
breadcrumbs.push({
|
||||
@@ -323,7 +309,7 @@ async function generateNestedBreadcrumbs(
|
||||
const supportingNestedRoute = navItem.nestedRoutes?.find(
|
||||
(route) => route.supportsDynamicSegment && route.nestedRoutes
|
||||
);
|
||||
|
||||
|
||||
if (supportingNestedRoute) {
|
||||
const nestedBreadcrumbs = await generateNestedBreadcrumbs(
|
||||
remainingSegments,
|
||||
@@ -356,7 +342,7 @@ export async function generateBreadcrumbs(
|
||||
context?: unknown
|
||||
): Promise<BreadcrumbItem[]> {
|
||||
const cacheKey = `${pathname}${searchParams}`;
|
||||
|
||||
|
||||
return getCachedOrGenerate(
|
||||
cacheKey,
|
||||
() => generateBreadcrumbsSync(pathname, searchParams, navItems, config, context),
|
||||
@@ -381,7 +367,7 @@ export function generateBreadcrumbsSync(
|
||||
// Extract path prefix and base path
|
||||
const pathPrefix = extractPathPrefix(pathname, config.pathPrefix);
|
||||
const basePath = getBasePath(pathname, config.basePath);
|
||||
|
||||
|
||||
// Get relative path after removing prefix
|
||||
const relativePath = pathname.replace(pathPrefix, '').replace(/^\//, '') || '';
|
||||
const pathSegments = relativePath.split('/').filter(Boolean);
|
||||
@@ -400,10 +386,8 @@ export function generateBreadcrumbsSync(
|
||||
}
|
||||
|
||||
// Add parent breadcrumb
|
||||
const parentHref = matchedNavItem.useHashUrl
|
||||
? `${basePath}${matchedNavItem.path}`
|
||||
: matchedNavItem.url;
|
||||
|
||||
const parentHref = matchedNavItem.useHashUrl ? `${basePath}${matchedNavItem.path}` : matchedNavItem.url;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
|
||||
href: parentHref
|
||||
@@ -411,10 +395,8 @@ export function generateBreadcrumbsSync(
|
||||
|
||||
// Handle query params (settings tabs, etc.)
|
||||
if (matchedNavItem.items && searchParams) {
|
||||
const subItem = matchedNavItem.items.find((sub) =>
|
||||
isActive(pathWithQuery, sub.url, undefined, true)
|
||||
);
|
||||
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
|
||||
|
||||
if (subItem) {
|
||||
breadcrumbs.push({
|
||||
label: subItem.breadcrumbLabel || subItem.title,
|
||||
@@ -428,7 +410,7 @@ export function generateBreadcrumbsSync(
|
||||
if (pathSegments.length > 1) {
|
||||
const remainingSegments = pathSegments.slice(1);
|
||||
const parentPath = `${basePath}${matchedNavItem.path}`;
|
||||
|
||||
|
||||
// Note: For async resolvers, you'd need to use generateBreadcrumbs instead
|
||||
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
|
||||
remainingSegments,
|
||||
@@ -440,7 +422,7 @@ export function generateBreadcrumbsSync(
|
||||
config,
|
||||
context
|
||||
);
|
||||
|
||||
|
||||
breadcrumbs.push(...nestedBreadcrumbs);
|
||||
}
|
||||
|
||||
@@ -475,7 +457,7 @@ function generateNestedBreadcrumbsSync(
|
||||
// Check for static nested route
|
||||
if (navItem.nestedRoutes) {
|
||||
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
|
||||
|
||||
|
||||
if (nestedRoute) {
|
||||
breadcrumbs.push({
|
||||
label: nestedRoute.titleKey,
|
||||
@@ -506,14 +488,9 @@ function generateNestedBreadcrumbsSync(
|
||||
if (navItem.supportsDynamicSegment) {
|
||||
// Try to resolve title synchronously (from context if available)
|
||||
let dynamicTitle: string | null = null;
|
||||
|
||||
|
||||
if (config.dynamicTitleResolver) {
|
||||
const result = config.dynamicTitleResolver(
|
||||
currentSegment,
|
||||
currentPath,
|
||||
fullPathname,
|
||||
context
|
||||
);
|
||||
const result = config.dynamicTitleResolver(currentSegment, currentPath, fullPathname, context);
|
||||
// Only use if it's not a promise
|
||||
if (typeof result === 'string') {
|
||||
dynamicTitle = result;
|
||||
@@ -531,7 +508,7 @@ function generateNestedBreadcrumbsSync(
|
||||
const supportingNestedRoute = navItem.nestedRoutes?.find(
|
||||
(route) => route.supportsDynamicSegment && route.nestedRoutes
|
||||
);
|
||||
|
||||
|
||||
if (supportingNestedRoute?.nestedRoutes) {
|
||||
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
|
||||
remainingSegments,
|
||||
@@ -560,4 +537,3 @@ export function clearBreadcrumbCache() {
|
||||
breadcrumbCache.clear();
|
||||
cacheTimestamps.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -105,4 +105,3 @@ export function generateLmsBreadcrumbs(
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
// Get breadcrumb title from store for LMS community posts
|
||||
const breadcrumbTitle = $derived($currentCommunityQuestion.title);
|
||||
|
||||
|
||||
const breadcrumbs = $derived(
|
||||
generateLmsBreadcrumbs(
|
||||
page.url.pathname,
|
||||
@@ -98,4 +98,3 @@
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@
|
||||
</script>
|
||||
|
||||
<div class="senja-embed" data-id={id} data-lazyload="false" data-spinner="false"></div>
|
||||
|
||||
|
||||
@@ -75,4 +75,3 @@
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
|
||||
@@ -108,78 +108,84 @@
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="w-full bg-white p-5 dark:bg-inherit">
|
||||
<UnderlineTabs.Root bind:value={currentTab}>
|
||||
<UnderlineTabs.List>
|
||||
{#each tabs as tab}
|
||||
<UnderlineTabs.Trigger value={tab.value}>
|
||||
{tab.label}
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value={tabs[1].value}>
|
||||
<!-- Your Upload content here -->
|
||||
<div class="w-full">
|
||||
<input
|
||||
type="file"
|
||||
style="display: none;"
|
||||
bind:this={fileInput}
|
||||
onchange={onFileSelected}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<Button onclick={handleUpload} loading={isUploading}>
|
||||
{label}
|
||||
</Button>
|
||||
<p class="my-2 text-center text-sm text-gray-500">
|
||||
{$t('course.navItem.landing_page.upload_widget.width')}
|
||||
</p>
|
||||
<p class="text-center text-sm text-gray-500">
|
||||
{$t('course.navItem.landing_page.upload_widget.size')}
|
||||
</p>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value={tabs[0].value}>
|
||||
<!-- Your Images content here -->
|
||||
<div class="h-full overflow-y-scroll">
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
|
||||
<input type="text" bind:value={searchQuery} name="" id="" class="ml-2 w-[85%] rounded-lg dark:text-black" />
|
||||
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
|
||||
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
|
||||
>
|
||||
</form>
|
||||
{#if unsplashImages && unsplashImages.length > 0}
|
||||
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
|
||||
{#each unsplashImages as unsplashImages (unsplashImages.id)}
|
||||
<div>
|
||||
<div class="relative h-[130px] w-[195px] overflow-hidden">
|
||||
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
|
||||
<img
|
||||
src={unsplashImages.urls.regular}
|
||||
alt={unsplashImages.alt_description}
|
||||
class="h-full w-full cursor-pointer rounded-md object-cover hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{#if unsplashImages.user.name}
|
||||
<p class="mt-1 text-center text-xs font-light text-gray-500">
|
||||
By <a
|
||||
href={`https://unsplash.com/@${unsplashImages.user.username}`}
|
||||
target="_blank"
|
||||
class="hover:text-red-700">{unsplashImages.user.name}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="pt-7 text-center">
|
||||
{$t('course.navItem.landing_page.upload_widget.no_images')}
|
||||
<UnderlineTabs.Root bind:value={currentTab}>
|
||||
<UnderlineTabs.List>
|
||||
{#each tabs as tab}
|
||||
<UnderlineTabs.Trigger value={tab.value}>
|
||||
{tab.label}
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value={tabs[1].value}>
|
||||
<!-- Your Upload content here -->
|
||||
<div class="w-full">
|
||||
<input
|
||||
type="file"
|
||||
style="display: none;"
|
||||
bind:this={fileInput}
|
||||
onchange={onFileSelected}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<Button onclick={handleUpload} loading={isUploading}>
|
||||
{label}
|
||||
</Button>
|
||||
<p class="my-2 text-center text-sm text-gray-500">
|
||||
{$t('course.navItem.landing_page.upload_widget.width')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500">
|
||||
{$t('course.navItem.landing_page.upload_widget.size')}
|
||||
</p>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value={tabs[0].value}>
|
||||
<!-- Your Images content here -->
|
||||
<div class="h-full overflow-y-scroll">
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
name=""
|
||||
id=""
|
||||
class="ml-2 w-[85%] rounded-lg dark:text-black"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg border border-gray-500 bg-white px-3 py-1 text-black"
|
||||
>{$t('course.navItem.landing_page.upload_widget.submit')}</button
|
||||
>
|
||||
</form>
|
||||
{#if unsplashImages && unsplashImages.length > 0}
|
||||
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
|
||||
{#each unsplashImages as unsplashImages (unsplashImages.id)}
|
||||
<div>
|
||||
<div class="relative h-[130px] w-[195px] overflow-hidden">
|
||||
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
|
||||
<img
|
||||
src={unsplashImages.urls.regular}
|
||||
alt={unsplashImages.alt_description}
|
||||
class="h-full w-full cursor-pointer rounded-md object-cover hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{#if unsplashImages.user.name}
|
||||
<p class="mt-1 text-center text-xs font-light text-gray-500">
|
||||
By <a
|
||||
href={`https://unsplash.com/@${unsplashImages.user.username}`}
|
||||
target="_blank"
|
||||
class="hover:text-red-700">{unsplashImages.user.name}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="pt-7 text-center">
|
||||
{$t('course.navItem.landing_page.upload_widget.no_images')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
|
||||
@@ -16,4 +16,3 @@
|
||||
<ArrowUpIcon size={16} />
|
||||
<Chip {value} />
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
* Server-side file type validation utilities for preventing malicious uploads
|
||||
* Specifically designed to prevent SVG XSS attacks and other security issues
|
||||
*/
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
] as const;
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] as const;
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
|
||||
|
||||
|
||||
@@ -24,11 +24,7 @@ export async function checkUserCoursePermissions(
|
||||
.single();
|
||||
|
||||
// Check if user is org admin
|
||||
const { data: orgData } = await supabase
|
||||
.from('group')
|
||||
.select('organization_id')
|
||||
.eq('id', courseGroupId)
|
||||
.single();
|
||||
const { data: orgData } = await supabase.from('group').select('organization_id').eq('id', courseGroupId).single();
|
||||
|
||||
let isOrgAdmin = false;
|
||||
if (orgData?.organization_id) {
|
||||
|
||||
@@ -1624,4 +1624,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1625,4 +1625,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1655,4 +1655,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## Overview
|
||||
|
||||
We use **convention-based translation keys** for Zod validation errors. This approach:
|
||||
|
||||
- ✅ No metadata needed - just follow the naming pattern
|
||||
- ✅ Clean shared schemas without hardcoded messages
|
||||
- ✅ Automatic fallback to generic translations
|
||||
@@ -18,14 +19,14 @@ validations.{fieldName}.{errorCode}
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Error Code | When It Occurs | Example Field |
|
||||
|------------|----------------|---------------|
|
||||
| `too_small` | String too short, number too small, array too few items | username.too_small |
|
||||
| `too_big` | String too long, number too large, array too many items | bio.too_big |
|
||||
| `invalid_email` | Invalid email format | email.invalid_email |
|
||||
| `invalid_url` | Invalid URL format | website.invalid_url |
|
||||
| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type |
|
||||
| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value |
|
||||
| Error Code | When It Occurs | Example Field |
|
||||
| -------------------- | ------------------------------------------------------- | ----------------------- |
|
||||
| `too_small` | String too short, number too small, array too few items | username.too_small |
|
||||
| `too_big` | String too long, number too large, array too many items | bio.too_big |
|
||||
| `invalid_email` | Invalid email format | email.invalid_email |
|
||||
| `invalid_url` | Invalid URL format | website.invalid_url |
|
||||
| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type |
|
||||
| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -40,12 +41,12 @@ let errors: Record<string, string> = {};
|
||||
|
||||
function handleSubmit() {
|
||||
const result = ZEmailData.safeParse(formData);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Use validated data
|
||||
sendEmail(result.data);
|
||||
}
|
||||
@@ -68,7 +69,7 @@ function handleSubmit() {
|
||||
"too_small": "Content must be at least 1 character",
|
||||
"too_big": "Content is too long"
|
||||
},
|
||||
|
||||
|
||||
"generic": {
|
||||
"required": "This field is required",
|
||||
"invalid_email": "Invalid email address",
|
||||
@@ -101,24 +102,24 @@ export const ZUserProfile = z.object({
|
||||
<script lang="ts">
|
||||
import { ZUserProfile } from '@cio/utils/validation/user';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
|
||||
let formData = {
|
||||
email: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
age: 0
|
||||
};
|
||||
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
|
||||
|
||||
function handleSubmit() {
|
||||
const result = ZUserProfile.safeParse(formData);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Success!
|
||||
saveProfile(result.data);
|
||||
}
|
||||
@@ -194,7 +195,7 @@ Zod validates in order and returns the **first error** it encounters:
|
||||
|
||||
```typescript
|
||||
// Schema
|
||||
z.string().min(3).max(20)
|
||||
z.string().min(3).max(20);
|
||||
|
||||
// Input: ""
|
||||
// Error: too_small (stops at first rule)
|
||||
@@ -226,12 +227,12 @@ import { validateWithTranslation } from '$lib/utils/validation';
|
||||
|
||||
function handleSubmit() {
|
||||
const result = validateWithTranslation(ZUserProfile, formData);
|
||||
|
||||
|
||||
if ('errors' in result) {
|
||||
errors = result.errors;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Success - result.data is typed!
|
||||
saveProfile(result.data);
|
||||
}
|
||||
@@ -240,6 +241,7 @@ function handleSubmit() {
|
||||
## Adding New Fields
|
||||
|
||||
1. **Add to schema** (in packages/utils):
|
||||
|
||||
```typescript
|
||||
export const ZUserProfile = z.object({
|
||||
// ... existing fields
|
||||
@@ -248,6 +250,7 @@ export const ZUserProfile = z.object({
|
||||
```
|
||||
|
||||
2. **Add translations**:
|
||||
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { message, title } = $derived.by(() => {
|
||||
const fields = {
|
||||
title: '',
|
||||
message: '',
|
||||
message: ''
|
||||
};
|
||||
if (!type) return fields;
|
||||
|
||||
@@ -42,10 +42,7 @@
|
||||
});
|
||||
|
||||
function handleSupport() {
|
||||
window.open(
|
||||
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type,
|
||||
'_blank'
|
||||
);
|
||||
window.open('mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + type, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,7 +52,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<Empty
|
||||
title={title}
|
||||
{title}
|
||||
description={message}
|
||||
icon={OctagonAlertIcon}
|
||||
variant="page"
|
||||
@@ -63,34 +60,27 @@
|
||||
showLogo={true}
|
||||
class="h-fit! flex-none! max-h-none!"
|
||||
>
|
||||
{#if type === 'legacy_method_blocked'}
|
||||
<div
|
||||
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$t('email_verification.security_notice.title')}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$t('email_verification.security_notice.message')}
|
||||
</p>
|
||||
{#if type === 'legacy_method_blocked'}
|
||||
<div class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
|
||||
<div class="flex items-center">
|
||||
<TriangleAlertIcon class="h-5 w-5 flex-shrink-0 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$t('email_verification.security_notice.title')}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$t('email_verification.security_notice.message')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<Button onclick={handleSupport} variant="ghost">
|
||||
{$t('email_verification.actions.contact_support')}
|
||||
</Button>
|
||||
<Button onclick={() => goto('/')}>
|
||||
{$t('email_verification.actions.back_to_dashboard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<Button
|
||||
onclick={handleSupport}
|
||||
variant="ghost"
|
||||
>
|
||||
{$t('email_verification.actions.contact_support')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => goto('/')}
|
||||
>
|
||||
{$t('email_verification.actions.back_to_dashboard')}
|
||||
</Button>
|
||||
</div>
|
||||
</Empty>
|
||||
|
||||
@@ -77,8 +77,7 @@
|
||||
}
|
||||
console.log('path', path);
|
||||
console.log('isPublicRoute', isPublicRoute(path));
|
||||
console.log('data', $session.data)
|
||||
|
||||
console.log('data', $session.data);
|
||||
|
||||
// No need to require login for public routes
|
||||
if (isPublicRoute(path) && path !== '/') return;
|
||||
|
||||
@@ -19,9 +19,7 @@ export async function POST({ request }) {
|
||||
|
||||
try {
|
||||
// Clean up expired tokens
|
||||
const { data: cleanupResult, error } = await supabase.rpc(
|
||||
'cleanup_expired_verification_tokens'
|
||||
);
|
||||
const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
|
||||
|
||||
if (error) {
|
||||
console.error('Token cleanup failed:', error);
|
||||
|
||||
@@ -43,14 +43,11 @@ export async function GET({ url, request }) {
|
||||
|
||||
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
|
||||
const used = tokenStats.filter((token) => token.used_at);
|
||||
const expired = tokenStats.filter(
|
||||
(token) => new Date(token.expires_at) < now && !token.used_at
|
||||
);
|
||||
const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
|
||||
|
||||
// Count unique IPs for anomaly detection
|
||||
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip))
|
||||
.size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
@@ -61,8 +58,7 @@ export async function GET({ url, request }) {
|
||||
expired_unused: expired.length,
|
||||
unique_creation_ips: uniqueCreationIPs,
|
||||
unique_usage_ips: uniqueUsageIPs,
|
||||
success_rate:
|
||||
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
},
|
||||
recent_activity: recent.slice(0, 10).map((token) => ({
|
||||
created_at: token.created_at,
|
||||
|
||||
@@ -108,9 +108,7 @@ async function getCourseAnalytics(supabase: any, courseId: string): Promise<Cour
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
members
|
||||
.filter((member) => member.role_id === 3)
|
||||
.map((member) => getStudentOverview(supabase, courseId, member))
|
||||
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
|
||||
);
|
||||
|
||||
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
|
||||
@@ -155,11 +153,7 @@ async function getLastLogin(supabase: any, userId: string): Promise<string | und
|
||||
}
|
||||
}
|
||||
|
||||
async function getStudentOverview(
|
||||
supabase: any,
|
||||
courseId: string,
|
||||
member: any
|
||||
): Promise<StudentOverview | null> {
|
||||
async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
|
||||
try {
|
||||
// Use the same service function that the working analytics/user API uses
|
||||
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
|
||||
|
||||
@@ -40,12 +40,7 @@ async function getCourse(supabase: any, courseId: string) {
|
||||
return course;
|
||||
}
|
||||
|
||||
async function getGroupMembers(
|
||||
supabase: any,
|
||||
groupId: string,
|
||||
isStudent: boolean,
|
||||
userMembership: any
|
||||
) {
|
||||
async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
|
||||
if (isStudent) {
|
||||
return {
|
||||
id: groupId,
|
||||
@@ -53,11 +48,7 @@ async function getGroupMembers(
|
||||
};
|
||||
}
|
||||
|
||||
const { data: group, error } = await supabase
|
||||
.from('group')
|
||||
.select(GROUP_MEMBERS_SELECT)
|
||||
.eq('id', groupId)
|
||||
.single();
|
||||
const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching group members');
|
||||
|
||||
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
@@ -41,9 +37,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { data: exercises, error } = await supabase
|
||||
.rpc('get_exercises')
|
||||
.eq('course_id', courseId);
|
||||
const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching exercises');
|
||||
|
||||
@@ -19,11 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
|
||||
@@ -37,8 +37,8 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
if (isSubscriptionActive) {
|
||||
try {
|
||||
const planData = {
|
||||
orgId,
|
||||
triggeredBy: parseInt(triggeredBy),
|
||||
orgId,
|
||||
triggeredBy: parseInt(triggeredBy),
|
||||
planName: PLAN.EARLY_ADOPTER as 'EARLY_ADOPTER' | 'ENTERPRISE' | 'BASIC',
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
@@ -56,9 +56,9 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
// Handle Cancel Subscription
|
||||
try {
|
||||
const result = await OrgPlanApiServer.cancelOrgPlan({
|
||||
subscriptionId,
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
});
|
||||
});
|
||||
console.log('Subscription canceled', result);
|
||||
} catch (error) {
|
||||
console.error('Error canceling org plan', error);
|
||||
@@ -67,9 +67,9 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
// Handle Update Subscription
|
||||
try {
|
||||
const result = await OrgPlanApiServer.updateOrgPlan({
|
||||
subscriptionId,
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
});
|
||||
});
|
||||
console.log('Subscription updated', result);
|
||||
} catch (error) {
|
||||
console.error('Error updating org plan', error);
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
<Sidebar.Provider>
|
||||
<CourseSidebar {path} />
|
||||
|
||||
<Sidebar.Inset class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]">
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<CourseHeader />
|
||||
|
||||
<Page.Root
|
||||
class="mx-auto flex px-4 lg:max-w-6xl"
|
||||
>
|
||||
<Page.Root class="mx-auto flex px-4 lg:max-w-6xl">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
</Sidebar.Inset>
|
||||
|
||||
@@ -130,12 +130,6 @@
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full items-center justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disableSubmit || loading}
|
||||
loading={loading || !$profile.id}
|
||||
>
|
||||
Join Course
|
||||
</Button>
|
||||
<Button type="submit" disabled={disableSubmit || loading} loading={loading || !$profile.id}>Join Course</Button>
|
||||
</div>
|
||||
</AuthUI>
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
<IntegrationsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
|
||||