Files
classroomio/apps/dashboard/src/lib/features/course/pages/newsfeed.svelte
T

175 lines
5.3 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
2026-03-09 11:18:22 +01:00
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';
2026-03-09 11:18:22 +01:00
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';
2026-03-09 11:18:22 +01:00
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';
import { BookIcon } from '@lucide/svelte';
import { ROLE } from '@cio/utils/constants';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
let edit = $state(false);
let editFeed: Feed | null = $state(null);
2026-03-09 11:18:22 +01:00
// 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') || '';
2026-03-09 11:18:22 +01:00
const author = $derived({
id: courseApi.group.memberId || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatarUrl: $profile.avatarUrl || ''
});
2026-03-09 11:18:22 +01:00
const deleteComment = async (feedId: string, commentId: string) => {
await newsfeedApi.deleteComment(courseId, feedId, commentId);
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
2026-03-09 11:18:22 +01:00
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
2026-03-09 11:18:22 +01:00
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
2026-03-09 11:18:22 +01:00
try {
await newsfeedApi.react(courseId, feedId, updatedReaction);
} finally {
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
}
2026-03-09 11:18:22 +01:00
if (!newsfeedApi.success) {
snackbar.error('snackbar.course.error.reaction_error');
}
};
2026-03-09 11:18:22 +01:00
const addNewComment = async (comment: string, feedId: string) => {
await newsfeedApi.createComment(courseId, feedId, comment, author);
2026-03-09 11:18:22 +01:00
if (!newsfeedApi.success) {
return snackbar.error('snackbar.course.error.commenting_error');
}
};
2026-03-09 11:18:22 +01:00
const onPin = async (feedId: string, isPinned: boolean) => {
const newIsPinned = !isPinned;
2026-03-09 11:18:22 +01:00
await newsfeedApi.update(courseId, feedId, { isPinned: newIsPinned });
2026-03-09 11:18:22 +01:00
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')}`);
2026-03-09 11:18:22 +01:00
};
2026-03-09 11:18:22 +01:00
const deleteFeed = async (id: string) => {
await newsfeedApi.delete(courseId, id);
};
2026-03-09 11:18:22 +01:00
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);
});
2026-03-09 11:18:22 +01:00
});
function getPageRoles(org: AccountOrg) {
const roles: number[] = [ROLE.ADMIN, ROLE.TUTOR];
if (org.customization.course.newsfeed) {
roles.push(ROLE.STUDENT);
}
return roles;
}
2026-03-09 11:18:22 +01:00
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)}>
2026-03-09 11:18:22 +01:00
<NewFeedModal {courseId} bind:edit bind:editFeed />
</RoleBasedSecurity>
2026-03-09 11:18:22 +01:00
{#if isListing}
2025-12-23 11:25:38 +01:00
<div class="flex w-full flex-col items-center">
<NewsFeedLoader />
<NewsFeedLoader />
<NewsFeedLoader />
</div>
2026-03-09 11:18:22 +01:00
{:else if !sortedFeeds.length}
<Empty
title={$t('course.navItem.news_feed.body_header')}
description={$t('course.navItem.news_feed.body_content')}
icon={BookIcon}
2026-03-09 11:18:22 +01:00
variant="page"
2025-12-23 11:25:38 +01:00
/>
{:else}
2026-03-09 11:18:22 +01:00
{#each sortedFeeds as feed}
{#if feed.isPinned}
2026-03-09 11:18:22 +01:00
<div class="flex items-center gap-2">
<PinIcon size={16} class="filled" />
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
</div>
{/if}
<NewsFeedCard
{feed}
2026-03-09 11:18:22 +01:00
comments={newsfeedApi.commentsByFeedId[feed.id]}
{courseId}
{deleteFeed}
{addNewComment}
{deleteComment}
{addNewReaction}
{onPin}
{author}
bind:edit
bind:editFeed
isActive={feedId === feed.id}
2026-03-09 11:18:22 +01:00
isReacting={Boolean(isReactingByFeedId[feed.id])}
/>
{/each}
{/if}
</RoleBasedSecurity>