feat: move community supabase request to api layer (#612)

* feat: move community supabase request to api layer

* chore: pr review checkpoint

* chore: PR fixes

* feat: implement role-based authorization middleware and align the community features with the developer guide.

- Added role-based authorization middleware for organization and course membership checks, including `orgMemberMiddleware`, `orgTeamMemberMiddleware`, and `courseMemberMiddleware`.
- Introduced route-specific middleware for comment and question authorization, ensuring only authors or team members can modify content.
- Updated community routes to utilize the new middleware for enhanced security and access control.
- Refactored community services to streamline upvote functionality and improve error handling.
- Enhanced UI components for community interactions, including new comment and question pages, and improved state management for user actions.
- Updated documentation to reflect new middleware usage and best practices for authorization in the API layer.

* feat: enhance course and organization routes with middleware for improved access control

- Added `courseMemberMiddleware` to course download and clone routes to enforce membership checks.
- Updated lesson route to include `courseMemberMiddleware` for PDF downloads.
- Integrated `orgMemberMiddleware` into organization routes for team and audience access, ensuring only authorized members can retrieve data.
- Refactored lesson and organization routes for consistency and clarity in middleware usage.

* chore: streamline dashboard and API development scripts in package.json

- Consolidated dashboard and API development commands to include all relevant filters in a single line for improved clarity and ease of use.
- Removed redundant full command variants to simplify the script section.

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Adeyemi Adetunji
2025-12-22 19:57:27 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9fb6c7cbaa
commit bc9e3b41ae
41 changed files with 2699 additions and 1598 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
</script>
<svelte:head>
<title>ClassroomIO - The Coure Platform That’s Actually Easy To Use</title>
<title>ClassroomIO - The Course Platform That’s Actually Easy To Use</title>
</svelte:head>
{#if !isLoadingApp && appInitApi.error}
@@ -1,553 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte';
import pluralize from 'pluralize';
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import { Skeleton } from '@cio/ui/base/skeleton';
import TrashIcon from '@lucide/svelte/icons/trash';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$features/course/utils/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import { Vote } from '$features/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Avatar from '@cio/ui/base/avatar';
import { InputField } from '@cio/ui/custom/input-field';
import { shortenName } from '$lib/utils/functions/string';
import { TextEditor } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { CommunityDeleteModal } from '$features/community/components';
import { currentCommunityQuestion } from '$features/community/utils/store';
import * as Page from '@cio/ui/base/page';
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;
}
<script>
import { CommunityQuestionPage } from '$features/community/pages';
let { data } = $props();
const { slug } = data;
let question: Question | undefined = $state();
let comment = $state('');
let errors: {
title?: string;
courseId?: string;
} = $state({});
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let voted: {
question: boolean;
comment: {
[key: string]: boolean;
};
} = $state({ question: false, comment: {} });
let isEditMode = $state(false);
let deleteComment = $state({
shouldDelete: false,
commentId: '',
isDeleting: false
});
let deleteQuestion = $state({
shouldDelete: false,
questionId: '',
isDeleting: false
});
let editContent = $state({
title: '',
body: '',
courseId: ''
});
let editorInstance = $state(false);
let fetchedCourses: Course[] = $state([]);
function mapResToQuestion(data): Question {
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.id || '',
name: data?.author?.fullname || '',
avatar: data?.author?.avatar_url || ''
},
body: data.body,
createdAt: calDateDiff(data.created_at),
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.id || '',
name: c.author?.fullname || '',
avatar: c.author?.avatar_url || '',
votes: c.votes,
comment: c.body,
createdAt: calDateDiff(c.created_at)
})),
totalComments: 0,
courseId: data.course_id
};
}
async function getCourses(userId: string | null, orgId: string) {
if ($courses.length) {
fetchedCourses = [...$courses];
return;
}
const coursesResults = await fetchCourses(userId, orgId);
fetchedCourses = coursesResults?.allCourses || [];
}
async function fetchCommunityQuestion(slug: string) {
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
course_id,
slug,
comments:community_answer(
id,
body,
votes,
created_at,
author:profile(id, fullname, avatar_url)
),
author:profile(id, fullname, avatar_url),
course!inner (
title
)
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('[LMS] Error loading community', error);
return goto(`/lms`);
}
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
// Update store for breadcrumb
currentCommunityQuestion.set({ title: question.title });
});
}
async function submitComment() {
if (!question) return;
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,
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
snackbar.error('snackbar.community.error.try_again');
} else {
console.log('Success: commenting', data);
snackbar.success('snackbar.community.success.comment_submitted');
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
authorId: $profile.id || '',
name: $profile?.fullname || '',
avatar: $profile?.avatarUrl || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
}
];
// Reset input
comment = '';
}
}
async function upvoteQuestion(type: string, commentId?: string) {
if (!question) return;
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && commentId && 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);
snackbar.error('snackbar.community.error.try_again');
} else {
if (isQuestion) {
voted.question = true;
} else if (commentId) {
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
if (!question) return;
if (isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
editorInstance = !editorInstance;
if (!isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update({
title: editContent.title,
body: editContent.body,
course_id: editContent.courseId
})
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
snackbar.error('snackbar.community.error.try_again');
} else {
question.title = editContent.title;
question.body = editContent.body;
question.courseId = editContent.courseId;
editContent.title = '';
editContent.body = '';
editContent.courseId = '';
}
} else {
editContent.title = question.title;
editContent.body = question.body;
editContent.courseId = question.courseId;
}
}
async function handleDelete(isQuestion: boolean) {
if (!question) return;
if (!isQuestion) {
deleteComment.isDeleting = true;
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
deleteComment.isDeleting = false;
if (error) {
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', error);
return;
}
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
.from('community_answer')
.delete()
.match({ question_id: deleteQuestion.questionId });
if (commentDeleteError) {
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', commentDeleteError);
deleteQuestion.isDeleting = false;
return;
}
const { error: questionDeleteError } = await supabase
.from('community_question')
.delete()
.match({ id: deleteQuestion.questionId });
if (questionDeleteError) {
snackbar.error('snackbar.community.error.deleting_question');
console.log('Error deleting question', questionDeleteError);
return;
}
snackbar.success('snackbar.community.success.success_delete');
goto(`/lms/community`);
deleteQuestion.isDeleting = false;
}
$effect(() => {
fetchCommunityQuestion(slug);
});
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
});
// Clear store when component unmounts
$effect(() => {
return () => {
currentCommunityQuestion.set({ title: null });
};
});
</script>
<svelte:head>
<title>{question?.title || 'Question'}</title>
<title>Question - ClassroomIO</title>
</svelte:head>
<CommunityDeleteModal
bind:open={deleteQuestion.shouldDelete}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
}}
onDelete={() => handleDelete(true)}
isQuestion={true}
/>
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
{@const currentQuestion = question}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<Page.Header>
<Page.HeaderContent>
{#if isEditMode}
<div class="flex w-full items-center gap-2">
<InputField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
{#if course.id}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
</div>
{:else}
<div class="flex items-center gap-2">
<Vote value={currentQuestion.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<Page.Title>{currentQuestion.title}</Page.Title>
</div>
{/if}
</Page.HeaderContent>
{#if currentQuestion.author.id === $profile.id}
<Page.Action>
<Button variant="outline" onclick={handleQuestionEdit}>
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
</Button>
{#if isEditMode}
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
{$t('community.ask.cancel')}
</Button>
{/if}
</Page.Action>
{/if}
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="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.Root class="h-7 w-7">
<Avatar.Image
src={currentQuestion.author.avatar ? currentQuestion.author.avatar : '/logo-192.png'}
alt={currentQuestion.author.name ? currentQuestion.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(currentQuestion.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{currentQuestion.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{currentQuestion.createdAt}
</p>
</div>
{#if currentQuestion.author.id === $profile.id || $isOrgAdmin}
<IconButton
onclick={() => {
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = currentQuestion.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html currentQuestion.body}
</section>
{/if}
</div>
<div class="my-8">
{pluralize($t('community.answers'), currentQuestion.totalComments, true)}
</div>
{#each currentQuestion.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
onclick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{/each}
<hr />
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
<div class="mt-2 flex justify-end">
<Button onclick={submitComment}>
{$t('community.ask.comment')}
</Button>
</div>
</div>
{/snippet}
</Page.Body>
</Page.Root>
{/if}
<CommunityQuestionPage slug={data.slug} isLMS={true} />
@@ -1,152 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
<script>
import { AskCommunityPage } from '$features/community/pages';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import * as Page from '@cio/ui/base/page';
let errors: {
title?: string;
courseId?: string;
body?: string;
} = $state({});
let fields = $state({
title: '',
body: '',
courseId: ''
});
let hasFetched = $state(false);
let fetchedCourses: Course[] = $state([]);
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
const coursesResults = await fetchCourses(userId, orgId);
if (!coursesResults) return;
fetchedCourses = coursesResults.allCourses;
hasFetched = true;
}
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({
title: fields.title,
body: fields.body,
organization_id: $currentOrg.id,
author_profile_id: $profile.id,
votes: 0,
slug: generateSlug(fields.title),
course_id: fields.courseId
})
.select();
if (error) {
console.error('Error: asking question', error);
snackbar.error('snackbar.community.error.try_again');
} else {
const slug = question[0]?.slug;
console.log('Success: asking question', question, slug);
snackbar.success('snackbar.community.success.question_submitted');
goto(`/lms/community/${slug}`);
}
}
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
});
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<Page.Root>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" onclick={handleSave}>
{$t('community.ask.publish')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<InputField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
{#if course.id}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor
placeholder="Give an answer"
content={fields.body}
onChange={(content) => (fields.body = content)}
/>
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
<AskCommunityPage isLMS={true} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,546 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte';
import pluralize from 'pluralize';
import { goto } from '$app/navigation';
import { Skeleton } from '@cio/ui/base/skeleton';
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$features/course/utils/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import { Button } from '@cio/ui/base/button';
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
import { Vote } from '$features/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Avatar from '@cio/ui/base/avatar';
import { InputField } from '@cio/ui/custom/input-field';
import { shortenName } from '$lib/utils/functions/string';
import { TextEditor } from '$features/ui';
import { CommunityDeleteModal } from '$features/community/components';
import { CircleCheckIcon } from '$features/ui/icons';
import * as Page from '@cio/ui/base/page';
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;
}
<script>
import { CommunityQuestionPage } from '$features/community/pages';
let { data } = $props();
const { slug } = data;
let question: Question | undefined = $state();
let comment = $state('');
let errors: {
title?: string;
} = $state({});
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
let voted: {
question: boolean;
comment: {
[key: string]: boolean;
};
} = $state({ question: false, comment: {} });
let isEditMode = $state(false);
let deleteComment = $state({
shouldDelete: false,
commentId: '',
isDeleting: false
});
let deleteQuestion = $state({
shouldDelete: false,
questionId: '',
isDeleting: false
});
let editContent = $state({
title: '',
body: '',
courseId: ''
});
let editorInstance = $state(false);
let fetchedCourses: Course[] = $state([]);
function mapResToQuestion(data): Question {
return {
id: data.id,
title: data.title,
votes: data.votes,
author: {
id: data?.author?.id || '',
name: data?.author?.fullname || '',
avatar: data?.author?.avatar_url || ''
},
body: data.body,
createdAt: calDateDiff(data.created_at),
comments: data.comments.map((c) => ({
id: c.id,
authorId: c.author?.id || '',
name: c.author?.fullname || '',
avatar: c.author?.avatar_url || '',
votes: c.votes,
comment: c.body,
createdAt: calDateDiff(c.created_at)
})),
totalComments: 0,
courseId: data.course_id
};
}
let hasFetched = $state(false);
function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
untrack(async () => {
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
const coursesResults = await fetchCourses(userId, orgId);
fetchedCourses = coursesResults?.allCourses || [];
hasFetched = true;
});
}
async function fetchCommunityQuestion(slug: string) {
if (!slug) return;
const { data, error } = await supabase
.from('community_question')
.select(
`
id,
title,
body,
votes,
created_at,
course_id,
slug,
comments:community_answer(
id,
body,
votes,
created_at,
author:profile(id, fullname, avatar_url)
),
author:profile(id, fullname, avatar_url),
course!inner (
title
)
`
)
.eq('slug', slug)
.single();
if (error) {
console.error('[ORG] Error loading community', error);
return goto(`${$currentOrgPath}`);
}
untrack(() => {
question = mapResToQuestion(data);
question.totalComments = question.comments.length;
});
}
async function submitComment() {
errors = commentInCommunityValidation({ comment });
console.log('submitComment errors', errors);
if (Object.keys(errors).length || !question) {
return;
}
const { data, error } = await supabase
.from('community_answer')
.insert({
id: undefined,
body: comment,
question_id: question.id,
author_profile_id: $profile.id,
votes: 0
})
.select();
if (error) {
console.error('Error: commenting', error);
snackbar.error('snackbar.community.error.try_again');
} else {
console.log('Success: commenting', data);
snackbar.success('snackbar.community.success.comment_submitted');
// Add to comment
const _c = data?.[0];
question.comments = [
...question.comments,
{
id: _c.id,
authorId: $profile.id || '',
name: $profile?.fullname || '',
avatar: $profile?.avatarUrl || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
}
];
// Reset input
comment = '';
}
}
async function upvoteQuestion(type: string, commentId?: string) {
if (!question) return;
const isQuestion = type === 'question';
if (isQuestion && voted.question) return;
if (!isQuestion && commentId && 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);
snackbar.error('snackbar.community.error.try_again');
} else {
if (isQuestion) {
voted.question = true;
} else if (commentId) {
voted.comment[commentId] = true;
}
}
}
async function handleQuestionEdit() {
if (!question) return;
if (isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
}
isEditMode = !isEditMode;
editorInstance = !editorInstance;
if (!isEditMode) {
errors = askCommunityValidation(editContent);
console.log('handleQuestionEdit errors', errors);
if (Object.keys(errors).length) {
return;
}
const { error } = await supabase
.from('community_question')
.update({
title: editContent.title,
body: editContent.body,
course_id: editContent.courseId
})
.match({ id: question.id });
if (error) {
console.error('Error: handleQuestionEdit', error);
snackbar.error('snackbar.community.error.try_again');
} else {
question.title = editContent.title;
question.body = editContent.body;
question.courseId = editContent.courseId;
editContent.title = '';
editContent.body = '';
editContent.courseId = '';
}
} else {
editContent.title = question.title;
editContent.body = question.body;
editContent.courseId = question.courseId;
}
}
async function handleDelete(isQuestion: boolean) {
if (!question) return;
if (!isQuestion) {
deleteComment.isDeleting = true;
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
deleteComment.isDeleting = false;
if (error) {
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', error);
return;
}
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
.from('community_answer')
.delete()
.match({ question_id: deleteQuestion.questionId });
if (commentDeleteError) {
snackbar.error('snackbar.community.error.deleting_comments');
console.log('Error deleting comments', commentDeleteError);
deleteQuestion.isDeleting = false;
return;
}
const { error: questionDeleteError } = await supabase
.from('community_question')
.delete()
.match({ id: deleteQuestion.questionId });
if (questionDeleteError) {
snackbar.error('snackbar.community.error.deleting_question');
console.log('Error deleting question', questionDeleteError);
return;
}
snackbar.success('snackbar.community.success.success_delete');
goto(`${$currentOrgPath}/community`);
deleteQuestion.isDeleting = false;
}
$effect(() => {
fetchCommunityQuestion(slug);
});
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
});
</script>
<svelte:head>
<title>{question?.title || $t('community.ask.question')}</title>
<title>Question - ClassroomIO</title>
</svelte:head>
<CommunityDeleteModal
bind:open={deleteComment.shouldDelete}
isDeleting={deleteComment.isDeleting}
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = '';
}}
onDelete={() => handleDelete(false)}
/>
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<Page.Header>
<Page.HeaderContent>
{#if isEditMode}
<div class="flex w-full items-center gap-2">
<InputField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else}
<div class="flex items-center gap-2">
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<Page.Title>{question.title}</Page.Title>
</div>
{/if}
</Page.HeaderContent>
{#if question.author.id === $profile.id}
<Page.Action>
<Button variant="outline" onclick={handleQuestionEdit}>
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
</Button>
{#if isEditMode}
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
{$t('community.ask.cancel')}
</Button>
{/if}
</Page.Action>
{/if}
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="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.Root class="h-7 w-7">
<Avatar.Image
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
alt={question.author.name ? question.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<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">
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
onclick={() => {
if (!question) return;
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
</div>
<div class="my-8">
{pluralize($t('community.answers'), question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
onclick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{/each}
<hr />
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
<div class="mt-2 flex justify-end">
<Button onclick={submitComment}>
{$t('community.ask.comment')}
</Button>
</div>
</div>
{/snippet}
</Page.Body>
</Page.Root>
{/if}
<CommunityQuestionPage slug={data.slug} isLMS={false} />
@@ -1,152 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import type { Course } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user';
<script>
import { AskCommunityPage } from '$features/community/pages';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$features/ui/snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { askCommunityValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { TextEditor } from '$features/ui';
import * as Page from '@cio/ui/base/page';
let errors: {
title?: string;
courseId?: string;
body?: string;
} = $state({});
let fields = $state({
title: '',
body: '',
courseId: ''
});
let hasFetched = $state(false);
let fetchedCourses: Course[] = $state([]);
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched) return;
if ($courses.length) {
fetchedCourses = [...$courses];
hasFetched = true;
return;
}
const coursesResults = await fetchCourses(userId, orgId);
if (!coursesResults) return;
fetchedCourses = coursesResults.allCourses;
hasFetched = true;
}
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({
title: fields.title,
body: fields.body,
organization_id: $currentOrg.id,
author_profile_id: $profile.id,
votes: 0,
slug: generateSlug(fields.title),
course_id: fields.courseId
})
.select();
if (error) {
console.error('Error: asking question', error);
snackbar.error('snackbar.community.error.try_again');
} else {
const slug = question[0]?.slug;
console.log('Success: asking question', question, slug);
snackbar.success('snackbar.community.success.question_submitted');
goto(`${$currentOrgPath}/community/${slug}`);
}
}
$effect(() => {
if ($profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
});
</script>
<svelte:head>
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<Page.Root>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" onclick={handleSave}>
{$t('community.ask.publish')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<InputField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
{#if course.id}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor
placeholder="Give an answer"
content={fields.body}
onChange={(content) => (fields.body = content)}
/>
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
<AskCommunityPage isLMS={false} />
{/snippet}
</Page.Body>
</Page.Root>