Dashboard improvements + LMS community

This commit is contained in:
Rotimi Best
2023-08-17 14:05:01 +01:00
parent df1b5f6cae
commit 471ea21ce0
12 changed files with 567 additions and 30 deletions
+13 -3
View File
@@ -2,6 +2,8 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
export let supabase;
export let handleSubmit;
@@ -34,9 +36,17 @@
<div class="container border border-gray bg-white dark:bg-gray-800">
<div class="flex items-center flex-col p-2 lg:px-8 lg:py-3">
{#if !showOnlyContent || showLogo}
<div class="flex items-center w-full justify-center pt-4">
<img src="/logo-192.png" alt="ClassroomIO logo" height="50" width="50" data-atf="1" />
<h4 class="dark:text-white text-xl">ClassroomIO</h4>
<div class="flex flex-col items-center w-full justify-center pt-2">
<Avatar
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="h-10"
/>
<h4 class="dark:text-white text-xl mt-1">
{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
</h4>
</div>
{/if}
<form
@@ -31,7 +31,6 @@
import { course } from '$lib/components/Course/store';
import * as CONSTANTS from './constants';
import { fetchLesson } from '$lib/utils/services/courses';
import type { LessonPage } from '$lib/utils/types';
export let mode = MODES.view;
export let prevMode = '';
@@ -81,7 +81,7 @@
</script>
<svelte:head>
<title>Courses - ClassroomIO</title>
<title>Create a new course</title>
</svelte:head>
<Modal
+1 -7
View File
@@ -47,7 +47,7 @@
];
</script>
<aside class=" root bg-gray-100 dark:bg-gray-700 h-full">
<aside class="w-[250px] min-w-[250px] bg-gray-100 dark:bg-gray-700 h-full">
<div class="h-full flex flex-col">
<div class="border-b border-gray-200 pt-5 px-4">
{#if $currentOrg.avatar_url}
@@ -109,9 +109,3 @@
</ul>
</div>
</aside>
<style>
.root {
width: 250px;
}
</style>
@@ -10,6 +10,8 @@
import { currentOrgPath, currentOrg } from '$lib/utils/store/org';
import { supabase } from '$lib/utils/functions/supabase';
export let isLMS = false;
let discussions = [];
dayjs.extend(relativeTime);
@@ -38,7 +40,7 @@
if (error) {
console.error('Error loading community', error);
return goto(`${currentOrgPath}`);
return goto(isLMS ? '/lms' : $currentOrgPath);
}
discussions =
@@ -48,7 +50,7 @@
author: discussion?.author?.profile?.fullname,
comments: discussion.comments?.[0]?.count || 0,
votes: discussion.votes,
createdAt: dayjs(discussion.created_at).fromNow(true),
createdAt: dayjs(discussion.created_at).fromNow(true)
})) || [];
}
@@ -65,7 +67,7 @@
<h4>
<a
class="text-black dark:text-white"
href="{$currentOrgPath}/community/{discussion.slug}"
href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}"
>
{discussion.title}
</a>
@@ -84,9 +86,7 @@
<Box>
<CoursesEmptyIcon />
<h3 class="dark:text-white text-2xl my-5">No Questions asked</h3>
<p class="dark:text-white w-1/3 text-center">
Ask a question to the community
</p>
<p class="dark:text-white w-1/3 text-center">Ask a question to the community</p>
</Box>
{/each}
</div>
@@ -133,7 +133,9 @@
</script>
<svelte:head>
{$currentOrg.name}
<title>
{$currentOrg.name}'s Landing Page
</title>
</svelte:head>
<main>
@@ -148,7 +150,7 @@
class="md:w-11/12 lg:w-5/6 w-full flex items-center justify-between flex-col-reverse md:flex-row z-20 relative"
>
<!-- Course Description -->
<div class="md:w-2/5 w-11/12 py-10 flex flex-col items-start">
<div class="md:w-2/5 w-11/12 py-10 flex flex-col items-start mr-3">
<p class=" text-blue-600 text-2xl font-semibold">Coding Dojo</p>
<h1 class="text-4xl md:text-5xl lg:text-6xl my-4 font-bold">
Web Developement Courses for <br /><span class="text-blue-600">Everyone</span>
@@ -164,7 +166,7 @@
onClick={() => goto('#contact')}
/>
</div>
<div class="w-4/6 md:w-2/3 md:max-w-[650px] flex">
<div class="w-4/6 md:w-1/2 md:max-w-[650px] flex">
<video class="w-full rounded-xl" controls loop autoplay>
<source src={video} type="video/mp4" />
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
+1 -7
View File
@@ -55,7 +55,7 @@
</script>
<aside
class="root bg-gray-100 dark:bg-gray-800 h-full border border-l-0 border-t-0 border-b-0 border-r-1"
class="w-[250px] min-w-[250px] bg-gray-100 dark:bg-gray-800 h-full border border-l-0 border-t-0 border-b-0 border-r-1"
>
<div class="h-full flex flex-col">
<div class="border-b border-gray-200 pt-5 px-4">
@@ -128,9 +128,3 @@
</ul>
</div>
</aside>
<style>
.root {
width: 250px;
}
</style>
+25 -1
View File
@@ -1 +1,25 @@
community page
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-4xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Community</h1>
<PrimaryButton label="Ask Community" onClick={askCommunity} />
</div>
<Community isLMS={true} />
</div>
</section>
+5
View File
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
slug: params.slug,
};
};
@@ -0,0 +1,414 @@
<script>
import { marked } from 'marked';
import { goto } from '$app/navigation';
import dayjs from 'dayjs';
import pluralize from 'pluralize';
import relativeTime from 'dayjs/plugin/relativeTime';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import Vote from '$lib/components/Vote/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { supabase } from '$lib/utils/functions/supabase';
import {
askCommunityValidation,
commentInCommunityValidation
} from '$lib/utils/functions/validator';
import { SNACKBAR_SEVERITY } from '$lib/components/Snackbar/constants';
import { snackbarStore } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import DeleteCommentModal from '$lib/components/Org/Community/DeleteCommentModal.svelte';
dayjs.extend(relativeTime);
export let data;
const { slug } = data;
let question;
let comment = '';
let errors = {};
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let resetInput = 1;
let voted = { question: false, comment: {} };
let isEditMode = false;
let deleteComment = {
shouldDelete: false,
commentId: null
};
let editContent = {
title: '',
body: ''
};
function mapResToQuestion(data) {
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.profile?.id || '',
name: data?.author?.profile?.fullname || '',
avatar: data?.author?.profile?.avatar_url || ''
},
body: data.body,
createdAt: dayjs(data.created_at).fromNow(true),
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.profile?.id || '',
name: c.author?.profile?.fullname || '',
avatar: c.author?.profile?.avatar_url || '',
votes: c.votes,
comment: c.body,
createdAt: dayjs(c.created_at).fromNow(true)
})),
totalComments: 0
};
}
async function fetchCommunityQuestion(slug) {
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
comments:community_answer(
id,
body,
votes,
created_at,
author:organizationmember!community_answer_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
),
author:organizationmember!community_question_author_id_fkey!inner(
profile!inner(id, fullname, avatar_url)
)
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('Error loading community', error);
return goto('/lms/community');
}
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
}
async function submitComment() {
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data, error } = await supabase
.from('community_answer')
.insert({
id: undefined,
body: comment,
question_id: question.id,
author_id: $currentOrg.memberId,
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
$snackbarStore.open = true;
$snackbarStore.message = 'Error - Please try again later';
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
} else {
console.log('Success: commenting', data);
// Notification
$snackbarStore.open = true;
$snackbarStore.message = 'Success';
$snackbarStore.severity = SNACKBAR_SEVERITY.SUCCESS;
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
votes: 0,
comment: _c.body,
createdAt: dayjs(_c.created_at).fromNow(true)
}
];
// Reset input
comment = '';
resetInput = new Date().getTime();
}
}
async function upvoteQuestion(type, commentId) {
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && voted.comment[commentId]) return;
const table = isQuestion ? 'community_question' : 'community_answer';
const matchId = isQuestion ? question.id : commentId;
let votes = 0;
if (isQuestion) {
question.votes = question.votes + 1;
votes = question.votes;
} else {
question.comments = question.comments.map((c) => {
if (c.id === commentId) {
c.votes = c.votes + 1;
votes = c.votes;
}
return c;
});
}
const { error } = await supabase.from(table).update({ votes }).match({ id: matchId });
if (error) {
console.error('Error: upvoteQuestion', error);
$snackbarStore.open = true;
$snackbarStore.message = 'Error - Please try again later';
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
} else {
if (isQuestion) {
voted.question = true;
} else {
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
if (isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
if (!isEditMode) {
const fields = { title: editContent.title, body: editContent.body };
errors = askCommunityValidation(fields);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update(fields)
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
$snackbarStore.open = true;
$snackbarStore.message = 'Error - Please try again later';
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
} else {
question.title = fields.title;
question.body = fields.body;
editContent.title = '';
editContent.body = '';
}
} else {
editContent.title = question.title;
editContent.body = question.body;
}
}
async function handleCommentDelete() {
const { error } = await supabase
.from('community_answer')
.delete()
.match({ id: deleteComment.commentId });
if (!error) {
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}
}
$: fetchCommunityQuestion(slug);
</script>
<svelte:head>
<title>{question?.title || 'Question'}</title>
</svelte:head>
<DeleteCommentModal
bind:open={deleteComment.shouldDelete}
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = null;
}}
onDelete={handleCommentDelete}
/>
<section class="max-w-3xl mx-auto md:mx-10 lg:mb-20">
{#if !question}
<div class="py-10 px-5 mb-3">
<SkeletonText style="width: 25%;" />
<SkeletonText style="width: 100%; margin-bottom: 2rem" />
<SkeletonPlaceholder style="width: 100%; height: 20rem;" />
</div>
{:else}
<div class="py-10 px-5">
<a class="text-gray-500 dark:text-white text-md flex items-center" href={`/lms/community`}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Go Back
</a>
<div class="my-5 flex justify-between items-center">
{#if isEditMode}
<TextField
bind:value={editContent.title}
className="w-full mr-2"
errorMessage={errors.title}
/>
{:else}
<div class="flex items-center">
<Vote
value={question.votes}
upVote={() => upvoteQuestion('question')}
disabled={voted.question}
/>
<h2 class="text-3xl">{question.title}</h2>
</div>
{/if}
{#if question.author.id === $profile.id}
<PrimaryButton
label={isEditMode ? 'Save' : 'Edit'}
variant={VARIANTS.OUTLINED}
onClick={handleQuestionEdit}
className="h-fit"
/>
{#if isEditMode}
<PrimaryButton
label="Cancel"
variant={VARIANTS.TEXT}
onClick={() => (isEditMode = !isEditMode)}
className="py-3 px-6 rounded-sm h-fit"
disablePadding={true}
/>
{/if}
{/if}
</div>
<div class="my-1 px-1 rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="flex items-center no-underline hover:underline text-black">
<Avatar
src={question.author.avatar}
name={question.author.name}
width="w-7"
height="h-7"
/>
<p class="dark:text-white ml-2 text-sm">{question.author.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
{question.createdAt}
</p>
</div>
</header>
{#if isEditMode}
<div class="my-2">
<TextEditor
content={question.body}
onChange={(c) => (editContent.body = c)}
placeholder="Update question"
errorMessage={errors.body}
docId={resetInput}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html marked(question.body)}
</section>
{/if}
</div>
<div class="my-8 font-bold">
{pluralize('answers', question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 px-1 flex items-start">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="w-full rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="dark:text-white ml-2 text-sm">{comment.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CheckmarkOutlineIcon size={20} />
{/if}
{#if comment.authorId === $profile.id}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html marked(comment.comment)}
</article>
</div>
</div>
{/each}
<hr />
<div>
<TextEditor
onChange={(c) => (comment = c)}
placeholder="Give an answer"
errorMessage={errors.comment}
docId={resetInput}
/>
<div class="flex justify-end mr-2">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
+94
View File
@@ -0,0 +1,94 @@
<script>
import { goto } from '$app/navigation';
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { SNACKBAR_SEVERITY } from '$lib/components/Snackbar/constants';
import { snackbarStore } from '$lib/components/Snackbar/store';
import generateSlug from '$lib/utils/functions/generateSlug';
let errors = {};
let fields = {
title: '',
body: ''
};
async function handleSave() {
errors = askCommunityValidation(fields);
console.log('handleSave errors', errors);
if (Object.keys(errors).length) {
return;
}
const { data: question, error } = await supabase
.from('community_question')
.insert({
...fields,
organization_id: $currentOrg.id,
author_id: $currentOrg.memberId,
votes: 0,
slug: generateSlug(fields.title)
})
.select();
if (error) {
console.error('Error: asking question', error);
$snackbarStore.open = true;
$snackbarStore.message = 'Error - Please try again later';
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
} else {
console.log('Success: asking question', question);
$snackbarStore.open = true;
$snackbarStore.message = 'Success';
$snackbarStore.severity = SNACKBAR_SEVERITY.SUCCESS;
goto(`/lms/community/${question[0].slug}`);
}
}
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-3xl mx-auto">
<div class="p-5">
<a class="text-gray-500 dark:text-white text-md flex items-center" href={`/lms/community`}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" /> Go Back
</a>
<div class="flex items-center justify-between">
<h1 class="dark:text-white text-3xl font-bold">Ask the community</h1>
<PrimaryButton label="Publish" onClick={handleSave} />
</div>
</div>
<div class="mb-3 p-2">
<input
class="title rounded-lg border border-1 border-gray p-2 w-full focus:outline-none"
error="false"
autocomplete="off"
placeholder="Title"
aria-label="Title"
aria-describedby="title-input-validation"
type="text"
name="discussion_title"
id="discussion_title"
bind:value={fields.title}
/>
{#if errors.title}
<p class="dark:text-white text-red-500 text-sm">
{errors.title}
</p>
{/if}
</div>
<TextEditor
onChange={(v) => (fields.body = v)}
placeholder="Ask the community any question you might have"
errorMessage={errors.body}
/>
</section>
+2 -1
View File
@@ -5,6 +5,7 @@
import { authValidation } from '$lib/utils/functions/validator';
import { LOGIN_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
let formRef;
let supabase = getSupabase();
@@ -39,7 +40,7 @@
</script>
<svelte:head>
<title>Welcome back to ClassroomIO</title>
<title>Welcome back to {$currentOrg.name ?? 'ClassroomIO'}</title>
</svelte:head>
<AuthUI {supabase} isLogin={true} {handleSubmit} isLoading={loading} bind:formRef>