feat: course news feed (#136)

* style: Improved font size of the answers in the FAQ section of the landing page

* woking on the anouncement page

* working on the anouncement feature

* fixed some bugs in the emoji and comment

* minor cleanup

* Delete .github/workflows/test-pull-request.yml

This commit was stashed but it seems i accidentally applied on this commit

* updated the navicon for the anouncement

* added role level security

* still working on the emoji click

* everything works now

* cleaned up the code

* cleaned up the code

* cleaned up the code

* cleaned up the code

* refined the formatTime function using dayjs

* refined the formatTime function using dayjs

* typed,the code,created a migration file

* increase editor width/height

* added the confirmation modal and also the edit functionality for a improve UX

* some cleanup

* handle edge case,only tutor or admin can edit or delete

* improved the fetchNews function and also added role level access for deleting the comment

* chore: refactor code

* clean up

* feat: add pinned feed

* feat: remove overview on sidebar

* fix formatting of todo

* feat: prevent async block when sending email

* feat: notify teacher and student of new post/comment

* refactoring

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
Co-authored-by: Rotimi Best <rotimiibitoyeemma@gmail.com>
This commit is contained in:
Em_Dev
2024-02-28 03:00:31 +01:00
committed by GitHub
co-authored by Rotimi Best Rotimi Best
parent 474e83c3d9
commit 5cdb74a6b5
39 changed files with 1141 additions and 275 deletions
@@ -46,7 +46,7 @@
return people.find((person) => person.profile_id === profileId);
};
const sendEmail = () => {
const notifyEducator = () => {
const student = getStudent($group.students, $profile.id);
const teacherFullname = $group.tutors[0]?.fullname;
const teacherEmail = $group.tutors[0]?.email;
@@ -118,7 +118,7 @@
$course.id,
getGroupMemberId($group.people, $profile.id)
);
sendEmail();
notifyEducator();
}
}
@@ -14,7 +14,7 @@
import Confetti from '$lib/components/Confetti/index.svelte';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { getTextFromHTML } from '$lib/utils/functions/course';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { writable } from 'svelte/store';
import { Circle3 } from 'svelte-loading-spinners';
@@ -1,22 +1,10 @@
<script lang="ts">
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
function isNoteEmpty(note: string) {
if (!note || typeof note !== 'string') return true;
if (!document) return false;
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = note;
const rawText = dummyDiv.textContent?.trim();
return rawText === '';
}
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
</script>
{#if !isNoteEmpty($lesson.materials?.note)}
{#if !isHtmlValueEmpty($lesson.materials?.note)}
<HtmlRender className="m-auto">
<svelte:fragment slot="content">
<div>
@@ -1,5 +1,4 @@
<script lang="ts">
import { browser } from '$app/environment';
import isEmpty from 'lodash/isEmpty';
import { useCompletion } from 'ai/svelte';
import MODES from '$lib/utils/constants/mode.js';
@@ -36,8 +35,8 @@
import ComponentVideo from './components/ComponentVideo.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import type { LessonPage } from '$lib/utils/types';
import { getTextFromHTML } from '$lib/utils/functions/course';
import { snackbar } from '$lib/components/Snackbar/store';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
export let mode = MODES.view;
export let prevMode = '';
@@ -80,18 +79,10 @@
return handleUpdateLessonMaterials(_lesson, lessonId);
}
function isNoteEmpty(note: string) {
if (!note || typeof note !== 'string') return true;
if (!document) return false;
return browser ? getTextFromHTML(note) === '' : false;
}
function isMaterialsEmpty(materials: LessonPage['materials']) {
const { slide_url, videos, note } = materials;
return isNoteEmpty(note) && !slide_url && isEmpty(videos);
return isHtmlValueEmpty(note) && !slide_url && isEmpty(videos);
}
function handleSave(prevMode: string) {
@@ -106,7 +97,7 @@
tabs = tabs.map((tab) => {
let badgeValue = 0;
if (tab.value === 1 && !isNoteEmpty(note)) {
if (tab.value === 1 && !isHtmlValueEmpty(note)) {
badgeValue = 1;
} else if (tab.value === 2 && !!slide_url) {
badgeValue = 1;
@@ -8,12 +8,15 @@
import Badge from 'carbon-icons-svelte/lib/Badge.svelte';
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
export let name = '';
</script>
{#if name === 'Overview'}
<TableOfContents size={20} class="carbon-icon mr-2" />
{:else if name === 'News Feed'}
<Bullhorn size={20} class="carbon-icon mr-2" />
{:else if name === 'Lessons'}
<IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" />
{:else if name === 'Attendance'}
@@ -146,7 +146,7 @@
$: {
navItems = [
{
label: 'Overview',
label: 'News Feed',
to: getNavItemRoute($course.id),
hideSortIcon: true
},
@@ -207,8 +207,6 @@
}
];
}
$: console.log({ isStudent: $globalStore.isStudent });
</script>
<aside
@@ -0,0 +1,40 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
export let openDeleteModal = false;
export let deleteFeed = () => {};
async function handleDelete() {
deleteFeed();
openDeleteModal = false;
}
</script>
<Modal
onClose={() => (openDeleteModal = false)}
bind:open={openDeleteModal}
width="w-96"
modalHeading="Delete Feed"
>
<div>
<h1 class="dark:text-white text-lg">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No"
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="Yes"
onClick={handleDelete}
/>
</div>
</div>
</Modal>
@@ -0,0 +1,127 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { createNewFeed } from '$lib/utils/services/newsfeed';
import { snackbar } from '$lib/components/Snackbar/store';
import type { Feed, Author } from '$lib/utils/types/feed';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
export let author: Author | any = {};
export let courseId = '';
export let onSave = (feed: Feed) => {};
export let onEdit = (id: string, content: string) => {};
export let edit = false;
export let editFeed: Feed;
let newPost = '';
let createdFeed;
const onPost = async () => {
if (!newPost) return;
// Edit state
if (edit) {
onEdit(editFeed.id, newPost);
snackbar.success('Feed edited successfully');
edit = false;
newPost = '';
$isNewFeedModal.open = false;
return;
}
// Create state
try {
const {
response: { data }
} = await createNewFeed({
content: newPost,
author_id: author.id,
course_id: courseId,
reaction: {
smile: [],
thumbsup: [],
thumbsdown: [],
clap: []
}
});
if (!data) return;
createdFeed = data[0];
} catch (error) {
return snackbar.error('An error occurred while creating feed');
}
if (!createdFeed) return;
onSave({
id: createdFeed.id,
content: newPost,
author: {
profile: {
id: author.id,
username: author.username,
fullname: author.fullname,
avatar_url: author.avatar
}
},
created_at: createdFeed.created_at,
comment: [],
reaction: createdFeed.reaction,
isPinned: false
});
snackbar.success('New feed added successfully');
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: createdFeed.author_id,
feedId: createdFeed.id
});
resetEditor();
};
const resetEditor = () => {
newPost = '';
edit = false;
$isNewFeedModal.open = false;
};
$: newPost = edit === true ? editFeed?.content : '';
</script>
<Modal
onClose={resetEditor}
bind:open={$isNewFeedModal.open}
width="w-4/5"
maxWidth="max-w-lg"
modalHeading={edit === true ? 'Edit Post' : 'Make a Post'}
>
<section class="flex flex-col rounded-xl pb-3 h-full w-2/">
<TextEditor
value={newPost}
onChange={(text) => {
newPost = text;
}}
placeholder="Share an update with your students"
maxHeight={400}
/>
<div class="flex items-center justify-end py-2">
<div class="flex gap-2">
<PrimaryButton
label="Cancel"
variant={VARIANTS.OUTLINED}
onClick={() => ($isNewFeedModal.open = false)}
/>
<PrimaryButton label="Post" onClick={onPost} />
</div>
</div>
</section>
</Modal>
@@ -0,0 +1,220 @@
<script lang="ts">
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import Send from 'carbon-icons-svelte/lib/Send.svelte';
import Chip from '$lib/components/Chip/index.svelte';
import UserMultiple from 'carbon-icons-svelte/lib/UserMultiple.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
import type { Author, Feed } from '$lib/utils/types/feed';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import pluralize from 'pluralize';
import { onMount } from 'svelte';
export let feed: Feed;
export let editFeed: Feed | any = {};
export let author: Author | any = {};
export let edit = false;
export let deleteFeed = (arg: string) => {};
export let deleteComment = (arg: string) => {};
export let addNewComment = (arg1: string, arg2: string, arg3: string) => {};
export let addNewReaction = (arg1: string, arg2: string, arg3: string) => {};
export let onPin = (feedId: Feed['id'], isPinned: Feed['isPinned']) => {};
export let isActive = false;
let comment = '';
let areCommentsExpanded = false;
let isDeleteFeedModal = false;
let reactions = {
smile: '😀',
thumbsup: '👍',
thumbsdown: '👎',
clap: '👏'
};
const openEditFeed = () => {
$isNewFeedModal.open = true;
edit = true;
if (edit === true) {
editFeed = feed;
}
};
const expandComment = () => {
areCommentsExpanded = !areCommentsExpanded;
};
const handleAddNewReaction = (reactionType: string) => {
addNewReaction(reactionType, feed.id, author.id);
};
const handleAddNewComment = (event) => {
if (event.key === 'Enter' || event.type === 'click') {
event.preventDefault();
addNewComment(comment, feed.id, author.id);
comment = '';
}
};
const handleDeleteFeed = () => {
deleteFeed(feed.id);
};
const handleDeleteComment = (id: string) => {
deleteComment(id);
};
onMount(() => {
if (isActive) {
const el = document.getElementById(feed.id);
el?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
}
});
</script>
<div
id={feed.id}
class="flex flex-col gap-5 {isActive
? 'border-2 border-primary-700'
: 'border border-gray-200'} rounded-md mb-7 max-w-3xl"
>
<section>
<div class="p-3 pb-0">
<div class="flex justify-between mb-2">
<span class="flex items-center gap-3">
<div class="w-9 h-9">
<img
src={feed.author?.profile.avatar_url}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<span>
<p class="text-base font-semibold capitalize">{feed.author?.profile.fullname}</p>
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.created_at)}</p>
</span>
</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu flipped>
<OverflowMenuItem
text={feed.isPinned ? 'Unpin' : 'Pin'}
on:click={() => onPin(feed.id, feed.isPinned)}
/>
<OverflowMenuItem text="Edit" on:click={openEditFeed} />
<OverflowMenuItem danger text="Delete" on:click={() => (isDeleteFeedModal = true)} />
</OverflowMenu>
</RoleBasedSecurity>
</div>
{#if !isHtmlValueEmpty(feed.content)}
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
<svelte:fragment slot="content">
<div>
{@html feed.content}
</div>
</svelte:fragment>
</HtmlRender>
{/if}
</div>
<div class="flex gap-4 px-3">
<div class="flex gap-4 px-3">
{#each Object.keys(feed.reaction) as reactionType}
{#if reactions[reactionType]}
<button
on:click={() => handleAddNewReaction(reactionType)}
class={`flex transition ${
feed.reaction[reactionType].length >= 1 &&
'bg-primary-200 dark:bg-gray-900 dark:text-black px-1 border border-primary-600 rounded-full'
}`}
>
<div class="text-[15px]">{reactions[reactionType]}</div>
{#if feed.reaction[reactionType].length >= 1}
<Chip value={feed.reaction[reactionType].length} className="bg-transparent" />
{/if}
</button>
{/if}
{/each}
</div>
</div>
</section>
<section class="border-t border-gray-200 p-3">
{#if feed.comment.length > 0}
<button
on:click={expandComment}
class="flex flex-row items-center gap-1 -mx-2 px-2 rounded-md"
>
<UserMultiple size={16} />
<p class="text-sm py-2">
{pluralize('comment', feed.comment.length, true)}
</p>
</button>
{/if}
<div>
{#each feed.comment as comment, index}
{#if comment.content && (areCommentsExpanded || index === feed.comment.length - 1)}
<div class="group flex justify-between items-center py-2">
<span class="flex items-center gap-3">
<div class="w-9 h-9">
<img
src={comment.author?.profile?.avatar_url}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<span>
<div class="flex items-center gap-2">
<p class="text-sm font-medium capitalize">{comment.author?.profile?.fullname}</p>
<p class="text-xs font-medium text-gray-600">{calDateDiff(comment.created_at)}</p>
</div>
<p>{comment.content}</p>
</span>
</span>
{#if comment.author?.profile?.id === $profile.id || $isOrgAdmin}
<OverflowMenu flipped class="hidden group-hover:flex">
<OverflowMenuItem
danger
text="Delete"
on:click={() => handleDeleteComment(comment.id)}
/>
</OverflowMenu>
{/if}
</div>
{/if}
{/each}
</div>
<div class="flex items-center justify-between gap-2">
<div class="w-7 h-7">
<img
src={author.avatar}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<div class="flex-1">
<input
type="text"
bind:value={comment}
on:keydown={handleAddNewComment}
placeholder="Add class comment"
class="w-full bg-transparent border border-gray-200 rounded-3xl"
/>
</div>
<button on:click={handleAddNewComment}>
<Send size={24} />
</button>
</div>
</section>
<DeleteFeedConfirmation bind:openDeleteModal={isDeleteFeedModal} deleteFeed={handleDeleteFeed} />
</div>
@@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const isNewFeedModal = writable({
open: false
});
@@ -1,56 +0,0 @@
<script>
import RoleBasedSecurity from '../RoleBasedSecurity/index.svelte';
import PageNav from '../PageNav/index.svelte';
import MODES from '$lib/utils/constants/mode';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '../PageBody/index.svelte';
import { course } from './store';
import { updateCourse } from '$lib/utils/services/courses';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
let mode = MODES.view;
let isDirty = false;
async function handleModeChange() {
if (mode === MODES.edit) {
mode = MODES.view;
if (isDirty) {
const { overview, id } = $course;
await updateCourse(id, undefined, { overview });
}
} else {
mode = MODES.edit;
}
}
</script>
<PageNav title="Overview" disableSticky={true}>
<svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
<PrimaryButton
className="mr-2"
label={mode === MODES.edit ? 'Save' : 'Edit'}
onClick={handleModeChange}
/>
</div>
</RoleBasedSecurity>
</svelte:fragment>
</PageNav>
<PageBody width="max-w-3xl px-3">
{#if mode === MODES.edit}
<TextEditor
value={$course.overview}
onChange={(html) => {
isDirty = true;
$course.overview = html;
}}
placeholder="Write your overview here"
height={700}
/>
{:else}
<HtmlRender className="p-2" content={$course.overview || ''} />
{/if}
</PageBody>
@@ -19,10 +19,8 @@
let prevCourseId = '';
async function onCourseIdChange(courseId = '') {
console.log('courseid changed', courseId);
if (!courseId || prevCourseId === courseId || !browser || $course.id === courseId) return;
console.log('making request', courseId);
isFetching = true;
course.set(defaultCourse);
@@ -6,7 +6,7 @@
import { courses, createCourseModal } from '$lib/components/Courses/store';
import { validateForm } from '$lib/components/Courses/functions';
import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember } from '$lib/utils/services/courses';
import { addGroupMember, addDefaultNewsFeed } from '$lib/utils/services/courses';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
@@ -81,12 +81,27 @@
});
// 3. Add group members
await addGroupMember({
const { data } = await addGroupMember({
profile_id: $profile.id,
email: $profile.email,
group_id,
role_id: ROLE.TUTOR
});
// 4. Add default news feed.
if (Array.isArray(data) && data.length) {
const { id: authorId } = data[0];
console.log('Add news feed into course');
await addDefaultNewsFeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
course_id: newCourse[0]?.id,
is_pinned: true,
author_id: authorId
});
}
if (newCourse[0] != null && newCourse[0].id) {
goto(`/courses/${newCourse[0]?.id}`);
}