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:
co-authored by
Rotimi Best
parent
9fb6c7cbaa
commit
bc9e3b41ae
@@ -0,0 +1,459 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CommunityQuestionSuccess,
|
||||
CommunityQuestionsSuccess,
|
||||
CreateCommentResponse,
|
||||
CreateCommunityQuestionResponse,
|
||||
UpdateQuestionResponse,
|
||||
UpvotePostResponse
|
||||
} from '../utils/types';
|
||||
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { currentOrgPath, currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { courses as coursesStore } from '$features/course/utils/store';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
/**
|
||||
* API class for Organization Community
|
||||
*/
|
||||
class CommunityApi extends BaseApiWithErrors {
|
||||
questions: CommunityQuestionsSuccess['data'] = $state([]);
|
||||
question: CommunityQuestionSuccess['data'] | null = $state(null);
|
||||
courses = $state<Course[]>([]);
|
||||
private coursesFetched = $state(false);
|
||||
isEditMode = $state(false);
|
||||
isEditing = $state(false);
|
||||
isCommenting = $state(false);
|
||||
editContent = $state<{
|
||||
title: string;
|
||||
body: string;
|
||||
courseId: string;
|
||||
}>({
|
||||
title: '',
|
||||
body: '',
|
||||
courseId: ''
|
||||
});
|
||||
comment = $state('');
|
||||
deleteCommentState = $state<{
|
||||
shouldDelete: boolean;
|
||||
commentId: string;
|
||||
}>({
|
||||
shouldDelete: false,
|
||||
commentId: ''
|
||||
});
|
||||
answer = $state<{
|
||||
id: number;
|
||||
body: string;
|
||||
votes: number;
|
||||
createdAt: string;
|
||||
authorId: number;
|
||||
}>({
|
||||
body: '',
|
||||
votes: 0,
|
||||
createdAt: '',
|
||||
authorId: 0,
|
||||
id: 0
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches community questions for an organization
|
||||
* Handles API call, success/error handling, and navigation
|
||||
* @param orgId Organization ID
|
||||
* @param isLMS Whether this is in LMS context (affects navigation path)
|
||||
*/
|
||||
async fetchCommunityQuestions({ orgId, isLMS = false }: { orgId: string; isLMS?: boolean }) {
|
||||
await this.execute<typeof classroomio.community.$get>({
|
||||
requestFn: () => classroomio.community.$get({ query: { orgId } }),
|
||||
logContext: 'fetching community questions',
|
||||
onSuccess: (response) => {
|
||||
this.questions = response.data;
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error loading community questions:', result);
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(basePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single community question by slug
|
||||
* Handles API call, success/error handling, navigation, and store updates
|
||||
* @param slug Question slug
|
||||
* @param isLMS Whether this is in LMS context (affects navigation path and store updates)
|
||||
*/
|
||||
async fetchCommunityQuestion({ slug, isLMS = false }: { slug: string; isLMS?: boolean }) {
|
||||
await this.execute<(typeof classroomio.community)[':slug']['$get']>({
|
||||
requestFn: () => classroomio.community[':slug'].$get({ param: { slug } }),
|
||||
logContext: 'fetching community question',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.question = response.data;
|
||||
|
||||
currentCommunityQuestion.set({ title: response.data.title });
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error(`[${isLMS ? 'LMS' : 'ORG'}] Error loading community`, result);
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(basePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new community question
|
||||
* Handles validation, API call, success/error handling, and navigation
|
||||
* @param fields User-provided fields (title, body, courseId)
|
||||
* @param isLMS Whether this is in LMS context (affects navigation path)
|
||||
*/
|
||||
async createQuestion(fields: TCreateCommunityQuestion, isLMS: boolean = false) {
|
||||
const result = ZCreateCommunityQuestion.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'community');
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = generateSlug(fields.title);
|
||||
const currentOrg = get(currentOrgStore);
|
||||
const profile = get(profileStore);
|
||||
|
||||
if (!currentOrg?.id || !profile?.id) {
|
||||
snackbar.error('snackbar.community.error.missing_context');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateCommunityQuestionResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.community.$post({
|
||||
json: {
|
||||
title: result.data.title,
|
||||
body: result.data.body,
|
||||
courseId: result.data.courseId || '',
|
||||
organizationId: currentOrg.id,
|
||||
authorProfileId: profile.id,
|
||||
votes: 0,
|
||||
slug
|
||||
}
|
||||
}),
|
||||
logContext: 'creating community question',
|
||||
onSuccess: (response) => {
|
||||
const questionData = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
const questionSlug = questionData?.slug || slug;
|
||||
|
||||
// Show success message
|
||||
snackbar.success('snackbar.community.success.question_submitted');
|
||||
|
||||
// Navigate to the question based on context
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(`${basePath}/community/${questionSlug}`);
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error creating question:', result);
|
||||
snackbar.error('snackbar.community.error.try_again');
|
||||
if (typeof result !== 'string' && 'error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a comment for a community question
|
||||
* Handles validation, API call, and success/error handling
|
||||
* Uses this.comment state for the comment body
|
||||
* @param questionId Question ID
|
||||
* @param isLMS Whether this is in LMS context (affects store updates)
|
||||
* @param onSuccess Optional callback to execute on successful comment creation
|
||||
*/
|
||||
async createComment(questionId: number, isLMS: boolean = false, onSuccess?: () => void) {
|
||||
const profile = get(profileStore);
|
||||
if (!profile?.id) {
|
||||
snackbar.error('snackbar.community.error.missing_context');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = ZCommunityComment.safeParse({
|
||||
body: this.comment,
|
||||
authorProfileId: profile.id,
|
||||
votes: 0
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'community');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCommenting = true;
|
||||
await this.execute<CreateCommentResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].comment.$post({
|
||||
param: { id: String(questionId) },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: {
|
||||
body: result.data.body,
|
||||
authorProfileId: result.data.authorProfileId,
|
||||
votes: result.data.votes
|
||||
}
|
||||
}),
|
||||
logContext: 'submitting comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.answer = response.data;
|
||||
|
||||
// Show success message
|
||||
snackbar.success('snackbar.community.success.comment_submitted');
|
||||
|
||||
// Refetch the question to get updated comments with proper structure
|
||||
if (this.question && this.question.slug) {
|
||||
this.fetchCommunityQuestion({ slug: this.question.slug, isLMS });
|
||||
}
|
||||
|
||||
// Call the onSuccess callback if provided
|
||||
onSuccess?.();
|
||||
}
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
this.isCommenting = false;
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error creating comment:', result);
|
||||
snackbar.error('snackbar.community.error.comment_failed');
|
||||
if (typeof result !== 'string' && 'error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
this.isCommenting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvotes a post (question or comment)
|
||||
* Handles API call and updates local state with returned vote count
|
||||
* @param id Post ID
|
||||
* @param isQuestion Boolean indicating if it's a question
|
||||
*/
|
||||
async upvotePost({ id, isQuestion }: { id: number | string; isQuestion: boolean }) {
|
||||
await this.execute<UpvotePostResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].upvote.$post({
|
||||
param: { id: id.toString() },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: { isQuestion }
|
||||
}),
|
||||
logContext: 'upvoting post',
|
||||
onSuccess: (response) => {
|
||||
// Update local question state with the vote count returned from backend
|
||||
if (this.question && response.data) {
|
||||
const newVoteCount = response.data.votes;
|
||||
if (isQuestion && String(this.question.id) === String(id)) {
|
||||
this.question.votes = newVoteCount;
|
||||
} else if (!isQuestion) {
|
||||
// Update comment votes
|
||||
this.question.comments = this.question.comments.map((comment) => {
|
||||
if (String(comment.id) === String(id)) {
|
||||
return { ...comment, votes: newVoteCount };
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error upvoting post:', result);
|
||||
snackbar.error('snackbar.community.error.try_again');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a community question
|
||||
* Handles validation, API call, and success/error handling
|
||||
* @param questionId Question ID
|
||||
* @param fields User-provided fields (title, body, courseId)
|
||||
*/
|
||||
async updateQuestion(questionId: number, fields: { title: string; body: string; courseId?: string }) {
|
||||
const result = ZCommunityQuestionUpdate.safeParse({
|
||||
title: fields.title,
|
||||
body: fields.body,
|
||||
courseId: fields.courseId || ''
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'community');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditing = true;
|
||||
await this.execute<UpdateQuestionResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].$put({
|
||||
param: { id: String(questionId) },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: {
|
||||
title: result.data.title,
|
||||
body: result.data.body,
|
||||
courseId: result.data.courseId
|
||||
}
|
||||
}),
|
||||
logContext: 'updating community question',
|
||||
onSuccess: () => {
|
||||
if (this.question) {
|
||||
this.question = { ...this.question, ...result.data };
|
||||
}
|
||||
|
||||
console.log('this.question', this.question);
|
||||
|
||||
// Show success message
|
||||
snackbar.success('snackbar.community.success.question_updated');
|
||||
|
||||
// Exit edit mode on successful update
|
||||
this.exitEditMode();
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
this.isEditing = false;
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error updating question:', result);
|
||||
snackbar.error('snackbar.community.error.update_failed');
|
||||
if (typeof result !== 'string' && 'error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
this.isEditing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters edit mode and initializes edit content with question data
|
||||
* @param question Question data to populate edit form
|
||||
*/
|
||||
enterEditMode(question: { title: string; body: string; courseId?: string }) {
|
||||
this.isEditMode = true;
|
||||
this.editContent = {
|
||||
title: question.title,
|
||||
body: question.body,
|
||||
courseId: question.courseId || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exits edit mode and resets edit content
|
||||
*/
|
||||
exitEditMode() {
|
||||
this.isEditMode = false;
|
||||
this.editContent = {
|
||||
title: '',
|
||||
body: '',
|
||||
courseId: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a question by its ID (including all comments)
|
||||
* Handles API call, success/error handling, and navigation
|
||||
* @param questionId Question ID
|
||||
* @param isLMS Whether this is in LMS context (affects navigation path)
|
||||
*/
|
||||
async deleteQuestion(questionId: string, isLMS: boolean = false) {
|
||||
await this.execute<(typeof classroomio.community)[':id']['$delete']>({
|
||||
requestFn: () => classroomio.community[':id'].$delete({ param: { id: questionId } }),
|
||||
logContext: 'deleting community question',
|
||||
onSuccess: () => {
|
||||
// Show success message
|
||||
snackbar.success('snackbar.community.success.success_delete');
|
||||
|
||||
// Navigate back to community page based on context
|
||||
const basePath = isLMS ? '/lms' : get(currentOrgPath);
|
||||
goto(`${basePath}/community`);
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error deleting question:', result);
|
||||
snackbar.error('snackbar.community.error.delete_failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a comment by its ID
|
||||
* Handles API call and success/error handling
|
||||
* Resets deleteComment state on success
|
||||
* @param commentId Comment ID
|
||||
*/
|
||||
async deleteComment(commentId: string) {
|
||||
await this.execute<(typeof classroomio.community)[':id']['comment']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.community[':id'].comment.$delete({
|
||||
param: { id: commentId }
|
||||
}),
|
||||
logContext: 'deleting community comment',
|
||||
onSuccess: () => {
|
||||
// Remove comment from local state
|
||||
if (this.question) {
|
||||
this.question.comments = this.question.comments.filter((comment) => String(comment.id) !== commentId);
|
||||
}
|
||||
|
||||
// Reset delete comment state
|
||||
this.deleteCommentState.shouldDelete = false;
|
||||
this.deleteCommentState.commentId = '';
|
||||
|
||||
// Show success message
|
||||
snackbar.success('snackbar.community.success.success_delete');
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
},
|
||||
onError: (result) => {
|
||||
console.error('Error deleting comment:', result);
|
||||
snackbar.error('snackbar.community.error.delete_failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches courses for the organization
|
||||
* Checks store first, then fetches if needed
|
||||
* @param userId User ID
|
||||
* @param orgId Organization ID
|
||||
*/
|
||||
async fetchCoursesForOrg(userId: string | null, orgId: string) {
|
||||
if (this.coursesFetched) return;
|
||||
|
||||
// Check store first
|
||||
const courses = get(coursesStore);
|
||||
if (courses.length) {
|
||||
this.courses = [...courses];
|
||||
this.coursesFetched = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const coursesResults = await fetchCourses(userId, orgId);
|
||||
if (coursesResults) {
|
||||
this.courses = coursesResults.allCourses || [];
|
||||
this.coursesFetched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const communityApi = new CommunityApi();
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { Vote } from '$features/ui';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
|
||||
interface CommentAuthor {
|
||||
id: number | string | null;
|
||||
fullname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface CommentItem {
|
||||
id: string;
|
||||
votes?: number | null;
|
||||
body: string | null;
|
||||
createdAt?: string | null;
|
||||
author?: CommentAuthor | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comment: CommentItem;
|
||||
isVoted?: boolean | null;
|
||||
isValidAnswer?: boolean;
|
||||
isAuthorOrAdmin: boolean;
|
||||
upVote: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { comment, isVoted, isValidAnswer = false, isAuthorOrAdmin, upVote, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-start px-1">
|
||||
<Vote value={comment.votes ?? 0} {upVote} disabled={isVoted ?? false} />
|
||||
<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="size-7">
|
||||
<Avatar.Image
|
||||
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'}
|
||||
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
|
||||
|
||||
<p class="ui:text-muted-foreground ml-2 text-sm">
|
||||
{comment.createdAt ? calDateDiff(comment.createdAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isValidAnswer}
|
||||
<CircleCheckIcon size={16} />
|
||||
{/if}
|
||||
|
||||
{#if isAuthorOrAdmin}
|
||||
<IconButton onclick={onDelete}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</header>
|
||||
<article class="prose prose-sm sm:prose p-2">
|
||||
{@html comment.body}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as CommunityListLoader } from './community-list-loader.svelte';
|
||||
export { default as CommunityDeleteModal } from './delete-modal.svelte';
|
||||
export { default as AskCommunityButton } from './ask-community-button.svelte';
|
||||
export { default as CommunityCommentItem } from './community-comment-item.svelte';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextEditor } from '$features/ui';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { communityApi } from '$features/community/api/community.svelte';
|
||||
|
||||
interface Props {
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
let { isLMS = false }: Props = $props();
|
||||
|
||||
let fields = $state({
|
||||
title: '',
|
||||
body: '',
|
||||
courseId: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id && $currentOrg.id) {
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex justify-between gap-x-5">
|
||||
<InputField
|
||||
bind:value={fields.title}
|
||||
placeholder={$t('community.ask.title')}
|
||||
errorMessage={communityApi.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
|
||||
? communityApi.courses.find((course) => course.id === fields.courseId)?.title
|
||||
: $t('community.ask.select_course')}
|
||||
</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each communityApi.courses as course}
|
||||
{#if course.id}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if communityApi.errors.courseId}
|
||||
<p class="mt-1 text-sm text-red-500">
|
||||
{communityApi.errors.courseId}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<TextEditor placeholder="Give an answer" content={fields.body} onChange={(content) => (fields.body = content)} />
|
||||
|
||||
{#if communityApi.errors.body}
|
||||
<p class="mt-2 text-sm text-red-500">{communityApi.errors.body}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={() => communityApi.createQuestion(fields, isLMS)}
|
||||
disabled={communityApi.isLoading}
|
||||
>
|
||||
{$t('community.ask.publish')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { communityApi } from '$features/community/api/community.svelte';
|
||||
import type { TiptapEditor } from '@cio/ui/custom/editor';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Vote } from '$features/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { TextEditor } from '$features/ui';
|
||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
let { slug, isLMS = false }: Props = $props();
|
||||
|
||||
let commentEditor: TiptapEditor | undefined = $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 deleteQuestionState = $state({
|
||||
shouldDelete: false,
|
||||
questionId: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id && $currentOrg.id) {
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
|
||||
communityApi.fetchCommunityQuestion({ slug, isLMS });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{communityApi.question?.title || $t('community.ask.question')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityDeleteModal
|
||||
bind:open={communityApi.deleteCommentState.shouldDelete}
|
||||
isDeleting={communityApi.isLoading}
|
||||
onCancel={() => {
|
||||
communityApi.deleteCommentState.shouldDelete = false;
|
||||
communityApi.deleteCommentState.commentId = '';
|
||||
}}
|
||||
onDelete={() => {
|
||||
communityApi.deleteComment(communityApi.deleteCommentState.commentId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CommunityDeleteModal
|
||||
bind:open={deleteQuestionState.shouldDelete}
|
||||
isDeleting={communityApi.isLoading}
|
||||
isQuestion={true}
|
||||
onCancel={() => {
|
||||
deleteQuestionState.shouldDelete = false;
|
||||
deleteQuestionState.questionId = '';
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (communityApi.question) {
|
||||
communityApi.deleteQuestion(String(communityApi.question.id), isLMS);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if !communityApi.question}
|
||||
<div class="mb-3 space-y-2 px-5 py-10">
|
||||
<Skeleton class="h-4 w-[25%]" />
|
||||
<Skeleton class="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 communityApi.isEditMode}
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<InputField
|
||||
bind:value={communityApi.editContent.title}
|
||||
className="flex-1"
|
||||
errorMessage={communityApi.errors.title}
|
||||
isDisabled={communityApi.isEditing}
|
||||
/>
|
||||
|
||||
<Select.Root type="single" bind:value={communityApi.editContent.courseId} disabled={communityApi.isEditing}>
|
||||
<Select.Trigger class="w-30! h-full truncate" disabled={communityApi.isEditing}>
|
||||
<span class="truncate">
|
||||
{communityApi.editContent.courseId
|
||||
? communityApi.courses.find((course) => course.id === communityApi.editContent.courseId)?.title
|
||||
: $t('community.ask.select_course')}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each communityApi.courses 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={communityApi.question.votes}
|
||||
upVote={() => {
|
||||
if (voted.question || !communityApi.question) return;
|
||||
voted.question = true;
|
||||
communityApi.upvotePost({
|
||||
id: Number(communityApi.question.id),
|
||||
isQuestion: true
|
||||
});
|
||||
}}
|
||||
disabled={voted.question}
|
||||
/>
|
||||
<Page.Title>{communityApi.question.title}</Page.Title>
|
||||
</div>
|
||||
{/if}
|
||||
</Page.HeaderContent>
|
||||
{#if communityApi.question.authorId === $profile.id}
|
||||
<Page.Action>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={communityApi.isEditing}
|
||||
onclick={() => {
|
||||
if (!communityApi.question) return;
|
||||
|
||||
if (communityApi.isEditMode) {
|
||||
communityApi.updateQuestion(Number(communityApi.question.id), communityApi.editContent);
|
||||
} else {
|
||||
communityApi.enterEditMode(communityApi.question);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{communityApi.isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
|
||||
</Button>
|
||||
|
||||
{#if communityApi.isEditMode}
|
||||
<Button variant="ghost" disabled={communityApi.isEditing} onclick={() => communityApi.exitEditMode()}>
|
||||
{$t('community.ask.cancel')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
{/if}
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="my-1 rounded-md border px-1">
|
||||
<div 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={communityApi.question?.authorAvatarUrl
|
||||
? communityApi.question?.authorAvatarUrl
|
||||
: '/logo-192.png'}
|
||||
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{communityApi.question?.createdAt ? calDateDiff(communityApi.question.createdAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
{#if communityApi.question?.authorId === $profile.id || $isOrgAdmin}
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
if (!communityApi.question) return;
|
||||
|
||||
deleteQuestionState.shouldDelete = true;
|
||||
deleteQuestionState.questionId = String(communityApi.question.id);
|
||||
}}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
{#if communityApi.isEditMode}
|
||||
<div class="my-2">
|
||||
<TextEditor
|
||||
placeholder="Give an answer"
|
||||
content={communityApi.editContent.body}
|
||||
onChange={(content) => (communityApi.editContent.body = content)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<section class="prose prose-sm sm:prose p-2">
|
||||
{@html communityApi.question?.body}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-8">
|
||||
{pluralize($t('community.answers'), communityApi.question?.comments.length ?? 0, true)}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each communityApi.question?.comments as commentItem}
|
||||
<CommunityCommentItem
|
||||
comment={commentItem}
|
||||
isVoted={!!voted.comment[commentItem.id]}
|
||||
{isValidAnswer}
|
||||
isAuthorOrAdmin={commentItem.author?.id === $profile.id || $isOrgAdmin === true}
|
||||
upVote={() => {
|
||||
if (voted.comment[commentItem.id]) return;
|
||||
|
||||
voted.comment[commentItem.id] = true;
|
||||
|
||||
communityApi.upvotePost({
|
||||
id: commentItem.id,
|
||||
isQuestion: false
|
||||
});
|
||||
}}
|
||||
onDelete={() => {
|
||||
console.log('deleting commentItem', commentItem);
|
||||
communityApi.deleteCommentState.shouldDelete = true;
|
||||
communityApi.deleteCommentState.commentId = commentItem.id;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="mt-4">
|
||||
<TextEditor
|
||||
placeholder="Give an answer"
|
||||
class="h-48!"
|
||||
bind:content={communityApi.comment}
|
||||
onReady={(editor) => (commentEditor = editor)}
|
||||
/>
|
||||
|
||||
<div class="mt-2 flex justify-end">
|
||||
<Button
|
||||
onclick={() => {
|
||||
console.log('comment', communityApi.comment);
|
||||
console.log('question', communityApi.question);
|
||||
if (communityApi.question) {
|
||||
communityApi.createComment(Number(communityApi.question.id), isLMS, () => {
|
||||
commentEditor?.commands?.clearContent?.();
|
||||
});
|
||||
}
|
||||
}}
|
||||
loading={communityApi.isCommenting}
|
||||
>
|
||||
{$t('community.ask.comment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
{/if}
|
||||
@@ -1,26 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courses } from '$features/course/utils/store';
|
||||
import { communityApi } from '../api/community.svelte';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
|
||||
import { CommunityListLoader } from '../components';
|
||||
import { Vote } from '$features/ui';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
|
||||
import { AskCommunityButton } from '../components';
|
||||
import { CommunityListLoader } from '../components';
|
||||
import { Vote } from '$features/ui';
|
||||
import type { CommunityQuestionData } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
isLMS?: boolean;
|
||||
@@ -28,70 +25,17 @@
|
||||
|
||||
let { isLMS = false }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let discussions = $state([]);
|
||||
let searchValue = $state('');
|
||||
let allCourses: any[] = $state([]);
|
||||
let selectedId = $state('');
|
||||
|
||||
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
|
||||
if (!orgId || !profileId) return;
|
||||
|
||||
untrack(async () => {
|
||||
isLoading = true;
|
||||
|
||||
if ($courses.length) {
|
||||
allCourses = [...$courses];
|
||||
} else {
|
||||
const courseResult = (await fetchCourses(profileId, orgId)) || { allCourses: [] };
|
||||
allCourses = courseResult.allCourses;
|
||||
}
|
||||
|
||||
const courseIds = allCourses.map((course) => course.id);
|
||||
const courseIdsFilter = `(${courseIds.join(',')})`;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('community_question')
|
||||
.select(
|
||||
`
|
||||
organization_id,
|
||||
course_id,
|
||||
title,
|
||||
votes,
|
||||
created_at,
|
||||
slug,
|
||||
comments:community_answer(count),
|
||||
author:profile(
|
||||
fullname
|
||||
),
|
||||
course!inner (
|
||||
title
|
||||
)
|
||||
`
|
||||
)
|
||||
.filter('course_id', 'in', courseIdsFilter)
|
||||
.order('created_at', { ascending: false });
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
|
||||
isLoading = false;
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading community', error);
|
||||
return goto(isLMS ? '/lms' : $currentOrgPath);
|
||||
}
|
||||
|
||||
discussions =
|
||||
data?.map((discussion) => ({
|
||||
title: discussion.title,
|
||||
courseId: discussion.course_id,
|
||||
courseTitle: discussion.course?.title,
|
||||
slug: discussion.slug,
|
||||
author: discussion?.author?.fullname,
|
||||
comments: discussion.comments?.[0]?.count || 0,
|
||||
votes: discussion.votes,
|
||||
createdAt: calDateDiff(discussion.created_at)
|
||||
})) || [];
|
||||
// Fetch courses - API class handles this
|
||||
await communityApi.fetchCoursesForOrg(profileId, orgId);
|
||||
// Call API - API class handles loading, errors, navigation
|
||||
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,11 +43,11 @@
|
||||
fetchCommunityQuestions($currentOrg.id, $profile.id);
|
||||
});
|
||||
|
||||
let filteredDiscussions = $derived(
|
||||
discussions.filter(
|
||||
(discussion) =>
|
||||
discussion.title.toLowerCase().includes(searchValue.toLowerCase()) &&
|
||||
(!selectedId || discussion.courseId === selectedId)
|
||||
let filteredQuestions = $derived(
|
||||
communityApi.questions.filter(
|
||||
(question: CommunityQuestionData[number]) =>
|
||||
question.title.toLowerCase().includes(searchValue.toLowerCase()) &&
|
||||
(!selectedId || question.courseId === selectedId)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
@@ -114,46 +58,50 @@
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
|
||||
<p class="truncate">
|
||||
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
|
||||
{selectedId
|
||||
? communityApi.courses.find((course) => course.id === selectedId)?.title || $t('community.all')
|
||||
: $t('community.all')}
|
||||
</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||
{#each allCourses as course}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{#each communityApi.courses as course}
|
||||
{#if course.id}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Page.BodyHeader>
|
||||
|
||||
{#if isLoading}
|
||||
{#if communityApi.isLoading}
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
{:else}
|
||||
{#each filteredDiscussions as discussion}
|
||||
{#each filteredQuestions as question}
|
||||
<Item.Root variant="outline">
|
||||
{#snippet child({ props })}
|
||||
<a href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}" {...props}>
|
||||
<Vote value={discussion.votes} />
|
||||
<a href="{isLMS ? '/lms' : $currentOrgPath}/community/{question.slug}" {...props}>
|
||||
<Vote value={question.votes} />
|
||||
<Item.Content class="gap-y-0.5">
|
||||
<Item.Title class="mt-0">
|
||||
{discussion.title}
|
||||
{question.title}
|
||||
</Item.Title>
|
||||
<Item.Description>
|
||||
{discussion.author} asked {discussion.createdAt}
|
||||
{question.authorFullname} asked {calDateDiff(question.createdAt)}
|
||||
</Item.Description>
|
||||
<a class="m-0" href="/courses/{discussion.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||
<span class="text-muted-foreground p-0 text-xs">
|
||||
#{discussion.courseTitle}
|
||||
#{question.courseTitle}
|
||||
</span>
|
||||
</a>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<div class="flex items-center">
|
||||
<MessageCirclePlusIcon size={16} />
|
||||
<span class="ml-1">{discussion.comments}</span>
|
||||
<span class="ml-1">{question.comments}</span>
|
||||
</div>
|
||||
</Item.Actions>
|
||||
</a>
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { default as CommunityPage } from './community.svelte';
|
||||
export { default as AskCommunityPage } from './ask-community.svelte';
|
||||
export { default as CommunityQuestionPage } from './community-question.svelte';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetCommunityQuestionsResponse = typeof classroomio.community.$get;
|
||||
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsResponse> | null;
|
||||
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsResponse>, { success: true }>;
|
||||
export type CommunityQuestionData = CommunityQuestionsSuccess['data'];
|
||||
|
||||
export type GetCommunityQuestionResponse = (typeof classroomio.community)[':slug']['$get'];
|
||||
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionResponse> | null;
|
||||
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionResponse>, { success: true }>;
|
||||
|
||||
export type CreateCommentResponse = typeof classroomio.community.$post;
|
||||
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentResponse>, { success: true }>;
|
||||
export type CreateCommentData = CreateCommentSuccess['data'];
|
||||
|
||||
export type UpvotePostResponse = (typeof classroomio.community)[':id']['upvote']['$post'];
|
||||
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostResponse>, { success: true }>;
|
||||
export type UpvotePostData = UpvotePostSuccess['data'];
|
||||
|
||||
export type CreateCommunityQuestionResponse = typeof classroomio.community.$post;
|
||||
export type CreateCommunityQuestionSuccess = Extract<
|
||||
InferResponseType<CreateCommunityQuestionResponse>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type UpdateQuestionResponse = (typeof classroomio.community)[':id']['$put'];
|
||||
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionResponse>, { success: true }>;
|
||||
export type UpdateQuestionData = UpdateQuestionSuccess['data'];
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
content,
|
||||
content = $bindable(''),
|
||||
showToolBar = true,
|
||||
editable = true,
|
||||
enablePersistence = false,
|
||||
@@ -43,7 +43,7 @@
|
||||
</script>
|
||||
|
||||
<Editor
|
||||
content={content || ''}
|
||||
bind:content
|
||||
{showToolBar}
|
||||
{editable}
|
||||
{enablePersistence}
|
||||
@@ -56,4 +56,3 @@
|
||||
onEditorReady={onReady}
|
||||
{onEditorDestroy}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1538,6 +1538,7 @@
|
||||
},
|
||||
|
||||
"success": {
|
||||
"question_updated": "Question Updated!",
|
||||
"comment_submitted": "Comment Submitted!",
|
||||
"success_delete": "Deleted successfully",
|
||||
"question_submitted": "Question Submitted!"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user