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="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" /> <link rel="stylesheet" href="https://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" <meta name="description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." /> 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="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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" <meta property="og:description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." /> content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." />
<meta property="og:type" content="website" /> <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 property="og:image:secure_url" content="https://classroomio.com/classroomio-opengraph-image.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content="@rotimi_best" /> <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" <meta name="twitter:description"
content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." /> content="Spend less time doing the boring stuffs, instead focus on impacting the lives of your students." />
@@ -1,25 +1,41 @@
<script lang="ts"> <script lang="ts">
import FormSection from './FormSection.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; 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 title = 'Poll';
export let onSubmit = (p: Poll) => {}; export let isSaving = false;
export let onSubmit = (p: PollType) => {};
export let onCancel = () => {}; export let onCancel = () => {};
let poll: Poll = { let poll: PollType = {
id: new Date().getTime().toString(),
courseId: $course.id || '',
question: '', question: '',
authorId: '', expiration: new Date().toDateString(),
isPublic: false, isPublic: false,
expiration: '', status: 'draft',
author: {
id: $profile.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatar_url || ''
},
options: [ options: [
{ {
id: '',
label: '', label: '',
selectedBy: [] selectedBy: []
} }
] ]
}; };
let isCreateDisabled = false;
function handleAddOptions() { function handleAddOptions() {
poll = { poll = {
@@ -27,6 +43,7 @@
options: [ options: [
...poll.options, ...poll.options,
{ {
id: '',
label: '', label: '',
selectedBy: [] selectedBy: []
} }
@@ -34,15 +51,17 @@
}; };
} }
function handleRemoveOptions(index: number) { function handleRemoveOptions(index: string | number) {
return () => { return () => {
poll.options = poll.options.filter((o, i) => i !== index); poll.options = poll.options.filter((_, i) => i !== index);
}; };
} }
function finishPoll() { function finishPoll() {
onSubmit(poll); onSubmit(poll);
} }
$: isCreateDisabled = poll.question === '' || poll.options.length < 2;
</script> </script>
<div class="border rounded-md"> <div class="border rounded-md">
@@ -50,32 +69,53 @@
{title} {title}
</div> </div>
<div class="p-3"> <div class="p-3">
<FormSection <TextField
label="Question" label="Question"
className="w-full mb-3"
bind:value={poll.question} bind:value={poll.question}
handleLabelButton={undefined} placeholder="Poll question"
handleInputButton={undefined} isRequired={true}
/> />
<FormSection <DateTime
label="Expiration" label="Expiration"
className="w-full mb-3"
bind:value={poll.expiration} bind:value={poll.expiration}
type="date" isRequired={true}
handleLabelButton={undefined}
handleInputButton={undefined}
/> />
<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} {#each poll.options as option, index}
<FormSection <div class="flex items-center gap-2 mb-3">
label={index === 0 ? 'Options' : ''} <TextField
bind:value={option.label} label=""
handleLabelButton={handleAddOptions} className="w-full"
handleInputButton={handleRemoveOptions(index)} bind:value={option.label}
/> placeholder="Option Label"
isRequired={true}
/>
<IconButton onClick={handleRemoveOptions(index)} contained={true} size="small">
<TrashCan size={16} />
</IconButton>
</div>
{/each} {/each}
</div> </div>
<div class="w-full flex justify-center mb-3"> <div class="w-full flex justify-center gap-2 mb-3">
<PrimaryButton label="Create poll" onClick={finishPoll} /> <PrimaryButton
label="Create poll"
onClick={finishPoll}
isLoading={isSaving}
isDisabled={isCreateDisabled}
/>
<PrimaryButton label="Cancel" variant={VARIANTS.OUTLINED} onClick={onCancel} /> <PrimaryButton label="Cancel" variant={VARIANTS.OUTLINED} onClick={onCancel} />
</div> </div>
</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 Label from './Label.svelte';
import Avatar from './Avatar.svelte'; import Avatar from './Avatar.svelte';
import type { PollType } from '../types';
import { updatePollStatus } from '../service';
export let poll = {}; export let poll: PollType;
export let onSelect = () => {}; export let onSelect: (a: string) => void;
export let handlePollDelete = () => {}; export let handlePollDelete = () => {};
export let currentUserId; export let currentUserId: number | string;
let viewResult = false; let viewResult = false;
let selectedOptionToView; let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
let isAuthor = false; let isAuthor = false;
let totalVotes = 0; let totalVotes = 0;
let hasUserVoted = false; let isThereAnyVote = false;
function handleSelect(optionIndex) { function getTotalVotes(options: PollType['options']) {
return () => {
onSelect(optionIndex);
};
}
function getSelectedUsers(users) {
return users.map((o) => o.label).join(', ');
}
function getTotalVotes(options) {
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0); 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 totalVoteOfAllOptions = getTotalVotes(options);
const totalVotesOfCurrentOption = currentOption.selectedBy.length; const totalVotesOfCurrentOption = currentOption.selectedBy.length;
return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0; return Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0;
} }
function didUserSelect(option) { function didUserSelect(
return option.selectedBy.find((user) => user.value === currentUserId); 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); $: totalVotes = getTotalVotes(poll.options);
$: { $: {
hasUserVoted = poll.options.some((option) => isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
option.selectedBy.find((u) => u.value === currentUserId)
);
} }
</script> </script>
@@ -59,9 +67,9 @@
</div> </div>
{#each selectedOptionToView.selectedBy as user} {#each selectedOptionToView.selectedBy as user}
<div class="flex items-center mb-2"> <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"> <p class="dark:text-white">
{user.label} {user.fullname}
</p> </p>
</div> </div>
{/each} {/each}
@@ -73,11 +81,7 @@
{poll.question} {poll.question}
</h3> </h3>
<p class="dark:text-white text-sm italic"> <p class="dark:text-white text-sm italic">
Created by <a Created by <span class="text-primary-500">@{poll.author.username}</span>
class="text-primary-500"
href="https://twitter.com/rotimi_best"
target="_blank">@{poll.author.label}</a
>
</p> </p>
</div> </div>
@@ -85,9 +89,14 @@
<div class="flex mb-3 justify-end items-center"> <div class="flex mb-3 justify-end items-center">
<label class="mr-2 font-bold" for="user-select">Status:</label> <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="draft">Draft</option>
<option value="Public">Public</option> <option value="published">Publish</option>
</select> </select>
</div> </div>
{/if} {/if}
@@ -132,7 +141,7 @@
<div class="flex items-center"> <div class="flex items-center">
{#each option.selectedBy.slice(0, 3) as user} {#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} {/each}
</div> </div>
</div> </div>
@@ -140,21 +149,26 @@
{:else} {:else}
{#each poll.options as option, index} {#each poll.options as option, index}
<button <button
class="bg-white dark:bg-black rounded-md border-2 border-gray-500 h-[50px] {didUserSelect( class="bg-white dark:bg-black rounded-md border-2 border-gray-100 h-[50px] {didUserSelect(
option option,
currentUserId
) && ) &&
'focus:border-primary-700 border-primary-700'} text-black p-2 w-full mb-3 text-left relative" 'focus:border-primary-700 border-primary-700'} text-black dark:text-white p-2 w-full mb-3 text-left relative"
on:click={handleSelect(index)} on:click={() => onSelect(option.id)}
> >
{#if hasUserVoted} {#if isThereAnyVote}
<span <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-primary-700'
: 'bg-gray-300'} h-full bg-opacity-25" : 'bg-gray-300'} h-full bg-opacity-25"
style="width: {calculatePercent(poll.options, option)}%;" style="width: {calculatePercent(poll.options, option)}%;"
/> />
{/if} {/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} {option.label}
({calculatePercent(poll.options, option)}%) ({calculatePercent(poll.options, option)}%)
</p> </p>
@@ -172,20 +186,12 @@
{/if} {/if}
{#if isAuthor} {#if isAuthor}
<div class="w-full text-center mt-3"> <div class="w-full flex items-center mt-3">
<button <PrimaryButton
class="px-5 py-3 bg-primary-700 text-white" label={viewResult ? 'Back' : 'Result'}
on:click={() => (viewResult = !viewResult)} onClick={() => (viewResult = !viewResult)}
> />
{#if viewResult} <PrimaryButton label="Delete" variant={VARIANTS.TEXT_DANGER} onClick={handlePollDelete} />
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> </div>
{/if} {/if}
{/if} {/if}
@@ -1,18 +1,34 @@
<script> <script lang="ts">
export let tabs = []; 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> </script>
<div class="mb-3"> <div class="mb-3 flex items-center justify-between">
{#each tabs as tab} <div>
<button {#each tabs as tab}
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value <button
? 'border-b-4 border-indigo-600 focus:border-indigo-600' class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
: 'border-b-0'}" ? 'border-b-4 border-indigo-600 focus:border-indigo-600'
on:click={() => (selectedTab = tab.value)} : 'border-b-0'}"
> on:click={() => (selectedTab = tab.value)}
{tab.label} >
</button> {tab.label}
{/each} <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> </div>
@@ -1,152 +1,251 @@
<script lang="ts"> <script lang="ts">
import PageNav from '$lib/components/PageNav/index.svelte'; import PageNav from '$lib/components/PageNav/index.svelte';
import CloseButton from '$lib/components/Buttons/Close/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 CreatePollForm from './components/CreatePollForm.svelte';
import Poll from './components/Poll.svelte'; import Poll from './components/Poll.svelte';
import Tabs from './components/Tabs.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 = () => {}; export let handleClose = () => {};
const users = [ const supabase = getSupabase();
{
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
}
];
let selectedTab = 0; let selectedTab = 0;
let userId = 'satoshi-nakamoto'; let currentGroupMember: Groupmember | undefined;
let shouldCreatePoll = false; let openCreatePollForm = false;
let polls = [ let isCreating = false;
{ let isLoading = false;
question: 'Who is the creator of Svelte?', let author: PollType['author'] = {
author: users[0], id: '',
status: 'draft', // public username: '',
expiration: new Date().toDateString(), fullname: '',
options: [ avatarUrl: ''
{ };
label: 'Dan Abrahmov', let pollSubmissionsChannel: RealtimeChannel;
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: []
}
]
}
];
function handleCreatePoll() { let tabs: TabsType = [];
shouldCreatePoll = !shouldCreatePoll; let activePolls: PollType[] = [];
let expiredPolls: PollType[] = [];
function setCoursePolls() {
$course.polls = $polls.map((p) => ({ status: p.status }));
} }
function handlePollCreate(poll) { async function createPoll(poll: PollType) {
if (poll) { if (!currentGroupMember || !$course.id) return;
polls = [
{ 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, ...poll,
author: { options: poll.options.map((option) => {
label: 'Satoshi Nakamoto', if (option.id === newVote.poll_option_id) {
value: 'satoshi-nakamoto', if (option.selectedBy.some((s) => s.id === newVote.selected_by_id)) {
avatar: 'https://i.pravatar.cc/150?img=4' return option;
}
},
...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);
} }
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> </script>
<PageNav title="Poll" overidableStyle="padding: 0 10px" paddingClass="w-full"> <PageNav title="Poll" overidableStyle="padding: 0 10px" paddingClass="w-full">
@@ -155,46 +254,35 @@
</div> </div>
</PageNav> </PageNav>
<div class="m-2 overlow-y-auto md:min-w-[350px] w-full"> <div class="p-2 overlow-y-auto md:max-w-[350px] md:min-w-[340px] w-full">
{#if shouldCreatePoll} {#if openCreatePollForm}
<CreatePollForm onSubmit={handlePollCreate} onCancel={handleCreatePoll} /> <CreatePollForm
{:else} onSubmit={handlePollCreate}
onCancel={() => (openCreatePollForm = !openCreatePollForm)}
bind:isSaving={isCreating}
/>
{:else if currentGroupMember}
<div> <div>
<Tabs {tabs} bind:selectedTab /> <Tabs {tabs} bind:selectedTab onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
{#if selectedTab === tabs[0].value} {#if isLoading}
<div class="w-full flex justify-center mb-3"> Loading...
<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}
{:else} {:else}
<div {#each selectedTab === tabs[0].value ? activePolls : expiredPolls as poll}
class="bg-gray-100 dark:bg-neutral-800 border rounded-md h-60 flex items-center justify-center" <Poll
> bind:poll
<h2 class="text-xl font-bold">No polls to display</h2> onSelect={handleVote(poll.id, currentGroupMember?.id || '', author)}
</div> handlePollDelete={handlePollDelete(poll.id)}
{/each} 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> </div>
{/if} {/if}
</div> </div>
@@ -1,87 +1,141 @@
import { snackbar } from '$lib/components/Snackbar/store'; 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 { getSupabase } from '$lib/utils/functions/supabase';
import type { GroupPerson } from '$lib/utils/types'; import { polls } from './store';
const supabase = getSupabase(); 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) { export async function fetchPolls(courseId: string) {
const {} = await supabase return await supabase
.from('app_poll') .from('apps_poll')
.select( .select(
` `
id, *,
question, created_at,
isPublic, author:groupmember(
status, profile(
expiration username,
groupmember:authorId(
profile!inner(
id,
fullname, fullname,
avatar_url avatar_url
) )
) ),
apps_poll_option!inner( options: apps_poll_option (
id, id,
label, label,
submissions:apps_poll_submission(
selectedBy:groupmember(
id,
profile(
username,
fullname,
avatar_url
)
)
)
) )
` `
) )
.match({ .match({ courseId })
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 = { export type PollOptionType = {
question: string; id: string;
authorId: string; // groupmember_id label: string;
isPublic: boolean; selectedBy: {
expiration: string | Date; id: string;
options: { username: string;
label: string; fullname: string;
selectedBy: { avatarUrl: string;
id: 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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import hotkeys from 'hotkeys-js';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte'; import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte'; import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
@@ -15,6 +14,7 @@
import Poll from './components/Poll/index.svelte'; import Poll from './components/Poll/index.svelte';
import APPS_CONSTANTS from './constants'; import APPS_CONSTANTS from './constants';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { course } from '$lib/components/Course/store';
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons'; import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
let appBarRef: HTMLDivElement; let appBarRef: HTMLDivElement;
@@ -55,24 +55,6 @@
onMount(() => { onMount(() => {
updateTopPadding($apps.isStudent); 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); $: updateTopPadding($apps.isStudent);
@@ -88,10 +70,8 @@
<div class="lg:hidden mb-2"> <div class="lg:hidden mb-2">
<IconButton <IconButton
buttonClassName="lg:hidden" buttonClassName="lg:hidden"
toolTipProps={{ title: 'Settings', hotkeys: ['A', '0'] }} toolTipProps={{ title: 'Settings', hotkeys: [] }}
onClick={() => { onClick={handleAppClick}
handleAppClick();
}}
> >
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" /> <Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
</IconButton> </IconButton>
@@ -99,24 +79,28 @@
<div class="mb-2 relative {getAppClass(APPS_CONSTANTS.APPS.COMMENTS, $apps.selectedApp)}"> <div class="mb-2 relative {getAppClass(APPS_CONSTANTS.APPS.COMMENTS, $apps.selectedApp)}">
<IconButton <IconButton
toolTipProps={{ title: 'Comments', hotkeys: ['A', '1'] }} toolTipProps={{ title: 'Comments', hotkeys: ['Ctrl/Command', '1'] }}
value={APPS_CONSTANTS.APPS.COMMENTS} value={APPS_CONSTANTS.APPS.COMMENTS}
onClick={handleAppClick} onClick={handleAppClick}
buttonClassName="relative" buttonClassName="relative"
> >
<Chip <Chip
value={$lesson.totalComments} 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" /> <SendAlt size={24} class="carbon-icon dark:text-white" />
</IconButton> </IconButton>
</div> </div>
<div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}"> <div class="mb-2 {getAppClass(APPS_CONSTANTS.APPS.POLL, $apps.selectedApp)}">
<IconButton <IconButton
toolTipProps={{ title: 'Poll', hotkeys: ['A', '2'] }} toolTipProps={{ title: 'Poll', hotkeys: ['Ctrl/Command', '2'] }}
value={APPS_CONSTANTS.APPS.POLL} value={APPS_CONSTANTS.APPS.POLL}
onClick={handleAppClick} 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" /> <ChartPie size={24} class="carbon-icon dark:text-white" />
</IconButton> </IconButton>
</div> </div>
@@ -378,8 +378,8 @@
</Tabs> </Tabs>
{:else if !isMaterialsEmpty($lesson.materials)} {:else if !isMaterialsEmpty($lesson.materials)}
<div class="w-full"> <div class="w-full">
{#each componentsToRender as Component, index} {#each componentsToRender as Component}
<svelte:component this={Component} key={index} /> <svelte:component this={Component} />
{/each} {/each}
</div> </div>
{:else} {:else}
@@ -24,7 +24,10 @@ export function generateQuestion(questions) {
return generatedQuestions; return generatedQuestions;
} }
export function getIsLessonComplete(completions: LessonCompletion[], profileId: string | null) { export function getIsLessonComplete(
completions: LessonCompletion[],
profileId: string | undefined
) {
if (!Array.isArray(completions)) return false; if (!Array.isArray(completions)) return false;
return completions.find((c) => { return completions.find((c) => {
return c.is_complete && c.profile_id === profileId; return c.is_complete && c.profile_id === profileId;
@@ -2,7 +2,6 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import hotkeys from 'hotkeys-js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte'; import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte'; import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
@@ -32,21 +31,20 @@
let isLessonActive: boolean = false; let isLessonActive: boolean = false;
let resize = false; let resize = false;
let isDragging = false; let isDragging = false;
let startX; let startX: number;
let initialWidth; let initialWidth: number;
let sidebarRef; let sidebarRef: HTMLElement;
let menuContentRef; let menuContentRef: HTMLUListElement;
const toggleSidebar = () => { const toggleSidebar = (defaultValue?: boolean) => {
if ($isMobile) { $sideBar.hidden = defaultValue ?? !$sideBar.hidden;
$sideBar.hidden = !$sideBar.hidden;
}
}; };
const toggleSidebarOnMobile = () => $isMobile && toggleSidebar();
function handleMainGroupClick(href: string) { function handleMainGroupClick(href: string) {
return () => { return () => {
goto(href); goto(href);
toggleSidebar(); toggleSidebarOnMobile();
}; };
} }
@@ -57,7 +55,7 @@
else show = true; else show = true;
} }
function handleCursor(event) { function handleCursor(event: MouseEvent) {
if (!resize && sidebarRef) { if (!resize && sidebarRef) {
const isNearLeftBorder = event.clientX - sidebarRef.getBoundingClientRect().left < 8; const isNearLeftBorder = event.clientX - sidebarRef.getBoundingClientRect().left < 8;
const isNearRightBorder = sidebarRef.getBoundingClientRect().right - event.clientX < 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) { if (event.button === 0 && sidebarRef) {
event.preventDefault(); event.preventDefault();
@@ -95,7 +93,7 @@
resize = false; resize = false;
} }
function dragSidebar(event) { function dragSidebar(event: MouseEvent) {
if (!(window.innerWidth >= 1024)) return; if (!(window.innerWidth >= 1024)) return;
if (!isDragging) return; if (!isDragging) return;
@@ -119,36 +117,21 @@
} }
onMount(() => { onMount(() => {
if (window.innerWidth >= 1024) { if (!$isMobile) {
sidebarRef.addEventListener('mousedown', startDragging); sidebarRef.addEventListener('mousedown', startDragging);
document.addEventListener('mousemove', dragSidebar); document.addEventListener('mousemove', dragSidebar);
document.addEventListener('mouseup', stopDragging); document.addEventListener('mouseup', stopDragging);
document.addEventListener('mousemove', handleCursor); document.addEventListener('mousemove', handleCursor);
} }
if (browser) { toggleSidebar(false);
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;
}
});
}); });
onDestroy(() => { onDestroy(() => {
if (!browser) { if (!browser) {
return; return;
} }
if (window.innerWidth >= 1024) { if (!$isMobile) {
sidebarRef.removeEventListener('mousedown', startDragging); sidebarRef.removeEventListener('mousedown', startDragging);
document.removeEventListener('mousemove', dragSidebar); document.removeEventListener('mousemove', dragSidebar);
document.removeEventListener('mouseup', stopDragging); document.removeEventListener('mouseup', stopDragging);
@@ -158,12 +141,6 @@
$: handleMobileChange($isMobile); $: handleMobileChange($isMobile);
$: {
if (browser) {
localStorage.setItem('hideCourseNav', `${!show}`);
}
}
$: isLessonActive = $page.url.pathname.includes('/lessons'); $: isLessonActive = $page.url.pathname.includes('/lessons');
$: { $: {
@@ -273,7 +250,7 @@
href={isStudent && !item.is_unlocked href={isStudent && !item.is_unlocked
? $page.url.pathname ? $page.url.pathname
: getLessonsRoute($course.id, item.id)} : getLessonsRoute($course.id, item.id)}
on:click={toggleSidebar} on:click={toggleSidebarOnMobile}
aria-disabled={!item.is_unlocked} aria-disabled={!item.is_unlocked}
> >
<div <div
+3 -1
View File
@@ -17,6 +17,7 @@ export const defaultCourse: Course = {
created_at: new Date().toDateString(), created_at: new Date().toDateString(),
updated_at: new Date().toDateString(), updated_at: new Date().toDateString(),
attendance: [], attendance: [],
polls: [],
metadata: { metadata: {
requirements: '', requirements: '',
description: '', description: '',
@@ -151,7 +152,8 @@ export async function setCourse(data: Course, setLesson = true) {
coursesNo: 0, coursesNo: 0,
description: '', description: '',
imgUrl: '' imgUrl: ''
} },
allowNewStudent: false
}; };
} }
@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { Moon } from 'svelte-loading-spinners'; import { Moon } from 'svelte-loading-spinners';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import Navigation from '../Course/components/Navigation/index.svelte'; import Navigation from '../Course/components/Navigation/index.svelte';
@@ -36,6 +36,12 @@
prevCourseId = courseId; prevCourseId = courseId;
} }
function filterPollsByStatus(shouldFilter: boolean) {
if (!shouldFilter) return;
$course.polls = $course.polls.filter((poll) => poll.status === 'published');
}
$: onCourseIdChange(courseId); $: onCourseIdChange(courseId);
$: { $: {
@@ -44,6 +50,8 @@
if (user) { if (user) {
isStudent = user.role_id === 3; isStudent = user.role_id === 3;
$apps.isStudent = isStudent; $apps.isStudent = isStudent;
filterPollsByStatus(isStudent);
} }
} }
</script> </script>
+12 -7
View File
@@ -1,19 +1,24 @@
<script> <script lang="ts">
export let label; export let label = '';
export let placeholder; export let placeholder = '';
export let value; export let value: string | Date = '';
export let className = ''; export let className = '';
export let onInput = () => {}; export let onInput = () => {};
$: console.log(`value`, value); export let isRequired: boolean = false;
</script> </script>
<label class="block {className}"> <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 <input
bind:value bind:value
on:input={onInput} on:input={onInput}
{placeholder} {placeholder}
type="datetime-local" 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> </label>
+9 -7
View File
@@ -23,17 +23,19 @@
export let helperMessage = ''; export let helperMessage = '';
export let autoComplete = true; export let autoComplete = true;
export let onChange = () => {}; // This is to know if element is 'dirty' export let onChange = () => {}; // This is to know if element is 'dirty'
export let onInputChange = (e) => {}; export let onInputChange = () => {};
let ref; let ref: HTMLInputElement | undefined = undefined;
let fieldNode; let fieldNode: HTMLInputElement | undefined = undefined;
let focusClass = ''; let focusClass = '';
function typeAction(node) { function typeAction(node: HTMLInputElement | undefined) {
node.type = type; if (!node) return;
node.type = type || 'text';
if (isRequired) { if (isRequired) {
node.required = ''; node.required = true;
} }
fieldNode = node; fieldNode = node;
@@ -45,7 +47,7 @@
} }
onMount(() => { onMount(() => {
if (autoFocus) { if (autoFocus && ref) {
ref.focus(); ref.focus();
} }
@@ -14,17 +14,17 @@
<section class="h-full"> <section class="h-full">
<p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Currently Learning</p> <p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Currently Learning</p>
<div <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} {#if $coursesInProgress.length > 0}
<div class="w-full h-full flex flex-col justify-start overflow-y-auto"> <div class="w-full h-full flex flex-col justify-start overflow-y-auto">
{#each $coursesInProgress as course} {#each $coursesInProgress as course}
<div class="p-5"> <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 <img
src={course.logo || '/images/classroomio-course-img-template.jpg'} src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt="course" 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"> <div class="w-full">
<p class="text-base font-semibold dark:text-white">{course.title}</p> <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.message = message;
store.severity = SNACKBAR_SEVERITY.ERROR; 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; 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), profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete) lesson_completion(id, profile_id, is_complete)
), ),
attendance:group_attendance(*) attendance:group_attendance(*),
polls:apps_poll(status)
` `
) )
.match(match) .match(match)
+1
View File
@@ -189,6 +189,7 @@ export interface Course {
id: number; id: number;
}[]; }[];
lessons?: Lesson[]; lessons?: Lesson[];
polls: { status: string }[];
} }
export interface Groupmember { export interface Groupmember {
+2 -2
View File
@@ -7,7 +7,7 @@
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase } from '$lib/utils/functions/supabase';
import { validateEmail } from '$lib/utils/functions/validateEmail'; import { validateEmail } from '$lib/utils/functions/validateEmail';
let email; let email = '';
let isAdding = false; let isAdding = false;
let success = false; let success = false;
@@ -42,7 +42,7 @@
setTimeout(() => { setTimeout(() => {
isAdding = false; isAdding = false;
success = false; success = false;
email = null; email = '';
}, 5000); }, 5000);
} }
</script> </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 Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte'; import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import MODES from '$lib/utils/constants/mode'; 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 Download from 'carbon-icons-svelte/lib/Download.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte'; import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import { apps } from '$lib/components/Apps/store'; import { apps } from '$lib/components/Apps/store';
@@ -72,23 +72,24 @@
isFetching = false; isFetching = false;
} }
async function markLessonComplete() { async function markLessonComplete(lessonId: string) {
let found = false; let found = false;
isMarkingComplete = true; isMarkingComplete = true;
let completion: LessonCompletion = { let completion: LessonCompletion = {
is_complete: true, is_complete: true,
profile_id: $profile.id || '', profile_id: $profile.id || '',
lesson_id: $lesson.id || '', lesson_id: lessonId,
created_at: new Date().toDateString(), created_at: new Date().toDateString(),
updated_at: new Date().toDateString() updated_at: new Date().toDateString()
}; };
const completions: LessonCompletion[] = $lesson.lesson_completion.map((_completion) => { const completions: LessonCompletion[] = $lesson.lesson_completion.map((_completion) => {
if (_completion.profile_id === $profile.id) { if (_completion.profile_id === $profile.id) {
found = true; found = true;
_completion.is_complete = !_completion.is_complete; completion = {
...completion,
completion = _completion; is_complete: _completion.is_complete
};
} }
return _completion; return _completion;
@@ -324,7 +325,11 @@
<SendAlt size={24} class="carbon-icon" /> <SendAlt size={24} class="carbon-icon" />
<span class="ml-1">{$lesson.totalComments}</span> <span class="ml-1">{$lesson.totalComments}</span>
</button> </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)} {#if getIsLessonComplete($lesson.lesson_completion, $profile.id)}
<CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600" /> <CheckmarkFilledIcon size={24} class="carbon-icon text-primary-600" />
{:else} {: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" class="w-28 md:block md:w-1/3 lg:w-[275px] lg:h-[205px] mb-3 md:mb-0"
/> />
</div> </div>
<section class="flex w-full flex-col md:flex-row md:gap-5"> <section class="flex w-full flex-col lg:flex-row md:gap-5">
<div class="w-full md:w-[50%] mt-10 xl:mt-2"> <div class="w-full lg:w-[50%] mt-10 xl:mt-2">
<Learning /> <Learning />
</div> </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> <p class="text-base font-semibold text-[#040F2D] pb-3 dark:text-white">Your Progress</p>
<div <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 <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> <div>
<img src="/images/target.svg" alt="student Learning score" /> <img src="/images/target.svg" alt="student Learning score" />
@@ -100,7 +100,7 @@
{/if} {/if}
</span> </span>
<h1 <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} % {progressPercentage} %
</h1> </h1>
+1 -1
View File
@@ -213,7 +213,7 @@
> >
<span> <span>
<p class="w-full md:w-[75%] lg:w-[80%] text-white text-xs lg:text-xl font-normal mb-5"> <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. 😊 you, the world is a better place. 😊
</p> </p>
<PrimaryButton <PrimaryButton