Upload avatar

This commit is contained in:
rotimi-best
2021-08-26 13:35:56 +03:00
parent 26798f1e93
commit 05ed2b74ee
16 changed files with 290 additions and 77 deletions
@@ -1,14 +1,17 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { stores, goto } from '@sapper/app'; import { stores, goto } from '@sapper/app';
import hotkeys from 'hotkeys-js'; import hotkeys from 'hotkeys-js';
import Home32 from 'carbon-icons-svelte/lib/Home32';
import CheckmarkFilled20 from 'carbon-icons-svelte/lib/CheckmarkFilled20'; import CheckmarkFilled20 from 'carbon-icons-svelte/lib/CheckmarkFilled20';
import Settings20 from 'carbon-icons-svelte/lib/Settings20'; import Settings20 from 'carbon-icons-svelte/lib/Settings20';
import ChevronLeft32 from 'carbon-icons-svelte/lib/ChevronLeft32'; import ChevronLeft16 from 'carbon-icons-svelte/lib/ChevronLeft16';
import ChevronRight32 from 'carbon-icons-svelte/lib/ChevronRight32'; import ChevronRight16 from 'carbon-icons-svelte/lib/ChevronRight16';
import Expandable from '../../../Expandable/index.svelte'; import Expandable from '../../../Expandable/index.svelte';
import PageNav from '../../../PageNav/index.svelte'; import PageNav from '../../../PageNav/index.svelte';
import IconButton from '../../../IconButton/index.svelte'; import IconButton from '../../../IconButton/index.svelte';
import Avatar from '../../../Avatar/index.svelte';
import { import {
getNavItemRoute, getNavItemRoute,
getLessonsRoute, getLessonsRoute,
@@ -20,10 +23,11 @@
import { lessons } from '../Lesson/store/lessons'; import { lessons } from '../Lesson/store/lessons';
import { course } from '../../store'; import { course } from '../../store';
import { updateCourse } from '../../../../utils/services/courses'; import { updateCourse } from '../../../../utils/services/courses';
import { profile } from '../../../../utils/store/user';
// export let lessonId; // export let lessonId;
export let path; export let path;
let show = null; let show = true;
const { page } = stores(); const { page } = stores();
@@ -45,7 +49,11 @@
} }
onMount(() => { onMount(() => {
show = localStorage.getItem('hideCourseNav') === 'false'; if (localStorage.getItem('hideCourseNav')) {
show = localStorage.getItem('hideCourseNav') === 'false';
} else {
show = true;
}
hotkeys('b', function (event, handler) { hotkeys('b', function (event, handler) {
event.preventDefault(); event.preventDefault();
@@ -101,23 +109,22 @@
</script> </script>
<Settings /> <Settings />
<div class="root z-10 {!show && 'hide'}"> <div class="root z-10 {!show && 'hide'}">
{#if show} {#if show}
<PageNav <div class="relative h-full">
bind:title={$course.title} <PageNav bind:title={$course.title} paddingClass="pl-2">
onEditComplete={handleSaveTitle} <slot:fragment slot="image">
isTitleEditable={true} <Avatar src={$course.logo} />
paddingClass="pl-2" </slot:fragment>
> <slot:fragment slot="widget">
<slot:fragment slot="widget"> <IconButton
<IconButton onClick={() => ($settingsDialog.open = !$settingsDialog.open)}
onClick={() => ($settingsDialog.open = !$settingsDialog.open)} >
> <Settings20 class="carbon-icon" />
<Settings20 class="carbon-icon" /> </IconButton>
</IconButton> </slot:fragment>
</slot:fragment> </PageNav>
</PageNav>
<div>
{#each navItems as navItem} {#each navItems as navItem}
<Expandable <Expandable
label={navItem.label} label={navItem.label}
@@ -134,7 +141,9 @@
'cursor-not-allowed'}" 'cursor-not-allowed'}"
href={getLessonsRoute($course.id, item.id)} href={getLessonsRoute($course.id, item.id)}
> >
<span class="course-counter"> {getLectureNo(index + 1)} </span> <span class="course-counter">
{getLectureNo(index + 1)}
</span>
<span>{item.title}</span> <span>{item.title}</span>
{#if item.is_complete} {#if item.is_complete}
<span class="ml-2 success"> <span class="ml-2 success">
@@ -148,12 +157,28 @@
{/if} {/if}
</Expandable> </Expandable>
{/each} {/each}
<div
class="w-full footer p-3 absolute bottom-2 flex items-center justify-between"
>
<IconButton
value="toggle"
onClick={() => goto('/courses')}
buttonClassName="mx-3"
>
<Home32 class="carbon-icon" />
</IconButton>
<a class="block mx-3" href={`/profile/${$profile.id}`}>
<Avatar src={$profile.avatar_url} name={$profile.username} />
</a>
</div>
</div> </div>
{/if} {/if}
<div class="toggler absolute bottom-0 -right-0.5"> <div class="toggler rounded-full shadow-lg absolute bottom-0">
<IconButton <IconButton
value="toggle" value="toggle"
onClick={() => (show = !show)} onClick={() => (show = !show)}
size="small"
toolTipProps={{ toolTipProps={{
title: 'Toggle sidebar', title: 'Toggle sidebar',
hotkeys: ['B'], hotkeys: ['B'],
@@ -161,9 +186,9 @@
}} }}
> >
{#if show} {#if show}
<ChevronLeft32 class="carbon-icon" /> <ChevronLeft16 class="carbon-icon" />
{:else} {:else}
<ChevronRight32 class="carbon-icon" /> <ChevronRight16 class="carbon-icon" />
{/if} {/if}
</IconButton> </IconButton>
</div> </div>
@@ -171,7 +196,7 @@
<style lang="scss"> <style lang="scss">
.root { .root {
height: 93vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 360px; min-width: 360px;
@@ -182,11 +207,16 @@
background-color: rgb(250, 251, 252); background-color: rgb(250, 251, 252);
&.hide { &.hide {
min-width: 0px; min-width: 15px;
max-width: 0px; max-width: 15px;
} }
} }
.footer {
border-top: 1px solid var(--border-color);
height: 50px;
}
.active { .active {
background-color: #cae2f9; background-color: #cae2f9;
border-left: 3px solid var(--main-primary-color); border-left: 3px solid var(--main-primary-color);
@@ -209,10 +239,11 @@
} }
.toggler { .toggler {
right: -58px; right: -15px;
z-index: 10; z-index: 10;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-top-right-radius: 20px; top: 50px;
border-bottom-right-radius: 20px; height: fit-content;
background: var(--border-color);
} }
</style> </style>
@@ -65,6 +65,7 @@
onClose={resetForm} onClose={resetForm}
bind:open={$invitationModal.open} bind:open={$invitationModal.open}
width="w-2/5" width="w-2/5"
maxWidth="max-w-lg"
modalHeading="Invite people" modalHeading="Invite people"
> >
<form on:submit|preventDefault={addPerson}> <form on:submit|preventDefault={addPerson}>
@@ -1,7 +1,9 @@
<script> <script>
import Modal from '../../../Modal/index.svelte'; import Modal from '../../../Modal/index.svelte';
import TextField from '../../../Form/TextField.svelte';
import TextArea from '../../../Form/TextArea.svelte'; import TextArea from '../../../Form/TextArea.svelte';
import PrimaryButton from '../../../PrimaryButton/index.svelte'; import PrimaryButton from '../../../PrimaryButton/index.svelte';
import UploadImage from '../../../UploadImage/index.svelte';
import { VARIANTS } from '../../../PrimaryButton/constants'; import { VARIANTS } from '../../../PrimaryButton/constants';
import { settingsDialog } from './store'; import { settingsDialog } from './store';
@@ -12,22 +14,43 @@
} from '../../../../utils/services/courses'; } from '../../../../utils/services/courses';
let shouldDelete = false; let shouldDelete = false;
let avatar;
let errors = {};
async function updateDescription() { async function updateDescription() {
console.log('update'); if (!$course.title) {
errors.title = 'Title cannot be empty';
return;
}
if (!$course.description) {
errors.description = 'Description cannot be empty';
return;
}
$settingsDialog.open = false; $settingsDialog.open = false;
await updateCourse($course.id, { const logo = await updateCourse($course.id, {
avatar,
title: $course.title,
description: $course.description, description: $course.description,
}); });
if (logo) {
$course.logo = logo;
}
avatar = undefined;
} }
async function handleDelete() { async function handleDelete() {
console.log('delete');
$settingsDialog.open = false; $settingsDialog.open = false;
await deleteCourse($course.id); await deleteCourse($course.id);
} }
function setAvatar(logo) {
avatar = logo;
}
$: setAvatar($course.logo);
</script> </script>
<Modal <Modal
@@ -57,6 +80,14 @@
</form> </form>
{:else} {:else}
<form on:submit|preventDefault={updateDescription}> <form on:submit|preventDefault={updateDescription}>
<UploadImage bind:avatar />
<TextField
bind:value={$course.title}
placeholder="Course title"
className="mb-4"
isRequired={true}
errorMessage={errors.title}
/>
<TextArea <TextArea
label="Short description" label="Short description"
bind:value={$course.description} bind:value={$course.description}
@@ -65,6 +96,7 @@
placeholder="A little description about this course" placeholder="A little description about this course"
className="mb-4" className="mb-4"
isRequired={true} isRequired={true}
errorMessage={errors.description}
/> />
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
+4 -2
View File
@@ -38,7 +38,7 @@
</svelte:fragment> </svelte:fragment>
</PageNav> </PageNav>
<PageBody> <PageBody width="max-w-3xl px-3">
{#if mode === MODES.edit} {#if mode === MODES.edit}
<EditContent <EditContent
writeLabel="Note" writeLabel="Note"
@@ -50,6 +50,8 @@
}} }}
/> />
{:else} {:else}
{@html marked($course.overview || '', { breaks: true, gfm: true })} <article class="preview prose prose-sm sm:prose p-2">
{@html marked($course.overview || '')}
</article>
{/if} {/if}
</PageBody> </PageBody>
+6 -1
View File
@@ -6,6 +6,7 @@
export let buttonClassName = ''; export let buttonClassName = '';
export let contained = false; export let contained = false;
export let value = ''; export let value = '';
export let size = 'large';
export let toolTipProps = { export let toolTipProps = {
title: '', title: '',
hotkeys: [], hotkeys: [],
@@ -23,7 +24,7 @@
direction={toolTipProps.direction} direction={toolTipProps.direction}
> >
<button <button
class="root {selected && 'active'} {contained && class="root {selected && 'active'} {size} {contained &&
'contained'} {buttonClassName}" 'contained'} {buttonClassName}"
{disabled} {disabled}
on:click={handleClick} on:click={handleClick}
@@ -58,6 +59,10 @@
} }
} }
.root.small {
padding: 5px;
}
.contained { .contained {
background-color: rgba(0, 0, 0, 0.04); background-color: rgba(0, 0, 0, 0.04);
} }
+3 -1
View File
@@ -6,6 +6,7 @@
export let onClose = () => {}; export let onClose = () => {};
export let modalHeading = ''; export let modalHeading = '';
export let width = ''; export let width = '';
export let maxWidth = '';
export let containerClass = ''; export let containerClass = '';
afterUpdate(() => { afterUpdate(() => {
@@ -23,7 +24,8 @@
> >
<div aria-hidden="true" class="backdrop" /> <div aria-hidden="true" class="backdrop" />
<div <div
class="container bg-white mx-auto {width} shadow-lg p-5 pt-2 rounded-md" class="{maxWidth ||
'container'} bg-white mx-auto {width} shadow-lg p-5 pt-2 rounded-md"
> >
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl m-0"> <h3 class="text-xl m-0">
+1 -1
View File
@@ -19,7 +19,7 @@
<style> <style>
.root { .root {
overflow-y: scroll; overflow-y: auto;
height: 85vh; height: 85vh;
} }
.head { .head {
+10 -9
View File
@@ -46,7 +46,10 @@
/> />
{/if} {/if}
{:else} {:else}
<h4 class="title">{title}</h4> <div class="flex items-center" {title}>
<slot name="image" />
<h4 class="title">{title}</h4>
</div>
{/if} {/if}
{/if} {/if}
{#if Array.isArray(navItems) && navItems.length} {#if Array.isArray(navItems) && navItems.length}
@@ -78,7 +81,12 @@
} }
.title { .title {
font-size: 20px; font-size: 18px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 250px;
margin-left: 5px;
} }
h4 { h4 {
@@ -121,11 +129,4 @@
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 10px; padding: 10px;
} }
@media only screen and (max-width: 1002px) {
.header {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>
+79
View File
@@ -0,0 +1,79 @@
<script>
export let avatar;
export let src;
export let widthHeight;
let fileinput;
const onFileSelected = (e) => {
const image = e.target.files[0];
let reader = new FileReader();
reader.readAsDataURL(image);
reader.onload = (e) => {
avatar = image;
src = e.target.result;
};
};
</script>
<section class="flex justify-center">
<div
class="avatar-container {widthHeight ||
'setwidthheight'} pointer border-2 border-gray-200 relative hover:bg-gray-400 hover:bg-opacity-75 cursor-pointer rounded-full"
on:click={() => {
fileinput.click();
}}
>
{#if src}
<img class="w-full h-full rounded-full" {src} alt="d" />
{:else if avatar}
<img class="w-full h-full rounded-full" src={avatar} alt="d" />
{:else}
<img
class="w-full h-full rounded-full"
src="https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png"
alt=""
/>
{/if}
<div
class="upload-icon w-full h-full flex items-center justify-center top-0"
>
<svg
class="text-white"
width="24"
height="24"
viewBox="0 0 24 24"
role="presentation"
><g fill="currentColor" fill-rule="evenodd"
><path
d="M2 6.994C2 5.893 2.898 5 3.99 5h16.02C21.108 5 22 5.895 22 6.994v12.012A1.997 1.997 0 0120.01 21H3.99A1.994 1.994 0 012 19.006V6.994zM12 17a4 4 0 100-8 4 4 0 000 8zm5-8c0 .556.448 1 1 1 .556 0 1-.448 1-1 0-.556-.448-1-1-1-.556 0-1 .448-1 1zM8 4c0-.552.453-1 .997-1h6.006c.55 0 .997.444.997 1v1H8V4z"
/><circle cx="12" cy="13" r="2" /></g
></svg
>
</div>
</div>
<input
style="display:none"
type="file"
accept=".jpg, .jpeg, .png"
on:change={(e) => onFileSelected(e)}
bind:this={fileinput}
/>
</section>
<style>
.avatar-container.setwidthheight {
height: 128px;
width: 128px;
}
.upload-icon {
display: none;
}
.avatar-container:hover .upload-icon {
display: flex;
position: absolute;
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
export default { export default {
// isProd: process.env.NODE_ENV === 'development', // isProd: process.env.NODE_ENV === 'development',
supabaseConfig: { supabaseConfig: {
url: process.env.SVELTE_APP_SUPABASE_URL, bucketPath:
anonKey: process.env.SVELTE_APP_SUPABASE_ANON_KEY, 'https://koxqonvbkeakwvmdegcf.supabase.in/storage/v1/object/sign/',
}, },
}; };
+7 -3
View File
@@ -62,7 +62,7 @@
$user.currentSession = authUser; $user.currentSession = authUser;
profile.set(data[0]); profile.set(data[0]);
await setProfileIdOfGroupMember(authUser.email, data[0].id); // await setProfileIdOfGroupMember(authUser.email, data[0].id);
// If user coming to login page, then // If user coming to login page, then
if (path.includes('login') || path.includes('signup')) { if (path.includes('login') || path.includes('signup')) {
@@ -77,7 +77,7 @@
console.log(`profileData`, profileData); console.log(`profileData`, profileData);
profile.set(profileData); profile.set(profileData);
setProfileIdOfGroupMember(authUser.email, profileData.id); // setProfileIdOfGroupMember(authUser.email, profileData.id);
// If user coming to login page, then // If user coming to login page, then
if (path.includes('login') || path.includes('signup')) { if (path.includes('login') || path.includes('signup')) {
@@ -86,6 +86,10 @@
} }
} }
function isCoursePage(path) {
return /courses\/[a-z 0-9 -]/.test(path);
}
onMount(() => { onMount(() => {
const _isMvpUser = localStorage.getItem('mvp'); const _isMvpUser = localStorage.getItem('mvp');
@@ -130,7 +134,7 @@
<!-- <Nav {segment} /> --> <!-- <Nav {segment} /> -->
<main> <main>
{#if !['login', 'signup'].includes(path)} {#if !['login', 'signup'].includes(path) && !isCoursePage(path)}
<Navigation {segment} disableLogin={!allowUser} /> <Navigation {segment} disableLogin={!allowUser} />
{/if} {/if}
+4 -2
View File
@@ -12,13 +12,15 @@
export let courseId; export let courseId;
let prevCourseId;
async function onCourseIdChange(courseId) { async function onCourseIdChange(courseId) {
if (!courseId) return; if (!courseId || prevCourseId === courseId) return;
console.log(`overview courseId`, courseId);
const { data } = await fetchCourse(courseId); const { data } = await fetchCourse(courseId);
setCourseData(data); setCourseData(data);
prevCourseId = courseId;
} }
$: onCourseIdChange(courseId); $: onCourseIdChange(courseId);
@@ -248,5 +248,6 @@
<style> <style>
.group { .group {
min-height: 170px; min-height: 170px;
background-color: #fafafa;
} }
</style> </style>
+38 -11
View File
@@ -14,17 +14,17 @@
import Book16 from 'carbon-icons-svelte/lib/Book16'; import Book16 from 'carbon-icons-svelte/lib/Book16';
import TextField from '../../components/Form/TextField.svelte'; import TextField from '../../components/Form/TextField.svelte';
import PrimaryButton from '../../components/PrimaryButton/index.svelte'; import PrimaryButton from '../../components/PrimaryButton/index.svelte';
import UploadImage from '../../components/UploadImage/index.svelte';
import { supabase } from '../../utils/functions/supabase'; import { supabase } from '../../utils/functions/supabase';
import { user, profile } from '../../utils/store/user'; import { user, profile } from '../../utils/store/user';
export let profileId; export let profileId;
let avatar;
let loading = false; let loading = false;
let currentProfile = {}; let currentProfile = {};
let isOwner = false; let isOwner = false;
let docs = []; let initialValueOfUserName, initialValueOfFullName, initialValueOfAvatar;
let initialValueOfUserName;
let initialValueOfFullName;
async function getProfile(profileId) { async function getProfile(profileId) {
loading = true; loading = true;
@@ -56,9 +56,28 @@
const updates = { const updates = {
fullname: currentProfile.fullname, fullname: currentProfile.fullname,
username: currentProfile.username, username: currentProfile.username,
avatar_url: currentProfile.avatar_url,
}; };
if (avatar) {
const filename = `user/${currentProfile.username + Date.now()}.webp`;
const { data } = await supabase.storage
.from('avatars')
.upload(filename, avatar, {
cacheControl: '3600',
upsert: false,
});
if (data && data.Key) {
const { publicURL } = supabase.storage
.from('avatars')
.getPublicUrl(filename);
updates.avatar_url = publicURL;
}
avatar = undefined;
}
let { error } = await supabase let { error } = await supabase
.from('profile') .from('profile')
.update(updates, { .update(updates, {
@@ -66,6 +85,13 @@
}) })
.match({ id: profileId }); .match({ id: profileId });
if (isOwner) {
profile.update((_profile) => ({
..._profile,
...updates,
}));
}
if (error) throw error; if (error) throw error;
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
@@ -78,12 +104,8 @@
} }
} }
onMount(() => {});
$: isOwner = $profile.id === profileId; $: isOwner = $profile.id === profileId;
$: { $: getProfile(profileId);
getProfile(profileId);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -93,12 +115,17 @@
<section class="root w-11/12 mt-3 m-auto flex items-start justify-between"> <section class="root w-11/12 mt-3 m-auto flex items-start justify-between">
{#if currentProfile.id} {#if currentProfile.id}
<div class="w-1/4 flex items-center flex-col"> <div class="w-1/4 flex items-center flex-col">
<img <!-- <img
alt={currentProfile.username} alt={currentProfile.username}
src={currentProfile.avatar_url} src={currentProfile.avatar_url}
height="260" height="260"
width="260" width="260"
class="rounded-full bg-light-blue-100 mb-4" class="rounded-full bg-light-blue-100 mb-4"
/> -->
<UploadImage
bind:avatar
src={currentProfile.avatar_url}
widthHeight="w-60 h-60"
/> />
{#if isOwner} {#if isOwner}
@@ -115,7 +142,7 @@
inputClassName="rounded-md" inputClassName="rounded-md"
/> />
{#if initialValueOfUserName !== currentProfile.username || initialValueOfFullName !== currentProfile.fullname} {#if avatar || initialValueOfUserName !== currentProfile.username || initialValueOfFullName !== currentProfile.fullname}
<PrimaryButton <PrimaryButton
label={loading ? 'Updating...' : 'Update profile'} label={loading ? 'Updating...' : 'Update profile'}
onClick={updateProfile} onClick={updateProfile}
+2
View File
@@ -14,6 +14,8 @@ const config = {
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === 'development',
telegramToken: process.env.SVELTE_APP_TELEGRAM_API_TOKEN, telegramToken: process.env.SVELTE_APP_TELEGRAM_API_TOKEN,
supabaseConfig: { supabaseConfig: {
bucketPath:
'https://koxqonvbkeakwvmdegcf.supabase.in/storage/v1/object/sign',
url: process.env.SVELTE_APP_SUPABASE_URL, url: process.env.SVELTE_APP_SUPABASE_URL,
anonKey: process.env.SVELTE_APP_SUPABASE_ANON_KEY, anonKey: process.env.SVELTE_APP_SUPABASE_ANON_KEY,
}, },
+36 -12
View File
@@ -7,16 +7,16 @@ export async function fetchCourse(courseId, session) {
let jsonUser; let jsonUser;
if (!process.browser) { if (!process.browser) {
try { // try {
jsonUser = JSON.parse(session.user); // jsonUser = JSON.parse(session.user);
} catch (error) { // } catch (error) {
console.log('Token is expired probably cause user not found'); // console.log('Token is expired probably cause user not found');
return { // return {
data: [], // data: [],
cantFetch: true, // cantFetch: true,
}; // };
// return this.redirect(301, '/login'); // // return this.redirect(301, '/login');
} // }
} }
const { data, error } = await supabase const { data, error } = await supabase
@@ -27,6 +27,7 @@ export async function fetchCourse(courseId, session) {
title, title,
description, description,
overview, overview,
logo,
group(*, group(*,
members:groupmember(*, members:groupmember(*,
profile(*) profile(*)
@@ -40,7 +41,7 @@ export async function fetchCourse(courseId, session) {
) )
.match({ id: courseId }) .match({ id: courseId })
.single(); .single();
console.log(`error`, error);
if (!data || error) { if (!data || error) {
console.log(`data`, data); console.log(`data`, data);
console.log(`fetchCourse => error`, error); console.log(`fetchCourse => error`, error);
@@ -82,10 +83,33 @@ export async function setProfileIdOfGroupMember(email, profileId) {
} }
export async function updateCourse(courseId, course) { export async function updateCourse(courseId, course) {
return await supabase if (course.avatar) {
const filename = `course/${courseId + Date.now()}.webp`;
const { data } = await supabase.storage
.from('avatars')
.upload(filename, course.avatar, {
cacheControl: '3600',
upsert: false,
});
if (data && data.Key) {
const { publicURL } = supabase.storage
.from('avatars')
.getPublicUrl(filename);
course.logo = publicURL;
}
}
delete course.avatar;
await supabase
.from('course') .from('course')
.update(course, { returning: 'minimal' }) .update(course, { returning: 'minimal' })
.match({ id: courseId }); .match({ id: courseId });
return course.logo;
} }
export async function deleteCourse(courseId, course) { export async function deleteCourse(courseId, course) {