feat(newsfeed): Improved UI flow for the course newsfeed (#791)
* feat(newsfeed): improved the newsfeed ui * feat(newsfeed): updated the ui * feat(newsfeed): some improvements * feat(newsfeed): cleanup * chore(newsfeed): added translations * fix(newsfeed): fixing comments and reviews from greptile and coderabbit * fix(newsfeed): fixing comments and reviews from greptile and coderabbit, improved replies to a reply * fix(newsfeed): fixing reviews * fix(ui): expose cancelLabel prop in comment-tree-input and pass localized text --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
88c4cf03b3
commit
a1c4ee720f
@@ -0,0 +1,76 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { getNewsfeedCommentAuthorAndCourse } from '@cio/db/queries/newsfeed';
|
||||
|
||||
/**
|
||||
* Middleware to check if user is strictly the comment author
|
||||
* Requires authMiddleware to be applied first
|
||||
* Expects commentId in route params as 'commentId'
|
||||
*/
|
||||
export const newsfeedCommentAuthorOnlyMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const commentId = c.req.param('commentId');
|
||||
if (!commentId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Comment ID is required',
|
||||
code: 'COMMENT_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const commentInfo = await getNewsfeedCommentAuthorAndCourse(Number(commentId));
|
||||
if (!commentInfo || !commentInfo.courseId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Comment not found',
|
||||
code: ErrorCodes.COMMENT_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const userGroupMemberId = await getGroupMemberIdByCourseAndProfile(commentInfo.courseId, user.id);
|
||||
const isAuthor = Boolean(userGroupMemberId && commentInfo.authorId && commentInfo.authorId === userGroupMemberId);
|
||||
|
||||
if (!isAuthor) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Only the author of this comment can edit it',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in newsfeedCommentAuthorOnlyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify comment authorization',
|
||||
code: 'COMMENT_AUTH_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -49,7 +49,7 @@ export const newsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Ne
|
||||
|
||||
// Check if user is the author of the comment
|
||||
const userGroupMemberId = await getGroupMemberIdByCourseAndProfile(commentInfo.courseId, user.id);
|
||||
const isAuthor = commentInfo.authorId === userGroupMemberId;
|
||||
const isAuthor = Boolean(userGroupMemberId && commentInfo.authorId && commentInfo.authorId === userGroupMemberId);
|
||||
|
||||
if (isAuthor) {
|
||||
await next();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { newsfeedCommentAuthorOnlyMiddleware } from '@api/middlewares/newsfeed-comment-author-only';
|
||||
import { newsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/newsfeed-comment-author-or-team';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
@@ -130,9 +131,9 @@ export const newsfeedRouter = new Hono()
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const { cursor, limit = 5 } = c.req.valid('query');
|
||||
const { parentId, cursor, limit = 5 } = c.req.valid('query');
|
||||
|
||||
const result = await getNewsfeedCommentsService(feedId, { cursor, limit });
|
||||
const result = await getNewsfeedCommentsService(feedId, { parentId, cursor, limit });
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
@@ -151,14 +152,14 @@ export const newsfeedRouter = new Hono()
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { feedId } = c.req.valid('param');
|
||||
const { content } = c.req.valid('json');
|
||||
const { content, parentId } = c.req.valid('json');
|
||||
|
||||
const groupMemberId = await getGroupMemberIdByCourseAndProfile(courseId, user.id);
|
||||
if (!groupMemberId) {
|
||||
return c.json({ success: false, error: 'User is not a member of this course' }, 403);
|
||||
}
|
||||
|
||||
const comment = await createNewsfeedCommentService(feedId, groupMemberId, content);
|
||||
const comment = await createNewsfeedCommentService(feedId, groupMemberId, content, parentId);
|
||||
|
||||
return c.json({ success: true, data: comment }, 201);
|
||||
} catch (error) {
|
||||
@@ -169,7 +170,7 @@ export const newsfeedRouter = new Hono()
|
||||
.put(
|
||||
'/comment/:commentId',
|
||||
authMiddleware,
|
||||
newsfeedCommentAuthorOrTeamMiddleware,
|
||||
newsfeedCommentAuthorOnlyMiddleware,
|
||||
zValidator('param', ZNewsfeedCommentGetParam),
|
||||
zValidator('json', ZNewsfeedCommentUpdate),
|
||||
async (c) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getNewsfeedByCourseId,
|
||||
getNewsfeedByCourseIdPaginated,
|
||||
getNewsfeedById,
|
||||
getNewsfeedCommentById,
|
||||
getNewsfeedCommentsByFeedId,
|
||||
getNewsfeedCommentsByFeedIdPaginated,
|
||||
getNewsfeedForEmail,
|
||||
@@ -231,10 +232,13 @@ export async function getNewsfeedComments(feedId: string) {
|
||||
/**
|
||||
* Gets paginated comments for a newsfeed item
|
||||
* @param feedId Newsfeed ID
|
||||
* @param options Pagination options (cursor, limit)
|
||||
* @param options Pagination options (parentId, cursor, limit)
|
||||
* @returns Paginated comments with metadata
|
||||
*/
|
||||
export async function getNewsfeedCommentsService(feedId: string, options: { cursor?: string; limit: number }) {
|
||||
export async function getNewsfeedCommentsService(
|
||||
feedId: string,
|
||||
options: { parentId?: number; cursor?: string; limit: number }
|
||||
) {
|
||||
try {
|
||||
const feed = await getNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
@@ -260,16 +264,44 @@ export async function getNewsfeedCommentsService(feedId: string, options: { curs
|
||||
* @param feedId Newsfeed ID
|
||||
* @param authorId Group member ID (author)
|
||||
* @param content Comment content
|
||||
* @param parentId Optional parent comment ID for replies
|
||||
* @returns Created comment
|
||||
*/
|
||||
export async function createNewsfeedCommentService(feedId: string, authorId: string, content: string) {
|
||||
export async function createNewsfeedCommentService(
|
||||
feedId: string,
|
||||
authorId: string,
|
||||
content: string,
|
||||
parentId?: number
|
||||
) {
|
||||
try {
|
||||
// Validate parentId when provided
|
||||
if (parentId !== undefined) {
|
||||
const parentComment = await getNewsfeedCommentById(parentId);
|
||||
|
||||
if (!parentComment) {
|
||||
throw new AppError('Parent comment not found', ErrorCodes.COMMENT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (parentComment.courseNewsfeedId !== feedId) {
|
||||
throw new AppError('Parent comment does not belong to this feed', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (parentComment.parentId !== null) {
|
||||
throw new AppError(
|
||||
'Cannot reply to a reply — only top-level comments can be replied to',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedContent = sanitizeHtml(content);
|
||||
|
||||
const commentData: TNewCourseNewsfeedComment = {
|
||||
courseNewsfeedId: feedId,
|
||||
authorId,
|
||||
content: sanitizedContent
|
||||
content: sanitizedContent,
|
||||
parentId: parentId ?? null
|
||||
};
|
||||
|
||||
const comment = await createNewsfeedComment(commentData);
|
||||
@@ -281,6 +313,10 @@ export async function createNewsfeedCommentService(feedId: string, authorId: str
|
||||
|
||||
return comment;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create newsfeed comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
|
||||
@@ -44,6 +44,8 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
|
||||
commentsByFeedId = $state<Record<string, NewsfeedCommentsByFeedId>>({});
|
||||
|
||||
repliesByParentId = $state<Record<number, NewsfeedCommentsByFeedId>>({});
|
||||
|
||||
isNewFeedModalOpen = $state(false);
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
if (response.data) {
|
||||
this.feeds = Array.isArray(response.data) ? response.data : response.data.items || [];
|
||||
for (const feed of this.feeds) {
|
||||
this.initComments(feed.id, 0);
|
||||
this.initComments(feed.id, feed.commentCount || 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -356,6 +358,91 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch replies for a specific parent comment
|
||||
*/
|
||||
async getReplies(courseId: string, feedId: string, parentId: number, limit = 5) {
|
||||
if (!this.repliesByParentId[parentId]) {
|
||||
this.repliesByParentId[parentId] = {
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
isLoading: true,
|
||||
cursor: null
|
||||
};
|
||||
} else {
|
||||
this.repliesByParentId[parentId].isLoading = true;
|
||||
}
|
||||
|
||||
await this.execute<GetNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { courseId, feedId },
|
||||
query: { parentId: String(parentId), limit: String(limit) }
|
||||
}),
|
||||
logContext: 'fetching newsfeed comment replies',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.repliesByParentId[parentId] = {
|
||||
items: response.data.items,
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (this.repliesByParentId[parentId]) {
|
||||
this.repliesByParentId[parentId].isLoading = false;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to fetch replies');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more replies for a specific parent comment (uses cursor)
|
||||
*/
|
||||
async loadMoreReplies(courseId: string, feedId: string, parentId: number, limit = 5) {
|
||||
const replyState = this.repliesByParentId[parentId];
|
||||
if (!replyState || !replyState.hasMore || replyState.isLoading || !replyState.cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
replyState.isLoading = true;
|
||||
|
||||
await this.execute<GetNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { courseId, feedId },
|
||||
query: { parentId: String(parentId), cursor: replyState.cursor!, limit: String(limit) }
|
||||
}),
|
||||
logContext: 'loading more newsfeed replies',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.repliesByParentId[parentId] = {
|
||||
items: [...replyState.items, ...response.data.items],
|
||||
totalCount: response.data.totalCount,
|
||||
hasMore: response.data.hasMore,
|
||||
isLoading: false,
|
||||
cursor: response.data.nextCursor
|
||||
};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (this.repliesByParentId[parentId]) {
|
||||
this.repliesByParentId[parentId].isLoading = false;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to load more replies');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more comments (uses cursor)
|
||||
*/
|
||||
@@ -395,16 +482,16 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a newsfeed comment
|
||||
* Creates a newsfeed comment or reply
|
||||
*/
|
||||
async createComment(
|
||||
courseId: string,
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string }
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string },
|
||||
parentId?: number
|
||||
) {
|
||||
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content });
|
||||
console.log('result', result);
|
||||
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content, parentId });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
return;
|
||||
@@ -421,17 +508,47 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
if (response.data) {
|
||||
snackbar.success('Comment added successfully');
|
||||
|
||||
// Add comment to local state
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
const newComment = {
|
||||
...response.data,
|
||||
// Map author data to the expected flat structure
|
||||
authorProfileId: author.id,
|
||||
authorFullname: author.fullname,
|
||||
authorUsername: author.username,
|
||||
authorAvatarUrl: author.avatarUrl
|
||||
authorAvatarUrl: author.avatarUrl,
|
||||
replyCount: 0
|
||||
};
|
||||
|
||||
if (parentId) {
|
||||
const repliesState = this.repliesByParentId[parentId];
|
||||
if (repliesState) {
|
||||
this.repliesByParentId[parentId] = {
|
||||
...repliesState,
|
||||
items: [...repliesState.items, newComment],
|
||||
totalCount: repliesState.totalCount + 1
|
||||
};
|
||||
} else {
|
||||
this.repliesByParentId[parentId] = {
|
||||
items: [newComment],
|
||||
totalCount: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
cursor: null
|
||||
};
|
||||
}
|
||||
|
||||
// Increment parent reply count in local top-level items
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
const updatedItems = commentState.items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
return { ...item, replyCount: (item.replyCount || 0) + 1 };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
|
||||
}
|
||||
} else {
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
...commentState,
|
||||
items: [newComment, ...commentState.items],
|
||||
@@ -439,6 +556,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
@@ -451,7 +569,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Updates a newsfeed comment
|
||||
*/
|
||||
async updateComment(courseId: string, commentId: string, content: string) {
|
||||
async updateComment(courseId: string, feedId: string, commentId: string, content: string, parentId?: number) {
|
||||
const result = ZNewsfeedCommentUpdate.safeParse({ content });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
|
||||
@@ -465,6 +583,34 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Comment updated successfully');
|
||||
|
||||
const numericId = Number(commentId);
|
||||
if (parentId && this.repliesByParentId[parentId]) {
|
||||
const replyState = this.repliesByParentId[parentId];
|
||||
const updatedItems = replyState.items.map((item) => {
|
||||
if (item.id === numericId) {
|
||||
return { ...item, content: response.data?.content ?? content };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.repliesByParentId[parentId] = { ...replyState, items: updatedItems };
|
||||
} else {
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
const updatedItems = commentState.items.map((item) => {
|
||||
if (item.id === numericId) {
|
||||
return { ...item, content: response.data?.content ?? content };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to update comment');
|
||||
@@ -476,7 +622,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
/**
|
||||
* Deletes a newsfeed comment
|
||||
*/
|
||||
async deleteComment(courseId: string, feedId: string, commentId: string) {
|
||||
async deleteComment(courseId: string, feedId: string, commentId: string, parentId?: number) {
|
||||
await this.execute<DeleteNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({
|
||||
@@ -487,7 +633,29 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
if (response.data) {
|
||||
snackbar.success('Comment deleted successfully');
|
||||
|
||||
// Remove comment from local state
|
||||
const numericId = Number(commentId);
|
||||
if (parentId && this.repliesByParentId[parentId]) {
|
||||
const replyState = this.repliesByParentId[parentId];
|
||||
this.repliesByParentId[parentId] = {
|
||||
...replyState,
|
||||
items: replyState.items.filter((item) => String(item.id) !== commentId),
|
||||
totalCount: Math.max(0, replyState.totalCount - 1)
|
||||
};
|
||||
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
const updatedItems = commentState.items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
return { ...item, replyCount: Math.max(0, (item.replyCount || 1) - 1) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
|
||||
}
|
||||
} else {
|
||||
if (this.repliesByParentId[numericId]) {
|
||||
delete this.repliesByParentId[numericId];
|
||||
}
|
||||
const commentState = this.commentsByFeedId[feedId];
|
||||
if (commentState) {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
@@ -497,6 +665,7 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
|
||||
@@ -80,13 +80,13 @@
|
||||
|
||||
<div
|
||||
id={feed.id}
|
||||
class="flex flex-col justify-between gap-2 {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'ui:border'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
class="bg-card text-card-foreground mb-6 flex w-full max-w-3xl flex-col rounded-xl shadow-sm transition-all duration-200 hover:shadow-md {isActive
|
||||
? 'ring-primary border-primary ring-2'
|
||||
: 'border-border/60 border'}"
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<div class="px-4 pb-3">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
@@ -100,11 +100,11 @@
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
onAddComment={async (content) => {
|
||||
await addNewComment(content, feed.id);
|
||||
onAddComment={async (content, parentId) => {
|
||||
await addNewComment(content, feed.id, parentId);
|
||||
}}
|
||||
onDeleteComment={(commentId) => {
|
||||
if (courseId) deleteComment(feed.id, String(commentId));
|
||||
onDeleteComment={(commentId, parentId) => {
|
||||
if (courseId) deleteComment(feed.id, String(commentId), parentId);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
|
||||
import * as CommentTree from '@cio/ui/custom/comment-tree';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
|
||||
@@ -26,34 +23,61 @@
|
||||
avatarUrl: string;
|
||||
};
|
||||
comments?: NewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
onAddComment: (content: string, parentId?: number) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number, parentId?: number) => void;
|
||||
}
|
||||
|
||||
let { courseId, feed, author, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let errors: { newComment: string } = $state({ newComment: '' });
|
||||
let activeReplyTarget = $state<{
|
||||
id: number;
|
||||
commentId: number;
|
||||
fullname: string;
|
||||
snippet: string;
|
||||
} | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let isBootstrapping = $state(false);
|
||||
let didBootstrap = $state(false);
|
||||
let expandedRepliesMap = $state<Record<number, boolean>>({});
|
||||
|
||||
// Pagination / progressive reveal (newest-first)
|
||||
const PAGE_SIZE = 5;
|
||||
let visibleCount = $state(1);
|
||||
let editingCommentId = $state<number | null>(null);
|
||||
let editingText = $state('');
|
||||
let isSavingEdit = $state(false);
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
let visibleCount = $state(2);
|
||||
|
||||
const handleStartEdit = (commentId: number, currentContent: string) => {
|
||||
editingCommentId = commentId;
|
||||
editingText = currentContent;
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
editingCommentId = null;
|
||||
editingText = '';
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (commentId: number, parentId?: number) => {
|
||||
if (!courseId || isSavingEdit || !editingText.trim()) return;
|
||||
isSavingEdit = true;
|
||||
try {
|
||||
await newsfeedApi.updateComment(courseId, feed.id, String(commentId), editingText.trim(), parentId);
|
||||
if (newsfeedApi.success) {
|
||||
editingCommentId = null;
|
||||
editingText = '';
|
||||
}
|
||||
} finally {
|
||||
isSavingEdit = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
|
||||
const totalCount = $derived(comments?.totalCount || feed.commentCount);
|
||||
const totalCount = $derived(comments?.totalCount ?? feed.commentCount ?? 0);
|
||||
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
const shownCount = $derived(Math.min(visibleCount, Math.max(loadedCount, 0)));
|
||||
|
||||
// Fetch the minimum info needed to:
|
||||
// - know totalCount
|
||||
// - show the latest comment (1) by default
|
||||
const bootstrap = async () => {
|
||||
if (!courseId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
if (!courseId || didBootstrap) return;
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
try {
|
||||
@@ -63,162 +87,245 @@
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await onAddComment(comment);
|
||||
comment = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
onMount(async () => {
|
||||
if (totalCount > 0 && (!comments || comments.items.length === 0)) {
|
||||
await bootstrap();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const maybeKey = event as KeyboardEvent & { key?: string };
|
||||
if (maybeKey.key === 'Enter' || event.type === 'click') {
|
||||
event.preventDefault();
|
||||
await submitComment();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ensureLoaded = async (targetVisible: number) => {
|
||||
if (!courseId || !comments) return;
|
||||
|
||||
const target = Math.min(targetVisible, comments.totalCount);
|
||||
|
||||
// Load pages until we have enough locally to render `target` items.
|
||||
while (comments.hasMore && comments.items.length < target && !comments.isLoading) {
|
||||
while (true) {
|
||||
const current = newsfeedApi.commentsByFeedId[feed.id];
|
||||
if (!current || !current.hasMore || current.isLoading || current.items.length >= target) break;
|
||||
const prevLength = current.items.length;
|
||||
await newsfeedApi.loadMoreComments(courseId, feed.id, PAGE_SIZE);
|
||||
const updated = newsfeedApi.commentsByFeedId[feed.id];
|
||||
if (!updated || updated.items.length <= prevLength) break;
|
||||
}
|
||||
};
|
||||
|
||||
const showMore = async () => {
|
||||
if (!courseId || !comments) return;
|
||||
|
||||
// Ensure we know totalCount and have at least 1 comment (if it exists).
|
||||
if (!didBootstrap) {
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
const next = visibleCount <= 1 ? PAGE_SIZE : visibleCount + PAGE_SIZE;
|
||||
if (!didBootstrap) await bootstrap();
|
||||
const next = visibleCount + PAGE_SIZE;
|
||||
visibleCount = next;
|
||||
await ensureLoaded(next);
|
||||
};
|
||||
|
||||
const minimize = () => {
|
||||
visibleCount = visibleCount > 1 ? 1 : shownCount;
|
||||
const handleToggleReplies = async (parentId: number) => {
|
||||
const isCurrentlyExpanded = Boolean(expandedRepliesMap[parentId]);
|
||||
expandedRepliesMap[parentId] = !isCurrentlyExpanded;
|
||||
|
||||
if (!isCurrentlyExpanded && courseId) {
|
||||
await newsfeedApi.getReplies(courseId, feed.id, parentId);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComments = async () => {
|
||||
// If already showing more than the latest comment, collapse back to 1.
|
||||
if (totalCount === shownCount) {
|
||||
minimize();
|
||||
return;
|
||||
const handleReplyClick = (topLevelId: number, targetCommentId: number, authorName: string, content: string) => {
|
||||
const cleanText = content.replace(/<blockquote[\s\S]*?<\/blockquote>/gi, '').trim();
|
||||
const snippet = cleanText.length > 60 ? cleanText.slice(0, 60) + '...' : cleanText;
|
||||
activeReplyTarget = {
|
||||
id: topLevelId,
|
||||
commentId: targetCommentId,
|
||||
fullname: authorName,
|
||||
snippet
|
||||
};
|
||||
};
|
||||
|
||||
const handleCancelReply = () => {
|
||||
activeReplyTarget = null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (text: string) => {
|
||||
if (isSubmitting) return;
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const parentId = activeReplyTarget?.id;
|
||||
let finalContent = text;
|
||||
|
||||
if (activeReplyTarget) {
|
||||
const quoteBlock = `<blockquote class="reply-quote ui:mt-0.5 ui:mb-1 ui:rounded-r-md ui:border-l-2 ui:border-primary ui:bg-muted/40 ui:px-2.5 ui:py-1.5 ui:text-xs ui:text-muted-foreground"><strong class="ui:font-medium ui:text-foreground">Replying to @${activeReplyTarget.fullname}</strong><br/><span class="ui:block ui:truncate ui:italic">"${activeReplyTarget.snippet}"</span></blockquote>`;
|
||||
finalContent = `${quoteBlock}${text}`;
|
||||
}
|
||||
|
||||
await showMore();
|
||||
await onAddComment(finalContent, parentId);
|
||||
if (parentId) {
|
||||
expandedRepliesMap[parentId] = true;
|
||||
}
|
||||
activeReplyTarget = null;
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="ui:border-t p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<section class="ui:border-t ui:border-border/60 p-4 pt-3">
|
||||
{#if totalCount > shownCount}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={toggleComments}
|
||||
loading={isBootstrapping || isLoading}
|
||||
class="justify-start"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<p class="py-2 text-sm">{pluralize($t('course.navItem.news_feed.comments.comment'), totalCount, true)}</p>
|
||||
|
||||
{#if totalCount > shownCount}
|
||||
<span class="ml-auto text-xs text-gray-600"
|
||||
>{$t('course.navItem.news_feed.comments.showing_of', { shown: shownCount, total: totalCount })}</span
|
||||
onclick={showMore}
|
||||
disabled={isBootstrapping || isLoading}
|
||||
class="text-muted-foreground hover:text-foreground ui:transition-colors ui:h-auto ui:p-0 ui:justify-start mb-3 text-sm font-medium"
|
||||
>
|
||||
{#if isBootstrapping || isLoading}
|
||||
{$t('course.navItem.news_feed.comments.loading')}
|
||||
{:else}
|
||||
{$t('course.navItem.news_feed.comments.view_more', { count: totalCount - shownCount })}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if shownCount > 1}
|
||||
<Button variant="ghost" size="sm" onclick={minimize}>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('course.navItem.news_feed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommentTree.Root class="max-h-[400px] overflow-y-auto py-2">
|
||||
{#if comments}
|
||||
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
|
||||
{#if commentItem.content}
|
||||
<div class="group flex items-center justify-between py-2">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
|
||||
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
|
||||
</div>
|
||||
<p>{commentItem.content}</p>
|
||||
</span>
|
||||
</span>
|
||||
{@const commentIdNum = Number(commentItem.id)}
|
||||
{@const repliesState = newsfeedApi.repliesByParentId[commentIdNum]}
|
||||
|
||||
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
<CommentTree.Item>
|
||||
<div id="comment-{commentItem.id}" class="flex w-full items-start justify-between">
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<CommentTree.Header
|
||||
avatarUrl={commentItem.authorAvatarUrl}
|
||||
fullname={commentItem.authorFullname}
|
||||
dateLabel={calDateDiff(commentItem.createdAt)}
|
||||
canEdit={commentItem.authorProfileId === $profile.id}
|
||||
onEdit={() => handleStartEdit(commentIdNum, commentItem.content)}
|
||||
canDelete={commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
onDelete={() => onDeleteComment(commentItem.id)}
|
||||
/>
|
||||
<div class="pl-10">
|
||||
{#if editingCommentId === commentIdNum}
|
||||
<div class="mt-1 flex flex-col gap-2">
|
||||
<Textarea bind:value={editingText} rows={2} class="text-sm" />
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="xs" onclick={handleCancelEdit} disabled={isSavingEdit}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onclick={() => handleSaveEdit(commentIdNum)}
|
||||
disabled={isSavingEdit || !editingText.trim()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('course.navItem.news_feed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{isSavingEdit
|
||||
? $t('course.navItem.news_feed.comments.saving')
|
||||
: $t('course.navItem.news_feed.comments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CommentTree.Content content={commentItem.content} />
|
||||
<CommentTree.Actions
|
||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||
deleteLabel={$t('course.navItem.news_feed.comments.delete')}
|
||||
onReply={() =>
|
||||
handleReplyClick(
|
||||
commentIdNum,
|
||||
commentIdNum,
|
||||
commentItem.authorFullname || $t('course.navItem.news_feed.user'),
|
||||
commentItem.content || ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nested Replies Thread -->
|
||||
<CommentTree.Replies
|
||||
replyCount={commentItem.replyCount || 0}
|
||||
isExpanded={Boolean(expandedRepliesMap[commentIdNum])}
|
||||
showLabel={$t('course.navItem.news_feed.comments.view_replies', { count: commentItem.replyCount || 0 })}
|
||||
hideLabel={$t('course.navItem.news_feed.comments.hide_replies')}
|
||||
onToggleExpand={() => handleToggleReplies(commentIdNum)}
|
||||
>
|
||||
{#if repliesState?.items}
|
||||
{#each repliesState.items as reply (reply.id)}
|
||||
{@const replyIdNum = Number(reply.id)}
|
||||
<CommentTree.Item>
|
||||
<div id="comment-{reply.id}" class="flex w-full flex-col gap-1">
|
||||
<CommentTree.Header
|
||||
avatarUrl={reply.authorAvatarUrl}
|
||||
fullname={reply.authorFullname}
|
||||
dateLabel={calDateDiff(reply.createdAt)}
|
||||
avatarSize="ui:size-7"
|
||||
canEdit={reply.authorProfileId === $profile.id}
|
||||
onEdit={() => handleStartEdit(replyIdNum, reply.content)}
|
||||
canDelete={reply.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
onDelete={() => onDeleteComment(reply.id, commentIdNum)}
|
||||
/>
|
||||
<div class="pl-9">
|
||||
{#if editingCommentId === replyIdNum}
|
||||
<div class="mt-1 flex flex-col gap-2">
|
||||
<Textarea bind:value={editingText} rows={2} class="text-sm" />
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="xs" onclick={handleCancelEdit} disabled={isSavingEdit}>
|
||||
{$t('course.navItem.news_feed.heading_button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onclick={() => handleSaveEdit(replyIdNum, commentIdNum)}
|
||||
disabled={isSavingEdit || !editingText.trim()}
|
||||
>
|
||||
{isSavingEdit
|
||||
? $t('course.navItem.news_feed.comments.saving')
|
||||
: $t('course.navItem.news_feed.comments.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CommentTree.Content content={reply.content} />
|
||||
<CommentTree.Actions
|
||||
replyLabel={$t('course.navItem.news_feed.comments.reply')}
|
||||
deleteLabel={$t('course.navItem.news_feed.comments.delete')}
|
||||
onReply={() =>
|
||||
handleReplyClick(
|
||||
commentIdNum,
|
||||
replyIdNum,
|
||||
reply.authorFullname || $t('course.navItem.news_feed.user'),
|
||||
reply.content || ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CommentTree.Item>
|
||||
{/each}
|
||||
|
||||
{#if repliesState.hasMore}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => courseId && newsfeedApi.loadMoreReplies(courseId, feed.id, commentIdNum)}
|
||||
disabled={repliesState.isLoading}
|
||||
class="text-muted-foreground hover:text-foreground ui:transition-colors ui:h-auto ui:p-0 ui:pl-9 ui:justify-start mt-1 text-sm font-medium"
|
||||
>
|
||||
{repliesState.isLoading
|
||||
? $t('course.navItem.news_feed.comments.loading')
|
||||
: $t('course.navItem.news_feed.comments.load_more_replies')}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</CommentTree.Replies>
|
||||
</CommentTree.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</CommentTree.Root>
|
||||
|
||||
<!-- Input for new comment -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('course.navItem.news_feed.user_avatar_alt')} class="size-7" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
<!-- Comment Input Box -->
|
||||
<CommentTree.Input
|
||||
authorAvatarUrl={author.avatarUrl}
|
||||
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
|
||||
class="ui:border w-full rounded-3xl bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
replyingToUser={activeReplyTarget?.fullname || null}
|
||||
replyingToSnippet={activeReplyTarget?.snippet || null}
|
||||
onCancelReply={handleCancelReply}
|
||||
cancelLabel={$t('course.navItem.news_feed.comments.cancel_reply')}
|
||||
onSubmit={handleSubmit}
|
||||
{isSubmitting}
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('course.navItem.news_feed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if errors?.newComment}
|
||||
<p class="text-sm text-red-500">{errors?.newComment}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
+7
-4
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
@@ -18,14 +19,16 @@
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Feed</Dialog.Title>
|
||||
<Dialog.Title>{$t('course.navItem.news_feed.delete_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<h1 class="text-lg dark:text-white">Are you sure you want to delete this feed?</h1>
|
||||
<h1 class="text-lg dark:text-white">{$t('course.navItem.news_feed.delete_modal.description')}</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>No</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>Yes</Button>
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}
|
||||
>{$t('course.navItem.news_feed.delete_modal.no')}</Button
|
||||
>
|
||||
<Button variant="outline" onclick={handleDelete}>{$t('course.navItem.news_feed.delete_modal.yes')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -21,38 +24,57 @@
|
||||
let { feed, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<UserAvatar src={feed.authorAvatarUrl} />
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
</span>
|
||||
</span>
|
||||
<div class="p-4 pb-2">
|
||||
{#if feed.isPinned}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<PinIcon size={14} class="fill-current" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar src={feed.authorAvatarUrl} class="size-10" />
|
||||
<div class="flex flex-col">
|
||||
<p class="text-foreground text-sm font-semibold capitalize">{feed.authorFullname || 'Anonymous'}</p>
|
||||
<p class="text-muted-foreground text-xs font-normal">{calDateDiff(feed.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
class="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
<EllipsisVerticalIcon class="h-4 w-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<PinIcon size={14} />
|
||||
{feed.isPinned ? $t('course.navItem.news_feed.card.unpin') : $t('course.navItem.news_feed.card.pin')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>
|
||||
<span class="flex items-center gap-2">
|
||||
<PencilIcon size={14} />
|
||||
{$t('course.navItem.news_feed.card.edit')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={onRequestDelete}>
|
||||
<span class="flex items-center gap-2">
|
||||
<Trash2Icon size={14} />
|
||||
{$t('course.navItem.news_feed.card.delete')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('course.navItem.news_feed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}
|
||||
>{$t('course.navItem.news_feed.card.delete')}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
{#if !isHtmlValueEmpty(feed.content || '')}
|
||||
<HTMLRender className="w-[80%]">
|
||||
<HTMLRender
|
||||
className="w-full text-foreground text-sm font-normal md:text-sm prose-headings:text-base prose-headings:font-semibold prose-headings:mb-2 prose-p:mb-2 prose-a:text-primary"
|
||||
>
|
||||
<div>
|
||||
<SafeHtmlContent content={feed.content || ''} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
@@ -44,8 +44,8 @@
|
||||
avatarUrl: $profile.avatarUrl || ''
|
||||
});
|
||||
|
||||
const deleteComment = async (feedId: string, commentId: string) => {
|
||||
await newsfeedApi.deleteComment(courseId, feedId, commentId);
|
||||
const deleteComment = async (feedId: string, commentId: string, parentId?: number) => {
|
||||
await newsfeedApi.deleteComment(courseId, feedId, commentId, parentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
@@ -69,8 +69,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string) => {
|
||||
await newsfeedApi.createComment(courseId, feedId, comment, author);
|
||||
const addNewComment = async (comment: string, feedId: string, parentId?: number) => {
|
||||
await newsfeedApi.createComment(courseId, feedId, comment, author, parentId);
|
||||
|
||||
if (!newsfeedApi.success) {
|
||||
return snackbar.error('snackbar.course.error.commenting_error');
|
||||
@@ -96,12 +96,12 @@
|
||||
await newsfeedApi.delete(courseId, id);
|
||||
};
|
||||
|
||||
const sortedFeeds = $derived.by(() => {
|
||||
return [...newsfeedApi.feeds].sort((a, b) => {
|
||||
const aPinned = (a as any).isPinned || (a as any).is_pinned || false;
|
||||
const bPinned = (b as any).isPinned || (b as any).is_pinned || false;
|
||||
return Number(bPinned) - Number(aPinned);
|
||||
const pinnedFeeds = $derived.by(() => {
|
||||
return newsfeedApi.feeds.filter((feed) => Boolean((feed as any).isPinned || (feed as any).is_pinned));
|
||||
});
|
||||
|
||||
const unpinnedFeeds = $derived.by(() => {
|
||||
return newsfeedApi.feeds.filter((feed) => !((feed as any).isPinned || (feed as any).is_pinned));
|
||||
});
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
@@ -138,7 +138,7 @@
|
||||
<NewsFeedLoader />
|
||||
<NewsFeedLoader />
|
||||
</div>
|
||||
{:else if !sortedFeeds.length}
|
||||
{:else if !pinnedFeeds.length && !unpinnedFeeds.length}
|
||||
<Empty
|
||||
title={$t('course.navItem.news_feed.body_header')}
|
||||
description={$t('course.navItem.news_feed.body_content')}
|
||||
@@ -146,14 +146,13 @@
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
{#each sortedFeeds as feed}
|
||||
{#if feed.isPinned}
|
||||
<div class="flex items-center gap-2">
|
||||
<PinIcon size={16} class="filled" />
|
||||
|
||||
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
|
||||
{#if pinnedFeeds.length > 0}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium tracking-wider uppercase">
|
||||
{$t('course.navItem.news_feed.pinned') || 'Pinned'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each pinnedFeeds as feed (feed.id)}
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
comments={newsfeedApi.commentsByFeedId[feed.id]}
|
||||
@@ -171,4 +170,33 @@
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pinnedFeeds.length > 0 && unpinnedFeeds.length > 0}
|
||||
<div class="text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium tracking-wider uppercase">
|
||||
{$t('course.navItem.news_feed.other_posts')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if unpinnedFeeds.length > 0}
|
||||
{#each unpinnedFeeds as feed (feed.id)}
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
comments={newsfeedApi.commentsByFeedId[feed.id]}
|
||||
{courseId}
|
||||
{deleteFeed}
|
||||
{addNewComment}
|
||||
{deleteComment}
|
||||
{addNewReaction}
|
||||
{onPin}
|
||||
{author}
|
||||
bind:edit
|
||||
bind:editFeed
|
||||
isActive={feedId === feed.id}
|
||||
isReacting={Boolean(isReactingByFeedId[feed.id])}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Skjul kommentarer",
|
||||
"placeholder": "Tilføj klassekommentar",
|
||||
"submit_aria": "Send kommentar",
|
||||
"delete": "Slet"
|
||||
"delete": "Slet",
|
||||
"reply": "Svar",
|
||||
"replying_to": "Svarer til @{name}",
|
||||
"cancel_reply": "Annuller",
|
||||
"view_replies": "Se {count} {count, plural, =1 {reply} anden {replies}}",
|
||||
"hide_replies": "Skjul svar",
|
||||
"view_more": "Se flere kommentarer ({count})",
|
||||
"loading": "Indlæser...",
|
||||
"save": "Gem",
|
||||
"saving": "Gemmer...",
|
||||
"send": "Send",
|
||||
"load_more_replies": "Indlæs flere svar"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Fastgør",
|
||||
"unpin": "Frigør",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet"
|
||||
},
|
||||
"other_posts": "Andre indlæg",
|
||||
"user": "Bruger",
|
||||
"delete_modal": {
|
||||
"title": "Slet feed",
|
||||
"description": "Er du sikker på, at du vil slette dette feed?",
|
||||
"no": "Nej",
|
||||
"yes": "Ja"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Kommentare ausblenden",
|
||||
"placeholder": "Klassenkommentar hinzufügen",
|
||||
"submit_aria": "Kommentar abgeben",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"reply": "Antwort",
|
||||
"replying_to": "Auf @{name} antworten",
|
||||
"cancel_reply": "Abbrechen",
|
||||
"view_replies": "{count} {count, plural, =1 {reply} other {replies}} ansehen",
|
||||
"hide_replies": "Antworten ausblenden",
|
||||
"view_more": "Weitere Kommentare anzeigen ({count})",
|
||||
"loading": "Laden...",
|
||||
"save": "Speichern",
|
||||
"saving": "Sparen...",
|
||||
"send": "Senden",
|
||||
"load_more_replies": "Weitere Antworten laden"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Anheften",
|
||||
"unpin": "Lösen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"other_posts": "Andere Beiträge",
|
||||
"user": "Benutzer",
|
||||
"delete_modal": {
|
||||
"title": "Feed löschen",
|
||||
"description": "Möchten Sie diesen Feed wirklich löschen?",
|
||||
"no": "Nein",
|
||||
"yes": "Ja"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2086,6 +2086,14 @@
|
||||
"body_header": "No post yet",
|
||||
"body_content": "Make a post to your class. Start by clicking on the Add button.",
|
||||
"pinned": "Pinned",
|
||||
"other_posts": "Other Posts",
|
||||
"user": "User",
|
||||
"delete_modal": {
|
||||
"title": "Delete Feed",
|
||||
"description": "Are you sure you want to delete this feed?",
|
||||
"no": "No",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -2098,7 +2106,18 @@
|
||||
"hide": "Hide comments",
|
||||
"placeholder": "Add class comment",
|
||||
"submit_aria": "Submit comment",
|
||||
"delete": "Delete"
|
||||
"reply": "Reply",
|
||||
"replying_to": "Replying to @{name}",
|
||||
"cancel_reply": "Cancel",
|
||||
"view_replies": "View {count} {count, plural, =1 {reply} other {replies}}",
|
||||
"hide_replies": "Hide replies",
|
||||
"delete": "Delete",
|
||||
"view_more": "View more comments ({count})",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"send": "Send",
|
||||
"load_more_replies": "Load more replies"
|
||||
},
|
||||
"user_avatar_alt": "User avatar",
|
||||
"snackbar": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Ocultar comentarios",
|
||||
"placeholder": "Agregar comentario de clase",
|
||||
"submit_aria": "Enviar comentario",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"reply": "Responder",
|
||||
"replying_to": "Respondiendo a @{nombre}",
|
||||
"cancel_reply": "Cancelar",
|
||||
"view_replies": "Ver {count} {count, plural, =1 {respuesta} otras {respuestas}}",
|
||||
"hide_replies": "Ocultar respuestas",
|
||||
"view_more": "Ver más comentarios ({count})",
|
||||
"loading": "Cargando...",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando...",
|
||||
"send": "enviar",
|
||||
"load_more_replies": "Cargar más respuestas"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Fijar",
|
||||
"unpin": "Desanclar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"other_posts": "Otras publicaciones",
|
||||
"user": "Usuario",
|
||||
"delete_modal": {
|
||||
"title": "Eliminar feed",
|
||||
"description": "¿Estás seguro de que deseas eliminar este feed?",
|
||||
"no": "No",
|
||||
"yes": "si"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Masquer les commentaires",
|
||||
"placeholder": "Ajouter un commentaire de classe",
|
||||
"submit_aria": "Soumettre un commentaire",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"reply": "Répondre",
|
||||
"replying_to": "Répondre à @{name}",
|
||||
"cancel_reply": "Annuler",
|
||||
"view_replies": "Voir {count} {count, plural, =1 {réponse} autres {réponses}}",
|
||||
"hide_replies": "Masquer les réponses",
|
||||
"view_more": "Afficher plus de commentaires ({count})",
|
||||
"loading": "Chargement...",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Sauvegarde...",
|
||||
"send": "Envoyer",
|
||||
"load_more_replies": "Charger plus de réponses"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Épingler",
|
||||
"unpin": "Désépingler",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"other_posts": "Autres messages",
|
||||
"user": "Utilisateur",
|
||||
"delete_modal": {
|
||||
"title": "Supprimer le flux",
|
||||
"description": "Êtes-vous sûr de vouloir supprimer ce flux ?",
|
||||
"no": "Non",
|
||||
"yes": "Oui"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "टिप्पणियाँ छिपाएँ",
|
||||
"placeholder": "कक्षा टिप्पणी जोड़ें",
|
||||
"submit_aria": "टिप्पणी सबमिट करें",
|
||||
"delete": "हटाएँ"
|
||||
"delete": "हटाएँ",
|
||||
"reply": "उत्तर",
|
||||
"replying_to": "@{name} को उत्तर दिया जा रहा है",
|
||||
"cancel_reply": "रद्द करें",
|
||||
"view_replies": "देखें {count} {count, plural, =1 {उत्तर} other {उत्तर}}",
|
||||
"hide_replies": "उत्तर छिपाएँ",
|
||||
"view_more": "अधिक टिप्पणियाँ देखें ({count})",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"save": "सहेजें",
|
||||
"saving": "सहेजा जा रहा है...",
|
||||
"send": "भेजें",
|
||||
"load_more_replies": "अधिक उत्तर लोड करें"
|
||||
},
|
||||
"card": {
|
||||
"pin": "पिन",
|
||||
"unpin": "अनपिन करें",
|
||||
"edit": "संपादित करें",
|
||||
"delete": "हटाएँ"
|
||||
},
|
||||
"other_posts": "अन्य पोस्ट",
|
||||
"user": "उपयोगकर्ता",
|
||||
"delete_modal": {
|
||||
"title": "फ़ीड हटाएँ",
|
||||
"description": "क्या आप वाकई इस फ़ीड को हटाना चाहते हैं?",
|
||||
"no": "नहीं",
|
||||
"yes": "हाँ"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Ukryj komentarze",
|
||||
"placeholder": "Dodaj komentarz do zajęć",
|
||||
"submit_aria": "Prześlij komentarz",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"reply": "Odpowiedz",
|
||||
"replying_to": "Odpowiadanie na @{name}",
|
||||
"cancel_reply": "Anuluj",
|
||||
"view_replies": "Wyświetl {count} {count, plural, =1 {odpowiedź} inne {replies}}",
|
||||
"hide_replies": "Ukryj odpowiedzi",
|
||||
"view_more": "Wyświetl więcej komentarzy ({count})",
|
||||
"loading": "Ładowanie...",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie...",
|
||||
"send": "Wyślij",
|
||||
"load_more_replies": "Załaduj więcej odpowiedzi"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Przypnij",
|
||||
"unpin": "Odepnij",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń"
|
||||
},
|
||||
"other_posts": "Inne posty",
|
||||
"user": "Użytkownik",
|
||||
"delete_modal": {
|
||||
"title": "Usuń kanał",
|
||||
"description": "Czy na pewno chcesz usunąć ten kanał?",
|
||||
"no": "Nie",
|
||||
"yes": "Tak"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Ocultar comentários",
|
||||
"placeholder": "Adicionar comentário da turma",
|
||||
"submit_aria": "Enviar comentário",
|
||||
"delete": "Excluir"
|
||||
"delete": "Excluir",
|
||||
"reply": "Responder",
|
||||
"replying_to": "Respondendo a @{name}",
|
||||
"cancel_reply": "Cancelar",
|
||||
"view_replies": "Ver {count} {count, plural, =1 {resposta} other {respostas}}",
|
||||
"hide_replies": "Ocultar respostas",
|
||||
"view_more": "Ver mais comentários ({count})",
|
||||
"loading": "Carregando...",
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando...",
|
||||
"send": "Enviar",
|
||||
"load_more_replies": "Carregar mais respostas"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Fixar",
|
||||
"unpin": "Liberar",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir"
|
||||
},
|
||||
"other_posts": "Outras postagens",
|
||||
"user": "Usuário",
|
||||
"delete_modal": {
|
||||
"title": "Excluir feed",
|
||||
"description": "Tem certeza de que deseja excluir este feed?",
|
||||
"no": "Não",
|
||||
"yes": "Sim"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Скрыть комментарии",
|
||||
"placeholder": "Добавить комментарий к классу",
|
||||
"submit_aria": "Отправить комментарий",
|
||||
"delete": "Удалить"
|
||||
"delete": "Удалить",
|
||||
"reply": "Ответить",
|
||||
"replying_to": "Отвечаю на @{name}",
|
||||
"cancel_reply": "Отмена",
|
||||
"view_replies": "Просмотреть {count} {count, множественное число, =1 {ответ} other {ответы}}",
|
||||
"hide_replies": "Скрыть ответы",
|
||||
"view_more": "Посмотреть больше комментариев ({count})",
|
||||
"loading": "Загрузка...",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение...",
|
||||
"send": "Отправить",
|
||||
"load_more_replies": "Загрузить больше ответов"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"other_posts": "Другие сообщения",
|
||||
"user": "Пользователь",
|
||||
"delete_modal": {
|
||||
"title": "Удалить канал",
|
||||
"description": "Вы уверены, что хотите удалить этот канал?",
|
||||
"no": "Нет",
|
||||
"yes": "Да"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -2260,13 +2260,32 @@
|
||||
"hide": "Ẩn bình luận",
|
||||
"placeholder": "Thêm nhận xét của lớp",
|
||||
"submit_aria": "Gửi bình luận",
|
||||
"delete": "Xóa"
|
||||
"delete": "Xóa",
|
||||
"reply": "trả lời",
|
||||
"replying_to": "Đang trả lời @{name}",
|
||||
"cancel_reply": "Hủy bỏ",
|
||||
"view_replies": "Xem {count} {count, plural, =1 {trả lời} other {trả lời}} khác",
|
||||
"hide_replies": "Ẩn câu trả lời",
|
||||
"view_more": "Xem thêm bình luận ({count})",
|
||||
"loading": "Đang tải...",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"send": "Gửi",
|
||||
"load_more_replies": "Tải thêm câu trả lời"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Ghim",
|
||||
"unpin": "Bỏ ghim",
|
||||
"edit": "Chỉnh sửa",
|
||||
"delete": "Xóa"
|
||||
},
|
||||
"other_posts": "Bài viết khác",
|
||||
"user": "người dùng",
|
||||
"delete_modal": {
|
||||
"title": "Xóa nguồn cấp dữ liệu",
|
||||
"description": "Bạn có chắc chắn muốn xóa nguồn cấp dữ liệu này không?",
|
||||
"no": "Không",
|
||||
"yes": "Có"
|
||||
}
|
||||
},
|
||||
"compliance": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "course_newsfeed_comment" ADD COLUMN "parent_id" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "course_newsfeed_comment" ADD CONSTRAINT "course_newsfeed_comment_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "public"."course_newsfeed_comment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "course_newsfeed_comment_parent_id_idx" ON "course_newsfeed_comment" USING btree ("parent_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1783938329367,
|
||||
"tag": "0004_organizationmember_email_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785228000000,
|
||||
"tag": "0005_add_parent_id_to_course_newsfeed_comment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
|
||||
import { and, db, desc, eq, lt, ne, sql } from '@db/drizzle';
|
||||
import { and, db, desc, eq, isNull, lt, ne, sql } from '@db/drizzle';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
/**
|
||||
@@ -221,36 +221,50 @@ export async function getNewsfeedCommentsByFeedId(feedId: string): Promise<TCour
|
||||
/**
|
||||
* Gets paginated comments for a newsfeed item with author profile
|
||||
* @param feedId Newsfeed ID
|
||||
* @param options Pagination options (cursor, limit)
|
||||
* @param options Pagination options (parentId, cursor, limit)
|
||||
* @returns Object with comments array, total count, hasMore flag, and nextCursor
|
||||
*/
|
||||
export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
feedId: string,
|
||||
options: { cursor?: string; limit: number }
|
||||
options: { parentId?: number; cursor?: string; limit: number }
|
||||
) {
|
||||
try {
|
||||
const { cursor, limit } = options;
|
||||
const { parentId, cursor, limit } = options;
|
||||
|
||||
// Build base parent-scoped conditions
|
||||
const parentCondition =
|
||||
parentId !== undefined
|
||||
? eq(schema.courseNewsfeedComment.parentId, parentId)
|
||||
: isNull(schema.courseNewsfeedComment.parentId);
|
||||
|
||||
const baseWhereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId), parentCondition];
|
||||
|
||||
const whereConditions = [...baseWhereConditions];
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId)];
|
||||
if (cursor) {
|
||||
// Cursor is the last comment ID, fetch comments created after it
|
||||
whereConditions.push(lt(schema.courseNewsfeedComment.id, Number(cursor)));
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`count(*)`.as('count') })
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.where(eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId));
|
||||
.where(and(...baseWhereConditions));
|
||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
||||
|
||||
// Fetch comments with author profile (limit + 1 to check if there are more)
|
||||
// Fetch comments with author profile & reply count
|
||||
const comments = await db
|
||||
.select({
|
||||
comment: schema.courseNewsfeedComment,
|
||||
groupmember: schema.groupmember,
|
||||
profile: schema.profile
|
||||
profile: schema.profile,
|
||||
replyCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.courseNewsfeedComment} c2
|
||||
WHERE c2.parent_id = ${schema.courseNewsfeedComment.id}),
|
||||
0
|
||||
)
|
||||
`.as('replyCount')
|
||||
})
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
|
||||
@@ -259,11 +273,9 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
// Check if there are more comments
|
||||
const hasMore = comments.length > limit;
|
||||
const items = comments.slice(0, limit);
|
||||
|
||||
// Get next cursor (ID of the oldest comment in this batch - for loading older comments)
|
||||
const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].comment.id) : null;
|
||||
|
||||
return {
|
||||
@@ -272,7 +284,8 @@ export async function getNewsfeedCommentsByFeedIdPaginated(
|
||||
authorProfileId: row.profile?.id || null,
|
||||
authorFullname: row.profile?.fullname || null,
|
||||
authorUsername: row.profile?.username || null,
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null,
|
||||
replyCount: Number(row.replyCount || 0)
|
||||
})),
|
||||
totalCount,
|
||||
hasMore,
|
||||
@@ -343,6 +356,27 @@ export async function deleteNewsfeedComment(commentId: number): Promise<TCourseN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single newsfeed comment by ID
|
||||
* @param commentId Comment ID
|
||||
* @returns Comment or null
|
||||
*/
|
||||
export async function getNewsfeedCommentById(commentId: number): Promise<TCourseNewsfeedComment | null> {
|
||||
try {
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(schema.courseNewsfeedComment)
|
||||
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
||||
.limit(1);
|
||||
return comment || null;
|
||||
} catch (error) {
|
||||
console.error('getNewsfeedCommentById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get newsfeed comment by ID "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets comment author and course ID for authorization checks
|
||||
* @param commentId Comment ID
|
||||
@@ -489,7 +523,8 @@ function getCommentCount() {
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.courseNewsfeedComment}
|
||||
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}),
|
||||
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}
|
||||
AND ${isNull(schema.courseNewsfeedComment.parentId)}),
|
||||
0
|
||||
)
|
||||
`.as('commentCount');
|
||||
|
||||
@@ -1692,7 +1692,8 @@ export const courseNewsfeedComment = pgTable(
|
||||
minValue: 1,
|
||||
cache: 1
|
||||
}),
|
||||
courseNewsfeedId: uuid('course_newsfeed_id')
|
||||
courseNewsfeedId: uuid('course_newsfeed_id'),
|
||||
parentId: bigint('parent_id', { mode: 'number' })
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
@@ -1704,7 +1705,13 @@ export const courseNewsfeedComment = pgTable(
|
||||
columns: [table.courseNewsfeedId],
|
||||
foreignColumns: [courseNewsfeed.id],
|
||||
name: 'course_newsfeed_comment_course_newsfeed_id_fkey'
|
||||
})
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: 'course_newsfeed_comment_parent_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
index('course_newsfeed_comment_parent_id_idx').on(table.parentId)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '../../base/button';
|
||||
import * as DropdownMenu from '../../base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
|
||||
|
||||
interface Props {
|
||||
onReply?: () => void;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
replyLabel?: string;
|
||||
deleteLabel?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onReply,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
replyLabel = 'Reply',
|
||||
deleteLabel = 'Delete',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-2 {className}">
|
||||
{#if onReply}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={onReply}
|
||||
class="ui:gap-1.5 ui:text-xs ui:text-muted-foreground ui:hover:text-foreground"
|
||||
>
|
||||
<MessageSquareIcon size={14} />
|
||||
<span>{replyLabel}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if canDelete && onDelete}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:inline-flex ui:h-7 ui:w-7 ui:items-center ui:justify-center ui:rounded-md ui:text-muted-foreground ui:hover:bg-muted ui:hover:text-foreground"
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="ui:text-destructive ui:focus:text-destructive" onclick={onDelete}>
|
||||
{deleteLabel}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { sanitizeHtml } from '../../tools/sanitize';
|
||||
|
||||
interface Props {
|
||||
content?: string | null;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { content, class: className = '', children }: Props = $props();
|
||||
|
||||
let formattedContent = $derived(content ? sanitizeHtml(content.replace(/(<\/blockquote>)\s+/gi, '$1')) : '');
|
||||
</script>
|
||||
|
||||
<div class="ui:text-sm ui:leading-relaxed ui:text-foreground/90 ui:whitespace-pre-line ui:break-words {className}">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if formattedContent}
|
||||
{@html formattedContent}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { Badge } from '../../base/badge';
|
||||
import * as DropdownMenu from '../../base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
interface Props {
|
||||
avatarUrl?: string | null;
|
||||
fullname?: string | null;
|
||||
createdAt?: string | null;
|
||||
dateLabel?: string | null;
|
||||
roleBadge?: string | null;
|
||||
avatarSize?: string;
|
||||
canEdit?: boolean;
|
||||
onEdit?: () => void;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
editLabel?: string;
|
||||
deleteLabel?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
avatarUrl,
|
||||
fullname = '',
|
||||
createdAt,
|
||||
dateLabel,
|
||||
roleBadge,
|
||||
avatarSize = 'ui:size-8',
|
||||
canEdit = false,
|
||||
onEdit,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
editLabel = 'Edit',
|
||||
deleteLabel = 'Delete',
|
||||
class: className = '',
|
||||
actions
|
||||
}: Props = $props();
|
||||
|
||||
const displayDate = $derived(
|
||||
dateLabel || (createdAt && !isNaN(Date.parse(createdAt)) ? new Date(createdAt).toLocaleDateString() : '')
|
||||
);
|
||||
const hasActions = $derived((canEdit && Boolean(onEdit)) || (canDelete && Boolean(onDelete)));
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:w-full ui:items-center ui:justify-between {className}">
|
||||
<div class="ui:flex ui:items-center ui:gap-2.5">
|
||||
<UserAvatar src={avatarUrl || undefined} alt={fullname || 'User Avatar'} class={avatarSize} />
|
||||
<div class="ui:flex ui:items-center ui:gap-2">
|
||||
{#if fullname}
|
||||
<span class="ui:text-sm ui:font-semibold ui:capitalize ui:text-foreground">{fullname}</span>
|
||||
{/if}
|
||||
{#if roleBadge}
|
||||
<Badge variant="secondary" class="ui:text-[10px] ui:px-1.5 ui:py-0.5">{roleBadge}</Badge>
|
||||
{/if}
|
||||
{#if displayDate}
|
||||
<span class="ui:text-xs ui:font-normal ui:text-muted-foreground">{displayDate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if hasActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:inline-flex ui:h-7 ui:w-7 ui:items-center ui:justify-center ui:rounded-md ui:text-muted-foreground ui:hover:bg-muted ui:hover:text-foreground"
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canEdit && onEdit}
|
||||
<DropdownMenu.Item onclick={onEdit}>
|
||||
<PencilIcon class="ui:mr-2 ui:size-3.5" />
|
||||
<span>{editLabel}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canDelete && onDelete}
|
||||
<DropdownMenu.Item class="ui:text-destructive ui:focus:text-destructive" onclick={onDelete}>
|
||||
<Trash2Icon class="ui:mr-2 ui:size-3.5" />
|
||||
<span>{deleteLabel}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { Button } from '../../base/button';
|
||||
import { Textarea } from '../../base/textarea';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
authorAvatarUrl?: string | null;
|
||||
placeholder?: string;
|
||||
replyingToUser?: string | null;
|
||||
replyingToSnippet?: string | null;
|
||||
onCancelReply?: () => void;
|
||||
cancelLabel?: string;
|
||||
onSubmit: (content: string) => Promise<void> | void;
|
||||
isSubmitting?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
authorAvatarUrl,
|
||||
placeholder = 'Add a comment...',
|
||||
replyingToUser = null,
|
||||
replyingToSnippet = null,
|
||||
onCancelReply,
|
||||
cancelLabel = 'Cancel',
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let value = $state('');
|
||||
|
||||
async function handleSend() {
|
||||
if (!value.trim() || isSubmitting) return;
|
||||
const text = value.trim();
|
||||
value = '';
|
||||
try {
|
||||
await onSubmit(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape' && replyingToUser && onCancelReply) {
|
||||
onCancelReply();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:gap-3 {className}">
|
||||
<UserAvatar src={authorAvatarUrl || undefined} class="ui:mt-1 ui:size-8 ui:shrink-0" />
|
||||
|
||||
<div
|
||||
class="ui:flex ui:w-full ui:flex-col ui:overflow-hidden ui:rounded-lg ui:border ui:border-input ui:shadow-xs ui:transition-[color,box-shadow] ui:focus-within:border-ring ui:focus-within:ring-[3px] ui:focus-within:ring-ring/50"
|
||||
>
|
||||
{#if replyingToUser}
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:bg-muted/30 ui:px-3 ui:py-1.5 ui:text-xs ui:text-muted-foreground ui:border-b ui:border-border/60"
|
||||
>
|
||||
<div class="ui:flex ui:max-w-[85%] ui:flex-col ui:gap-0.5 ui:truncate">
|
||||
<span>Replying to <strong class="ui:font-medium ui:text-foreground">@{replyingToUser}</strong></span>
|
||||
{#if replyingToSnippet}
|
||||
<span class="ui:truncate ui:italic ui:text-muted-foreground/80">"{replyingToSnippet}"</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onCancelReply}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={onCancelReply}
|
||||
class="ui:h-auto ui:gap-1 ui:px-0 ui:text-xs ui:font-normal ui:text-muted-foreground ui:hover:bg-transparent ui:hover:text-foreground"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
<span>{cancelLabel}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ui:flex ui:flex-col">
|
||||
<Textarea
|
||||
bind:value
|
||||
onkeydown={handleKeyDown}
|
||||
{placeholder}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
class="ui:min-h-0 ui:max-h-32 ui:resize-none ui:border-0 ui:bg-transparent ui:px-3 ui:py-2.5 ui:text-sm ui:shadow-none ui:focus-visible:ring-0"
|
||||
/>
|
||||
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-2">
|
||||
<Button
|
||||
variant={value.trim() ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
class="ui:h-8 ui:gap-1.5 ui:rounded-md"
|
||||
loading={isSubmitting}
|
||||
disabled={!value.trim() || isSubmitting}
|
||||
onclick={handleSend}
|
||||
>
|
||||
<span>Send</span>
|
||||
<SendHorizontalIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:group ui:relative ui:flex ui:w-full ui:flex-col ui:gap-1.5 ui:rounded-md ui:py-1 ui:transition-colors {className}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
|
||||
interface Props {
|
||||
replyCount?: number;
|
||||
showLabel?: string;
|
||||
hideLabel?: string;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
replyCount = 0,
|
||||
showLabel,
|
||||
hideLabel = 'Hide replies',
|
||||
isExpanded = $bindable(false),
|
||||
onToggleExpand,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const label = $derived(
|
||||
isExpanded ? hideLabel : (showLabel ?? `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`)
|
||||
);
|
||||
|
||||
function handleToggle() {
|
||||
if (onToggleExpand) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if replyCount > 0 || isExpanded || children}
|
||||
<div class="ui:flex ui:flex-col ui:gap-2 ui:pl-11 {className}">
|
||||
{#if replyCount > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onclick={handleToggle}
|
||||
class="ui:w-fit ui:gap-1.5 ui:px-0 ui:text-xs ui:font-medium ui:text-muted-foreground ui:hover:bg-transparent ui:hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronUpIcon size={14} />
|
||||
{:else}
|
||||
<ChevronDownIcon size={14} />
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="ui:relative ui:flex ui:flex-col ui:gap-3 ui:border-l-2 ui:border-border/60 ui:pl-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:flex ui:w-full ui:flex-col ui:gap-3 {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
import Root from './comment-tree-root.svelte';
|
||||
import Item from './comment-tree-item.svelte';
|
||||
import Header from './comment-tree-header.svelte';
|
||||
import Content from './comment-tree-content.svelte';
|
||||
import Actions from './comment-tree-actions.svelte';
|
||||
import Input from './comment-tree-input.svelte';
|
||||
import Replies from './comment-tree-replies.svelte';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
Header,
|
||||
Content,
|
||||
Actions,
|
||||
Input,
|
||||
Replies,
|
||||
// Alias names
|
||||
Root as CommentRoot,
|
||||
Item as CommentItem,
|
||||
Header as CommentHeader,
|
||||
Content as CommentContent,
|
||||
Actions as CommentActions,
|
||||
Input as CommentInput,
|
||||
Replies as CommentReplies
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface CommentAuthor {
|
||||
id: string;
|
||||
fullname?: string | null;
|
||||
username?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
roleBadge?: string | null;
|
||||
}
|
||||
|
||||
export interface GenericComment {
|
||||
id: string | number;
|
||||
content: string | null;
|
||||
createdAt: string;
|
||||
authorProfileId?: string | null;
|
||||
authorFullname?: string | null;
|
||||
authorUsername?: string | null;
|
||||
authorAvatarUrl?: string | null;
|
||||
parentId?: number | null;
|
||||
replyCount?: number;
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content side="top" align="start" sideOffset={10} class="ui:w-auto ui:max-w-none ui:p-1!">
|
||||
<Popover.Content side="bottom" align="start" sideOffset={10} class="ui:w-auto ui:max-w-none ui:p-1!">
|
||||
<div class="ui:grid ui:grid-cols-4 ui:gap-2">
|
||||
{#each NEWSFEED_REACTION_OPTIONS as reactionOption (reactionOption.type)}
|
||||
<Button
|
||||
|
||||
@@ -87,6 +87,7 @@ export { BackButton } from './custom/back-button';
|
||||
export { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from './custom/course-card';
|
||||
export { NewsfeedReactions } from './custom/newsfeed-reactions';
|
||||
export * as PublicCourse from './custom/public-course';
|
||||
export * as CommentTree from './custom/comment-tree';
|
||||
export * as Certificate from './custom/certificate-preview';
|
||||
export * as EmailPreview from './custom/email-preview';
|
||||
export * from './custom/animation';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,3 +12,4 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
// HMR trigger
|
||||
|
||||
@@ -28,7 +28,7 @@ export const FORBID_ATTR = [
|
||||
'onunload'
|
||||
] as const;
|
||||
|
||||
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth', 'target', 'rel'] as const;
|
||||
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth', 'target', 'rel', 'data-comment-id'] as const;
|
||||
|
||||
export const ALLOWED_URI_REGEXP =
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
||||
|
||||
@@ -32,7 +32,8 @@ export type TNewsfeedListQuery = z.infer<typeof ZNewsfeedListQuery>;
|
||||
// Newsfeed Comment Schemas
|
||||
export const ZNewsfeedCommentCreate = z.object({
|
||||
courseNewsfeedId: z.string().min(1),
|
||||
content: z.string().min(1)
|
||||
content: z.string().min(1),
|
||||
parentId: z.number().int().positive().optional()
|
||||
});
|
||||
export type TNewsfeedCommentCreate = z.infer<typeof ZNewsfeedCommentCreate>;
|
||||
|
||||
@@ -47,6 +48,7 @@ export const ZNewsfeedCommentUpdate = z.object({
|
||||
export type TNewsfeedCommentUpdate = z.infer<typeof ZNewsfeedCommentUpdate>;
|
||||
|
||||
export const ZNewsfeedCommentsQuery = z.object({
|
||||
parentId: z.coerce.number().int().positive().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional().default(5)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user