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
+2 -2
View File
@@ -4,8 +4,8 @@ PUBLIC_SUPABASE_URL=http://localhost:54321
PUBLIC_SUPABASE_ANON_KEY= PUBLIC_SUPABASE_ANON_KEY=
# Email setup - Optional if you want to get/send emails # Email setup - Optional if you want to get/send emails
SMTP_PASSWORD_HELP= SMTP_PASSWORD_NOTIFY=
SMTP_USER_HELP= SMTP_USER_NOTIFY=
SMTP_HOST= SMTP_HOST=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
@@ -46,7 +46,7 @@
return people.find((person) => person.profile_id === profileId); return people.find((person) => person.profile_id === profileId);
}; };
const sendEmail = () => { const notifyEducator = () => {
const student = getStudent($group.students, $profile.id); const student = getStudent($group.students, $profile.id);
const teacherFullname = $group.tutors[0]?.fullname; const teacherFullname = $group.tutors[0]?.fullname;
const teacherEmail = $group.tutors[0]?.email; const teacherEmail = $group.tutors[0]?.email;
@@ -118,7 +118,7 @@
$course.id, $course.id,
getGroupMemberId($group.people, $profile.id) getGroupMemberId($group.people, $profile.id)
); );
sendEmail(); notifyEducator();
} }
} }
@@ -14,7 +14,7 @@
import Confetti from '$lib/components/Confetti/index.svelte'; import Confetti from '$lib/components/Confetti/index.svelte';
import { toggleConfetti } from '$lib/components/Confetti/store'; import { toggleConfetti } from '$lib/components/Confetti/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; 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 { writable } from 'svelte/store';
import { Circle3 } from 'svelte-loading-spinners'; import { Circle3 } from 'svelte-loading-spinners';
@@ -1,22 +1,10 @@
<script lang="ts"> <script lang="ts">
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons'; import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
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 === '';
}
</script> </script>
{#if !isNoteEmpty($lesson.materials?.note)} {#if !isHtmlValueEmpty($lesson.materials?.note)}
<HtmlRender className="m-auto"> <HtmlRender className="m-auto">
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<div> <div>
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import { useCompletion } from 'ai/svelte'; import { useCompletion } from 'ai/svelte';
import MODES from '$lib/utils/constants/mode.js'; import MODES from '$lib/utils/constants/mode.js';
@@ -36,8 +35,8 @@
import ComponentVideo from './components/ComponentVideo.svelte'; import ComponentVideo from './components/ComponentVideo.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import type { LessonPage } from '$lib/utils/types'; import type { LessonPage } from '$lib/utils/types';
import { getTextFromHTML } from '$lib/utils/functions/course';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
export let mode = MODES.view; export let mode = MODES.view;
export let prevMode = ''; export let prevMode = '';
@@ -80,18 +79,10 @@
return handleUpdateLessonMaterials(_lesson, lessonId); 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']) { function isMaterialsEmpty(materials: LessonPage['materials']) {
const { slide_url, videos, note } = 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) { function handleSave(prevMode: string) {
@@ -106,7 +97,7 @@
tabs = tabs.map((tab) => { tabs = tabs.map((tab) => {
let badgeValue = 0; let badgeValue = 0;
if (tab.value === 1 && !isNoteEmpty(note)) { if (tab.value === 1 && !isHtmlValueEmpty(note)) {
badgeValue = 1; badgeValue = 1;
} else if (tab.value === 2 && !!slide_url) { } else if (tab.value === 2 && !!slide_url) {
badgeValue = 1; badgeValue = 1;
@@ -8,12 +8,15 @@
import Badge from 'carbon-icons-svelte/lib/Badge.svelte'; import Badge from 'carbon-icons-svelte/lib/Badge.svelte';
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte'; import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte'; import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
export let name = ''; export let name = '';
</script> </script>
{#if name === 'Overview'} {#if name === 'Overview'}
<TableOfContents size={20} class="carbon-icon mr-2" /> <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'} {:else if name === 'Lessons'}
<IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" /> <IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" />
{:else if name === 'Attendance'} {:else if name === 'Attendance'}
@@ -146,7 +146,7 @@
$: { $: {
navItems = [ navItems = [
{ {
label: 'Overview', label: 'News Feed',
to: getNavItemRoute($course.id), to: getNavItemRoute($course.id),
hideSortIcon: true hideSortIcon: true
}, },
@@ -207,8 +207,6 @@
} }
]; ];
} }
$: console.log({ isStudent: $globalStore.isStudent });
</script> </script>
<aside <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 = ''; let prevCourseId = '';
async function onCourseIdChange(courseId = '') { async function onCourseIdChange(courseId = '') {
console.log('courseid changed', courseId);
if (!courseId || prevCourseId === courseId || !browser || $course.id === courseId) return; if (!courseId || prevCourseId === courseId || !browser || $course.id === courseId) return;
console.log('making request', courseId);
isFetching = true; isFetching = true;
course.set(defaultCourse); course.set(defaultCourse);
@@ -6,7 +6,7 @@
import { courses, createCourseModal } from '$lib/components/Courses/store'; import { courses, createCourseModal } from '$lib/components/Courses/store';
import { validateForm } from '$lib/components/Courses/functions'; import { validateForm } from '$lib/components/Courses/functions';
import { ROLE } from '$lib/utils/constants/roles'; 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 { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
@@ -81,12 +81,27 @@
}); });
// 3. Add group members // 3. Add group members
await addGroupMember({ const { data } = await addGroupMember({
profile_id: $profile.id, profile_id: $profile.id,
email: $profile.email, email: $profile.email,
group_id, group_id,
role_id: ROLE.TUTOR 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) { if (newCourse[0] != null && newCourse[0].id) {
goto(`/courses/${newCourse[0]?.id}`); goto(`/courses/${newCourse[0]?.id}`);
} }
@@ -1,105 +0,0 @@
<script lang="ts">
import { Container, Head, Hr, Html, Img, Preview, Section, Text } from 'svelte-email';
export let firstName = 'Seun';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const main = {
background: `radial-gradient(
circle at 10% 20%,
rgb(239, 246, 249) 0%,
rgb(206, 239, 253) 90%
)`
};
const container = {
margin: '100px auto',
padding: '40px',
backgroundColor: '#fff',
border: '1px solid #cccccc',
borderRadius: '10px'
};
const logo = {
margin: '0 auto'
};
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
const footer = {
fontFamily,
color: '#8898aa',
fontSize: '12px'
};
</script>
<Html lang="en">
<Head />
<Preview preview="Welcome to ClassroomIO" />
<Section style={main}>
<Container style={container}>
<Img
src="https://app.classroomio.com/logo-512.png"
alt="Svelte logo"
style={logo}
width="50"
height="50"
/>
<Text
style={{
textAlign: 'center',
color: '#1d4ee2',
fontWeight: 'bold',
fontSize: '20px'
}}>ClassroomIO</Text
>
<Text style={paragraph}>Dear {firstName},</Text>
<Text style={paragraph}>Welcome to the vibrant community of ClassroomIO!</Text>
<Text style={paragraph}>
We're thrilled to have you join us in shaping the future of education. Your decision to
embark on this journey with us is deeply appreciated, and we're genuinely excited about the
possibilities that lie ahead.
</Text>
<Text style={paragraph}>
At ClassroomIO, we understand the dedication and passion that educators like you bring to
the table. That's why we've built a platform that's not only designed to make your teaching
experience seamless but also to empower you to create transformative learning moments for
your students.
</Text>
<Text style={paragraph}>
Your choice to use ClassroomIO is a testament to your commitment to innovation in education.
It's educators like you who drive progress and inspire change, and we're here to support you
every step of the way.
</Text>
<Text style={paragraph}>
Our team is working diligently behind the scenes, shipping new features and updates daily.
This means you can expect a platform that's constantly evolving, adapting to your needs, and
staying at the forefront of educational technology.
</Text>
<Text style={paragraph}>
We encourage you to explore ClassroomIO's range of features, from interactive quizzes and
robust course management tools to collaborative forums and AI-integrated lesson planning.
Your feedback is invaluable to us as we work together to refine and enhance these features
to best suit your teaching style and objectives.
</Text>
<Text style={paragraph}>
Once again, welcome to the ClassroomIO family. We're here to help, to listen, and to
celebrate your successes. Get ready to unlock a world of possibilities in education!
</Text>
<Text style={footerParagraph}>Best regards</Text>
<Text style={footerParagraph}>Rotimi Best</Text>
<Text style={footerParagraph}>ClassroomIO</Text>
</Container>
</Section>
</Html>
@@ -17,13 +17,6 @@ export const getStudentInviteLink = (_course: Course, orgSiteName: string, origi
return `${origin}/invite/s/${hash}`; return `${origin}/invite/s/${hash}`;
}; };
export const getTextFromHTML = (html: string): string => {
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = html;
return dummyDiv.textContent?.trim() || '';
};
const tagsToReplace: { [k: string]: string } = { const tagsToReplace: { [k: string]: string } = {
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
@@ -0,0 +1,16 @@
import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true;
if (!document || !browser) return false;
return getTextFromHTML(html) === '';
};
export const getTextFromHTML = (html: string): string => {
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = html;
return dummyDiv.textContent?.trim() || '';
};
@@ -228,7 +228,6 @@ export async function cloneCourse(courseId: string, newTitle: string): Promise<C
-> Note the following information will be copied -> Note the following information will be copied
3. Things to copy 3. Things to copy
Overview
Lessons Lessons
Exercises - questions, options, everything basically. Exercises - questions, options, everything basically.
@@ -142,6 +142,10 @@ export function addGroupMember(member: any) {
return supabase.from('groupmember').insert(member).select(); return supabase.from('groupmember').insert(member).select();
} }
export function addDefaultNewsFeed(feed) {
return supabase.from('course_newsfeed').insert(feed);
}
export function updatedGroupMember(update: any, match: any) { export function updatedGroupMember(update: any, match: any) {
return supabase.from('groupmember').update(update).match(match); return supabase.from('groupmember').update(update).match(match);
} }
@@ -0,0 +1,181 @@
import type { Course } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
import type { Reaction, FeedApi } from '$lib/utils/types/feed';
export async function fetchNewsFeeds(courseId?: Course['id']) {
const response = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false })
.returns<FeedApi[]>();
const { data, error } = response;
return { data, error };
}
export async function createNewFeed(post: {
content: string;
author_id: string;
course_id: string;
reaction: Reaction;
}) {
const response = await supabase
.from('course_newsfeed')
.insert({
content: post.content,
author_id: post.author_id,
course_id: post.course_id,
reaction: post.reaction
})
.select();
return { response };
}
export async function handleEditFeed(feedId: string, content: string) {
const response = await supabase
.from('course_newsfeed')
.update({ content: content })
.match({ id: feedId })
.select();
return response;
}
export async function createComment(comment: {
content: string;
author_id: string;
course_newsfeed_id: string;
}) {
const response = await supabase
.from('course_newsfeed_comment')
.insert({
content: comment.content,
author_id: comment.author_id,
course_newsfeed_id: comment.course_newsfeed_id
})
.select();
return { response };
}
export async function toggleFeedIsPinned(feedId: string, isPinned: boolean) {
const response = await supabase
.from('course_newsfeed')
.update({
is_pinned: isPinned
})
.match({ id: feedId });
return { response };
}
export async function deleteNewsFeedComment(commentId: string) {
const response = await supabase.from('course_newsfeed_comment').delete().match({ id: commentId });
return response;
}
export async function deleteNewsFeed(feedId: string) {
await supabase.from('course_newsfeed_comment').delete().match({ course_newsfeed_id: feedId });
const response = await supabase.from('course_newsfeed').delete().match({ id: feedId });
return response;
}
export async function getFeedForNotification(feedId: string, authorId: string) {
const { data, error } = await supabase
.from('course_newsfeed')
.select(
`
content,
author:groupmember(profile(fullname, email)),
course(
id,
title,
group(
organization(siteName, name),
members:groupmember(id, profile(email, fullname))
)
)
`
)
.eq('id', feedId)
.limit(1)
.returns<
{
content: string;
author: {
profile: {
email: string;
fullname: string;
};
};
course: {
id: string;
title: string;
group: {
organization: {
name: string;
siteName: string;
};
members: {
id: string;
profile: {
email: string;
fullname: string;
};
}[];
};
};
}[]
>();
if (error) {
console.error('Failed to get feed', error);
return null;
}
console.log({
data
});
const [feed] = data || [];
if (!feed) return;
return {
id: feedId,
courseId: feed.course.id,
courseTitle: feed.course.title,
teacherName: feed.author?.profile?.fullname,
teacherEmail: feed.author?.profile?.email,
content: feed.content,
org: feed.course.group?.organization,
courseMembers: feed.course?.group?.members
?.filter((member) => member.id !== authorId)
?.map((member) => {
return member.profile;
})
};
}
@@ -9,7 +9,8 @@ export const NOTIFICATION_NAME = {
STUDENT_COURSE_WELCOME: 'STUDENT COURSE WELCOME', STUDENT_COURSE_WELCOME: 'STUDENT COURSE WELCOME',
TEACHER_STUDENT_JOINED: 'TEACHER STUDENT JOINED', TEACHER_STUDENT_JOINED: 'TEACHER STUDENT JOINED',
SUBMISSION_UPDATE: 'SUBMISSION UPDATE', SUBMISSION_UPDATE: 'SUBMISSION UPDATE',
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE' EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE',
NEWSFEED: 'NEWSFEED'
}; };
const NAME_TO_PATH = { const NAME_TO_PATH = {
@@ -22,7 +23,8 @@ const NAME_TO_PATH = {
[NOTIFICATION_NAME.STUDENT_COURSE_WELCOME]: '/api/email/course/student_welcome', [NOTIFICATION_NAME.STUDENT_COURSE_WELCOME]: '/api/email/course/student_welcome',
[NOTIFICATION_NAME.TEACHER_STUDENT_JOINED]: '/api/email/course/teacher_student_joined', [NOTIFICATION_NAME.TEACHER_STUDENT_JOINED]: '/api/email/course/teacher_student_joined',
[NOTIFICATION_NAME.SUBMISSION_UPDATE]: '/api/email/course/submission_update', [NOTIFICATION_NAME.SUBMISSION_UPDATE]: '/api/email/course/submission_update',
[NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE]: '/api/email/course/exercise_submission_update' [NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE]: '/api/email/course/exercise_submission_update',
[NOTIFICATION_NAME.NEWSFEED]: '/api/email/course/newsfeed'
}; };
const getAccessToken = async () => { const getAccessToken = async () => {
@@ -30,7 +32,7 @@ const getAccessToken = async () => {
return data.session?.access_token || ''; return data.session?.access_token || '';
}; };
export const triggerSendEmail = async (name: string, body: { [k: string]: any }) => { export const triggerSendEmail = async (name: string, body: { [k: string]: unknown }) => {
try { try {
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
const path = NAME_TO_PATH[name]; const path = NAME_TO_PATH[name];
@@ -51,7 +53,6 @@ export const triggerSendEmail = async (name: string, body: { [k: string]: any })
console.log(response); console.log(response);
throw new Error(await response.text()); throw new Error(await response.text());
} }
const result = await response.json();
} catch (error) { } catch (error) {
console.error(name, error); console.error(name, error);
} }
@@ -4,21 +4,10 @@ import {
SMTP_HOST, SMTP_HOST,
SMTP_USER, SMTP_USER,
SMTP_PASSWORD, SMTP_PASSWORD,
SMTP_USER_HELP, SMTP_USER_NOTIFY,
SMTP_PASSWORD_HELP SMTP_PASSWORD_NOTIFY
} from '$env/static/private'; } from '$env/static/private';
const content = `
<div>
<h1>Welcome</h1>
<br/>
<p>Thank you so much for trying out ClassroomIO</p>
<a href="https://app.classroomio.com/login">Login</a><br/>
<br/>
Your ClassroomIO Team
</div>
`;
let transporter: nodemailer.Transporter; let transporter: nodemailer.Transporter;
const getTransporter = async (isPersonal?: boolean): Promise<boolean> => { const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
@@ -28,8 +17,8 @@ const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
port: 465, port: 465,
secure: true, secure: true,
auth: { auth: {
user: isPersonal ? SMTP_USER : SMTP_USER_HELP, user: isPersonal ? SMTP_USER : SMTP_USER_NOTIFY,
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_HELP pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_NOTIFY
} }
}); });
@@ -60,14 +49,22 @@ export const sendEmail = async ({
isPersonalEmail?: boolean; isPersonalEmail?: boolean;
replyTo?: string; replyTo?: string;
}) => { }) => {
await getTransporter(isPersonalEmail); try {
const info = await transporter.sendMail({ await getTransporter(isPersonalEmail);
from: from || '"Best from ClassroomIO" <best@classroomio.com>', // sender address const info = await transporter.sendMail({
to, // list of receivers from: from || '"Best from ClassroomIO" <best@classroomio.com>', // sender address
subject, // Subject line to, // list of receivers
replyTo, subject, // Subject line
html: withEmailTemplate(content) // html body replyTo,
}); html: withEmailTemplate(content) // html body
});
console.log('Message sent: %s', info.messageId); console.log('Message sent: %s', info.messageId);
return info;
} catch (error) {
console.error({ error });
return error;
}
}; };
@@ -0,0 +1,46 @@
export interface Author {
id: string;
username: string;
fullname: string;
avatar_url: string;
}
export interface Comment {
id: string;
created_at: string;
content: string;
author: {
profile: Author;
};
}
export interface Reaction {
smile: string[];
thumbsup: string[];
thumbsdown: string[];
clap: string[];
}
export interface FeedApi {
id: string;
content: string;
author: {
profile: Author;
};
created_at: string;
comment: Comment[];
reaction: Reaction[];
is_pinned: false;
}
export interface Feed {
id: string;
content: string;
author: {
profile: Author;
};
created_at: string;
comment: Comment[];
reaction: Reaction[];
isPinned: boolean;
}
@@ -24,12 +24,12 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`, from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to, to,
subject: `[Submitted]: ${exerciseTitle}`, subject: `[Submitted]: ${exerciseTitle}`,
content content
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -0,0 +1,85 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
import { getFeedForNotification } from '$lib/utils/services/newsfeed/index';
const supabase = getSupabase();
const sendEmailNotification = async (feedId: string, authorId: string, comment?: string) => {
const feed = await getFeedForNotification(feedId, authorId);
if (!feed) return;
const postLink = `https://${feed.org.siteName}.classroomio.com/courses/${feed.courseId}?feedId=${feed.id}`;
if (comment) {
// send only to teacher
sendEmail({
from: `"${feed.org.name} - ClassroomIO" <notify@classroomio.com>`,
to: feed.teacherEmail,
subject: `[${feed.courseTitle}] - News feed comment`,
content: `
<p>A student left you a comment on your newsfeed post</p>
<div style="font-style: italic; margin-top: 10px;">${comment}</div>
<div>
<a class="button" href="${postLink}">View comment</a>
</div>
`
}).then((info) => console.log('Email sent:', info));
// dont continue
return;
}
// else send to everyone except the author of the post
Promise.all(
feed.courseMembers.map((member) => {
return sendEmail({
from: `"${feed.org.name} - ClassroomIO" <notify@classroomio.com>`,
to: member.email,
replyTo: feed.teacherEmail,
subject: `[${feed.courseTitle}] - New post in course`,
content: `
<p>${feed.teacherName} made a post in a course you are taking: ${feed.courseTitle}.</p>
<div style="font-style: italic; margin-top: 10px;">${feed.content}</div>
<div>
<a class="button" href="${postLink}">View post</a>
</div>
`
});
})
).then((emails) => console.log(`Sent emails to ${emails.length} students`));
};
export async function POST({ request }) {
const { authorId, feedId, comment } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/newsfeed');
if (!authorId || !feedId || !accessToken) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
sendEmailNotification(feedId, authorId, comment);
// TODO: Support sending to other platforms like telegram and discord
// sendDiscordNotification(); sendTelegramNotification();
return json({
success: true,
message: 'Email sent'
});
}
@@ -32,8 +32,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `"${orgName} - ClassroomIO" <help@classroomio.com>`, from: `"${orgName} - ClassroomIO" <notify@classroomio.com>`,
to, to,
replyTo: teacherEmail, replyTo: teacherEmail,
subject: `[${courseName}] One more step left`, subject: `[${courseName}] One more step left`,
@@ -44,7 +44,7 @@ export async function POST({ request }) {
<p>Talk to you soon and see you in class.</p> <p>Talk to you soon and see you in class.</p>
<p>${orgName}</p> <p>${orgName}</p>
` `
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -26,8 +26,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`, from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to, to,
subject: `${orgName} - Welcome to Class 🎉`, subject: `${orgName} - Welcome to Class 🎉`,
content: ` content: `
@@ -38,7 +38,7 @@ export async function POST({ request }) {
<p>Cheers,</p> <p>Cheers,</p>
<p>${orgName}</p> <p>${orgName}</p>
` `
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -24,12 +24,12 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`, from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to, to,
subject: 'Submission Update', subject: 'Submission Update',
content content
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -31,8 +31,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `ClassroomIO" <help@classroomio.com>`, from: `ClassroomIO" <notify@classroomio.com>`,
to, to,
subject: `[${courseName}] Request to Join Course!`, subject: `[${courseName}] Request to Join Course!`,
content: ` content: `
@@ -44,7 +44,7 @@ export async function POST({ request }) {
Email: ${studentEmail} Email: ${studentEmail}
</p> </p>
` `
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -25,8 +25,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
from: `"ClassroomIO" <help@classroomio.com>`, from: `"ClassroomIO" <notify@classroomio.com>`,
to, to,
subject: `[${courseName}] You've got a new student 🎉!`, subject: `[${courseName}] You've got a new student 🎉!`,
content: ` content: `
@@ -36,7 +36,7 @@ export async function POST({ request }) {
<p>If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching <p>If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching
experience as easy as possible.</p> experience as easy as possible.</p>
` `
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -28,8 +28,8 @@ export async function POST({ request }) {
const origin = request.headers.get('origin'); const origin = request.headers.get('origin');
const inviteLink = `${origin}/org/${orgSiteName}/courses`; const inviteLink = `${origin}/org/${orgSiteName}/courses`;
await sendEmail({ sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`, from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to, to,
subject: `You have been invited to a course in ${orgName}!`, subject: `You have been invited to a course in ${orgName}!`,
content: ` content: `
@@ -40,7 +40,7 @@ export async function POST({ request }) {
<a class="button" href="${inviteLink}">Open Dashboard</a> <a class="button" href="${inviteLink}">Open Dashboard</a>
</div> </div>
` `
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -12,7 +12,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization'); const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/invite', body); console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org).length || !email) { if (!org || !Object.keys(org).length || !email || !accessToken) {
return json( return json(
{ success: false, message: 'Org data and Teacher name are required' }, { success: false, message: 'Org data and Teacher name are required' },
{ status: 400 } { status: 400 }
@@ -42,7 +42,7 @@ export async function POST({ request }) {
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`; const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData); console.log('inviteData', inviteData);
await sendEmail({ sendEmail({
to: email, to: email,
subject: `Join ${name} on ClassroomIO 😃`, subject: `Join ${name} on ClassroomIO 😃`,
content: ` content: `
@@ -53,7 +53,7 @@ export async function POST({ request }) {
</div> </div>
`, `,
isPersonalEmail: true isPersonalEmail: true
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -9,7 +9,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization'); const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/welcome', to, name); console.log('/POST api/email/welcome', to, name);
if (!to || !name) { if (!to || !name || !accessToken) {
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 }); return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
} }
@@ -25,7 +25,7 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 }); return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
} }
await sendEmail({ sendEmail({
to, to,
subject: 'Thank you so so so much for choosing ClassroomIO!', subject: 'Thank you so so so much for choosing ClassroomIO!',
content: ` content: `
@@ -48,7 +48,7 @@ export async function POST({ request }) {
</div> </div>
`, `,
isPersonalEmail: true isPersonalEmail: true
}); }).then((info) => console.log('Email sent:', info));
return json({ return json({
success: true, success: true,
@@ -1,10 +1,279 @@
<script> <script lang="ts">
import Overview from '$lib/components/Course/index.svelte'; import { page } from '$app/stores';
import PinFilled from 'carbon-icons-svelte/lib/PinFilled.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import { group } from '$lib/components/Course/store';
import { profile } from '$lib/utils/store/user';
import CourseContainer from '$lib/components/CourseContainer/index.svelte'; import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import NewsFeedCard from '$lib/components/Course/components/NewsFeed/NewsFeedCard.svelte';
import NewFeedModal from '$lib/components/Course/components/NewsFeed/NewFeedModal.svelte';
import {
createComment,
deleteNewsFeedComment,
deleteNewsFeed,
fetchNewsFeeds,
handleEditFeed,
toggleFeedIsPinned
} from '$lib/utils/services/newsfeed';
import Box from '$lib/components/Box/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { Feed } from '$lib/utils/types/feed';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
export let data; export let data;
let createdComment;
let edit = false;
let editFeed: Feed;
let author = {
id: '',
username: '',
fullname: '',
avatar: ''
};
let newsFeed: Feed[] = [];
let query = new URLSearchParams($page.url.search);
let feedId = query.get('feedId') || '';
const onEdit = (id: string, content: string) => {
handleEditFeed(id, content);
newsFeed = newsFeed.map((feed) => {
if (feed.id === id) {
return { ...feed, content: content };
}
return feed;
});
};
const deleteComment = (id: string) => {
deleteNewsFeedComment(id);
newsFeed = newsFeed.flatMap((feed) => ({
...feed,
comment: feed.comment.filter((comment) => comment.id !== id)
}));
return snackbar.success('Comment Deleted');
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const reactedFeed = newsFeed.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
if (reactedAuthorIds.includes(authorId)) {
reactedAuthorIds = reactedAuthorIds.filter(
(reactionAuthorId) => reactionAuthorId !== authorId
);
} else {
reactedAuthorIds = [...reactedAuthorIds, authorId];
}
reactedFeed.reaction = {
...reactedFeed.reaction,
[reactionType]: reactedAuthorIds
};
const response = await supabase
.from('course_newsfeed')
.update({
reaction: reactedFeed.reaction
})
.eq('id', feedId);
if (response.error) {
return snackbar.error('An error occurred while reacting to news feed');
}
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
feed.reaction = reactedFeed.reaction;
}
return feed;
});
};
const addNewComment = async (comment: string, feedId: string, authorId: string) => {
const { response } = await createComment({
content: comment,
author_id: authorId,
course_newsfeed_id: feedId
});
if (response.error) {
return snackbar.error('An error occurred while creating comment');
}
if (!response.data) return;
createdComment = response?.data[0];
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: author.id,
feedId: feedId,
comment
});
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
const newComment = {
id: createdComment.id,
author: {
profile: {
id: author.id,
username: author.username,
fullname: author.fullname,
avatar_url: author.avatar
}
},
created_at: createdComment.created_at,
content: comment
};
snackbar.success('Comment added');
return {
...feed,
comment: [...feed.comment, { ...newComment }]
};
}
return feed;
});
};
const onPin = async (feedId, isPinned) => {
const newIsPinned = !isPinned;
const { response } = await toggleFeedIsPinned(feedId, newIsPinned);
if (response.error) {
return snackbar.error('Failed to toggle pinned feed');
}
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
snackbar.success(`${newIsPinned ? 'Pinned' : 'Unpinned'} Successfully`);
feed.isPinned = newIsPinned;
return feed;
}
return feed;
});
};
const deleteFeed = (id: string) => {
deleteNewsFeed(id);
const deletedFeed = newsFeed.filter((feed) => feed.id !== id);
snackbar.success('Feed deleted successfully');
newsFeed = deletedFeed;
};
const initNewsFeed = async (courseId: string) => {
if (!courseId) return;
const { data, error } = await fetchNewsFeeds(courseId);
if (error) {
snackbar.error('Failed to fetch news feeds');
return;
}
if (!data) return;
newsFeed = data.map((feedItem) => ({
...feedItem,
isPinned: feedItem.is_pinned
}));
};
function setAuthor(groups, profileId) {
const currentGroupMember = groups.people.find((person) => person.profile_id === profileId);
author = {
id: currentGroupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatar: $profile.avatar_url || ''
};
}
$: initNewsFeed(data.courseId);
$: setAuthor($group, $profile.id);
$: newsFeed = newsFeed.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
</script> </script>
<CourseContainer bind:courseId={data.courseId}> <CourseContainer bind:courseId={data.courseId}>
<Overview /> <PageNav title="News Feed" disableSticky={true}>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton className="mr-2" label="Add" onClick={() => ($isNewFeedModal.open = true)} />
</RoleBasedSecurity>
</slot:fragment>
</PageNav>
<PageBody width="max-w-4xl px-3">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<NewFeedModal
courseId={data.courseId}
{author}
bind:edit
bind:editFeed
onSave={(newFeed) => {
newsFeed = [newFeed, ...newsFeed];
}}
{onEdit}
/>
</RoleBasedSecurity>
{#if !newsFeed.length}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No post yet</h2>
<p class="text-sm text-center text-slate-500">
Make a post to your class. Start by clicking on the Add button.
</p>
</div>
</Box>
{:else}
{#each newsFeed as feed}
{#if feed.isPinned}
<div class="flex items-center gap-2 mb-3">
<PinFilled size={16} />
<p class="text-sm">Pinned</p>
</div>
{/if}
<NewsFeedCard
{feed}
{deleteFeed}
{addNewComment}
{deleteComment}
{addNewReaction}
{onPin}
{author}
bind:edit
bind:editFeed
isActive={feedId === feed.id}
/>
{/each}
{/if}
</PageBody>
</CourseContainer> </CourseContainer>
@@ -97,7 +97,7 @@
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0); return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
} }
const sendEmail = (submissionData) => { const notifyStudent = (submissionData) => {
maxMark = getMaxPoints(submissionData?.questions); maxMark = getMaxPoints(submissionData?.questions);
totalMark = calculateTotal(submissionData?.questionAnswerByPoint); totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
@@ -155,7 +155,7 @@
status_id: itemToWithNewStatus.statusId status_id: itemToWithNewStatus.statusId
}; };
sendEmail(submissionIdData[itemToWithNewStatus.id]); notifyStudent(submissionIdData[itemToWithNewStatus.id]);
updateSubmission({ updateSubmission({
id: itemToWithNewStatus.id, id: itemToWithNewStatus.id,
@@ -219,7 +219,7 @@
status_id: itemToWithNewStatus.statusId status_id: itemToWithNewStatus.statusId
}; };
sendEmail(submissionIdData[itemToWithNewStatus.id]); notifyStudent(submissionIdData[itemToWithNewStatus.id]);
// Update backend // Update backend
updateSubmission({ updateSubmission({
id: itemToWithNewStatus.id, id: itemToWithNewStatus.id,
+3 -3
View File
@@ -4944,7 +4944,7 @@ packages:
/@vue/compiler-core@3.3.8: /@vue/compiler-core@3.3.8:
resolution: {integrity: sha512-hN/NNBUECw8SusQvDSqqcVv6gWq8L6iAktUR0UF3vGu2OhzRqcOiAno0FmBJWwxhYEXRlQJT5XnoKsVq1WZx4g==} resolution: {integrity: sha512-hN/NNBUECw8SusQvDSqqcVv6gWq8L6iAktUR0UF3vGu2OhzRqcOiAno0FmBJWwxhYEXRlQJT5XnoKsVq1WZx4g==}
dependencies: dependencies:
'@babel/parser': 7.23.4 '@babel/parser': 7.23.9
'@vue/shared': 3.3.8 '@vue/shared': 3.3.8
estree-walker: 2.0.2 estree-walker: 2.0.2
source-map-js: 1.0.2 source-map-js: 1.0.2
@@ -4960,7 +4960,7 @@ packages:
/@vue/compiler-sfc@3.3.8: /@vue/compiler-sfc@3.3.8:
resolution: {integrity: sha512-WMzbUrlTjfYF8joyT84HfwwXo+8WPALuPxhy+BZ6R4Aafls+jDBnSz8PDz60uFhuqFbl3HxRfxvDzrUf3THwpA==} resolution: {integrity: sha512-WMzbUrlTjfYF8joyT84HfwwXo+8WPALuPxhy+BZ6R4Aafls+jDBnSz8PDz60uFhuqFbl3HxRfxvDzrUf3THwpA==}
dependencies: dependencies:
'@babel/parser': 7.23.4 '@babel/parser': 7.23.9
'@vue/compiler-core': 3.3.8 '@vue/compiler-core': 3.3.8
'@vue/compiler-dom': 3.3.8 '@vue/compiler-dom': 3.3.8
'@vue/compiler-ssr': 3.3.8 '@vue/compiler-ssr': 3.3.8
@@ -4982,7 +4982,7 @@ packages:
/@vue/reactivity-transform@3.3.8: /@vue/reactivity-transform@3.3.8:
resolution: {integrity: sha512-49CvBzmZNtcHua0XJ7GdGifM8GOXoUMOX4dD40Y5DxI3R8OUhMlvf2nvgUAcPxaXiV5MQQ1Nwy09ADpnLQUqRw==} resolution: {integrity: sha512-49CvBzmZNtcHua0XJ7GdGifM8GOXoUMOX4dD40Y5DxI3R8OUhMlvf2nvgUAcPxaXiV5MQQ1Nwy09ADpnLQUqRw==}
dependencies: dependencies:
'@babel/parser': 7.23.4 '@babel/parser': 7.23.9
'@vue/compiler-core': 3.3.8 '@vue/compiler-core': 3.3.8
'@vue/shared': 3.3.8 '@vue/shared': 3.3.8
estree-walker: 2.0.2 estree-walker: 2.0.2
@@ -0,0 +1,48 @@
create table "public"."course_newsfeed" (
"created_at" timestamp with time zone not null default now(),
"author_id" uuid,
"content" text,
"id" uuid not null default gen_random_uuid(),
"course_id" uuid,
"reaction" jsonb default '{"clap": [], "smile": [], "thumbsup": [], "thumbsdown": []}'::jsonb,
"is_pinned" boolean not null default false
);
create table "public"."course_newsfeed_comment" (
"created_at" timestamp with time zone not null default now(),
"author_id" uuid,
"content" text,
"id" bigint generated by default as identity not null,
"course_newsfeed_id" uuid
);
CREATE UNIQUE INDEX course_newsfeed_comment_id_key ON public.course_newsfeed_comment USING btree (id);
CREATE UNIQUE INDEX course_newsfeed_comment_pkey ON public.course_newsfeed_comment USING btree (id);
CREATE UNIQUE INDEX course_newsfeed_pkey ON public.course_newsfeed USING btree (id);
alter table "public"."course_newsfeed" add constraint "course_newsfeed_pkey" PRIMARY KEY using index "course_newsfeed_pkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_pkey" PRIMARY KEY using index
"course_newsfeed_comment_pkey";
alter table "public"."course_newsfeed" add constraint "course_newsfeed_author_id_fkey" FOREIGN KEY (author_id) REFERENCES groupmember(id) not valid;
alter table "public"."course_newsfeed" validate constraint "course_newsfeed_author_id_fkey";
alter table "public"."course_newsfeed" add constraint "course_newsfeed_course_id_fkey" FOREIGN KEY (course_id) REFERENCES course(id) not valid;
alter table "public"."course_newsfeed" validate constraint "course_newsfeed_course_id_fkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_id_key" UNIQUE using index "course_newsfeed_comment_id_key";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_author_id_fkey" FOREIGN KEY (author_id) REFERENCES groupmember(id) not valid;
alter table "public"."course_newsfeed_comment" validate constraint "course_newsfeed_comment_author_id_fkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_course_newsfeed_id_fkey" FOREIGN KEY (course_newsfeed_id) REFERENCES course_newsfeed(id) not valid;
alter table "public"."course_newsfeed_comment" validate constraint "course_newsfeed_comment_course_newsfeed_id_fkey";
+7 -4
View File
@@ -12,15 +12,18 @@ Features that would implemented internally with possible collaboration with the
### Backlog ### Backlog
- [] Fix video upload functionality and make it open source - [] rrefactor email sending
- [] move email sending to defer.run - [] don't send email address from FE
- [] don't await sending via nodemailer
- [] implement multi language - [] implement multi language
- [] Implement supabase RLS - [] implement supabase RLS
- [] Integrate lemonsqueezy - [] integrate lemonsqueezy
### In progress ### In progress
- [] Course announcements - [] Course announcements
- [] Create basic API for ClassroomIO - [] Create basic API for ClassroomIO
- [] Link shorterner API
- [] Fix video upload functionality and make it open source
### Done ### Done