Files
classroomio/apps/dashboard/src/routes/lms/community/[slug]/+page.svelte
T

534 lines
16 KiB
Svelte
Raw Normal View History

<script lang="ts">
2023-08-17 14:05:01 +01:00
import { goto } from '$app/navigation';
import pluralize from 'pluralize';
2025-11-01 15:49:29 +01:00
import TrashIcon from '@lucide/svelte/icons/trash';
import { Dropdown, SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
2025-11-01 15:49:29 +01:00
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
2023-08-17 14:05:01 +01:00
import Vote from '$lib/components/Vote/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Avatar from '$lib/components/Avatar/index.svelte';
2025-11-01 15:49:29 +01:00
import { IconButton } from '$lib/components/IconButton';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
2023-08-17 14:05:01 +01:00
import { profile } from '$lib/utils/store/user';
import { supabase } from '$lib/utils/functions/supabase';
2025-11-01 15:49:29 +01:00
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
2023-09-27 05:35:06 +01:00
import { snackbar } from '$lib/components/Snackbar/store';
2023-08-17 14:05:01 +01:00
import TextField from '$lib/components/Form/TextField.svelte';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
2023-09-05 13:21:44 +00:00
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
2025-11-01 15:49:29 +01:00
import { untrack } from 'svelte';
2024-07-21 03:02:33 +01:00
import { fetchCourses } from '$lib/utils/services/courses';
2024-05-22 05:49:10 +01:00
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
2023-08-17 14:05:01 +01:00
2025-11-01 15:49:29 +01:00
let { data } = $props();
2023-08-17 14:05:01 +01:00
const { slug } = data;
interface Comment {
id: string;
authorId: string;
name: string;
avatar: string;
votes: number;
comment: string;
createdAt: string;
}
interface Question {
id: string;
title: string;
votes: number;
author: {
id: string;
name: string;
avatar: string;
};
body: string;
createdAt: string;
comments: Comment[];
totalComments: number;
courseId: string;
}
2025-11-01 15:49:29 +01:00
let question: Question | undefined = $state();
let comment = $state('');
let errors: {
title?: string;
courseId?: string;
2025-11-01 15:49:29 +01:00
} = $state({});
2023-08-17 14:05:01 +01:00
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let voted: {
question: boolean;
comment: {
[key: string]: boolean;
};
2025-11-01 15:49:29 +01:00
} = $state({ question: false, comment: {} });
let isEditMode = $state(false);
let deleteComment = $state({
2023-08-17 14:05:01 +01:00
shouldDelete: false,
commentId: '',
isDeleting: false
2025-11-01 15:49:29 +01:00
});
let deleteQuestion = $state({
shouldDelete: false,
questionId: '',
isDeleting: false
2025-11-01 15:49:29 +01:00
});
let editContent = $state({
2023-08-17 14:05:01 +01:00
title: '',
body: '',
courseId: ''
2025-11-01 15:49:29 +01:00
});
2023-08-17 14:05:01 +01:00
2025-11-01 15:49:29 +01:00
let editorInstance = $state(false);
let fetchedCourses: Course[] = $state([]);
2023-09-05 13:21:44 +00:00
function mapResToQuestion(data): Question {
2023-08-17 14:05:01 +01:00
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.id || '',
name: data?.author?.fullname || '',
avatar: data?.author?.avatar_url || ''
2023-08-17 14:05:01 +01:00
},
body: data.body,
createdAt: calDateDiff(data.created_at),
2023-08-17 14:05:01 +01:00
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.id || '',
name: c.author?.fullname || '',
avatar: c.author?.avatar_url || '',
2023-08-17 14:05:01 +01:00
votes: c.votes,
comment: c.body,
createdAt: calDateDiff(c.created_at)
2023-08-17 14:05:01 +01:00
})),
totalComments: 0,
courseId: data.course_id
2023-08-17 14:05:01 +01:00
};
}
async function getCourses(userId: string | null, orgId: string) {
2024-05-22 05:49:10 +01:00
if ($courses.length) {
fetchedCourses = [...$courses];
return;
}
const coursesResults = await fetchCourses(userId, orgId);
2024-05-22 05:49:10 +01:00
fetchedCourses = coursesResults?.allCourses || [];
}
async function fetchCommunityQuestion(slug: string) {
2023-08-17 14:05:01 +01:00
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
course_id,
slug,
2023-08-17 14:05:01 +01:00
comments:community_answer(
id,
body,
votes,
created_at,
author:profile(id, fullname, avatar_url)
2023-08-17 14:05:01 +01:00
),
author:profile(id, fullname, avatar_url),
course!inner (
title
)
2023-08-17 14:05:01 +01:00
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('[LMS] Error loading community', error);
return goto(`/lms`);
2023-08-17 14:05:01 +01:00
}
2025-11-01 15:49:29 +01:00
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
});
2023-08-17 14:05:01 +01:00
}
async function submitComment() {
2025-11-01 15:49:29 +01:00
if (!question) return;
2023-08-17 14:05:01 +01:00
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_profile_id: $profile.id,
2023-08-17 14:05:01 +01:00
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.try_again');
2023-08-17 14:05:01 +01:00
} else {
console.log('Success: commenting', data);
2024-04-19 05:45:48 +01:00
snackbar.success('snackbar.community.success.comment_submitted');
2023-08-17 14:05:01 +01:00
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
authorId: $profile.id || '',
2023-08-17 14:05:01 +01:00
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
2023-08-17 14:05:01 +01:00
}
];
// Reset input
comment = '';
}
}
async function upvoteQuestion(type: string, commentId?: string) {
2025-11-01 15:49:29 +01:00
if (!question) return;
2023-08-17 14:05:01 +01:00
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && commentId && voted.comment[commentId]) return;
2023-08-17 14:05:01 +01:00
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);
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.try_again');
2023-08-17 14:05:01 +01:00
} else {
if (isQuestion) {
voted.question = true;
} else if (commentId) {
2023-08-17 14:05:01 +01:00
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
2025-11-01 15:49:29 +01:00
if (!question) return;
2023-08-17 14:05:01 +01:00
if (isEditMode) {
errors = askCommunityValidation(editContent);
2023-08-17 14:05:01 +01:00
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
2023-09-05 13:21:44 +00:00
editorInstance = !editorInstance;
2023-08-17 14:05:01 +01:00
if (!isEditMode) {
errors = askCommunityValidation(editContent);
2023-08-17 14:05:01 +01:00
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update({
2024-04-19 05:45:48 +01:00
title: editContent.title,
body: editContent.body,
course_id: editContent.courseId
})
2023-08-17 14:05:01 +01:00
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.try_again');
2023-08-17 14:05:01 +01:00
} else {
question.title = editContent.title;
question.body = editContent.body;
question.courseId = editContent.courseId;
2023-08-17 14:05:01 +01:00
editContent.title = '';
editContent.body = '';
editContent.courseId = '';
2023-08-17 14:05:01 +01:00
}
} else {
editContent.title = question.title;
editContent.body = question.body;
editContent.courseId = question.courseId;
2023-08-17 14:05:01 +01:00
}
}
async function handleDelete(isQuestion: boolean) {
2025-11-01 15:49:29 +01:00
if (!question) return;
if (!isQuestion) {
deleteComment.isDeleting = true;
2025-11-01 15:49:29 +01:00
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
deleteComment.isDeleting = false;
if (error) {
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', error);
return;
}
2024-04-19 05:45:48 +01:00
snackbar.success('snackbar.community.success.success_delete');
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
deleteComment.shouldDelete = false;
deleteComment.commentId = '';
// Handle only delete comment
return;
}
deleteQuestion.isDeleting = true;
const { error: commentDeleteError } = await supabase
2023-08-17 14:05:01 +01:00
.from('community_answer')
.delete()
.match({ question_id: deleteQuestion.questionId });
2023-08-17 14:05:01 +01:00
if (commentDeleteError) {
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', commentDeleteError);
deleteQuestion.isDeleting = false;
return;
2023-08-17 14:05:01 +01:00
}
const { error: questionDeleteError } = await supabase
.from('community_question')
.delete()
.match({ id: deleteQuestion.questionId });
if (questionDeleteError) {
2024-04-19 05:45:48 +01:00
snackbar.error('snackbar.community.error.deleting_question');
console.log('Error deleting question', questionDeleteError);
return;
}
2024-04-19 05:45:48 +01:00
snackbar.success('snackbar.community.success.success_delete');
goto(`/lms/community`);
deleteQuestion.isDeleting = false;
2023-08-17 14:05:01 +01:00
}
2025-11-01 15:49:29 +01:00
$effect(() => {
fetchCommunityQuestion(slug);
});
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
2025-11-01 15:49:29 +01:00
});
2023-08-17 14:05:01 +01:00
</script>
<svelte:head>
<title>{question?.title || 'Question'}</title>
</svelte:head>
<DeleteModal
bind:open={deleteQuestion.shouldDelete}
2025-11-01 15:49:29 +01:00
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
}}
onDelete={() => handleDelete(true)}
isQuestion={true}
/>
<DeleteModal
2023-08-17 14:05:01 +01:00
bind:open={deleteComment.shouldDelete}
isDeleting={deleteComment.isDeleting}
2023-08-17 14:05:01 +01:00
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = '';
2023-08-17 14:05:01 +01:00
}}
onDelete={() => handleDelete(false)}
2023-08-17 14:05:01 +01:00
/>
2025-11-01 15:49:29 +01:00
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
2023-08-17 14:05:01 +01:00
{#if !question}
2025-11-01 15:49:29 +01:00
<div class="mb-3 px-5 py-10">
2023-08-17 14:05:01 +01:00
<SkeletonText style="width: 25%;" />
<SkeletonText style="width: 100%; margin-bottom: 2rem" />
<SkeletonPlaceholder style="width: 100%; height: 20rem;" />
</div>
{:else}
2025-11-01 15:49:29 +01:00
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href="/lms/community">
<ArrowLeftIcon size={16} />
2024-04-24 23:01:20 +01:00
{$t('community.ask.go_back')}
2023-08-17 14:05:01 +01:00
</a>
2025-11-01 15:49:29 +01:00
<div class="my-5 flex items-center justify-between">
2023-08-17 14:05:01 +01:00
{#if isEditMode}
2025-11-01 15:49:29 +01:00
<TextField bind:value={editContent.title} className="w-full mr-2" errorMessage={errors.title} />
<Dropdown
2025-11-01 15:49:29 +01:00
class="h-full w-[25%]"
size="xl"
label="Select Course"
items={fetchedCourses.map((course) => ({ id: course.id, text: course.title }))}
bind:selectedId={editContent.courseId}
/>
2023-08-17 14:05:01 +01:00
{:else}
<div class="flex items-center">
2025-11-01 15:49:29 +01:00
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
2023-08-17 14:05:01 +01:00
<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>
2025-11-01 15:49:29 +01:00
<div class="border-1 border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
2023-08-17 14:05:01 +01:00
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
2025-11-01 15:49:29 +01:00
if (!question) return;
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
2025-11-01 15:49:29 +01:00
<TrashIcon size={16} />
</IconButton>
{/if}
2023-08-17 14:05:01 +01:00
</header>
2023-09-05 13:21:44 +00:00
{#if isEditMode && editorInstance}
2023-08-17 14:05:01 +01:00
<div class="my-2">
<TextEditor
2023-09-05 13:21:44 +00:00
bind:value={editContent.body}
placeholder="Give an answer"
onChange={(html) => (editContent.body = html)}
2023-08-17 14:05:01 +01:00
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
2023-08-17 14:05:01 +01:00
</section>
{/if}
</div>
<div class="my-8 font-bold">
2024-04-24 23:01:20 +01:00
{pluralize($t('community.answers'), question.totalComments, true)}
2023-08-17 14:05:01 +01:00
</div>
{#each question.comments as comment}
2025-11-01 15:49:29 +01:00
<div class="my-5 flex items-start px-1">
2023-08-17 14:05:01 +01:00
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
2025-11-01 15:49:29 +01:00
<div class="border-1 border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
2023-08-17 14:05:01 +01:00
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
2025-11-01 15:49:29 +01:00
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
2023-08-17 14:05:01 +01:00
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
2025-11-01 15:49:29 +01:00
<CircleCheckIcon size={16} />
2023-08-17 14:05:01 +01:00
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
2023-08-17 14:05:01 +01:00
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
2025-11-01 15:49:29 +01:00
<TrashIcon size={16} />
2023-08-17 14:05:01 +01:00
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
2023-08-17 14:05:01 +01:00
</article>
</div>
</div>
{/each}
<hr />
<div>
2023-09-05 13:21:44 +00:00
{#if !editorInstance}
2025-11-01 15:49:29 +01:00
<TextEditor bind:value={comment} placeholder="Give an answer" onChange={(html) => (comment = html)} />
2023-09-05 13:21:44 +00:00
{/if}
2023-08-17 14:05:01 +01:00
2025-11-01 15:49:29 +01:00
<div class="mr-2 flex justify-end">
2023-08-17 14:05:01 +01:00
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>