chore: move backend to hono + typescript (#558)
* feat: setup secure presigned uploads and downloads * clean up * update lock * Get presigned url finally working * 557: Migrate backend to hono + ts * chore: update Dockerfile and package.json for backend configuration * add dev command * remove unused packages * Enhance backend API and frontend components for course management - Updated CORS configuration in the backend to allow specific methods and headers. - Refactored lesson download endpoint to use a new route for PDF downloads. - Removed unused certificate download functionality from the lesson router. - Updated frontend components to reflect changes in API endpoints and improve video upload functionality. - Introduced new video upload component and adjusted video handling in lesson materials. - Cleaned up unnecessary imports and optimized component structure for better performance. * PR cleanups * Enhance email configuration by adding SMTP_SENDER environment variable - Updated docker-compose.yaml to include SMTP_SENDER. - Modified Dockerfile to accept SMTP_SENDER as an argument. - Updated environment schema in env.ts to define SMTP_SENDER as optional. - Adjusted mail service to use SMTP_SENDER for the email 'from' field. - Updated documentation to reflect the new SMTP_SENDER variable. * Remove debug log statement from sendEmail function --------- Co-authored-by: Iniubong Obonguko <[email protected]>
This commit is contained in:
co-authored by
Iniubong Obonguko
parent
9eb7b01587
commit
82be557a42
+14
-15
@@ -13,6 +13,7 @@
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import type { ProfileCourseProgress } from '$lib/utils/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = false;
|
||||
let isCourseComplete = false;
|
||||
@@ -23,24 +24,22 @@
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadCertificate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiClient.post(
|
||||
'/course/download/certificate',
|
||||
{
|
||||
theme: `${$course.certificate_theme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${$course.title}`,
|
||||
courseDescription: `${$course.description}`,
|
||||
orgLogoUrl: `${$currentOrg.avatar_url}`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
})
|
||||
});
|
||||
const data = await response.blob();
|
||||
console.log(data);
|
||||
const file = new Blob([data], { type: 'application/pdf' });
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
|
||||
const file = new Blob([response.data], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
@@ -85,12 +84,12 @@
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
|
||||
<div class="flex h-full w-max flex-col items-center justify-center gap-5">
|
||||
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
|
||||
<p class="text-xl font-normal text-center">
|
||||
<p class="text-center text-xl font-normal">
|
||||
{$t(title)}
|
||||
</p>
|
||||
<p class="text-sm font-normal text-center max-w-md">
|
||||
<p class="max-w-md text-center text-sm font-normal">
|
||||
{$t(subtitle)}
|
||||
</p>
|
||||
<PrimaryButton
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type { LessonVideoType } from '$lib/utils/types';
|
||||
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
@@ -26,7 +27,7 @@
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'generic',
|
||||
type: 'generic' as LessonVideoType,
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import YoutubeVideo from './YoutubeVideo.svelte';
|
||||
import LocalVideo from './LocalVideo.svelte';
|
||||
import UploadVideo from './UploadVideo.svelte';
|
||||
import EmbedLink from './EmbedLink.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -9,25 +9,25 @@
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let tabs = CONSTANTS.videoTabs;
|
||||
const tabs = CONSTANTS.videoTabs;
|
||||
let currentTab = tabs[0].value;
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
</script>
|
||||
|
||||
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
|
||||
<div class="flex flex-row md:flex-col gap-2 items-center">
|
||||
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full">
|
||||
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
|
||||
<div class="flex flex-row items-center gap-2 md:flex-col">
|
||||
<p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
|
||||
</p>
|
||||
{#each tabs as item (item.value)}
|
||||
<button
|
||||
on:click={onChange(item.value)}
|
||||
class={`w-full px-4 py-3 my-1 border ${
|
||||
class={`my-1 w-full border px-4 py-3 ${
|
||||
currentTab === item.value
|
||||
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
|
||||
: 'border border-gray-200 dark:border-neutral-600'
|
||||
} rounded-md cursor-pointer flex flex-row items-center justify-start gap-2 whitespace-nowrap`}
|
||||
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
|
||||
>
|
||||
{#if $isFreePlan && item.value === 3}
|
||||
<FlashFilled size={20} class="text-blue-700" />
|
||||
@@ -42,13 +42,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<main class="w-full h-full">
|
||||
<main class="h-full w-full">
|
||||
{#if currentTab === 1}
|
||||
<YoutubeVideo />
|
||||
{:else if currentTab === 2}
|
||||
<EmbedLink />
|
||||
{:else}
|
||||
<LocalVideo {lessonId} />
|
||||
<UploadVideo {lessonId} />
|
||||
{/if}
|
||||
</main>
|
||||
</section>
|
||||
|
||||
+72
-77
@@ -1,69 +1,96 @@
|
||||
<script lang="ts">
|
||||
import axios from 'axios';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
lesson,
|
||||
deleteLessonVideo,
|
||||
uploadCourseVideoStore
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { type ComponentProps } from 'svelte';
|
||||
import { ProgressBar } from 'carbon-components-svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
|
||||
let value = 0;
|
||||
let max = 100;
|
||||
let status = 'active';
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let value = 0;
|
||||
let max = 100;
|
||||
let status: ComponentProps<ProgressBar>['status'] = 'active';
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
|
||||
let formRes;
|
||||
let isLoaded = false;
|
||||
let fileInput;
|
||||
let submit;
|
||||
let uploadedFileUrl = '';
|
||||
let isLoading = false;
|
||||
let prevProgress = 0;
|
||||
let uploadChannel;
|
||||
let fileName = '';
|
||||
|
||||
function isVideoAdded(link) {
|
||||
return $lesson.materials?.videos?.find((v) => v.link === link);
|
||||
}
|
||||
|
||||
async function onUpload(e) {
|
||||
isLoading = true;
|
||||
async function onUpload() {
|
||||
if (!fileInput) return;
|
||||
|
||||
const formData = new FormData();
|
||||
$uploadCourseVideoStore.isUploading = true;
|
||||
|
||||
const videoFile = fileInput.files[0];
|
||||
formData.append('videoFile', videoFile);
|
||||
|
||||
console.log({ size: videoFile?.size });
|
||||
|
||||
fileSize = videoFile?.size / (1024 * 1024);
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url: env.PUBLIC_SERVER_URL + '/uploadVideo?lessonId=' + lessonId,
|
||||
data: formData,
|
||||
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
|
||||
fileName: videoFile.name,
|
||||
fileType: videoFile.type
|
||||
});
|
||||
|
||||
const presignedUrl = uploadPresignResponse.data.url;
|
||||
|
||||
const uploadResponse = await axios.put(presignedUrl, videoFile, {
|
||||
headers: {
|
||||
'Content-Type': videoFile.type
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=MyBoundary'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
value = Math.round((progressEvent.loaded * 100) / progressEvent?.total / 2);
|
||||
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
|
||||
}
|
||||
});
|
||||
|
||||
formRes = response.data;
|
||||
console.log('Upload res', formRes);
|
||||
isLoading = false;
|
||||
const downloadPresignedResponse = await apiClient.post('/course/presign/download', {
|
||||
keys: [uploadPresignResponse.data.fileKey]
|
||||
});
|
||||
|
||||
if (!downloadPresignedResponse.data.success) {
|
||||
formRes = {
|
||||
type: 'INTERNAL_ERROR',
|
||||
status: 500,
|
||||
message: downloadPresignedResponse.data.message
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fileKey = uploadPresignResponse.data.fileKey;
|
||||
const presignedUrls = downloadPresignedResponse.data.urls;
|
||||
|
||||
formRes = {
|
||||
url: presignedUrls[fileKey],
|
||||
fileKey: uploadPresignResponse.data.fileKey,
|
||||
status: uploadResponse.status
|
||||
};
|
||||
|
||||
console.log('formRes', formRes);
|
||||
|
||||
$lesson.materials.videos = [
|
||||
...$lesson.materials.videos,
|
||||
{
|
||||
type: 'upload',
|
||||
link: formRes.url,
|
||||
key: formRes?.fileKey
|
||||
}
|
||||
];
|
||||
|
||||
console.log('Upload res', uploadResponse, 'formRes', formRes);
|
||||
$uploadCourseVideoStore.isUploading = false;
|
||||
isLoaded = false;
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
@@ -76,50 +103,20 @@
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
async function isDoneUploading(response) {
|
||||
uploadedFileUrl = response?.success && response?.url;
|
||||
|
||||
if (uploadedFileUrl && !isVideoAdded(uploadedFileUrl)) {
|
||||
$lesson.materials.videos = [
|
||||
...$lesson.materials.videos,
|
||||
{
|
||||
type: 'muse',
|
||||
link: uploadedFileUrl,
|
||||
metadata: response?.metadata || {}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
formRes = null;
|
||||
isLoaded = false;
|
||||
isLoading = false;
|
||||
$uploadCourseVideoStore.isUploading = false;
|
||||
value = 0;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
uploadChannel = supabase
|
||||
.channel('upload-progress')
|
||||
.on('broadcast', { event: lessonId }, (payload) => {
|
||||
console.log('The progress of the upload', payload);
|
||||
value = value + payload?.payload - prevProgress;
|
||||
prevProgress = payload.payload;
|
||||
})
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
supabase.removeChannel(uploadChannel);
|
||||
});
|
||||
|
||||
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
|
||||
$: if (value === max) {
|
||||
helperText = 'Done';
|
||||
status = 'finished';
|
||||
}
|
||||
|
||||
$: isDoneUploading(formRes);
|
||||
$: isDisabled = isLoading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
|
||||
@@ -129,16 +126,16 @@
|
||||
{#if !isLoaded}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'hover:cursor-not-allowed opacity-50'}"
|
||||
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<form
|
||||
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
|
||||
on:submit|preventDefault={onUpload}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col gap-5 max-w-[500px] w-[60%] justify-center">
|
||||
{#if $uploadCourseVideoStore.isUploading}
|
||||
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
|
||||
<p class="mt-5 text-center">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')}
|
||||
</p>
|
||||
@@ -186,7 +183,7 @@
|
||||
onClick={tryAgain}
|
||||
/>
|
||||
</div>
|
||||
{:else if !formRes?.success}
|
||||
{:else if formRes?.status !== 200}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
|
||||
<img src="/video-upload-error.svg" alt="upload error" />
|
||||
<span class="pb-2 pt-3">
|
||||
@@ -210,13 +207,11 @@
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<video class="w-[200px]">
|
||||
<source src={uploadedFileUrl} type="video/mp4" />
|
||||
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
|
||||
<!-- Captions are optional -->
|
||||
<source src={formRes.url} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p>
|
||||
<p>{fileInput?.files?.[0]?.name || fileName}</p>
|
||||
</span>
|
||||
<button on:click={deleteLessonVideo}>
|
||||
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { CopyButton, Tag } from 'carbon-components-svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
@@ -6,6 +6,7 @@
|
||||
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LessonVideoType } from '$lib/utils/types';
|
||||
|
||||
let youtubeLinks = '';
|
||||
let error = '';
|
||||
@@ -26,7 +27,7 @@
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'youtube',
|
||||
type: 'youtube' as LessonVideoType,
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
@@ -48,7 +49,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full flex items-{error ? 'center' : 'end'} justify-between gap-5">
|
||||
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
|
||||
<TextField
|
||||
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
|
||||
bind:value={youtubeLinks}
|
||||
|
||||
+33
-16
@@ -1,29 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
let errors = {};
|
||||
let player = null;
|
||||
let errors: Record<string, string> = {};
|
||||
let videoElements: (HTMLVideoElement | null)[] = [];
|
||||
|
||||
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;
|
||||
function initPlyr() {
|
||||
if (typeof (window as any).Plyr === 'undefined') {
|
||||
console.warn('Plyr is not defined. Make sure it is properly imported.');
|
||||
return;
|
||||
}
|
||||
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map(
|
||||
(p) => new (window as any).Plyr(p as HTMLElement)
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).players = players;
|
||||
}
|
||||
}
|
||||
|
||||
$: initPlyr(player, $lesson.materials.videos);
|
||||
$: {
|
||||
videoElements = videoElements.slice(0, $lesson.materials.videos.length);
|
||||
videoElements = [
|
||||
...videoElements,
|
||||
...Array($lesson.materials.videos.length - videoElements.length).fill(null)
|
||||
];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initPlyr();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $lesson.materials.videos.length}
|
||||
<div class="w-full">
|
||||
{#each $lesson.materials.videos as video}
|
||||
<div class="w-full overflow-hidden mb-5">
|
||||
{#each $lesson.materials.videos as video, index}
|
||||
<div class="mb-5 w-full overflow-hidden">
|
||||
{#key video.link}
|
||||
<div class="mb-5">
|
||||
{#if video.type === 'youtube'}
|
||||
@@ -58,7 +70,12 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
|
||||
<video
|
||||
bind:this={videoElements[index]}
|
||||
class="plyr-video-trigger h-full w-full"
|
||||
playsinline
|
||||
controls
|
||||
>
|
||||
<source src={video.link} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let errors: {
|
||||
video: string;
|
||||
};
|
||||
let errors: Record<string, string> = {};
|
||||
let editorWindowRef: Window;
|
||||
let aiButtonRef: HTMLDivElement;
|
||||
let openPopover = false;
|
||||
@@ -268,7 +266,10 @@
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if ($uploadCourseVideoStore.isUploading) return;
|
||||
|
||||
$uploadCourseVideoStore.isModalOpen = false;
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
};
|
||||
|
||||
function getComponentOrder(tabs = CONSTANTS.tabs) {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable, Updater } from 'svelte/store';
|
||||
import type { Course, Lesson, LessonComment, LessonPage, LessonSection } from '$lib/utils/types';
|
||||
import type { Updater, Writable } from 'svelte/store';
|
||||
import {
|
||||
createLesson,
|
||||
updateLesson,
|
||||
deleteLesson,
|
||||
createLessonSection,
|
||||
updateLessonSection,
|
||||
deleteLessonSection
|
||||
deleteLesson,
|
||||
deleteLessonSection,
|
||||
updateLesson,
|
||||
updateLessonSection
|
||||
} from '$lib/utils/services/courses';
|
||||
import type { Lesson, Course, LessonPage, LessonComment, LessonSection } from '$lib/utils/types';
|
||||
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { lessonValidation } from '$lib/utils/functions/validator';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const uploadCourseVideoStore = writable({
|
||||
isUploading: false,
|
||||
isModalOpen: false
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="overflow-y-auto {isPageNavHidden
|
||||
? 'h-[calc(100vh-65px)] lg:h-[calc(100vh-127px)]'
|
||||
|
||||
Reference in New Issue
Block a user