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 <ifeanyi@digdippa.com>
This commit is contained in:
committed by
GitHub
co-authored by
emmanuel
parent
b4587483fb
commit
465b2341fd
+3
-3
@@ -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 – The Open Source Platform for Tech Bootcamp</title>
|
||||
<title>ClassroomIO – 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 – The Open Source Platform for Tech Bootcamp" />
|
||||
<meta property="og:title" content="ClassroomIO – 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 – The Open Source Platform for Tech Bootcamp" />
|
||||
<meta name="twitter:title" content="ClassroomIO – 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));
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -189,6 +189,7 @@ export interface Course {
|
||||
id: number;
|
||||
}[];
|
||||
lessons?: Lesson[];
|
||||
polls: { status: string }[];
|
||||
}
|
||||
|
||||
export interface Groupmember {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user