feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

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

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

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

* fix: align course sidebar design to match organization design

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

---------

Co-authored-by: Rotimi Best <[email protected]>

* refactor: replace PrimaryButton with Button component across multiple files

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

* refactor: standardize button and input usage across components

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

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

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

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

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

* refactor: update UI components and improve styling consistency

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

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <[email protected]>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -0,0 +1,71 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { deleteModal, quizesStore } from '$lib/utils/store/org';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
let { onDelete = () => {} } = $props();
function closeModal() {
deleteModal.update((m) => ({
...m,
open: false,
id: null,
isQuestion: false
}));
}
async function deleteQuiz() {
const { data, error } = await supabase.from('quiz').delete().match({ id: $deleteModal.id });
console.log('data', data);
console.log('error', error);
if (error) {
return;
}
$quizesStore = $quizesStore.filter((q) => q.id !== $deleteModal.id);
closeModal();
}
</script>
<Dialog.Root
bind:open={$deleteModal.open}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}
>
<Dialog.Content class="w-2/5 max-w-[388px]">
<Dialog.Header>
<Dialog.Title>
{$deleteModal.isQuestion ? $t('components.quiz.question_delete') : $t('components.quiz.quiz_delete')}
</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>
<div class="mt-5">
<Button
onclick={() => {
if ($deleteModal.isQuestion) {
onDelete();
$deleteModal.open = false;
} else {
deleteQuiz();
}
}}
>
{$t('components.quiz.delete')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div
class="root relative mb-4 mr-4 flex w-full rounded-md border border-gray-100 p-3 transition ease-in-out dark:border-neutral-600 dark:bg-neutral-800"
>
<Skeleton class="h-full w-[20%]" />
<div class="mx-5 flex w-full flex-col justify-evenly">
<!-- Title -->
<Skeleton class="h-4 w-2/4" />
<!-- Description -->
<Skeleton class="h-16 w-2/4" />
</div>
</div>
<style>
.root {
height: 180px;
}
</style>
@@ -0,0 +1,141 @@
<script>
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { InputField } from '@cio/ui/custom/input-field';
import { snackbar } from '$features/ui/snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { currentOrg, createQuizModal, currentOrgPath, quizesStore } from '$lib/utils/store/org';
import { createQuizValidation } from '$lib/utils/functions/validator';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
let errors = $state({
title: ''
});
let isLoading = $state(false);
function handleClose() {
$createQuizModal.id = null;
$createQuizModal.title = '';
$createQuizModal.open = false;
$createQuizModal.openEdit = false;
}
function errorNotification() {
snackbar.error();
}
async function createQuiz() {
errors = createQuizValidation($createQuizModal);
console.log('createQuiz|updateQuiz errors', errors);
if (Object.keys(errors).length) {
return;
}
isLoading = true;
if ($createQuizModal.openEdit) {
const { error } = await supabase
.from('quiz')
.update({ title: $createQuizModal.title, updated_at: new Date() })
.match({ id: $createQuizModal.id });
isLoading = false;
if (error) {
return errorNotification();
} else {
$quizesStore = $quizesStore.map((s) => {
if (s.id === $createQuizModal.id) {
s.title = $createQuizModal.title;
}
return s;
});
handleClose();
}
} else {
const { data, error } = await supabase
.from('quiz')
.insert({
title: $createQuizModal.title,
organization_id: $currentOrg.id,
questions: [
{
id: 1,
label: '',
type: 'multichoice',
options: [
{
id: 'circle',
label: '',
isCorrect: false
},
{
id: 'triangle',
label: '',
isCorrect: false
}
]
}
]
})
.select();
isLoading = false;
if (error) {
return errorNotification();
} else {
$quizesStore = [...$quizesStore, data[0]];
handleClose();
goto(`${$currentOrgPath}/quiz/${data[0].id}`);
}
}
}
$effect(() => {
open = $createQuizModal.openEdit || $createQuizModal.open;
});
</script>
<svelte:head>
<title>{$t('components.quiz.title')} - ClassroomIO</title>
</svelte:head>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<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'
)}
</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}
/>
<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>
@@ -0,0 +1,59 @@
<script>
import { onMount } from 'svelte';
import { Spinner } from '@cio/ui/base/spinner';
import PlayContainer from './container.svelte';
import PlayHeader from './header/index.svelte';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
import { STEPS } from '$lib/utils/constants/quiz';
import { genQuizPin } from '$lib/utils/functions/org';
import { Button } from '@cio/ui/base/button';
let isGettingPin = $state(true);
function getPin() {
setTimeout(() => {
$quizStore.pin = genQuizPin();
isGettingPin = false;
}, 3000);
}
function goToPlayersStep() {
$playQuizStore.step = STEPS.WAIT_FOR_PLAYERS;
}
onMount(() => {
getPin();
});
</script>
<PlayContainer>
{#snippet header()}
<div>
<PlayHeader startCount={true} showCountDown={true} />
</div>
{/snippet}
{#snippet body()}
<div class="w-full rounded-md bg-white px-5 py-7 dark:bg-neutral-800">
<div class="mb-3">
<p>1. Visit</p>
<h3>play.classroomio.com</h3>
</div>
<div class="">
<p>2. Enter Pin</p>
{#if isGettingPin}
<Spinner class="size-10! text-blue-700!" />
{:else}
<h3>{$quizStore.pin}</h3>
{/if}
</div>
</div>
{/snippet}
{#snippet footer()}
<div class="flex items-center justify-center">
<p class="mr-3">Let's go</p>
<Button variant="outline" onclick={goToPlayersStep}>View Players</Button>
</div>
{/snippet}
</PlayContainer>
@@ -0,0 +1,53 @@
<script lang="ts">
interface Props {
header?: import('svelte').Snippet;
body?: import('svelte').Snippet;
footer?: import('svelte').Snippet;
}
let { header, body, footer }: Props = $props();
</script>
<div class="root m-auto flex h-full flex-col justify-between">
{#if header}
<!-- HEADER -->
<div class="header">
{@render header?.()}
</div>
{/if}
{#if body}
<!-- BODY -->
<div class="body mb-3">
{@render body?.()}
</div>
{/if}
{#if footer}
<!-- FOOTER -->
<div class="footer m-auto bg-white px-2 py-6 dark:bg-neutral-800">
{@render footer?.()}
</div>
{/if}
</div>
<style>
.root {
max-width: 680px;
width: 80%;
height: 95vh;
}
.header {
height: 15%;
}
.body {
min-height: 60%;
height: fit-content;
}
.footer {
max-width: 400px;
}
</style>
@@ -0,0 +1,11 @@
<script lang="ts">
import CircleContent from './circle-content.svelte';
interface Props {
answers?: number;
}
let { answers = 0 }: Props = $props();
</script>
<CircleContent value={answers} label="Answer(s)" />
@@ -0,0 +1,49 @@
<script lang="ts">
import { slide } from 'svelte/transition';
interface Props {
value?: boolean;
label?: string;
className?: string;
}
let { value = false, label = '', className = '' }: Props = $props();
function fadeSlide(node, options) {
const slideTrans = slide(node, options);
return {
duration: options.duration,
css: (t) => `
${slideTrans.css(t)}
opacity: ${t};
`
};
}
</script>
<div class="circle flex flex-col items-center justify-center {className}">
{#key value}
<h3 class="ui:text-primary" transition:fadeSlide={{ duration: 500 }}>
{value}
</h3>
{/key}
<p class="ui:text-primary text-md">{label}</p>
</div>
<style>
.circle {
width: 100px;
height: 100px;
line-height: 100px;
border-radius: 50%;
font-size: 50px;
text-align: center;
background: #fff;
}
.circle h3 {
font-size: 40px;
margin: 0;
padding: 0;
line-height: 1;
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import { quizStore } from '$lib/utils/store/org';
import Answer from './answer.svelte';
import Timer from './timer.svelte';
interface Props {
startCount?: boolean;
showCountDown?: boolean;
}
let { startCount = false, showCountDown = false }: Props = $props();
</script>
<div class="flex w-full items-center justify-between">
<h3 class="text-white">{$quizStore.title}</h3>
{#if showCountDown}
<div class="flex">
<Timer {startCount} timelimit={$quizStore.timelimit} />
<Answer />
</div>
{/if}
</div>
@@ -0,0 +1,45 @@
<script lang="ts">
import { onMount } from 'svelte';
import CircleContent from './circle-content.svelte';
interface Props {
startCount?: boolean;
timelimit?: string;
}
let { startCount = false, timelimit = '' }: Props = $props();
let countDownNo = $state(0);
let started = false;
function triggerCountDown(start) {
if (!start || started) return;
const getNum = (k) => parseInt(timelimit.replace(k, ''));
if (timelimit.includes('s')) {
countDownNo = getNum('s');
} else if (timelimit.includes('m')) {
countDownNo = getNum('m') * 60;
}
window.refreshIntervalId = setInterval(() => {
if (--countDownNo <= 0) {
clearInterval(window.refreshIntervalId);
}
}, 1000);
started = true;
}
onMount(() => {
if (startCount) {
triggerCountDown(startCount);
}
});
$effect(() => {
triggerCountDown(startCount);
});
</script>
<CircleContent value={countDownNo} label="Second(s)" className="mr-3" />
@@ -0,0 +1,47 @@
<script>
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { Spinner } from '@cio/ui/base/spinner';
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
import ConnectToPlay from './connect-to-play.svelte';
import Players from './players.svelte';
import Question from './question.svelte';
import Scoreboard from './scoreboard.svelte';
import Podium from './podium.svelte';
let bgImg = $state();
onMount(() => {
bgImg = themeImages[$quizStore.theme].play;
});
</script>
{#if bgImg}
<div
class="absolute inset-0 z-50 h-screen w-screen bg-white"
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<div
class="h-full w-full p-5"
style="background: url({bgImg}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;min-height: 100vh;height: fit-content;"
>
{#if $playQuizStore.step === STEPS.CONNECT_TO_PLAY}
<ConnectToPlay />
{:else if $playQuizStore.step === STEPS.WAIT_FOR_PLAYERS}
<Players />
{:else if $playQuizStore.step === STEPS.SHOW_NEXT_QUESTION}
<Question />
{:else if $playQuizStore.step === STEPS.SCOREBOARD}
<Scoreboard />
{:else if $playQuizStore.step === STEPS.PODIUM}
<Podium />
{:else}
<div class="m-auto w-full">
<Spinner class="size-8! text-blue-700!" />
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1 @@
players
@@ -0,0 +1 @@
Podium
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import QuizQuestion from '../quiz-question.svelte';
import PlayContainer from './container.svelte';
import PlayHeader from './header/index.svelte';
import { quizStore } from '$lib/utils/store/org';
import { themeImages } from '$lib/utils/constants/quiz';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
let { exitPreview = () => {} } = $props();
let bgImg = $state();
let totalQ = $quizStore.questions.length;
let curQId = $state(0);
let currentQuestion = $state($quizStore.questions[0] || {});
function handlePrev() {
const prevQId = curQId - 1;
console.log('prevQId', prevQId);
const prev = $quizStore.questions[prevQId];
console.log('prev', prev);
if (prev) {
currentQuestion = prev;
curQId = prevQId;
}
}
function handleNext() {
const nextQId = curQId + 1;
console.log('nextqid', nextQId);
const next = $quizStore.questions[nextQId];
console.log('next', next);
if (next) {
currentQuestion = next;
curQId = nextQId;
}
}
onMount(() => {
bgImg = themeImages[$quizStore.theme].play;
});
</script>
{#if bgImg}
<div
class="absolute inset-0 z-50 h-screen w-screen bg-white"
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<div
class="h-full w-full p-5"
style="background: url({bgImg}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;min-height: 100vh;height: fit-content;"
>
<PlayContainer>
{#snippet header()}
<div>
<PlayHeader startCount={true} showCountDown={true} />
</div>
{/snippet}
{#snippet body()}
<div class="quiz-body">
{#if currentQuestion}
<QuizQuestion {currentQuestion} isPreview={true} />
{:else}
<h2>No question added</h2>
{/if}
</div>
{/snippet}
{#snippet footer()}
<div class="flex flex-col justify-center">
{#if currentQuestion}
<Button variant="ghost" onclick={exitPreview} class="w-fit">Exit Preview</Button>
<div class="flex items-center justify-center">
<IconButton onclick={handlePrev}>
<ChevronLeftIcon size={16} />
</IconButton>
<p class="mx-3">{curQId + 1} / {totalQ}</p>
<IconButton onclick={handleNext}>
<ChevronRightIcon size={16} />
</IconButton>
</div>
{/if}
</div>
{/snippet}
</PlayContainer>
</div>
</div>
{/if}
<style>
.quiz-body {
width: 500px;
margin: auto;
}
</style>
@@ -0,0 +1 @@
Question
@@ -0,0 +1 @@
Scoreboard
@@ -0,0 +1,95 @@
<script lang="ts">
import pluralize from 'pluralize';
// import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { currentOrgPath } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { themeImages } from '$lib/utils/constants/quiz';
import { calDateDiff } from '$lib/utils/functions/date';
// import { onRename, onDelete } from '$lib/utils/services/org/quiz';
import { Button } from '@cio/ui/base/button';
import { Image } from '$features/ui';
let { quiz, totalQuestions } = $props();
/*function getOptions() {
return [
// {
// label: 'Edit',
// onClick: onEdit(quiz),
// },
// {
// label: 'Share',
// onClick: onShare(quiz),
// },
// {
// label: 'Report',
// onClick: onReport(quiz),
// },
{
label: $t('components.quiz.rename'),
onClick() {
onRename(quiz);
}
},
// {
// label: 'Make a copy',
// onClick() {
// onMakeCopy(quiz);
// },
// },
{
label: $t('components.quiz.delete'),
onClick() {
onDelete(quiz);
}
}
];
}*/
function startQuiz() {}
</script>
{#if quiz}
<div
class="root relative mb-5 flex w-full flex-col rounded-lg border bg-gray-100 p-3 transition ease-in-out hover:shadow-2xl lg:flex-row dark:bg-black"
>
<Image
src={themeImages[quiz.theme]?.card || themeImages.standard.card}
alt="quiz-card"
className="max-w-[300px] min-w-[200px]"
/>
<div class="flex w-full flex-col justify-between p-2 md:p-5">
<div class="flex justify-between">
<h4 class="title text-md capitalize dark:text-white">
<a href="{$currentOrgPath}/quiz/{quiz.id}">{quiz.title}</a>
</h4>
<!-- <Dropdown options={getOptions()} classNames="absolute top-4 right-4" isIcon={true}>
<div class="rounded-full bg-gray-200 p-1 dark:bg-gray-600">
<EllipsisIcon size={16} />
</div>
</Dropdown> -->
</div>
<div class="flex flex-col justify-between md:flex-row">
<div>
<p class="mb-2">{pluralize('question', totalQuestions, true)}</p>
<p class="mb-2 md:mb-0">{$t('components.quiz.updated')} {calDateDiff(quiz.updated_at)}</p>
</div>
<Button variant="outline" onclick={startQuiz}>
{$t('components.quiz.start')}
</Button>
</div>
</div>
</div>
{/if}
<style>
.root {
min-height: 180px;
}
</style>
@@ -0,0 +1,66 @@
<script lang="ts">
import { CircleCheckIcon } from '$features/ui/icons';
import { optionImage } from '$lib/utils/constants/quiz';
import { InputField } from '@cio/ui/custom/input-field';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
currentQuestion?: any;
currentError?: any;
optionHasError?: any;
isPreview?: boolean;
}
let {
currentQuestion = $bindable({
label: ''
}),
currentError = {},
optionHasError = () => false,
isPreview = false
}: Props = $props();
</script>
{#if isPreview}
<h1 class="mb-5 text-white">{currentQuestion.label}</h1>
{:else}
<InputField
placeholder={$t('components.quiz.start_typing')}
bind:value={currentQuestion.label}
className="w-full my-4"
errorMessage={currentError.isLabelEmpty && $t('components.quiz.required_field')}
isRequired={true}
bgColor="bg-white"
/>
{/if}
<!-- Options -->
<div class="flex w-full flex-wrap justify-between">
{#each currentQuestion.options || [] as option}
<div class="relative mb-5 w-2/5 rounded bg-white p-5 dark:bg-neutral-800">
<img src={optionImage[option.id]} alt={option.id} />
{#if isPreview}
<p class="mt-5">{option.label}</p>
{:else}
<InputField
bind:value={option.label}
placeholder={$t('components.quiz.type_answer')}
bgColor="bg-white"
className="mt-3"
isDisabled={currentQuestion.type === 'boolean'}
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' : ''}
>
<CircleCheckIcon size={16} filled={option.isCorrect} />
</IconButton>
</div>
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,54 @@
<script>
import QuizLoader from './loader.svelte';
import QuizCard from './quiz-card.svelte';
import { Empty } from '@cio/ui/custom/empty';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import { snackbar } from '$features/ui/snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { currentOrg, quizesStore } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let isLoading = $state(false);
async function fetchQuizes(id) {
if (!id) return;
isLoading = true;
const { data, error } = await supabase
.from('quiz')
.select(`*`)
.eq('organization_id', id)
.order('updated_at', { ascending: false });
if (error) {
snackbar.error();
return;
}
console.log('data', data);
quizesStore.set(data);
isLoading = false;
}
$effect(() => {
fetchQuizes($currentOrg.id);
});
</script>
<div class="m-auto my-4 flex flex-wrap items-center justify-center lg:justify-start">
{#if isLoading}
<QuizLoader />
<QuizLoader />
<QuizLoader />
{:else}
{#each $quizesStore as quiz}
<QuizCard {quiz} totalQuestions={quiz.questions?.length || 0} />
{:else}
<Empty
title={$t('components.quiz.no_quizz')}
description={$t('components.quiz.interactive')}
icon={HelpCircleIcon}
variant="page"
/>
{/each}
{/if}
</div>