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 <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Rotimi Best
parent
474e83c3d9
commit
5cdb74a6b5
@@ -4,8 +4,8 @@ PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# Email setup - Optional if you want to get/send emails
|
||||
SMTP_PASSWORD_HELP=
|
||||
SMTP_USER_HELP=
|
||||
SMTP_PASSWORD_NOTIFY=
|
||||
SMTP_USER_NOTIFY=
|
||||
SMTP_HOST=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+2
-14
@@ -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
|
||||
|
||||
+40
@@ -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 🎉 </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}`);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
export const getTextFromHTML = (html: string): string => {
|
||||
const dummyDiv = document.createElement('div');
|
||||
dummyDiv.innerHTML = html;
|
||||
|
||||
return dummyDiv.textContent?.trim() || '';
|
||||
};
|
||||
|
||||
const tagsToReplace: { [k: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
|
||||
@@ -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
|
||||
|
||||
3. Things to copy
|
||||
Overview
|
||||
Lessons
|
||||
Exercises - questions, options, everything basically.
|
||||
|
||||
|
||||
@@ -142,6 +142,10 @@ export function addGroupMember(member: any) {
|
||||
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) {
|
||||
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',
|
||||
TEACHER_STUDENT_JOINED: 'TEACHER STUDENT JOINED',
|
||||
SUBMISSION_UPDATE: 'SUBMISSION UPDATE',
|
||||
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE'
|
||||
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE',
|
||||
NEWSFEED: 'NEWSFEED'
|
||||
};
|
||||
|
||||
const NAME_TO_PATH = {
|
||||
@@ -22,7 +23,8 @@ const NAME_TO_PATH = {
|
||||
[NOTIFICATION_NAME.STUDENT_COURSE_WELCOME]: '/api/email/course/student_welcome',
|
||||
[NOTIFICATION_NAME.TEACHER_STUDENT_JOINED]: '/api/email/course/teacher_student_joined',
|
||||
[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 () => {
|
||||
@@ -30,7 +32,7 @@ const getAccessToken = async () => {
|
||||
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 {
|
||||
const accessToken = await getAccessToken();
|
||||
const path = NAME_TO_PATH[name];
|
||||
@@ -51,7 +53,6 @@ export const triggerSendEmail = async (name: string, body: { [k: string]: any })
|
||||
console.log(response);
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const result = await response.json();
|
||||
} catch (error) {
|
||||
console.error(name, error);
|
||||
}
|
||||
|
||||
@@ -4,21 +4,10 @@ import {
|
||||
SMTP_HOST,
|
||||
SMTP_USER,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_USER_HELP,
|
||||
SMTP_PASSWORD_HELP
|
||||
SMTP_USER_NOTIFY,
|
||||
SMTP_PASSWORD_NOTIFY
|
||||
} 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;
|
||||
|
||||
const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
|
||||
@@ -28,8 +17,8 @@ const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: isPersonal ? SMTP_USER : SMTP_USER_HELP,
|
||||
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_HELP
|
||||
user: isPersonal ? SMTP_USER : SMTP_USER_NOTIFY,
|
||||
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_NOTIFY
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,14 +49,22 @@ export const sendEmail = async ({
|
||||
isPersonalEmail?: boolean;
|
||||
replyTo?: string;
|
||||
}) => {
|
||||
await getTransporter(isPersonalEmail);
|
||||
const info = await transporter.sendMail({
|
||||
from: from || '"Best from ClassroomIO" <[email protected]>', // sender address
|
||||
to, // list of receivers
|
||||
subject, // Subject line
|
||||
replyTo,
|
||||
html: withEmailTemplate(content) // html body
|
||||
});
|
||||
try {
|
||||
await getTransporter(isPersonalEmail);
|
||||
const info = await transporter.sendMail({
|
||||
from: from || '"Best from ClassroomIO" <[email protected]>', // sender address
|
||||
to, // list of receivers
|
||||
subject, // Subject line
|
||||
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 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: `[Submitted]: ${exerciseTitle}`,
|
||||
content
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
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" <[email protected]>`,
|
||||
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" <[email protected]>`,
|
||||
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 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `"${orgName} - ClassroomIO" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"${orgName} - ClassroomIO" <notify@classroomio.com>`,
|
||||
to,
|
||||
replyTo: teacherEmail,
|
||||
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>${orgName}</p>
|
||||
`
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -26,8 +26,8 @@ export async function POST({ request }) {
|
||||
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: `${orgName} - Welcome to Class 🎉`,
|
||||
content: `
|
||||
@@ -38,7 +38,7 @@ export async function POST({ request }) {
|
||||
<p>Cheers,</p>
|
||||
<p>${orgName}</p>
|
||||
`
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -24,12 +24,12 @@ export async function POST({ request }) {
|
||||
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: 'Submission Update',
|
||||
content
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -31,8 +31,8 @@ export async function POST({ request }) {
|
||||
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `ClassroomIO" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `ClassroomIO" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: `[${courseName}] Request to Join Course!`,
|
||||
content: `
|
||||
@@ -44,7 +44,7 @@ export async function POST({ request }) {
|
||||
Email: ${studentEmail}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function POST({ request }) {
|
||||
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
from: `"ClassroomIO" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"ClassroomIO" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: `[${courseName}] You've got a new student 🎉!`,
|
||||
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
|
||||
experience as easy as possible.</p>
|
||||
`
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -28,8 +28,8 @@ export async function POST({ request }) {
|
||||
const origin = request.headers.get('origin');
|
||||
const inviteLink = `${origin}/org/${orgSiteName}/courses`;
|
||||
|
||||
await sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
|
||||
sendEmail({
|
||||
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
|
||||
to,
|
||||
subject: `You have been invited to a course in ${orgName}!`,
|
||||
content: `
|
||||
@@ -40,7 +40,7 @@ export async function POST({ request }) {
|
||||
<a class="button" href="${inviteLink}">Open Dashboard</a>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function POST({ request }) {
|
||||
const accessToken = request.headers.get('Authorization');
|
||||
console.log('/POST api/email/invite', body);
|
||||
|
||||
if (!org || !Object.keys(org).length || !email) {
|
||||
if (!org || !Object.keys(org).length || !email || !accessToken) {
|
||||
return json(
|
||||
{ success: false, message: 'Org data and Teacher name are required' },
|
||||
{ status: 400 }
|
||||
@@ -42,7 +42,7 @@ export async function POST({ request }) {
|
||||
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
|
||||
console.log('inviteData', inviteData);
|
||||
|
||||
await sendEmail({
|
||||
sendEmail({
|
||||
to: email,
|
||||
subject: `Join ${name} on ClassroomIO 😃`,
|
||||
content: `
|
||||
@@ -53,7 +53,7 @@ export async function POST({ request }) {
|
||||
</div>
|
||||
`,
|
||||
isPersonalEmail: true
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function POST({ request }) {
|
||||
const accessToken = request.headers.get('Authorization');
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function POST({ request }) {
|
||||
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
sendEmail({
|
||||
to,
|
||||
subject: 'Thank you so so so much for choosing ClassroomIO!',
|
||||
content: `
|
||||
@@ -48,7 +48,7 @@ export async function POST({ request }) {
|
||||
</div>
|
||||
`,
|
||||
isPersonalEmail: true
|
||||
});
|
||||
}).then((info) => console.log('Email sent:', info));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -1,10 +1,279 @@
|
||||
<script>
|
||||
import Overview from '$lib/components/Course/index.svelte';
|
||||
<script lang="ts">
|
||||
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 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;
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
|
||||
}
|
||||
|
||||
const sendEmail = (submissionData) => {
|
||||
const notifyStudent = (submissionData) => {
|
||||
maxMark = getMaxPoints(submissionData?.questions);
|
||||
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
status_id: itemToWithNewStatus.statusId
|
||||
};
|
||||
|
||||
sendEmail(submissionIdData[itemToWithNewStatus.id]);
|
||||
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
|
||||
|
||||
updateSubmission({
|
||||
id: itemToWithNewStatus.id,
|
||||
@@ -219,7 +219,7 @@
|
||||
status_id: itemToWithNewStatus.statusId
|
||||
};
|
||||
|
||||
sendEmail(submissionIdData[itemToWithNewStatus.id]);
|
||||
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
|
||||
// Update backend
|
||||
updateSubmission({
|
||||
id: itemToWithNewStatus.id,
|
||||
|
||||
Reference in New Issue
Block a user