feat: 🚀 😃 convert repo into a monorepo 🚀 😃 (#51)
* Merge main into turbo-monrepo * feat: rename apps * feat: remove supbase videos and serve all from project * feat: move supabase to root * feat: update README * docs: update link
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import type {
|
||||
RealtimeChannel,
|
||||
RealtimePostgresChangesPayload,
|
||||
PostgrestSingleResponse
|
||||
} from '@supabase/supabase-js';
|
||||
import type { LessonCommentInsertPayload, GroupPerson, LessonComment } from '$lib/utils/types';
|
||||
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
|
||||
export let handleClose = () => {};
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
let comment = '';
|
||||
let bodyRef: HTMLDivElement;
|
||||
let lessonCommentChannel: RealtimeChannel;
|
||||
let comments: LessonComment[] = [];
|
||||
let groupmember: GroupPerson | undefined;
|
||||
let isSaving: boolean = false;
|
||||
let isFetched = false;
|
||||
|
||||
interface FetchComments {
|
||||
comment: string;
|
||||
created_at: string;
|
||||
groupmember: {
|
||||
id: string;
|
||||
profile: {
|
||||
avatar_url: string;
|
||||
fullname: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
console.log('groupmember', groupmember);
|
||||
if (!comment || !groupmember) {
|
||||
return;
|
||||
}
|
||||
|
||||
comments = [
|
||||
...comments,
|
||||
{
|
||||
comment: comment,
|
||||
name: 'You',
|
||||
avatar: $profile.avatar_url,
|
||||
commentAt: new Date()
|
||||
}
|
||||
];
|
||||
$lesson.totalComments = comments.length;
|
||||
scrollToBottom();
|
||||
|
||||
isSaving = true;
|
||||
supabase
|
||||
.from('lesson_comment')
|
||||
.insert({
|
||||
lesson_id: $lesson.id,
|
||||
groupmember_id: groupmember.id,
|
||||
comment
|
||||
})
|
||||
.then(() => {
|
||||
isSaving = false;
|
||||
comment = '';
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function handleInsert(payload: RealtimePostgresChangesPayload<LessonCommentInsertPayload>) {
|
||||
const insertedComment = payload.new as LessonCommentInsertPayload;
|
||||
|
||||
if (groupmember && groupmember.id === insertedComment.groupmember_id) {
|
||||
console.log('is my comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
error
|
||||
}: PostgrestSingleResponse<{
|
||||
id: string;
|
||||
profile: {
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}> = await supabase
|
||||
.from('groupmember')
|
||||
.select('id, profile:profile_id(fullname, avatar_url)')
|
||||
.eq('id', insertedComment.groupmember_id)
|
||||
.single();
|
||||
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
comments = [
|
||||
...comments,
|
||||
{
|
||||
comment: insertedComment.comment,
|
||||
name: data.profile.fullname,
|
||||
avatar: data.profile.avatar_url,
|
||||
commentAt: insertedComment.created_at
|
||||
}
|
||||
];
|
||||
$lesson.totalComments = comments.length;
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
async function fetchComments(people: GroupPerson[], lessonId?: string | null) {
|
||||
groupmember = people.find((person) => person.profile_id === $profile.id);
|
||||
|
||||
if (!groupmember || !lessonId) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('lesson_comment')
|
||||
.select(
|
||||
`
|
||||
groupmember_id,
|
||||
created_at,
|
||||
comment,
|
||||
groupmember:groupmember_id(
|
||||
id,
|
||||
profile:profile_id(fullname, avatar_url)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({
|
||||
lesson_id: lessonId
|
||||
})
|
||||
.returns<FetchComments[]>();
|
||||
|
||||
if (error || !data) {
|
||||
console.error('error');
|
||||
return;
|
||||
}
|
||||
console.log('fetch comments', data);
|
||||
|
||||
comments = data.map((lessonComment) => {
|
||||
return {
|
||||
comment: lessonComment.comment,
|
||||
avatar: lessonComment.groupmember.profile.avatar_url,
|
||||
commentAt: lessonComment.created_at,
|
||||
name:
|
||||
lessonComment.groupmember.id === groupmember?.id
|
||||
? 'You'
|
||||
: lessonComment.groupmember.profile.fullname
|
||||
};
|
||||
});
|
||||
isFetched = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
lessonCommentChannel = supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'lesson_comment' },
|
||||
handleInsert
|
||||
)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
supabase.removeChannel(lessonCommentChannel);
|
||||
});
|
||||
|
||||
$: !isFetched && fetchComments($group.people, $lesson.id);
|
||||
</script>
|
||||
|
||||
<PageNav title="Comments" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="overflow-y-auto h-[87%] pb-10 px-2 md:min-w-[350px] w-full">
|
||||
{#each comments as comment}
|
||||
<div class="pb-2 mt-2">
|
||||
<div class="flex items-start">
|
||||
<img
|
||||
alt="Placeholder"
|
||||
class="block rounded-full"
|
||||
width="24"
|
||||
height="20"
|
||||
src={comment.avatar}
|
||||
/>
|
||||
<div class="ml-2">
|
||||
<p class="dark:text-white text-sm font-bold">{comment.name}</p>
|
||||
<p class="dark:text-white text-xs font-normal text-gray-800">
|
||||
{calDateDiff(comment.commentAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<article class="prose prose-sm sm:prose ml-8 max-w-[300px] dark:text-white">
|
||||
{comment.comment}
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField
|
||||
placeholder="Say something"
|
||||
bind:value={comment}
|
||||
onKeyDown={handleKeyDown}
|
||||
isDisabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
|
||||
export let handleClose;
|
||||
</script>
|
||||
|
||||
<PageNav title="Note" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
export let name;
|
||||
export let src;
|
||||
export let className;
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="w-10 h-10 rounded-full bg-primary-100 {className}" />
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
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 isSaving = false;
|
||||
export let onSubmit = (p: PollType) => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let poll: PollType = {
|
||||
id: new Date().getTime().toString(),
|
||||
courseId: $course.id || '',
|
||||
question: '',
|
||||
expiration: new Date().toDateString(),
|
||||
isPublic: false,
|
||||
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 = {
|
||||
...poll,
|
||||
options: [
|
||||
...poll.options,
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
selectedBy: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function handleRemoveOptions(index: string | number) {
|
||||
return () => {
|
||||
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">
|
||||
<div class="border-b-2 p-4 text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<TextField
|
||||
label="Question"
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.question}
|
||||
placeholder="Poll question"
|
||||
isRequired={true}
|
||||
/>
|
||||
<DateTime
|
||||
label="Expiration"
|
||||
className="w-full mb-3"
|
||||
bind:value={poll.expiration}
|
||||
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}
|
||||
<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 gap-2 mb-3">
|
||||
<PrimaryButton
|
||||
label="Create poll"
|
||||
onClick={finishPoll}
|
||||
isLoading={isSaving}
|
||||
isDisabled={isCreateDisabled}
|
||||
/>
|
||||
<PrimaryButton label="Cancel" variant={VARIANTS.OUTLINED} onClick={onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<p
|
||||
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,204 @@
|
||||
<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: PollType;
|
||||
export let onSelect: (a: string) => void;
|
||||
export let handlePollDelete = () => {};
|
||||
export let currentUserId: number | string;
|
||||
|
||||
let viewResult = false;
|
||||
let selectedOptionToView: { selectedBy: PollType['options'][0]['selectedBy'] } | null;
|
||||
let isAuthor = false;
|
||||
let totalVotes = 0;
|
||||
let isThereAnyVote = false;
|
||||
|
||||
function getTotalVotes(options: PollType['options']) {
|
||||
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
|
||||
}
|
||||
|
||||
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: { selectedBy: PollType['options'][0]['selectedBy'] },
|
||||
userId: number | string
|
||||
) {
|
||||
return option.selectedBy.find((user) => user.id === userId);
|
||||
}
|
||||
|
||||
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);
|
||||
$: {
|
||||
isThereAnyVote = poll?.options?.some((option) => option.selectedBy.length) || false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 flex-wrap bg-white dark:bg-black border-2 rounded-md mb-3 w-full">
|
||||
{#if selectedOptionToView}
|
||||
<div>
|
||||
<div class="flex items-center mb-5">
|
||||
<button class="text-xl w-10 px-3" on:click={() => (selectedOptionToView = null)}>
|
||||
{`<`}
|
||||
</button>
|
||||
<div class="flex-grow text-center">
|
||||
<h3 class="dark:text-white font-bold">Voted</h3>
|
||||
</div>
|
||||
</div>
|
||||
{#each selectedOptionToView.selectedBy as user}
|
||||
<div class="flex items-center mb-2">
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
<p class="dark:text-white">
|
||||
{user.fullname}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2 mb-3 justify-between">
|
||||
<div>
|
||||
<h3 class="dark:text-white font-bold">
|
||||
{poll.question}
|
||||
</h3>
|
||||
<p class="dark:text-white text-sm italic">
|
||||
Created by <span class="text-primary-500">@{poll.author.username}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAuthor}
|
||||
<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"
|
||||
class="dark:text-white dark:bg-black"
|
||||
bind:value={poll.status}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Publish</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if viewResult}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-5">
|
||||
<Label>Expiration</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{poll.expiration || 'Today'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<Label>Total Votes</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>Responses</Label>
|
||||
{#each poll.options as option}
|
||||
<div class="border-b p-5">
|
||||
<p class="dark:text-white">
|
||||
{option.label}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-4/5 h-2 bg-gray-200 relative">
|
||||
<span
|
||||
class="absolute top-0 left-0 bg-primary-700 h-full"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
</div>
|
||||
{#if option.selectedBy.length}
|
||||
<button class="text-xl px-3 ml-5" on:click={() => (selectedOptionToView = option)}>
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#each option.selectedBy.slice(0, 3) as user}
|
||||
<Avatar src={user.avatarUrl} name={user.username} className="mr-2" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each poll.options as option, index}
|
||||
<button
|
||||
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 dark:text-white p-2 w-full mb-3 text-left relative"
|
||||
on:click={() => onSelect(option.id)}
|
||||
>
|
||||
{#if isThereAnyVote}
|
||||
<span
|
||||
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 {didUserSelect(option, currentUserId)
|
||||
? 'text-white'
|
||||
: 'dark:text-white text-black'} left-2"
|
||||
>
|
||||
{option.label}
|
||||
({calculatePercent(poll.options, option)}%)
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Expires: {poll.expiration || 'Today'}
|
||||
</p>
|
||||
<span class="mx-2">|</span>
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Voted: {totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor}
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
transition: width 0.5s linear;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<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 onCreate: () => void;
|
||||
export let tabs: TabsType = [];
|
||||
|
||||
export let selectedTab: number;
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
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 supabase = getSupabase();
|
||||
|
||||
let selectedTab = 0;
|
||||
let currentGroupMember: Groupmember | undefined;
|
||||
let openCreatePollForm = false;
|
||||
let isCreating = false;
|
||||
let isLoading = false;
|
||||
let author: PollType['author'] = {
|
||||
id: '',
|
||||
username: '',
|
||||
fullname: '',
|
||||
avatarUrl: ''
|
||||
};
|
||||
let pollSubmissionsChannel: RealtimeChannel;
|
||||
|
||||
let tabs: TabsType = [];
|
||||
let activePolls: PollType[] = [];
|
||||
let expiredPolls: PollType[] = [];
|
||||
|
||||
function setCoursePolls() {
|
||||
$course.polls = $polls.map((p) => ({ status: p.status }));
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
selectedBy: [
|
||||
...option.selectedBy,
|
||||
{
|
||||
id: newVote.selected_by_id,
|
||||
username: data.profile.username,
|
||||
fullname: data.profile.fullname,
|
||||
avatarUrl: data.profile.avatar_url
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
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">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<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 onCreate={() => (openCreatePollForm = !openCreatePollForm)} />
|
||||
|
||||
{#if isLoading}
|
||||
Loading...
|
||||
{:else}
|
||||
{#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>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { PollType, PollOptionType, FetchPollsResponse } from './types';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { polls } from './store';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
export async function fetchPolls(courseId: string) {
|
||||
return await supabase
|
||||
.from('apps_poll')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
created_at,
|
||||
author:groupmember(
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
),
|
||||
options: apps_poll_option (
|
||||
id,
|
||||
label,
|
||||
submissions:apps_poll_submission(
|
||||
selectedBy:groupmember(
|
||||
id,
|
||||
profile(
|
||||
username,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.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([]);
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
isPublic: boolean;
|
||||
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));
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
<script>
|
||||
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
|
||||
export let handleClose;
|
||||
let bodyRef;
|
||||
let question;
|
||||
|
||||
let questions = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Evgeniy',
|
||||
question: "I'am looking at this and it looks great",
|
||||
vote: 1,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sergey Semko',
|
||||
question:
|
||||
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
|
||||
vote: 4,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Vitalii Marushko',
|
||||
question: 'Всем привет',
|
||||
vote: 20,
|
||||
answered: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Natasha',
|
||||
question: 'Привет Виталий',
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
questions = [
|
||||
...questions,
|
||||
{
|
||||
id: questions.length,
|
||||
name: 'You',
|
||||
question,
|
||||
vote: 0,
|
||||
answered: false
|
||||
}
|
||||
];
|
||||
|
||||
question = null;
|
||||
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleMarkAnswered(id) {
|
||||
return () => {
|
||||
questions = questions
|
||||
.map((question) => {
|
||||
if (question.id === id) {
|
||||
question.answered = !question.answered;
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
.sort((a, b) => Number(a.answered) - Number(b.answered));
|
||||
};
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Q/A" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="body">
|
||||
{#each questions as question}
|
||||
<div class="flex items-start ml-2 mb-2 mt-2">
|
||||
<Vote bind:value={question.vote} />
|
||||
<div class="ml-2 flex-grow">
|
||||
<p class="dark:text-white text-sm font-bold">{question.name}</p>
|
||||
<article class="prose prose-sm sm:prose">
|
||||
{question.question}
|
||||
</article>
|
||||
</div>
|
||||
<button class={`${question.answered && 'active'}`} on:click={handleMarkAnswered(question.id)}>
|
||||
<CheckmarkOutline size={20} class="carbon-icon dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField placeholder="Say something" bind:value={question} onKeyDown={handleKeyDown} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.body {
|
||||
overflow: auto;
|
||||
height: 80%;
|
||||
|
||||
& button {
|
||||
display: none;
|
||||
}
|
||||
& div:hover button,
|
||||
& button.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
APPS: {
|
||||
QANDA: 'QANDA',
|
||||
NOTES: 'NOTES',
|
||||
COMMENTS: 'COMMENTS',
|
||||
POLL: 'POLL'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
|
||||
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import { apps } from './store';
|
||||
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import QandA from './components/QandA/index.svelte';
|
||||
import Comments from './components/Comments/index.svelte';
|
||||
import Notes from './components/Notes/index.svelte';
|
||||
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;
|
||||
let appContentRef: HTMLDivElement;
|
||||
let topPadding = $apps.isStudent ? '48px' : '109px';
|
||||
|
||||
const getResizableSidebar = () => {
|
||||
if (!browser) return;
|
||||
|
||||
return window.innerWidth >= 1024;
|
||||
};
|
||||
|
||||
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.dropdown = !$apps.dropdown;
|
||||
}
|
||||
|
||||
function updateTopPadding(isStudent: boolean) {
|
||||
topPadding = isStudent ? '48px' : '109px';
|
||||
}
|
||||
|
||||
function getAppClass(appName: string, selected: string | undefined) {
|
||||
return `transition duration-500 delay-150 ease-in-out border-t-0 border-b-0 border-r-0 ${
|
||||
appName === selected ? 'border-l-4 border-indigo-600 focus:border-indigo-600' : 'border-l-0'
|
||||
}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTopPadding($apps.isStudent);
|
||||
});
|
||||
|
||||
$: updateTopPadding($apps.isStudent);
|
||||
$: getResizableSidebar();
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={`--top-padding:${topPadding}`}
|
||||
class={`${$apps.open ? 'open ' : 'close'} root bg-white dark:bg-black`}
|
||||
bind:this={appBarRef}
|
||||
>
|
||||
<div class="apps">
|
||||
<div class="lg:hidden mb-2">
|
||||
<IconButton
|
||||
buttonClassName="lg:hidden"
|
||||
toolTipProps={{ title: 'Settings', hotkeys: [] }}
|
||||
onClick={handleAppClick}
|
||||
>
|
||||
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 relative {getAppClass(APPS_CONSTANTS.APPS.COMMENTS, $apps.selectedApp)}">
|
||||
<IconButton
|
||||
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-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: ['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>
|
||||
<!-- <div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'QandA', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.QANDA}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.QANDA === $apps.selectedApp}
|
||||
>
|
||||
<Forum size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Notes', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.NOTES}
|
||||
onClick={handleAppClick}
|
||||
contained={APPS_CONSTANTS.APPS.NOTES === $apps.selectedApp}
|
||||
>
|
||||
<AlignBoxTopLeft size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
{#if !!$apps.selectedApp}
|
||||
<div
|
||||
class="app max-w-[350px] transition ease-in-out delay-150 duration-100"
|
||||
bind:this={appContentRef}
|
||||
>
|
||||
{#key $apps.selectedApp}
|
||||
<div class="h-full w-full" transition:fade={{ duration: 200 }}>
|
||||
{#if $apps.selectedApp === APPS_CONSTANTS.APPS.QANDA}
|
||||
<QandA {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.COMMENTS}
|
||||
<Comments {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.POLL}
|
||||
<Poll {handleClose} />
|
||||
{:else if $apps.selectedApp === APPS_CONSTANTS.APPS.NOTES}
|
||||
<Notes {handleClose} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1023px) {
|
||||
.app {
|
||||
width: 50vw;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.app {
|
||||
width: 100vw;
|
||||
min-width: calc(100vw - 60px);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.root {
|
||||
position: absolute;
|
||||
height: calc(100vh - var(--top-padding));
|
||||
top: var(--top-padding);
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
.open {
|
||||
position: absolute;
|
||||
transform: translateX(0);
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
transform: translateX(100%);
|
||||
display: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.root {
|
||||
height: calc(100vh - 48px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const apps = writable<{
|
||||
open: boolean;
|
||||
dropdown: boolean;
|
||||
selectedApp: string | undefined;
|
||||
isStudent: boolean;
|
||||
}>({
|
||||
open: false,
|
||||
dropdown: false,
|
||||
selectedApp: undefined,
|
||||
isStudent: false
|
||||
});
|
||||
Reference in New Issue
Block a user