Files
classroomio/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte
T
Nwosu EmmanuelandRotimi Best a1c4ee720f 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 <best@classroomio.com>
2026-07-31 00:26:59 +01:00

203 lines
6.3 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { NewsFeedLoader, NewsFeedCard, NewFeedModal } from '$features/course/components/newsfeed';
import { courseApi } from '$features/course/api';
import { RoleBasedSecurity } from '$features/ui';
import { snackbar } from '$features/ui/snackbar/store';
import { newsfeedApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Feed } from '$features/course/utils/types';
import { onMount } from 'svelte';
import type { AccountOrg } from '$features/app/types';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { BookIcon } from '@lucide/svelte';
import { ROLE } from '@cio/utils/constants';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
let edit = $state(false);
let editFeed: Feed | null = $state(null);
// Page-level loading state: only used for the initial list fetch.
// Prevents other interactions (react/update/comment/delete) from triggering the full-page skeleton.
let isListing = $state(true);
// Per-feed interaction states
let isReactingByFeedId = $state<Record<string, boolean>>({});
let query = new URLSearchParams(page.url.search);
let feedId = query.get('feedId') || '';
const author = $derived({
id: courseApi.group.memberId || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatarUrl || ''
});
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) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
try {
await newsfeedApi.react(courseId, feedId, updatedReaction);
} finally {
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
}
if (!newsfeedApi.success) {
snackbar.error('snackbar.course.error.reaction_error');
}
};
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');
}
};
const onPin = async (feedId: string, isPinned: boolean) => {
const newIsPinned = !isPinned;
await newsfeedApi.update(courseId, feedId, { isPinned: newIsPinned });
if (!newsfeedApi.success) {
return snackbar.error('snackbar.course.error.toggle_error');
}
const pinStatusMessage = newIsPinned
? t.get('snackbar.course.success.pinned')
: t.get('snackbar.course.success.unpinned');
snackbar.success(`${pinStatusMessage} ${t.get('snackbar.course.success.successfully')}`);
};
const deleteFeed = async (id: string) => {
await newsfeedApi.delete(courseId, id);
};
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) {
const roles: number[] = [ROLE.ADMIN, ROLE.TUTOR];
if (org.customization.course.newsfeed) {
roles.push(ROLE.STUDENT);
}
return roles;
}
onMount(async () => {
try {
await newsfeedApi.list(courseId);
} finally {
isListing = false;
}
});
</script>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${courseId}/lessons?next=true`);
}}
>
<RoleBasedSecurity allowedRoles={getPageRoles($currentOrg)}>
<NewFeedModal {courseId} bind:edit bind:editFeed />
</RoleBasedSecurity>
{#if isListing}
<div class="flex w-full flex-col items-center">
<NewsFeedLoader />
<NewsFeedLoader />
<NewsFeedLoader />
</div>
{:else if !pinnedFeeds.length && !unpinnedFeeds.length}
<Empty
title={$t('course.navItem.news_feed.body_header')}
description={$t('course.navItem.news_feed.body_content')}
icon={BookIcon}
variant="page"
/>
{:else}
{#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}
{#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>