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:
Nwosu Emmanuel
2026-07-31 00:26:59 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 88c4cf03b3
commit a1c4ee720f
40 changed files with 13639 additions and 280 deletions
@@ -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,22 +508,53 @@ 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
};
this.commentsByFeedId[feedId] = {
...commentState,
items: [newComment, ...commentState.items],
totalCount: commentState.totalCount + 1
};
const newComment = {
...response.data,
authorProfileId: author.id,
authorFullname: author.fullname,
authorUsername: author.username,
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],
totalCount: commentState.totalCount + 1
};
}
}
}
},
@@ -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,14 +633,37 @@ export class NewsfeedApi extends BaseApiWithErrors {
if (response.data) {
snackbar.success('Comment deleted successfully');
// Remove comment from local state
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
this.commentsByFeedId[feedId] = {
...commentState,
items: commentState.items.filter((item) => String(item.id) !== commentId),
totalCount: Math.max(0, commentState.totalCount - 1)
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] = {
...commentState,
items: commentState.items.filter((item) => String(item.id) !== commentId),
totalCount: Math.max(0, commentState.totalCount - 1)
};
}
}
}
},
@@ -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
};
};
await showMore();
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 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"
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"
>
<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
>
{#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}
{#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"
>
<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>
{/if}
</div>
<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()}
>
{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}
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
/>
</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}
<!-- Comment Input Box -->
<CommentTree.Input
authorAvatarUrl={author.avatarUrl}
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
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"
/>
</section>
@@ -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)}>
{feed.isPinned ? $t('course.navItem.news_feed.card.unpin') : $t('course.navItem.news_feed.card.pin')}
<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,29 +146,57 @@
variant="page"
/>
{:else}
{#each sortedFeeds as feed}
{#if feed.isPinned}
<div class="flex items-center gap-2">
<PinIcon size={16} class="filled" />
{#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>
{#each pinnedFeeds 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}
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
</div>
{/if}
<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 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>