feat: interactive course poll (#5)

* CLASSROOMIO-T-218:Made the save button to be at the bottom right

* CLASSROOMIO-T-218:Removed unused class

* CLASSROOMIO-T-119:Made poll to be created by teachers only

* CLASSROOMIO-T-119:Updated the interface

* CLASSROOMIO-T-218:Made the save button to be at the bottom right

* CLASSROOMIO-T-218:Removed unused class

* CLASSROOMIO-T-119:Made poll to be created by teachers only

* CLASSROOMIO-T-119:Updated the interface

* polls improvements

* CLASSROOMIO-T-119:Made some corrections to the store

* fix types

* poll imporvements

* CLASSROOMIO-T-119: Connect create/delete poll to backend

* remove console

* CLASSROOMIO-T-119: Improve hotkeys + better abstract poll feature

* realtime poll

---------

Co-authored-by: emmanuel <[email protected]>
This commit is contained in:
Rotimi Best
2023-11-07 08:51:49 +00:00
committed by GitHub
co-authored by emmanuel
parent b4587483fb
commit 465b2341fd
27 changed files with 787 additions and 479 deletions
+3 -3
View File
@@ -12,12 +12,12 @@
<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" />
<title>ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamp</title>
<title>ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamps</title>
<meta name="description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamp" />
<meta property="og:title" content="ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamps" />
<meta property="og:description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." />
<meta property="og:type" content="website" />
@@ -27,7 +27,7 @@
<meta property="og:image:secure_url" content="https://classroomio.com/classroomio-opengraph-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content="@rotimi_best" />
<meta name="twitter:title" content="ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamp" />
<meta name="twitter:title" content="ClassroomIO –&nbsp;The Open Source Platform for Tech Bootcamps" />
<meta name="twitter:description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." />
@@ -1,25 +1,41 @@
<script lang="ts">
import FormSection from './FormSection.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { Poll } from '../types';
import DateTime from '$lib/components/Form/DateTime.svelte';
import type { PollType } from '../types';
import { course } from '$lib/components/Course/store';
import { profile } from '$lib/utils/store/user';
import TextField from '$lib/components/Form/TextField.svelte';
import { Add, TrashCan } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
export let title = 'Poll';
export let onSubmit = (p: Poll) => {};
export let isSaving = false;
export let onSubmit = (p: PollType) => {};
export let onCancel = () => {};
let poll: Poll = {
let poll: PollType = {
id: new Date().getTime().toString(),
courseId: $course.id || '',
question: '',
authorId: '',
expiration: new Date().toDateString(),
isPublic: false,
expiration: '',
status: 'draft',
author: {
id: $profile.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatar_url || ''
},
options: [
{
id: '',
label: '',
selectedBy: []
}
]
};
let isCreateDisabled = false;
function handleAddOptions() {
poll = {
@@ -27,6 +43,7 @@
options: [
...poll.options,
{
id: '',
label: '',
selectedBy: []
}
@@ -34,15 +51,17 @@
};
}
function handleRemoveOptions(index: number) {
function handleRemoveOptions(index: string | number) {
return () => {
poll.options = poll.options.filter((o, i) => i !== index);
poll.options = poll.options.filter((_, i) => i !== index);
};
}
function finishPoll() {
onSubmit(poll);
}
$: isCreateDisabled = poll.question === '' || poll.options.length < 2;
</script>
<div class="border rounded-md">
@@ -50,32 +69,53 @@
{title}
</div>
<div class="p-3">
<FormSection
<TextField
label="Question"
className="w-full mb-3"
bind:value={poll.question}
handleLabelButton={undefined}
handleInputButton={undefined}
placeholder="Poll question"
isRequired={true}
/>
<FormSection
<DateTime
label="Expiration"
className="w-full mb-3"
bind:value={poll.expiration}
type="date"
handleLabelButton={undefined}
handleInputButton={undefined}
isRequired={true}
/>
<div>
<p class="dark:text-white p-0 m-0 mb-1 text-md flex items-center gap-2">
Options
<IconButton onClick={handleAddOptions} contained={true} size="small">
<Add size={16} />
</IconButton>
</p>
</div>
{#each poll.options as option, index}
<FormSection
label={index === 0 ? 'Options' : ''}
bind:value={option.label}
handleLabelButton={handleAddOptions}
handleInputButton={handleRemoveOptions(index)}
/>
<div class="flex items-center gap-2 mb-3">
<TextField
label=""
className="w-full"
bind:value={option.label}
placeholder="Option Label"
isRequired={true}
/>
<IconButton onClick={handleRemoveOptions(index)} contained={true} size="small">
<TrashCan size={16} />
</IconButton>
</div>
{/each}
</div>
<div class="w-full flex justify-center mb-3">
<PrimaryButton label="Create poll" onClick={finishPoll} />
<div class="w-full flex justify-center gap-2 mb-3">
<PrimaryButton
label="Create poll"
onClick={finishPoll}
isLoading={isSaving}
isDisabled={isCreateDisabled}
/>
<PrimaryButton label="Cancel" variant={VARIANTS.OUTLINED} onClick={onCancel} />
</div>
</div>
@@ -1,34 +0,0 @@
<script lang="ts">
export let value: string | Date;
export let label: string;
export let type = 'text';
export let name = 'text';
export let handleLabelButton: undefined | (() => void);
export let handleInputButton: undefined | (() => void);
</script>
<div class="mb-3">
{#if label}
<p class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600">
{label}
{#if typeof handleLabelButton === 'function'}
<button class="text-xl px-3 ml-5" on:click={handleLabelButton}> + </button>
{/if}
</p>
{/if}
<div class="w-full">
{#if type === 'text'}
<input type="text" {name} class="p-2 rounded-md w-4/5" bind:value />
{/if}
{#if type === 'date'}
<input type="date" {name} class="p-2 rounded-md w-4/5" bind:value />
{/if}
{#if typeof handleInputButton === 'function'}
<button class="text-xl px-3 ml-5" on:click={handleInputButton}> - </button>
{/if}
</div>
</div>
@@ -1,48 +1,56 @@
<script>
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Label from './Label.svelte';
import Avatar from './Avatar.svelte';
import type { PollType } from '../types';
import { updatePollStatus } from '../service';
export let poll = {};
export let onSelect = () => {};
export let poll: PollType;
export let onSelect: (a: string) => void;
export let handlePollDelete = () => {};
export let currentUserId;
export let currentUserId: number | string;
let viewResult = false;
let selectedOptionToView;
let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
let isAuthor = false;
let totalVotes = 0;
let hasUserVoted = false;
let isThereAnyVote = false;
function handleSelect(optionIndex) {
return () => {
onSelect(optionIndex);
};
}
function getSelectedUsers(users) {
return users.map((o) => o.label).join(', ');
}
function getTotalVotes(options) {
function getTotalVotes(options: PollType['options']) {
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
}
function calculatePercent(options, currentOption) {
function calculatePercent(
options: PollType['options'],
currentOption: { selectedBy: string | PollType['options'][0]['selectedBy'] }
) {
const totalVoteOfAllOptions = getTotalVotes(options);
const totalVotesOfCurrentOption = currentOption.selectedBy.length;
return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0;
}
function didUserSelect(option) {
return option.selectedBy.find((user) => user.value === currentUserId);
function didUserSelect(
option: { selectedBy: PollType['options'][0]['selectedBy'] },
userId: number | string
) {
return option.selectedBy.find((user) => user.id === userId);
}
$: isAuthor = currentUserId === poll.author.value;
let prevStatus = poll.status;
function onStatusChange(status: string) {
if (status === prevStatus || !status) return;
updatePollStatus(poll.id, status);
prevStatus = status;
}
$: onStatusChange(poll.status);
$: isAuthor = currentUserId === poll.author.id;
$: totalVotes = getTotalVotes(poll.options);
$: {
hasUserVoted = poll.options.some((option) =>
option.selectedBy.find((u) => u.value === currentUserId)
);
isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
}
</script>
@@ -59,9 +67,9 @@
</div>
{#each selectedOptionToView.selectedBy as user}
<div class="flex items-center mb-2">
<Avatar src={user.avatar} name={user.label} className="mr-2" />
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
<p class="dark:text-white">
{user.label}
{user.fullname}
</p>
</div>
{/each}
@@ -73,11 +81,7 @@
{poll.question}
</h3>
<p class="dark:text-white text-sm italic">
Created by <a
class="text-primary-500"
href="https://twitter.com/rotimi_best"
target="_blank">@{poll.author.label}</a
>
Created by <span class="text-primary-500">@{poll.author.username}</span>
</p>
</div>
@@ -85,9 +89,14 @@
<div class="flex mb-3 justify-end items-center">
<label class="mr-2 font-bold" for="user-select">Status:</label>
<select name="users" id="user-select" bind:value={poll.status}>
<select
name="users"
id="user-select"
class="dark:text-white dark:bg-black"
bind:value={poll.status}
>
<option value="draft">Draft</option>
<option value="Public">Public</option>
<option value="published">Publish</option>
</select>
</div>
{/if}
@@ -132,7 +141,7 @@
<div class="flex items-center">
{#each option.selectedBy.slice(0, 3) as user}
<Avatar src={user.avatar} name={user.label} className="mr-2" />
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
{/each}
</div>
</div>
@@ -140,21 +149,26 @@
{:else}
{#each poll.options as option, index}
<button
class="bg-white dark:bg-black rounded-md border-2 border-gray-500 h-[50px] {didUserSelect(
option
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 h-[50px] {didUserSelect(
option,
currentUserId
) &&
'focus:border-primary-700 border-primary-700'} text-black p-2 w-full mb-3 text-left relative"
on:click={handleSelect(index)}
'focus:border-primary-700 border-primary-700'} text-black dark:text-white p-2 w-full mb-3 text-left relative"
on:click={() => onSelect(option.id)}
>
{#if hasUserVoted}
{#if isThereAnyVote}
<span
class="progress absolute top-0 text-black left-0 {didUserSelect(option)
class="progress absolute top-0 text-black left-0 {didUserSelect(option, currentUserId)
? 'bg-primary-700'
: 'bg-gray-300'} h-full bg-opacity-25"
style="width: {calculatePercent(poll.options, option)}%;"
/>
{/if}
<p class="absolute top-3 text-black left-2">
<p
class="absolute top-3 {didUserSelect(option, currentUserId)
? 'text-white'
: 'dark:text-white text-black'} left-2"
>
{option.label}
({calculatePercent(poll.options, option)}%)
</p>
@@ -172,20 +186,12 @@
{/if}
{#if isAuthor}
<div class="w-full text-center mt-3">
<button
class="px-5 py-3 bg-primary-700 text-white"
on:click={() => (viewResult = !viewResult)}
>
{#if viewResult}
Back to poll
{:else}
View results
{/if}
</button>
<button class="px-5 py-3 bg-red-500 text-white" on:click={handlePollDelete}>
Delete
</button>
<div class="w-full flex items-center mt-3">
<PrimaryButton
label={viewResult ? 'Back' : 'Result'}
onClick={() => (viewResult = !viewResult)}
/>
<PrimaryButton label="Delete" variant={VARIANTS.TEXT_DANGER} onClick={handlePollDelete} />
</div>
{/if}
{/if}
@@ -1,18 +1,34 @@
<script>
export let tabs = [];
<script lang="ts">
import Chip from '$lib/components/Chip/index.svelte';
import RoleBaseSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { Add } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import type { TabsType } from '../types';
export let selectedTab;
export let onCreate: () => void;
export let tabs: TabsType = [];
export let selectedTab: number;
</script>
<div class="mb-3">
{#each tabs as tab}
<button
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
? 'border-b-4 border-indigo-600 focus:border-indigo-600'
: 'border-b-0'}"
on:click={() => (selectedTab = tab.value)}
>
{tab.label}
</button>
{/each}
<div class="mb-3 flex items-center justify-between">
<div>
{#each tabs as tab}
<button
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
? 'border-b-4 border-indigo-600 focus:border-indigo-600'
: 'border-b-0'}"
on:click={() => (selectedTab = tab.value)}
>
{tab.label}
<Chip bind:value={tab.number} />
</button>
{/each}
</div>
<RoleBaseSecurity allowedRoles={[1, 2]}>
<IconButton onClick={onCreate} contained={true} size="large">
<Add size={16} />
</IconButton>
</RoleBaseSecurity>
</div>
@@ -1,152 +1,251 @@
<script lang="ts">
import PageNav from '$lib/components/PageNav/index.svelte';
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CreatePollForm from './components/CreatePollForm.svelte';
import Poll from './components/Poll.svelte';
import Tabs from './components/Tabs.svelte';
import { polls } from './store';
import type { PollType, TabsType, PollOptionsSubmissionType } from './types';
import { apps } from '$lib/components/Apps/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
import { group, course } from '$lib/components/Course/store';
import type { Groupmember } from '$lib/utils/types';
import { fetchPolls, handleVote } from './service';
import { getPollsData } from './utils';
import { snackbar } from '$lib/components/Snackbar/store';
import { onMount, onDestroy } from 'svelte';
import type {
RealtimeChannel,
RealtimePostgresChangesPayload,
PostgrestSingleResponse
} from '@supabase/supabase-js';
export let handleClose = () => {};
const users = [
{
label: 'Rich Harris',
value: 'rich-harris',
avatar: 'https://i.pravatar.cc/150?img=1'
},
{
label: 'Linus Torvalds',
value: 'linus-torvalds',
avatar: 'https://i.pravatar.cc/150?img=2'
},
{
label: 'Elon Musk',
value: 'elon-musk',
avatar: 'https://i.pravatar.cc/150?img=3'
},
{
label: 'Satoshi Nakamoto',
value: 'satoshi-nakamoto',
avatar: 'https://i.pravatar.cc/150?img=4'
}
];
const tabs = [
{
label: 'My polls',
value: 0
},
{
label: 'Other polls',
value: 1
}
];
const supabase = getSupabase();
let selectedTab = 0;
let userId = 'satoshi-nakamoto';
let shouldCreatePoll = false;
let polls = [
{
question: 'Who is the creator of Svelte?',
author: users[0],
status: 'draft', // public
expiration: new Date().toDateString(),
options: [
{
label: 'Dan Abrahmov',
selectedBy: []
},
{
label: 'Evan You',
selectedBy: [
{
label: 'Rich Harris',
value: 'rich-harris',
avatar: 'https://i.pravatar.cc/150?img=1'
},
{
label: 'Linus Torvalds',
value: 'linus-torvalds',
avatar: 'https://i.pravatar.cc/150?img=2'
}
]
},
{
label: 'Rich Harris',
selectedBy: []
},
{
label: 'Yehuda Katz',
selectedBy: []
}
]
}
];
let currentGroupMember: Groupmember | undefined;
let openCreatePollForm = false;
let isCreating = false;
let isLoading = false;
let author: PollType['author'] = {
id: '',
username: '',
fullname: '',
avatarUrl: ''
};
let pollSubmissionsChannel: RealtimeChannel;
function handleCreatePoll() {
shouldCreatePoll = !shouldCreatePoll;
let tabs: TabsType = [];
let activePolls: PollType[] = [];
let expiredPolls: PollType[] = [];
function setCoursePolls() {
$course.polls = $polls.map((p) => ({ status: p.status }));
}
function handlePollCreate(poll) {
if (poll) {
polls = [
{
async function createPoll(poll: PollType) {
if (!currentGroupMember || !$course.id) return;
const { data, error } = await supabase
.from('apps_poll')
.insert({
question: poll.question,
authorId: currentGroupMember.id,
status: poll.status,
expiration: poll.expiration,
courseId: $course.id
})
.select('id');
if (error || !data) {
console.log(error);
snackbar.error('An error occurred while creating poll');
return;
}
const pollId = data[0].id;
const appsPollOptions = poll.options.map((option) => ({
poll_id: pollId,
label: option.label
}));
const { data: appsPollOptionsData, error: appsPollOptionsError } = await supabase
.from('apps_poll_option')
.insert(appsPollOptions)
.select();
if (appsPollOptionsError || !appsPollOptionsData) {
console.log({ appsPollOptionsError });
snackbar.error('An error occurred while creating poll');
return;
}
$polls = [
{
...poll,
id: pollId,
courseId: $course.id,
author,
options: appsPollOptionsData.map((option) => ({
...option,
id: option.id,
selectedBy: []
}))
},
...$polls
];
setCoursePolls();
}
// now the most interesting part,
// how do I implement real time functionality for polls?
// the only thing that is real time is the apps_poll_submission
// table, so I will have to listen to changes in that table
// and update the polls accordingly.
async function handleInsert(payload: RealtimePostgresChangesPayload<PollOptionsSubmissionType>) {
const newVote = payload.new as PollOptionsSubmissionType;
const {
data,
error
}: PostgrestSingleResponse<{
profile: {
username: string;
fullname: string;
avatar_url: string;
};
}> = await supabase
.from('groupmember')
.select('profile:profile_id(username, fullname, avatar_url)')
.eq('id', newVote.selected_by_id)
.single();
console.log('newVote => data', data);
console.log('newVote => error', error);
if (error || !data) {
return;
}
$polls = $polls.map((poll) => {
if (poll.id === newVote.poll_id) {
return {
...poll,
author: {
label: 'Satoshi Nakamoto',
value: 'satoshi-nakamoto',
avatar: 'https://i.pravatar.cc/150?img=4'
}
},
...polls
];
}
handleCreatePoll();
}
function handlePollDelete(pollId) {
return () => {
polls = [...polls.filter((p, i) => i !== pollId)];
};
}
function handleVote(pollId) {
return (optionId) => {
polls = [
...polls.map((poll, index) => {
if (index === pollId) {
poll.options = poll.options.map((option, optionIndex) => {
if (optionIndex === optionId) {
if (option.selectedBy.find((user) => user.value === userId)) {
// Unselect if already selected
// <<update: let me keep this commented so after a user selects an answer it can't be unselected>>
// option.selectedBy = option.selectedBy.filter(user => user.value !== userId)
} else {
// Select if already selected
option.selectedBy = [
...option.selectedBy,
{
label: 'Satoshi Nakamoto',
value: 'satoshi-nakamoto',
avatar: 'https://i.pravatar.cc/150?img=4'
}
];
}
} else if (option.selectedBy.find((user) => user.value === userId)) {
// This makes the user to select only one option
option.selectedBy = option.selectedBy.filter((user) => user.value !== userId);
options: poll.options.map((option) => {
if (option.id === newVote.poll_option_id) {
if (option.selectedBy.some((s) => s.id === newVote.selected_by_id)) {
return option;
}
return option;
});
}
return {
...option,
selectedBy: [
...option.selectedBy,
{
id: newVote.selected_by_id,
username: data.profile.username,
fullname: data.profile.fullname,
avatarUrl: data.profile.avatar_url
}
]
};
}
return poll;
})
];
return option;
})
};
}
return poll;
});
}
async function handlePollCreate(poll: PollType) {
if (!currentGroupMember) return;
isCreating = true;
await createPoll(poll);
isCreating = false;
openCreatePollForm = false;
}
function handlePollDelete(pollId: string) {
return async () => {
await supabase.from('apps_poll_submission').delete().match({ poll_id: pollId });
await supabase.from('apps_poll_option').delete().match({ poll_id: pollId });
await supabase.from('apps_poll').delete().match({ id: pollId });
$polls = [...$polls.filter((p, i) => p.id !== pollId)];
setCoursePolls();
};
}
onMount(async () => {
if (!$course.id) return;
isLoading = true;
const { data, error } = await fetchPolls($course.id);
if (!data || error) {
console.log(error);
isLoading = false;
return;
}
polls.set(getPollsData(data, $apps.isStudent));
setCoursePolls();
isLoading = false;
pollSubmissionsChannel = supabase
.channel('any')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'apps_poll_submission' },
handleInsert
)
.subscribe();
});
onDestroy(() => {
supabase.removeChannel(pollSubmissionsChannel);
});
$: currentGroupMember = $group.people.find((person) => person.profile_id === $profile.id);
$: author = {
id: currentGroupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatar_url || ''
};
$: {
activePolls = $polls.filter(
(poll) => new Date(poll.expiration).getTime() >= new Date().getTime()
);
expiredPolls = $polls.filter(
(poll) => new Date(poll.expiration).getTime() <= new Date().getTime()
);
tabs = [
{
label: 'Active polls',
value: 0,
number: activePolls.length
},
{
label: 'Expired polls',
value: 1,
number: expiredPolls.length
}
];
}
</script>
<PageNav title="Poll" overidableStyle="padding: 0 10px" paddingClass="w-full">
@@ -155,46 +254,35 @@
</div>
</PageNav>
<div class="m-2 overlow-y-auto md:min-w-[350px] w-full">
{#if shouldCreatePoll}
<CreatePollForm onSubmit={handlePollCreate} onCancel={handleCreatePoll} />
{:else}
<div class="p-2 overlow-y-auto md:max-w-[350px] md:min-w-[340px] w-full">
{#if openCreatePollForm}
<CreatePollForm
onSubmit={handlePollCreate}
onCancel={() => (openCreatePollForm = !openCreatePollForm)}
bind:isSaving={isCreating}
/>
{:else if currentGroupMember}
<div>
<Tabs {tabs} bind:selectedTab />
<Tabs {tabs} bind:selectedTab onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
{#if selectedTab === tabs[0].value}
<div class="w-full flex justify-center mb-3">
<PrimaryButton
label="Create poll"
variant={VARIANTS.CONTAINED}
onClick={handleCreatePoll}
/>
</div>
{/if}
{#each polls as poll, index}
{#if selectedTab === tabs[0].value && poll.author.value === userId}
<Poll
bind:poll
onSelect={handleVote(index)}
handlePollDelete={handlePollDelete(index)}
currentUserId={userId}
/>
{:else if selectedTab === tabs[1].value && poll.author.value !== userId}
<Poll
bind:poll
onSelect={handleVote(index)}
handlePollDelete={handlePollDelete(index)}
currentUserId={userId}
/>
{/if}
{#if isLoading}
Loading...
{:else}
<div
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center"
>
<h2 class="text-xl font-bold">No polls to display</h2>
</div>
{/each}
{#each selectedTab === tabs[0].value ? activePolls : expiredPolls as poll}
<Poll
bind:poll
onSelect={handleVote(poll.id, currentGroupMember?.id || '', author)}
handlePollDelete={handlePollDelete(poll.id)}
bind:currentUserId={currentGroupMember.id}
/>
{:else}
<div
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center"
>
<h2 class="text-xl font-bold">No polls to display</h2>
</div>
{/each}
{/if}
</div>
{/if}
</div>
@@ -1,87 +1,141 @@
import { snackbar } from '$lib/components/Snackbar/store';
import type { PollType } from './types';
import type { PollType, PollOptionType, FetchPollsResponse } from './types';
import { getSupabase } from '$lib/utils/functions/supabase';
import type { GroupPerson } from '$lib/utils/types';
import { polls } from './store';
const supabase = getSupabase();
export async function createPoll(poll: PollType, groupmember: GroupPerson, courseId: string) {
if (!groupmember) return;
const { data: newPolls, error } = await supabase
.from('apps_poll')
.insert({
question: poll.question,
authorId: groupmember.id,
isPublic: poll.isPublic,
status: poll.question,
expiration: poll.expiration,
courseId
})
.select();
if (!newPolls || error) {
snackbar.error('Error creating poll');
console.error(error);
return;
}
const [newPoll] = newPolls;
if (!newPoll.id) return;
poll = {
poll,
...newPoll
};
poll.options = await Promise.all(
poll.options.map(async (option) => {
const { data: newPollOptions, error: newPollOptionsError } = await supabase
.from('apps_poll_option')
.insert({
poll_id: newPoll.id,
label: option.label
})
.select();
if (!newPollOptions || newPollOptionsError) {
return option;
}
const [newPollOption] = newPollOptions;
option.id = newPollOption.id;
return option;
})
);
return poll;
}
export async function fetchPolls(courseId: string) {
const {} = await supabase
.from('app_poll')
return await supabase
.from('apps_poll')
.select(
`
id,
question,
isPublic,
status,
expiration
groupmember:authorId(
profile!inner(
id,
*,
created_at,
author:groupmember(
profile(
username,
fullname,
avatar_url
)
)
apps_poll_option!inner(
),
options: apps_poll_option (
id,
label,
submissions:apps_poll_submission(
selectedBy:groupmember(
id,
profile(
username,
fullname,
avatar_url
)
)
)
)
`
)
.match({
courseId
.match({ courseId })
.order('created_at', { ascending: false })
.returns<FetchPollsResponse>();
}
export const updatePollStatus = async (pollId: string, status: PollType['status']) => {
const { error } = await supabase.from('apps_poll').update({ status }).match({ id: pollId });
if (error) {
console.log(error);
snackbar.error('An error occurred while updating poll status');
return;
}
};
export const togglePollSubmission = async (
pollId: PollType['id'],
pollOptionId: PollOptionType['id'],
groupmemberId: string,
add: boolean
) => {
if (add) {
const { error } = await supabase.from('apps_poll_submission').insert({
poll_id: pollId,
poll_option_id: pollOptionId,
selected_by_id: groupmemberId
});
if (error) {
console.log(error);
snackbar.error('An error occurred while submitting poll');
return;
}
} else {
const { error } = await supabase.from('apps_poll_submission').delete().match({
poll_id: pollId,
poll_option_id: pollOptionId,
selected_by_id: groupmemberId
});
if (error) {
console.log(error);
snackbar.error('An error occurred while submitting poll');
return;
}
}
};
function isOptionSelectedByCurrentUser(option: PollOptionType, groupmemberId: string) {
return option.selectedBy.some((gmember) => gmember.id === groupmemberId);
}
export function handleVote(pollId: string, groupmemberId: string, author: PollType['author']) {
return (optionId: string) => {
// we have the pollId and the optionId
polls.update((_polls) => {
return [
..._polls.map((poll) => {
// Prevent user from voting on their own poll
// if (poll.author.id === currentGroupMember?.id) return poll;
// Prevent user from voting on a poll that has expired
if (poll.expiration && new Date(poll.expiration) < new Date()) return poll;
if (poll.id === pollId) {
// Prevent user from voting on a poll that the status is not published
if (poll.status !== 'published') {
snackbar.info('Poll is not published yet');
return poll;
}
// Prevent user from voting twice
if (
poll.options.some((option) => isOptionSelectedByCurrentUser(option, groupmemberId))
) {
snackbar.info('You can only vote once');
return poll;
}
poll.options = poll.options.map((option) => {
const isSelected = isOptionSelectedByCurrentUser(option, groupmemberId);
// If user has not voted on this option, add their vote
if (option.id === optionId && !isSelected) {
option.selectedBy = [...option.selectedBy, author];
togglePollSubmission(pollId, optionId, groupmemberId, true);
}
// else if (isSelected) {
// // If user has voted on this option, remove their vote
// option.selectedBy = option.selectedBy.filter(
// (gmember) => gmember.id !== groupmemberId
// );
// }
return option;
});
}
return poll;
})
];
});
};
}
@@ -0,0 +1,4 @@
import { writable, type Writable } from 'svelte/store';
import type { PollType } from './types';
export const polls: Writable<PollType[]> = writable([]);
@@ -1,14 +1,68 @@
export type Poll = {
question: string;
authorId: string; // groupmember_id
isPublic: boolean;
expiration: string | Date;
options: {
label: string;
selectedBy: {
id: string;
fullname: string;
avatarUrl: string;
}[];
export type PollOptionType = {
id: string;
label: string;
selectedBy: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
}[];
};
export type PollType = {
courseId: string;
id: string;
question: string;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
status: string;
expiration: string | Date;
options: PollOptionType[];
};
export type TabsType = {
label: string;
value: number;
number: number;
}[];
export type PollOptionsSubmissionType = {
poll_id: string;
poll_option_id: string;
selected_by_id: string;
};
export type FetchPollsResponse = {
id: string;
courseId: string;
expiration: string;
authorId: string;
status: string;
question: string;
created_at: string;
author: {
profile: {
username: string;
fullname: string;
avatar_url: string;
};
};
options: {
id: string;
label: string;
submissions: {
selectedBy: {
id: string;
profile: {
username: string;
fullname: string;
avatar_url: string;
};
};
}[];
}[];
}[];
@@ -0,0 +1,31 @@
import type { PollType, FetchPollsResponse } from './types';
export const getPollsData = (data: FetchPollsResponse, isStudent: boolean) => {
const pollsData: PollType[] = data.map((appsPollItem) => ({
id: appsPollItem.id,
courseId: appsPollItem.courseId,
expiration: appsPollItem.expiration,
status: appsPollItem.status,
question: appsPollItem.question,
author: {
id: appsPollItem.authorId,
username: appsPollItem.author.profile.username,
fullname: appsPollItem.author.profile.fullname,
avatarUrl: appsPollItem.author.profile.avatar_url
},
options: appsPollItem.options.map((option) => ({
id: option.id,
label: option.label,
selectedBy: option.submissions.map((submission) => {
return {
id: submission.selectedBy.id,
username: submission.selectedBy.profile.username,
fullname: submission.selectedBy.profile.fullname,
avatarUrl: submission.selectedBy.profile.avatar_url
};
})
}))
}));
return pollsData.filter((p) => (isStudent ? p.status === 'published' : true));
};
+10 -26
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import hotkeys from 'hotkeys-js';
import { fade } from 'svelte/transition';
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
@@ -15,6 +14,7 @@
import Poll from './components/Poll/index.svelte';
import APPS_CONSTANTS from './constants';
import { browser } from '$app/environment';
import { course } from '$lib/components/Course/store';
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
let appBarRef: HTMLDivElement;
@@ -55,24 +55,6 @@
onMount(() => {
updateTopPadding($apps.isStudent);
hotkeys('A+1,A+2', function (event, handler) {
event.preventDefault();
switch (handler.key) {
case 'A+1':
handleAppClick(APPS_CONSTANTS.APPS.COMMENTS);
break;
case 'A+2':
handleAppClick(APPS_CONSTANTS.APPS.POLL);
break;
// case 'A+3':
// handleAppClick(APPS_CONSTANTS.APPS.NOTES);
// break;
// case 'A+4':
// handleAppClick(APPS_CONSTANTS.APPS.QANDA);
// break;
}
});
});
$: updateTopPadding($apps.isStudent);
@@ -88,10 +70,8 @@
<div class="lg:hidden mb-2">
<IconButton
buttonClassName="lg:hidden"
toolTipProps={{ title: 'Settings', hotkeys: ['A', '0'] }}
onClick={() => {
handleAppClick();
}}
toolTipProps={{ title: 'Settings', hotkeys: [] }}
onClick={handleAppClick}
>
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
</IconButton>
@@ -99,24 +79,28 @@
<div class="mb-2 relative {getAppClass(APPS_CONSTANTS.APPS.COMMENTS, $apps.selectedApp)}">
<IconButton
toolTipProps={{ title: 'Comments', hotkeys: ['A', '1'] }}
toolTipProps={{ title: 'Comments', hotkeys: ['Ctrl/Command', '1'] }}
value={APPS_CONSTANTS.APPS.COMMENTS}
onClick={handleAppClick}
buttonClassName="relative"
>
<Chip
value={$lesson.totalComments}
className="absolute -top-1 right-0 bg-primary-600 text-white"
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
/>
<SendAlt size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
<div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}">
<IconButton
toolTipProps={{ title: 'Poll', hotkeys: ['A', '2'] }}
toolTipProps={{ title: 'Poll', hotkeys: ['Ctrl/Command', '2'] }}
value={APPS_CONSTANTS.APPS.POLL}
onClick={handleAppClick}
>
<Chip
value={$course.polls.length}
className="absolute -top-1 right-0 bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-white"
/>
<ChartPie size={24} class="carbon-icon dark:text-white" />
</IconButton>
</div>
@@ -378,8 +378,8 @@
</Tabs>
{:else if !isMaterialsEmpty($lesson.materials)}
<div class="w-full">
{#each componentsToRender as Component, index}
<svelte:component this={Component} key={index} />
{#each componentsToRender as Component}
<svelte:component this={Component} />
{/each}
</div>
{:else}
@@ -24,7 +24,10 @@ export function generateQuestion(questions) {
return generatedQuestions;
}
export function getIsLessonComplete(completions: LessonCompletion[], profileId: string | null) {
export function getIsLessonComplete(
completions: LessonCompletion[],
profileId: string | undefined
) {
if (!Array.isArray(completions)) return false;
return completions.find((c) => {
return c.is_complete && c.profile_id === profileId;
@@ -2,7 +2,6 @@
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import hotkeys from 'hotkeys-js';
import { browser } from '$app/environment';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
@@ -32,21 +31,20 @@
let isLessonActive: boolean = false;
let resize = false;
let isDragging = false;
let startX;
let initialWidth;
let sidebarRef;
let menuContentRef;
let startX: number;
let initialWidth: number;
let sidebarRef: HTMLElement;
let menuContentRef: HTMLUListElement;
const toggleSidebar = () => {
if ($isMobile) {
$sideBar.hidden = !$sideBar.hidden;
}
const toggleSidebar = (defaultValue?: boolean) => {
$sideBar.hidden = defaultValue ?? !$sideBar.hidden;
};
const toggleSidebarOnMobile = () => $isMobile && toggleSidebar();
function handleMainGroupClick(href: string) {
return () => {
goto(href);
toggleSidebar();
toggleSidebarOnMobile();
};
}
@@ -57,7 +55,7 @@
else show = true;
}
function handleCursor(event) {
function handleCursor(event: MouseEvent) {
if (!resize && sidebarRef) {
const isNearLeftBorder = event.clientX - sidebarRef.getBoundingClientRect().left < 8;
const isNearRightBorder = sidebarRef.getBoundingClientRect().right - event.clientX < 8;
@@ -70,7 +68,7 @@
}
}
function startDragging(event) {
function startDragging(event: MouseEvent) {
if (event.button === 0 && sidebarRef) {
event.preventDefault();
@@ -95,7 +93,7 @@
resize = false;
}
function dragSidebar(event) {
function dragSidebar(event: MouseEvent) {
if (!(window.innerWidth >= 1024)) return;
if (!isDragging) return;
@@ -119,36 +117,21 @@
}
onMount(() => {
if (window.innerWidth >= 1024) {
if (!$isMobile) {
sidebarRef.addEventListener('mousedown', startDragging);
document.addEventListener('mousemove', dragSidebar);
document.addEventListener('mouseup', stopDragging);
document.addEventListener('mousemove', handleCursor);
}
if (browser) {
if (localStorage.getItem('hideCourseNav')) {
show = localStorage.getItem('hideCourseNav') === 'false';
}
} else {
show = true;
}
hotkeys('b', function (event, handler) {
event.preventDefault();
switch (handler.key) {
case 'b':
show = !show;
break;
}
});
toggleSidebar(false);
});
onDestroy(() => {
if (!browser) {
return;
}
if (window.innerWidth >= 1024) {
if (!$isMobile) {
sidebarRef.removeEventListener('mousedown', startDragging);
document.removeEventListener('mousemove', dragSidebar);
document.removeEventListener('mouseup', stopDragging);
@@ -158,12 +141,6 @@
$: handleMobileChange($isMobile);
$: {
if (browser) {
localStorage.setItem('hideCourseNav', `${!show}`);
}
}
$: isLessonActive = $page.url.pathname.includes('/lessons');
$: {
@@ -273,7 +250,7 @@
href={isStudent && !item.is_unlocked
? $page.url.pathname
: getLessonsRoute($course.id, item.id)}
on:click={toggleSidebar}
on:click={toggleSidebarOnMobile}
aria-disabled={!item.is_unlocked}
>
<div
+3 -1
View File
@@ -17,6 +17,7 @@ export const defaultCourse: Course = {
created_at: new Date().toDateString(),
updated_at: new Date().toDateString(),
attendance: [],
polls: [],
metadata: {
requirements: '',
description: '',
@@ -151,7 +152,8 @@ export async function setCourse(data: Course, setLesson = true) {
coursesNo: 0,
description: '',
imgUrl: ''
}
},
allowNewStudent: false
};
}
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { Moon } from 'svelte-loading-spinners';
import { browser } from '$app/environment';
import Navigation from '../Course/components/Navigation/index.svelte';
@@ -36,6 +36,12 @@
prevCourseId = courseId;
}
function filterPollsByStatus(shouldFilter: boolean) {
if (!shouldFilter) return;
$course.polls = $course.polls.filter((poll) => poll.status === 'published');
}
$: onCourseIdChange(courseId);
$: {
@@ -44,6 +50,8 @@
if (user) {
isStudent = user.role_id === 3;
$apps.isStudent = isStudent;
filterPollsByStatus(isStudent);
}
}
</script>
+12 -7
View File
@@ -1,19 +1,24 @@
<script>
export let label;
export let placeholder;
export let value;
<script lang="ts">
export let label = '';
export let placeholder = '';
export let value: string | Date = '';
export let className = '';
export let onInput = () => {};
$: console.log(`value`, value);
export let isRequired: boolean = false;
</script>
<label class="block {className}">
<span class="text-gray-700 dark:text-white">{label}</span>
<span class="text-gray-700 dark:text-white"
>{label}
{#if isRequired}
<span class="text-red-700">*</span>
{/if}
</span>
<input
bind:value
on:input={onInput}
{placeholder}
type="datetime-local"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-700 dark:text-white"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-neutral-800 dark:text-white"
/>
</label>
+9 -7
View File
@@ -23,17 +23,19 @@
export let helperMessage = '';
export let autoComplete = true;
export let onChange = () => {}; // This is to know if element is 'dirty'
export let onInputChange = (e) => {};
export let onInputChange = () => {};
let ref;
let fieldNode;
let ref: HTMLInputElement | undefined = undefined;
let fieldNode: HTMLInputElement | undefined = undefined;
let focusClass = '';
function typeAction(node) {
node.type = type;
function typeAction(node: HTMLInputElement | undefined) {
if (!node) return;
node.type = type || 'text';
if (isRequired) {
node.required = '';
node.required = true;
}
fieldNode = node;
@@ -45,7 +47,7 @@
}
onMount(() => {
if (autoFocus) {
if (autoFocus && ref) {
ref.focus();
}
@@ -14,17 +14,17 @@
<section class="h-full">
<p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Currently Learning</p>
<div
class="flex items-center flex-col border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded w-full md:h-[40vh] p-3"
class="flex items-center flex-col border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded w-full lg:h-[40vh] p-3"
>
{#if $coursesInProgress.length > 0}
<div class="w-full h-full flex flex-col justify-start overflow-y-auto">
{#each $coursesInProgress as course}
<div class="p-5">
<span class="flex flex-col md:flex-row gap-3 items-start pb-5">
<span class="flex flex-col lg:flex-row gap-3 items-start pb-5">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt="course"
class="hidden md:block md:w-[60px] md:h-[60px]"
class="hidden lg:block lg:w-[60px] lg:h-[60px]"
/>
<div class="w-full">
<p class="text-base font-semibold dark:text-white">{course.title}</p>
+9
View File
@@ -27,6 +27,15 @@ export const snackbar = {
store.message = message;
store.severity = SNACKBAR_SEVERITY.ERROR;
return store;
});
},
info(message = 'Update') {
snackbarStore.update((store) => {
store.open = true;
store.message = message;
store.severity = SNACKBAR_SEVERITY.INFO;
return store;
});
}
+2 -1
View File
@@ -51,7 +51,8 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete)
),
attendance:group_attendance(*)
attendance:group_attendance(*),
polls:apps_poll(status)
`
)
.match(match)
+1
View File
@@ -189,6 +189,7 @@ export interface Course {
id: number;
}[];
lessons?: Lesson[];
polls: { status: string }[];
}
export interface Groupmember {
+2 -2
View File
@@ -7,7 +7,7 @@
import { getSupabase } from '$lib/utils/functions/supabase';
import { validateEmail } from '$lib/utils/functions/validateEmail';
let email;
let email = '';
let isAdding = false;
let success = false;
@@ -42,7 +42,7 @@
setTimeout(() => {
isAdding = false;
success = false;
email = null;
email = '';
}, 5000);
}
</script>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sideBar } from '$lib/components/Org/store';
import { apps } from '$lib/components/Apps/store';
import APPS_CONSTANTS from '$lib/components/Apps/constants';
import hotkeys from 'hotkeys-js';
function handleClose() {
$apps.selectedApp = undefined;
if ($apps.isStudent) {
$apps.open = false;
}
}
function handleAppClick(appName?: string) {
if (appName === $apps.selectedApp) {
handleClose();
} else {
$apps.selectedApp = appName;
}
$apps.open = true;
$apps.dropdown = !$apps.dropdown;
}
function toggleSidebar() {
$sideBar.hidden = !$sideBar.hidden;
}
onMount(() => {
hotkeys('ctrl+b,command+b,ctrl+1,command+1,ctrl+2,command+2', function (event, handler) {
// Prevent default behavior
event.preventDefault();
switch (handler.key) {
case 'ctrl+b':
case 'command+b':
toggleSidebar();
break;
case 'ctrl+1':
case 'command+1':
handleAppClick(APPS_CONSTANTS.APPS.COMMENTS);
break;
case 'ctrl+2':
case 'command+2':
handleAppClick(APPS_CONSTANTS.APPS.POLL);
break;
}
});
});
</script>
<slot />
@@ -18,7 +18,7 @@
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import MODES from '$lib/utils/constants/mode';
import { setCourse, course } from '$lib/components/Course/store';
import { course } from '$lib/components/Course/store';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import { apps } from '$lib/components/Apps/store';
@@ -72,23 +72,24 @@
isFetching = false;
}
async function markLessonComplete() {
async function markLessonComplete(lessonId: string) {
let found = false;
isMarkingComplete = true;
let completion: LessonCompletion = {
is_complete: true,
profile_id: $profile.id || '',
lesson_id: $lesson.id || '',
lesson_id: lessonId,
created_at: new Date().toDateString(),
updated_at: new Date().toDateString()
};
const completions: LessonCompletion[] = $lesson.lesson_completion.map((_completion) => {
if (_completion.profile_id === $profile.id) {
found = true;
_completion.is_complete = !_completion.is_complete;
completion = _completion;
completion = {
...completion,
is_complete: _completion.is_complete
};
}
return _completion;
@@ -324,7 +325,11 @@
<SendAlt size={24} class="carbon-icon" />
<span class="ml-1">{$lesson.totalComments}</span>
</button>
<button class="px-2 my-2" on:click={markLessonComplete} disabled={isMarkingComplete}>
<button
class="px-2 my-2"
on:click={() => markLessonComplete(data.lessonId)}
disabled={isMarkingComplete}
>
{#if getIsLessonComplete($lesson.lesson_completion, $profile.id)}
<CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600" />
{:else}
+6 -6
View File
@@ -72,17 +72,17 @@
class="w-28 md:block md:w-1/3 lg:w-[275px] lg:h-[205px] mb-3 md:mb-0"
/>
</div>
<section class="flex w-full flex-col md:flex-row md:gap-5">
<div class="w-full md:w-[50%] mt-10 xl:mt-2">
<section class="flex w-full flex-col lg:flex-row md:gap-5">
<div class="w-full lg:w-[50%] mt-10 xl:mt-2">
<Learning />
</div>
<div class="mt-10 xl:mt-2 w-full md:w-[50%]">
<div class="mt-10 xl:mt-2 w-full lg:w-[50%]">
<p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Your Progress</p>
<div
class="flex items-center justify-center border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded h-fit md:h-[40vh] lg:overflow-y-auto p-3"
class="flex items-center justify-center border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded h-fit lg:h-[40vh] lg:overflow-y-auto p-3"
>
<div
class="w-full h-full flex flex-col sm:flex-row xl:flex-col items-center justify-between md:items-center xl:items-start gap-5 md:justify-around"
class="w-full h-full flex flex-col sm:flex-row xl:flex-col items-center justify-between lg:items-center xl:items-start gap-5 lg:justify-around"
>
<div>
<img src="/images/target.svg" alt="student Learning score" />
@@ -100,7 +100,7 @@
{/if}
</span>
<h1
class="text-5xl md:text-6xl font-bold text-[#262626] dark:text-white my-0 whitespace-nowrap"
class="text-5xl lg:text-6xl font-bold text-[#262626] dark:text-white my-0 whitespace-nowrap"
>
{progressPercentage} %
</h1>
+1 -1
View File
@@ -213,7 +213,7 @@
>
<span>
<p class="w-full md:w-[75%] lg:w-[80%] text-white text-xs lg:text-xl font-normal mb-5">
Thank you for what you do ❤️. You are changing lives, one classroom at a time, thanks to
Thank you for what you do ❤️. You are changing lives one classroom at a time and thanks to
you, the world is a better place. 😊
</p>
<PrimaryButton