Merge branch 'main' into CLASSROOMIO-T-84

This commit is contained in:
tunny17
2023-08-11 12:39:37 +01:00
31 changed files with 525 additions and 251 deletions
+3 -2
View File
@@ -13,6 +13,7 @@
"prepare": "svelte-kit sync"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.386.0",
"@sentry/browser": "^7.61.0",
"@sentry/integrations": "^7.61.0",
"@sentry/tracing": "^7.61.0",
@@ -25,10 +26,12 @@
"@types/pluralize": "0.0.30",
"ai": "^2.1.31",
"autoprefixer": "^10.4.14",
"axios": "^1.4.0",
"body-parser": "^1.20.2",
"canvas-confetti": "^1.6.0",
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.3",
"form-data": "^4.0.0",
"hotkeys-js": "^3.11.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
@@ -47,8 +50,6 @@
"@babel/plugin-transform-runtime": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/runtime": "^7.22.6",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@sveltejs/kit": "^1.22.4",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.14.196",
@@ -3,6 +3,9 @@
import LocalVideo from './LocalVideo.svelte';
import * as CONSTANTS from './constants';
export let lessonId = '';
export let saveLesson = () => {};
let tabs = CONSTANTS.videoTabs;
let currentTab = tabs[0].value;
@@ -34,7 +37,7 @@
{#if currentTab === 1}
<YoutubeVideo />
{:else}
<LocalVideo />
<LocalVideo {lessonId} {saveLesson} />
{/if}
</main>
</section>
@@ -1,45 +1,95 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { deleteLessonVideo } from '$lib/components/Course/components/Lesson/store/lessons';
import {
lesson,
deleteLessonVideo,
uploadCourseVideoStore
} from '$lib/components/Course/components/Lesson/store/lessons';
import { enhance } from '$app/forms';
import { Moon } from 'svelte-loading-spinners';
export let lessonId = '';
export let saveLesson = () => {};
let isWrongFormat = false;
let isUploaded = false;
let isLoaded = false;
let isBig = false;
let fileInput;
let submit;
let uploadedFileUrl = '';
let isLoading = false;
const uploadVideo = () => {
// if the file is too big or not the correct format
isWrongFormat = true;
function isVideoAdded(link) {
return $lesson.materials?.videos?.find((v) => v.link === link);
}
// when the upload has been sucessfully completed
// here the you set the video_url to something and that would be displayed on the page
isUploaded = false;
async function isDoneUploading(response) {
uploadedFileUrl = response?.success && response?.url;
// closes the modal
// $uploadCourseVideo.isModalOpen = false;
};
if (uploadedFileUrl && !isVideoAdded(uploadedFileUrl)) {
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'muse',
link: uploadedFileUrl,
metadata: {}
}
];
saveLesson();
}
}
const onUpload = () => {
// PROBABLY GET THE SNAPSHOT OR SOMETHING
isLoaded = true;
};
$: isDoneUploading($uploadCourseVideoStore.formRes);
</script>
{#if isLoaded == false}
{#if isLoaded === false}
<button
on:click={onUpload}
class="h-full w-full flex flex-col items-center justify-center border border-blue-300 border-dashed rounded-xl"
type="button"
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
class="w-full h-full"
disabled={isLoading}
>
<img src="/upload-video.svg" alt="upload" />
<span class="pt-3">
<h3 class="text-center text-xl font-normal dark:text-white">Upload video</h3>
<p class=" text-center text-sm font-normal">
Select file( Mp4, MOV, AVI) to upload or drag and drop video here.<br /> Size must be less than
20 MB.
</p>
</span>
<form
class="h-full w-full flex flex-col items-center justify-center border border-blue-300 border-dashed rounded-xl"
method="POST"
action="?/create"
enctype="multipart/form-data"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
await update();
isLoading = false;
isLoaded = true;
};
}}
>
{#if isLoading}
<Moon size="40" color="#1d4ed8" unit="px" duration="1s" />
<p class="mt-5">Uploading Video</p>
{:else}
<img src="/upload-video.svg" alt="upload" />
<span class="pt-3">
<h3 class="text-center text-xl font-normal dark:text-white">Upload video</h3>
<p class=" text-center text-sm font-normal">
Select file( Mp4, MOV, AVI) to upload to your lesson.
</p>
</span>
{/if}
<input
style="display:none;"
type="file"
accept="video/*"
name="file"
on:change={() => submit.click()}
bind:this={fileInput}
/>
<input type="text" name="lessonId" value={lessonId} style="display: none;" />
<input style="display:none;" type="submit" bind:this={submit} />
</form>
</button>
{:else if isBig && isUploaded == false}
{:else if $uploadCourseVideoStore.formRes?.type === 'FILE_TOO_LARGE'}
<div class="h-full w-full flex flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
@@ -52,7 +102,7 @@
</span>
<PrimaryButton label="Try again" />
</div>
{:else if isWrongFormat && isUploaded == false}
{:else if !$uploadCourseVideoStore.formRes?.success}
<div class="h-full w-full flex flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pt-3 pb-2">
@@ -72,17 +122,19 @@
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="rounded-sm overflow-hidden">
<img src="/course-video.png" alt="coursevideo" class="object-cover" />
<video class="w-[200px]">
<source src={uploadedFileUrl} type="video/mp4" />
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
<!-- Captions are optional -->
<track kind="captions" />
</video>
</div>
<p>Video.png</p>
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p>
</span>
<button on:click={deleteLessonVideo}>
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
</button>
</div>
</div>
<div class="ml-auto">
<PrimaryButton label="upload" onClick={uploadVideo} />
</div>
</div>
{/if}
@@ -1,47 +1,49 @@
<script>
import { CopyButton, Tag } from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Chip from '$lib/components/Chip/Text.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
let youtubeLinks = '';
let error = '';
function getVideoUrls(urls = '') {
return (urls || '').split(',').filter((url) => !!url.trim());
}
function addVideo() {
const links = youtubeLinks.split(',').map((link) => link.trim());
const links = getVideoUrls(youtubeLinks);
const validLinks = links.filter(isValidYouTubeLink);
if (validLinks.length === 0) {
error = 'Invalid YouTube link(s)';
} else {
const existingLinks = $lesson.materials.video_url
.split(',')
.map((link) => link.trim())
.filter(Boolean);
const updatedLinks = [
const existingLinks = $lesson?.materials?.videos || [];
$lesson.materials.videos = [
...existingLinks,
...validLinks.filter((link) => !existingLinks.includes(link))
...validLinks.map((link = '') => ({
type: 'youtube',
link,
metadata: {}
}))
];
$lesson.materials.video_url = updatedLinks.join(',').trim();
youtubeLinks = '';
error = '';
}
}
function isValidYouTubeLink(link) {
function isValidYouTubeLink(link = '') {
const youtubeRegex =
/^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
return youtubeRegex.test(link.trim());
}
function removeVideo(index) {
const links = $lesson.materials.video_url.split(',').map((link) => link.trim());
links.splice(index, 1);
$lesson.materials.video_url = links.join(',').trim();
function removeVideo(index = 0) {
$lesson.materials.videos = $lesson.materials.videos.filter((v, i) => i !== index);
}
</script>
@@ -58,22 +60,23 @@
<PrimaryButton label="Add Video" className="rounded-md" onClick={addVideo} />
</div>
<p class="mt-4 pl-2 text-sm">
Added : <strong
>{($lesson?.materials?.video_url && $lesson.materials.video_url.trim().split(',').length) || 0} Videos</strong
>
Videos added: <strong>
{$lesson?.materials?.videos.filter((v) => v.type === 'youtube' && isValidYouTubeLink(v.link))
.length || 0}
</strong>
</p>
<div class="">
{#each $lesson?.materials?.video_url && $lesson.materials.video_url
.trim()
.split(',') as videoLink, index}
<div class="flex items-center gap-1">
<Tag type="blue">
{videoLink}
</Tag>
<CopyButton text={videoLink} feedback="Copied to clipboard" />
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
</div>
{#each $lesson?.materials?.videos as video, index}
{#if video.type === 'youtube'}
<div class="flex items-center gap-1">
<Tag type="blue">
{video.link}
</Tag>
<CopyButton text={video.link} feedback="Copied to clipboard" />
<IconButton value="delete-video" onClick={() => removeVideo(index)}>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
</div>
{/if}
{/each}
</div>
@@ -1,11 +1,13 @@
<script lang="ts">
import { useCompletion } from 'ai/svelte';
import merge from 'lodash/merge';
import MODES from '$lib/utils/constants/mode.js';
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import Modal from '$lib/components/Modal/index.svelte';
import { Popover } from 'carbon-components-svelte';
import AlignBoxTopLeftIcon from 'carbon-icons-svelte/lib/AlignBoxTopLeft.svelte';
import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte';
import ListIcon from 'carbon-icons-svelte/lib/List.svelte';
import IbmWatsonKnowledgeStudioIcon from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeStudio.svelte';
import MachineLearningModel from 'carbon-icons-svelte/lib/MachineLearningModel.svelte';
@@ -27,7 +29,8 @@
import VideoUploader from '$lib/components/Course/components/Lesson/Materials/Video/Index.svelte';
import { course } from '$lib/components/Course/store';
import * as CONSTANTS from './constants';
import { browser } from '$app/environment';
import { fetchLesson } from '$lib/utils/services/courses';
import type { LessonPage } from '$lib/utils/types';
export let mode = MODES.view;
export let prevMode = '';
@@ -36,11 +39,10 @@
let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value;
let errors = {};
let player = {};
let textareaRef = {};
let aiButtonRef = {};
let openPopover = false;
let videoUrls: Array<string> = [];
let player = null;
let aiButtonClass =
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 rounded-md w-full mb-2';
@@ -49,26 +51,26 @@
() =>
(currentTab = tab);
function initPlyr(_player, videoUrl = '') {
if (!browser) return;
// problem: after we save an uploaded video,
async function saveLesson() {
const { data } = await fetchLesson(lessonId);
const materials = merge(data, $lesson.materials);
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
// @ts-ignore
return new Plyr(p);
});
// @ts-ignore
window.players = players;
lesson.update((l) => ({
...l,
materials
}));
handleUpdateLessonMaterials($lesson, lessonId);
}
function handleSave(prevMode) {
function handleSave(prevMode: string) {
if (prevMode === MODES.edit) {
handleUpdateLessonMaterials($lesson, lessonId);
saveLesson();
}
}
function addBadgeValueToTab(materials) {
const { slide_url, video_url, note } = materials;
const { slide_url, videos, note } = materials;
tabs = tabs.map((tab) => {
let badgeValue;
@@ -78,7 +80,7 @@
} else if (tab.value === 2) {
badgeValue = !!note ? 1 : 0;
} else {
badgeValue = typeof video_url === 'string' && !!video_url ? video_url.split(',').length : 0;
badgeValue = Array.isArray(videos) ? videos.length : 0;
}
tab.badgeValue = badgeValue;
@@ -113,20 +115,31 @@
}, 500);
}
function initPlyr(_player: any, _video: string | undefined) {
if (!_player) return;
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
// @ts-ignore
return new Plyr(p);
});
// @ts-ignore
window.players = players;
}
const onClose = () => {
handleUpdateLessonMaterials($lesson, lessonId);
saveLesson();
$uploadCourseVideoStore.isModalOpen = false;
$uploadCourseVideoStore.formRes = null;
};
$: handleSave(prevMode);
$: initPlyr(player, $lesson.materials.video_url);
$: addBadgeValueToTab($lesson.materials);
$: updateNoteByCompletion($completion);
$: videoUrls = ($lesson?.materials?.video_url || '').split(',').filter((url) => !!url.trim());
$: initPlyr(player, $lesson.materials.videos);
</script>
<Modal
@@ -135,7 +148,7 @@
width="w-4/5 h-[566px]"
modalHeading="Add a Video"
>
<VideoUploader {onClose} />
<VideoUploader {lessonId} {saveLesson} />
</Modal>
<Tabs {tabs} {currentTab} {onChange}>
@@ -236,34 +249,56 @@
</TabContent>
<TabContent value={tabs[2].value} index={currentTab}>
{#if mode === MODES.edit && videoUrls.length}
<PrimaryButton label="Add/Edit Video" onClick={openAddVideoModal} className="mb-2" />
{#if mode === MODES.edit && $lesson.materials.videos.length}
<PrimaryButton label="Add/Edit Video(s)" onClick={openAddVideoModal} className="mb-2" />
{/if}
<!-- Line 230 would run if its in edit mode or the video is truthy but then in line 235 we are mapping for an empty string -->
{#if videoUrls.length}
{#if $lesson.materials.videos.length}
<div class="flex flex-col items-start w-full h-full">
{#each videoUrls as url, index}
{#each $lesson.materials.videos as video, index}
{#if mode === MODES.edit}
<div class="ml-auto">
<IconButton
value="delete-video"
contained={true}
onClick={() => deleteLessonVideo(index)}
>
<TrashCanIcon size={20} class="carbon-icon dark:text-white" />
</IconButton>
</div>
{/if}
<div class="w-full h-full flex flex-col gap-2 overflow-hidden">
{#if url.includes('youtube')}
{#key video.link}
<div class="mb-5">
<div
bind:this={player}
class="plyr-video-trigger"
data-plyr-provider="youtube"
data-plyr-embed-id={getEmbedId(url)}
/>
{#if video.type === 'youtube'}
<iframe
width="100%"
height="569"
class="iframe"
src={formatYoutubeVideo(video.link, errors)}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
{:else if video.metadata?.svid}
<div style="position:relative;padding-bottom:51.416579%">
<iframe
src="https://muse.ai/embed/{video.metadata
?.svid}?logo=https://app.classroomio.com/logo-512.png&subtitles=auto&cover_play_position=center"
style="width:100%;height:100%;position:absolute;left:0;top:0"
frameborder="0"
allowfullscreen
title="Muse AI Video Embed"
/>
</div>
{:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
<source
src="https://pub-bd38ff7251aa402a93762181a947f7ea.r2.dev/generate-lesson-draft.mp4"
type="video/mp4"
/>
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
<!-- Captions are optional -->
<track kind="captions" />
</video>
{/if}
{/key}
</div>
{/each}
</div>
@@ -1,21 +1,23 @@
import { writable } from 'svelte/store';
import type { Writable, Updater } from 'svelte/store';
import { createLesson, updateLesson, deleteLesson } from '$lib/utils/services/courses';
import type { Lesson, Course } from '$lib/utils/types';
import type { Lesson, Course, LessonPage } from '$lib/utils/types';
export const uploadCourseVideoStore = writable({
isModalOpen: false
isModalOpen: false,
formRes: null
});
export const lessons: Writable<Lesson[]> = writable([]);
export const lesson = writable({
export const lesson = writable<LessonPage>({
id: null,
totalExercises: 0,
is_complete: false,
materials: {
note: '',
slide_url: '',
video_url: ''
videos: []
},
exercises: []
});
@@ -103,14 +105,12 @@ export async function handleUpdateLessonMaterials(lesson: any, lessonId: Lesson[
export const deleteLessonVideo = (index: any) => {
lesson.update((currentLesson) => {
const links = currentLesson.materials.video_url.split(',').map((link) => link.trim());
links.splice(index, 1);
const updatedVideoUrl = links.join(',').trim();
const videos = currentLesson.materials.videos || [];
return {
...currentLesson,
materials: {
...currentLesson.materials,
video_url: updatedVideoUrl
videos: videos?.filter((video, i) => i !== index)
}
};
});
@@ -1,8 +1,9 @@
<script>
import CaretSortDownIcon from 'carbon-icons-svelte/lib/CaretSortDown.svelte';
import { slide } from 'svelte/transition';
import ChevronDownIcon from 'carbon-icons-svelte/lib/ChevronDown.svelte';
import ChevronUpIcon from 'carbon-icons-svelte/lib/ChevronUp.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import NavIcons from './NavIcons.svelte';
export let handleClick = () => {};
@@ -12,8 +13,6 @@
export let total = 0;
// export let subMenuItems = [];
let activeClass = 'bg-gray-200 dark:bg-gray-800';
function onClick() {
handleClick();
}
@@ -25,8 +24,8 @@
<div class="">
<button
class="item relative flex items-center py-3 px-4 ml-2 mb-1 rounded hover:bg-gray-200 dark:hover:bg-gray-800 {isGroupActive &&
activeClass} w-[95%]"
class="item relative flex items-center py-3 px-4 ml-2 mb-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 {isGroupActive &&
NavClasses.active} w-[95%]"
tabindex="0"
on:click={onClick}
>
@@ -48,7 +47,7 @@
{/if}
</button>
{#if isExpanded}
<div class="flex flex-col">
<div in:slide out:slide class="flex flex-col">
<slot />
</div>
{/if}
@@ -24,7 +24,7 @@
import { course } from '$lib/components/Course/store';
import { updateCourse } from '$lib/utils/services/courses';
import { profile } from '$lib/utils/store/user';
import { currentOrgPath } from '$lib/utils/store/org';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import { isMobile } from '$lib/utils/store/useMobile';
// export let lessonId;
@@ -32,7 +32,6 @@
export let isStudent = null;
let show = null;
let activeClass = 'bg-gray-200 dark:bg-gray-800';
let isLessonActive = false;
function handleMainGroupClick(href) {
@@ -151,7 +150,7 @@
<div class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}">
{#if show}
<div class="relative h-full dark:bg-gray-700">
<div class="relative h-full dark:bg-gray-800">
<!-- <PageNav title="Back to courses" paddingClass="pl-2" titleSize="text-md">
<slot:fragment slot="image">
<Avatar src={$course.logo} />
@@ -202,12 +201,12 @@
<div
class="flex items-center py-3 px-4 rounded hover:bg-gray-200 dark:hover:bg-gray-800 w-full {(
path || $page.url.pathname
).includes(item.id) && activeClass}"
).includes(item.id) && NavClasses.active}"
>
<TextChip
value={getLectureNo(index + 1)}
className="bg-blue-200 text-blue-600 text-xs mr-2"
size="xl"
size="sm"
shape="rounded-full"
/>
<span>{item.title}</span>
@@ -249,10 +249,13 @@
{/if}
</div>
<div class="flex items-center">
{#if lesson.video_url}
{#if lesson.videos}
<span class="text-sm font-light flex w-2/4"
><Video size={16} class="mr-1" />1 video</span
>
><Video size={16} class="mr-1" />{lesson.videos.length} video{lesson.videos
.length > 1
? 's'
: ''}
</span>
{/if}
{#if get(lesson, 'totalExercises[0].count')}
<span class="text-sm font-light flex w-2/4"
@@ -3,7 +3,7 @@
</script>
<div
class="root w-72 border bg-gray-100 dark:bg-gray-700 mr-4 mb-4 rounded-md transition ease-in-out relative"
class="root w-72 border bg-gray-100 dark:bg-gray-800 mr-4 mb-4 rounded-md transition ease-in-out relative"
>
<SkeletonPlaceholder style="width: 100%; height: 10rem;" />
<div class="p-3 pb-0">
@@ -47,7 +47,7 @@
<a rel="prefetch" href={isOnLandingPage ? `/course/${slug}` : `/courses/${id}`} class=" text-black">
<div
class="root w-80 border bg-gray-100 dark:bg-gray-700 sm:mr-4 mb-4 rounded-md hover:shadow-2xl transition ease-in-out relative"
class="root w-80 border bg-gray-100 dark:bg-gray-800 sm:mr-4 mb-4 rounded-md hover:shadow-2xl transition ease-in-out relative"
>
{#if !isOnLandingPage}
<Dropdown options={getOptions()} classNames="absolute top-4 right-4" isIcon={true}>
+4 -5
View File
@@ -11,8 +11,7 @@
import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
let activeClass = 'bg-gray-200 dark:bg-gray-800';
import { NavClasses } from '$lib/utils/constants/reusableClass';
function isActive(pagePath, itemPath) {
const pageLinkItems = pagePath.split('/');
@@ -77,7 +76,7 @@
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-500 {isActive(
$page.url.pathname,
`${item.link}`
) && activeClass}"
) && NavClasses.active}"
>
<svelte:component
this={item.icon}
@@ -101,10 +100,10 @@
</a>
<a href="/lms/settings" class="text-black">
<li
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-800 {isActive(
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 {isActive(
$page.url.pathname,
`/lms/settings`
) && activeClass}"
) && NavClasses.active}"
>
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-7" height="h-7" />
<p class="dark:text-white ml-2">Settings</p>
+1 -1
View File
@@ -46,7 +46,7 @@
}}
size="small"
>
<CourseIcon fill="#fff" />
<CourseIcon color="#fff" />
</IconButton>
{/if}
<a
+7 -11
View File
@@ -1,5 +1,6 @@
<script>
import pluralize from 'pluralize';
import { ImageLoader } from 'carbon-components-svelte';
import OverflowMenuHorizontalIcon from 'carbon-icons-svelte/lib/OverflowMenuHorizontal.svelte';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -33,7 +34,7 @@
label: 'Rename',
onClick() {
onRename(quiz);
},
}
},
// {
// label: 'Make a copy',
@@ -45,8 +46,8 @@
label: 'Delete',
onClick() {
onDelete(quiz);
},
},
}
}
];
}
@@ -55,12 +56,11 @@
{#if quiz}
<div
class="root w-full border hover:shadow-2xl transition ease-in-out rounded-lg bg-gray-100 dark:bg-gray-700 mr-4 mb-5 relative flex p-3"
class="root w-full border hover:shadow-2xl transition ease-in-out rounded-lg bg-gray-100 dark:bg-gray-800 mr-4 mb-5 relative flex p-3"
>
<img
<ImageLoader
src={themeImages[quiz.theme]?.card || themeImages.standard.card}
alt="quiz-card"
class=""
style="max-width: 300px;"
/>
@@ -70,11 +70,7 @@
<a href="{$currentOrgPath}/quiz/{quiz.id}">{quiz.title}</a>
</h4>
<Dropdown
options={getOptions()}
classNames="absolute top-4 right-4"
isIcon={true}
>
<Dropdown options={getOptions()} classNames="absolute top-4 right-4" isIcon={true}>
<div class="p-1 rounded-full bg-gray-200 dark:bg-gray-600">
<OverflowMenuHorizontalIcon size={20} class="carbon-icon active" />
</div>
+14 -10
View File
@@ -13,8 +13,7 @@
import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
let activeClass = 'bg-gray-200 dark:bg-gray-800';
import { NavClasses } from '$lib/utils/constants/reusableClass';
const menuItems = [
{
@@ -55,7 +54,9 @@
}
</script>
<aside class="root bg-gray-100 dark:bg-gray-700 h-full">
<aside
class="root bg-gray-100 dark:bg-gray-800 h-full border border-l-0 border-t-0 border-b-0 border-r-1"
>
<div class="h-full flex flex-col">
<div class="border-b border-gray-200 pt-5 px-4">
{#if $currentOrg.avatar_url && $currentOrg.name}
@@ -76,19 +77,22 @@
{#each menuItems as menuItem}
<a href="{$currentOrgPath}{menuItem.path}" class="text-black no-underline">
<li
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-800 {isActive(
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 {isActive(
$page.url.pathname,
`${$currentOrgPath}${menuItem.path}`
) && activeClass}"
) && NavClasses.active}"
>
{#if menuItem.path === ''}
<HomeIcon />
<HomeIcon color="#7A797D" />
{:else if menuItem.path === '/courses'}
<CourseIcon />
<CourseIcon color="#7A797D" />
{:else if menuItem.path === '/site'}
<SiteSettingsIcon />
{:else if menuItem.path === '/community'}
<ForumIcon size={20} class="carbon-icon dark:text-white" />
<ForumIcon
size={24}
class="carbon-icon dark:text-white fill-[#7A797D] dark:fill-[#fff]"
/>
{:else if menuItem.path === '/quiz'}
<QuizIcon />
{:else if menuItem.path === '/audience'}
@@ -112,10 +116,10 @@
</a>
<a href="{$currentOrgPath}/settings" class="text-black no-underline">
<li
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-800 {isActive(
class="flex items-center py-3 px-4 mb-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 {isActive(
$page.url.pathname,
`${$currentOrgPath}/settings`
) && activeClass}"
) && NavClasses.active}"
>
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-7" height="h-7" />
<p class="dark:text-white ml-2">Settings</p>
+2 -2
View File
@@ -20,7 +20,7 @@
</script>
<div
class="header dark:bg-gray-700 bg-white flex items-center justify-between {!disableSticky &&
class="header dark:bg-gray-800 bg-white flex items-center justify-between {!disableSticky &&
'sticky'} {paddingClass} {dynamicRootClass}"
style={overidableStyle}
>
@@ -29,7 +29,7 @@
{#if !enterEditTitleMode}
<button class="w-full" on:click={() => (enterEditTitleMode = true)}>
<h4
class="editable-title hover:bg-gray-100 dark:bg-gray-700 px-3 rounded-md overflow-ellipsis"
class="editable-title hover:bg-gray-100 dark:bg-gray-800 px-3 rounded-md overflow-ellipsis"
>
{title}
</h4>
@@ -1,6 +1,7 @@
<script>
import { Moon } from 'svelte-loading-spinners';
// import { Moon } from 'svelte-loading-spinners';
import { VARIANTS, VARIANTS_CLASS } from './constants';
import { Loading } from 'carbon-components-svelte';
export let label = '';
export let className = '';
@@ -33,7 +34,8 @@
disabled={isDisabled}
>
{#if isLoading}
<Moon size="20" color="#1d4ed8" unit="px" duration="1s" />
<Loading withOverlay={false} small />
<!-- <Moon size="20" color="#1d4ed8" unit="px" duration="1s" /> -->
{:else if !!label}
{label}
{:else}
-58
View File
@@ -1,58 +0,0 @@
<script>
import Icon from 'svelte-awesome/components/Icon.svelte';
import { faTelegram } from '@fortawesome/free-brands-svg-icons';
import { faEnvelope } from '@fortawesome/free-regular-svg-icons';
export let avatarUrl =
'https://tailwindcss.com/_next/static/media/sarah-dayan.a8ff3f1095a58085a82e3bb6aab12eb2.jpg';
export let name = 'Sarah Dayan';
export let title = 'Staff Engineer, Algolia';
export let mailLink;
export let telegramLink;
</script>
<figure class="w-52 bg-gray-100 dark:bg-gray-700 px-4 py-7 mr-4 mb-4">
<img
class="w-32 h-32 rounded-full mx-auto"
src={avatarUrl}
alt=""
width="384"
height="512"
/>
<div class="pt-6 text-center space-y-4">
<div class="description font-medium flex flex-col justify-around">
<div class="text-cyan-600">{name}</div>
<div class="text-gray-500 dark:text-white">{title}</div>
</div>
</div>
{#if telegramLink && mailLink}
<div class="flex justify-center">
<a href={telegramLink} target="_blank">
<Icon
data={faTelegram}
scale="1"
class="social-icon"
style="color:#4dabf7;margin:0 5px;"
/>
</a>
<a href={mailLink} target="_blank">
<Icon
data={faEnvelope}
scale="1"
class="social-icon"
style="color:#d44c3d;margin:0 5px;"
/>
</a>
</div>
{/if}
</figure>
<style>
img {
width: 104px;
height: 104px;
}
.description {
min-height: 70px;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
export const NavClasses = {
active: 'bg-gray-200 dark:bg-gray-700',
background: ''
};
+1
View File
@@ -0,0 +1 @@
export default () => Date.now().toString(36) + Math.random().toString(36).substr(2);
+1 -1
View File
@@ -108,7 +108,7 @@ async function cloneLessons(lessons: Lesson[], courseId: Course['id']) {
public: lesson.public,
slide_url: lesson.slide_url,
title: lesson.title,
video_url: lesson.video_url
videos: lesson.videos
}));
const { data } = await supabase.from('lesson').insert(clonedLessons).select();
+2 -2
View File
@@ -34,7 +34,7 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
certificate_theme,
lessons:lesson(
id, title,public, lesson_at, call_url, is_unlocked, is_complete, order, created_at,
note, video_url, slide_url, call_url, totalExercises:exercise(count),
note, videos, slide_url, call_url, totalExercises:exercise(count),
profile:teacher_id(id, avatar_url, fullname)
),
attendance:group_attendance(*)
@@ -150,7 +150,7 @@ export function deleteGroupMember(groupMemberId: Groupmember['id']) {
export function fetchLesson(lessonId: Lesson['id']) {
return supabase
.from('lesson')
.select(`id, note, video_url, slide_url, call_url, totalExercises:exercise(count)`)
.select(`id, note, videos, slide_url, call_url, totalExercises:exercise(count)`)
.eq('id', lessonId)
.single();
}
+21 -1
View File
@@ -136,9 +136,29 @@ export interface Group_attendance {
groupmember?: Groupmember;
}
enum VideoType {
youtube,
muse
}
export interface LessonPage {
id?: string | null;
totalExercises: Number;
is_complete: Boolean;
materials: {
note: string;
slide_url: string;
videos: Array<{
type: string;
link: string;
metadata?: {};
}>;
};
exercises: [];
}
export interface Lesson {
note?: any; // type unknown;
video_url?: any; // type unknown;
videos?: []; // type unknown;
slide_url?: any; // type unknown;
course_id: string /* foreign key to course.id */;
id?: string /* primary key */;
+8 -2
View File
@@ -39,7 +39,7 @@
import '../app.postcss';
let supabase = getSupabase();
let path = $page.url.pathname.replace('/', '');
let path = $page.url?.pathname?.replace('/', '');
let theme = 'white';
let skipAuth = false;
@@ -261,7 +261,7 @@
};
});
$: path = $page.url.pathname.replace('/', '');
$: path = $page.url?.pathname?.replace('/', '');
$: theme = $appStore.isDark ? 'g100' : 'white';
$: setOrgBasedOnUrl($page.url.host);
</script>
@@ -384,4 +384,10 @@
:global(.dark .bx--search-close:hover > svg) {
fill: #fff;
}
:global(.plyr__controls) {
background: url(/logo-192.png) 99% 70% no-repeat,
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5)) !important;
background-size: 50px auto, auto !important;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import {
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_PUBLIC_ACCOUNT_ID,
MUSE_API_KEY
} from '$env/static/private';
const S3 = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE_ACCESS_KEY,
secretAccessKey: CLOUDFLARE_SECRET_ACCESS_KEY
}
});
const downloadTranscript = async () => {};
const uploadTranscriptToS3 = async () => {};
export async function GET({ url }) {
const museFid = url.searchParams.get('museFid');
console.log('museFid');
// check the transcript table and see if we find a record
// if we find one
// check if downloaded = true
// if true
// return the transcript
// if false
// download the transcript from muse
// upload it to S3
// return the transcript
// if we dont
return json({ success: true, museFid });
}
+2 -1
View File
@@ -3,6 +3,7 @@
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { setCourse } from '$lib/components/Course/store';
import { fetchCourse } from '$lib/utils/services/courses';
import { browser } from '$app/environment';
export let data;
const { courseId } = data;
@@ -10,7 +11,7 @@
let prevCourseId = '';
async function onCourseIdChange(courseId = '') {
if (!courseId || prevCourseId === courseId) return;
if (!courseId || prevCourseId === courseId || !browser) return;
const { data: _data } = await fetchCourse(courseId);
+2 -2
View File
@@ -135,12 +135,12 @@
{#each $lessons as lesson (lesson.id)}
<div
bind:this={ref}
class="sm:h-48 md:h-40 lg:h-32 relative m-auto rounded-md border-2 border-gray-100 py-3 px-5 mb-4 flex items-center dark:bg-gray-700"
class="sm:h-48 md:h-40 lg:h-32 relative m-auto rounded-md border-2 border-gray-200 py-3 px-5 mb-4 flex items-center dark:bg-gray-700"
>
<div class="mr-5">
<TextChip
value={getLessonOrder(lesson.id)}
size="xl"
size="sm"
shape="rounded-full"
className="bg-blue-200 text-blue-600 text-xs"
/>
@@ -1,13 +0,0 @@
export const load = ({ params = { id: '' } }) => {
const { id: courseId, lessonParams = [] } = params;
const splitparams = lessonParams.split('/');
const [lessonId, exerciseRouteName, exerciseId] = splitparams;
return {
courseId,
lessonId,
exerciseRouteName,
exerciseId,
isMaterialsTabActive: !exerciseRouteName
};
};
@@ -0,0 +1,167 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import FormData from 'form-data';
import axios from 'axios';
import { fail } from '@sveltejs/kit';
import genUniqueId from '$lib/utils/functions/genUniqueId.js';
import { getSupabase } from '$lib/utils/functions/supabase';
import {
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_PUBLIC_ACCOUNT_ID,
MUSE_API_KEY
} from '$env/static/private';
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
const S3 = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE_ACCESS_KEY,
secretAccessKey: CLOUDFLARE_SECRET_ACCESS_KEY
}
});
const supabase = getSupabase();
const uploadToMuse = (s3Data, fileName, lessonId, fileUrl) => {
try {
const form = new FormData();
form.append('file', s3Data, fileName);
axios({
method: 'POST',
url: 'https://api.muse.ai/api/files/upload',
data: form,
maxContentLength: Infinity,
maxBodyLength: Infinity,
headers: {
Key: MUSE_API_KEY
}
})
.then(async function (response) {
console.log('Upload to muse complete', response.data);
const { data: lesson } = await supabase
.from('lesson')
.select(`*`)
.eq('id', lessonId)
.single();
if (lesson) {
// Add Muse response to metadata
const videos = lesson.videos.map((v) => {
if (v.link === fileUrl) {
console.log('matches');
v.metadata = JSON.parse(JSON.stringify(response.data));
}
return v;
});
const { error, data } = await supabase
.from('lesson')
.update({ videos })
.eq('id', lessonId)
.select();
if (error) {
console.log('Error updating lesson', error);
}
console.log('update data', data);
}
})
.catch(function (error) {
console.log('Upload to Muse failed', error);
});
} catch (error) {
console.log('Upload to Muse failed', error);
}
};
export const load = ({ params = { id: '' } }) => {
const { id: courseId, lessonParams = [] } = params;
const splitparams = lessonParams.split('/');
const [lessonId, exerciseRouteName, exerciseId] = splitparams;
return {
courseId,
lessonId,
exerciseRouteName,
exerciseId,
isMaterialsTabActive: !exerciseRouteName
};
};
export const actions = {
create: async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
console.log('formData', formData);
if (!formData.file?.name || formData.file?.name === 'undefined') {
return fail(400, {
success: false,
message: 'You must provide a file to upload'
});
}
if (formData.file?.size > 20 * 1024 * 1024) {
return fail(400, {
success: false,
type: 'FILE_TOO_LARGE',
message: 'File is too large'
});
}
const { file, lessonId } = formData;
console.log('file', file);
console.log('lessonId', lessonId);
const stream = await file.arrayBuffer();
const fileName = `${genUniqueId()}-${file.name}`;
const params = {
Bucket: 'videos',
Key: fileName,
Body: stream,
ContentLength: stream.length
};
console.log('params', params);
let upload;
const fileUrl = `https://pub-${CLOUDFLARE_PUBLIC_ACCOUNT_ID}.r2.dev/${fileName}`;
try {
upload = await S3.send(new PutObjectCommand(params));
// process data.
} catch (error) {
// error handling.
const { requestId, cfId, extendedRequestId } = error.$$metadata;
console.error('Error uploading file');
console.log({ requestId, cfId, extendedRequestId });
} finally {
// finally.
console.log('File upload complete', upload);
console.log('\n\nInitilizing Muse Request');
const data = await S3.send(
new GetObjectCommand({
Bucket: 'videos',
Key: fileName
})
);
// console.log('GetObjectCommand', data);
uploadToMuse(data.Body, file.name, lessonId, fileUrl);
}
return {
success: true,
url: fileUrl,
message: 'Uploaded successfully'
};
}
};
@@ -12,11 +12,17 @@
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import MODES from '$lib/utils/constants/mode.js';
import { setCourse, course } from '$lib/components/Course/store';
import { lesson, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
import {
lesson,
handleSaveLesson,
uploadCourseVideoStore
} from '$lib/components/Course/components/Lesson/store/lessons';
import { browser } from '$app/environment';
export let data;
export let form;
let path = '';
let mode = MODES.view;
let prevMode = '';
@@ -68,12 +74,14 @@
mode = mode === MODES.edit ? MODES.view : MODES.edit;
}
$: path = $page.url.pathname.replace(/\/exercises[\/ 0-9 a-z -]*/, '');
$: path = $page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, '');
$: if (data.courseId && browser) {
mode = MODES.view;
fetchReqData(data.lessonId);
}
$: $uploadCourseVideoStore.formRes = form;
</script>
<CourseContainer {isFetching} {path} isExercisePage={!data.isMaterialsTabActive && data.exerciseId}>
@@ -112,7 +120,7 @@
<Exercises lessonId={data.lessonId} exerciseId={data.exerciseId} path={`${path}/exercises`} />
{:else if !!data.lessonId}
<PageBody>
<Materials lessonId={data.lessonId} {mode} {prevMode} {toggleMode} />
<Materials lessonId={data.lessonId} {mode} {prevMode} />
<div class="w-full flex flex-row-reverse">
<PrimaryButton
@@ -248,7 +248,7 @@
<div class="flex items-center w-full">
{#each sections as { id, title, items }, idx (id)}
<div
class="section rounded-md bg-gray-100 dark:bg-gray-700 border border-gray-50 p-3 h-80 mr-3 overflow-hidden"
class="section rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-50 p-3 h-80 mr-3 overflow-hidden"
animate:flip={{ duration: flipDurationMs }}
>
<div class="flex items-center mb-2">
@@ -293,7 +293,7 @@
</a>
<a
class="flex items-center no-underline hover:underline text-black my-2"
href="{$page.url.pathname.replace('submissions', 'lessons')}/{item.lesson
href="{$page.url?.pathname?.replace('submissions', 'lessons')}/{item.lesson
.id}/exercises/{item.exercise.id}"
>
<p class="dark:text-white text-grey text-sm">