From 937f1d8155396d76f46a1718ca633fdb3a9ba2a9 Mon Sep 17 00:00:00 2001
From: Nwosu Emmanuel
Date: Wed, 10 Sep 2025 11:39:06 +0100
Subject: [PATCH] feat: add documents to Lesson materials (#572)
* feat: Initial logic for the pdf upload
* feat: uploa logic, translations and ui flow complete
* chore:console log cleanup
* feat: fixed upload issue and added cancel upload button
* feat: fixing the download document ish
* feat: All done and ready for use
* feat: updated the document viewing logic
* feat: some minor refactor
* feat: added migration file
* fix: dark mode, translations, restyling,
---------
Co-authored-by: Rotimi Best
---
apps/backend/src/constants/upload.ts | 12 +
apps/backend/src/routes/course/presign.ts | 102 +++-
apps/backend/src/types/course/index.ts | 17 +-
.../Materials/Document/DocumentList.svelte | 159 ++++++
.../Lesson/Materials/Document/Index.svelte | 361 ++++++++++++++
.../Lesson/Materials/Document/store.ts | 32 ++
.../Lesson/Materials/Video/UploadVideo.svelte | 53 +-
.../Materials/components/Comments.svelte | 2 +-
.../components/ComponentDocument.svelte | 454 ++++++++++++++++++
.../components/ComponentVideo.svelte | 3 +-
.../components/Lesson/Materials/constants.ts | 8 +-
.../components/Lesson/Materials/index.svelte | 50 +-
.../Course/components/Lesson/store/lessons.ts | 53 +-
.../Course/components/Settings/store.js | 3 +-
.../src/lib/components/Course/store.ts | 26 +-
.../src/lib/utils/services/courses/index.ts | 2 +
.../src/lib/utils/translations/de.json | 41 +-
.../src/lib/utils/translations/en.json | 40 +-
.../src/lib/utils/translations/es.json | 41 +-
.../src/lib/utils/translations/fr.json | 39 +-
.../src/lib/utils/translations/hi.json | 41 +-
.../src/lib/utils/translations/pl.json | 41 +-
.../src/lib/utils/translations/pt.json | 41 +-
.../src/lib/utils/translations/ru.json | 39 +-
.../src/lib/utils/translations/vi.json | 41 +-
apps/dashboard/src/lib/utils/types/index.ts | 8 +
.../src/routes/api/completion/+server.ts | 4 +-
.../api/completion/customprompt/+server.ts | 7 +-
.../lessons/[...lessonParams]/+page.svelte | 5 +-
package.json | 1 -
.../migrations/20250909000000_lesson_pdf.sql | 4 +
supabase/seed.sql | 2 +-
32 files changed, 1682 insertions(+), 50 deletions(-)
create mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/DocumentList.svelte
create mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/Index.svelte
create mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/store.ts
create mode 100644 apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte
create mode 100644 supabase/migrations/20250909000000_lesson_pdf.sql
diff --git a/apps/backend/src/constants/upload.ts b/apps/backend/src/constants/upload.ts
index c629ebb3..ba4b2ebb 100644
--- a/apps/backend/src/constants/upload.ts
+++ b/apps/backend/src/constants/upload.ts
@@ -5,4 +5,16 @@ export const ALLOWED_CONTENT_TYPES = [
'video/x-matroska'
] as const;
+export const ALLOWED_DOCUMENT_TYPES = [
+ 'application/pdf',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
+ 'application/msword' // .doc
+] as const;
+
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
+export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
+
+export const BUCKET_NAME = {
+ VIDEOS: 'videos',
+ DOCUMENTS: 'documents'
+} as const;
diff --git a/apps/backend/src/routes/course/presign.ts b/apps/backend/src/routes/course/presign.ts
index 412df8a3..15523e47 100644
--- a/apps/backend/src/routes/course/presign.ts
+++ b/apps/backend/src/routes/course/presign.ts
@@ -1,6 +1,11 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
-import { ZCourseDownloadPresignedUrl, ZCoursePresignUrlUpload } from '$src/types/course';
+import {
+ ZCourseDocumentPresignUrlUpload,
+ ZCourseDownloadPresignedUrl,
+ ZCoursePresignUrlUpload
+} from '$src/types/course';
+import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
@@ -20,7 +25,7 @@ presignRouter.post('/upload', authMiddleware, async (c) => {
try {
const command = new PutObjectCommand({
- Bucket: CLOUDFLARE.R2.BUCKET,
+ Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
@@ -60,6 +65,55 @@ presignRouter.post('/upload', authMiddleware, async (c) => {
}
});
+presignRouter.post('/document/upload', authMiddleware, async (c) => {
+ const body = await c.req.json();
+ const result = ZCourseDocumentPresignUrlUpload.parse(body);
+
+ const { fileName, fileType } = result;
+ const fileKey = generateFileKey(fileName);
+
+ try {
+ const command = new PutObjectCommand({
+ Bucket: BUCKET_NAME.DOCUMENTS,
+ Key: fileKey,
+ ContentType: fileType
+ }) as GetSignedUrlParameters[1];
+
+ const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
+ expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
+ });
+
+ return c.json({
+ success: true,
+ url: presignedUrl,
+ fileKey,
+ message: 'Document pre-signed URL generated successfully'
+ });
+ } catch (error) {
+ console.error('Error generating document pre-signed URL:', error);
+
+ if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
+ return c.json(
+ {
+ success: false,
+ type: 'SIGNATURE_ERROR',
+ message: 'Invalid signature for the request'
+ },
+ 403
+ );
+ }
+
+ return c.json(
+ {
+ success: false,
+ type: 'INTERNAL_ERROR',
+ message: 'Error generating document pre-signed URL'
+ },
+ 500
+ );
+ }
+});
+
presignRouter.post('/download', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
@@ -72,7 +126,7 @@ presignRouter.post('/download', authMiddleware, async (c) => {
await Promise.all(
keys.map(async (key) => {
const command = new GetObjectCommand({
- Bucket: CLOUDFLARE.R2.BUCKET,
+ Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
@@ -101,3 +155,45 @@ presignRouter.post('/download', authMiddleware, async (c) => {
);
}
});
+
+presignRouter.post('/download/document', authMiddleware, async (c) => {
+ const body = await c.req.json();
+ const result = ZCourseDownloadPresignedUrl.parse(body);
+
+ const { keys } = result;
+
+ try {
+ const signedUrls: Record = {};
+
+ await Promise.all(
+ keys.map(async (key) => {
+ const command = new GetObjectCommand({
+ Bucket: BUCKET_NAME.DOCUMENTS,
+ Key: key
+ }) as GetSignedUrlParameters[1];
+
+ const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
+ expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
+ });
+
+ signedUrls[key] = presignedUrl;
+ })
+ );
+
+ return c.json({
+ success: true,
+ urls: signedUrls,
+ message: 'Document URLs retrieved successfully'
+ });
+ } catch (error) {
+ console.error('Error Retrieving Document:', error);
+ return c.json(
+ {
+ success: false,
+ type: 'INTERNAL_ERROR',
+ message: 'Error Retrieving Document'
+ },
+ 500
+ );
+ }
+});
diff --git a/apps/backend/src/types/course/index.ts b/apps/backend/src/types/course/index.ts
index 4ccb7062..5fb6c1ec 100644
--- a/apps/backend/src/types/course/index.ts
+++ b/apps/backend/src/types/course/index.ts
@@ -1,4 +1,8 @@
-import { ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE } from '$src/constants/upload';
+import {
+ ALLOWED_CONTENT_TYPES,
+ ALLOWED_DOCUMENT_TYPES,
+ MAX_FILE_SIZE
+} from '$src/constants/upload';
import { z } from 'zod';
@@ -32,6 +36,17 @@ export const ZCoursePresignUrlUpload = z.object({
export type TCoursePresignUrlUpload = z.infer;
+export const ZCourseDocumentPresignUrlUpload = z.object({
+ fileName: z.string().min(1, 'File name is required'),
+ fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
+ errorMap: () => ({
+ message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
+ })
+ })
+});
+
+export type TCourseDocumentPresignUrlUpload = z.infer;
+
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/DocumentList.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/DocumentList.svelte
new file mode 100644
index 00000000..64310413
--- /dev/null
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/DocumentList.svelte
@@ -0,0 +1,159 @@
+
+
+{#if mode === MODES.edit}
+
+
+ {#if !isEmpty(displayDocuments)}
+
+ {#each displayDocuments as document, index}
+
+
+
+
+
{document.name}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}
+
+ {#if document.size}
+
+ {$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
+ document.size
+ )}
+
+ {/if}
+
+
+ {#if document.type === 'pdf'}
+
+ {:else}
+
+ {$t('course.navItem.lessons.materials.tabs.document.view_document')}
+
+ {/if}
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+{:else if !isEmpty(displayDocuments)}
+
+ {#each displayDocuments as document}
+
+
+
+
+
{document.name}
+
+ {$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}
+ {#if document.size}
+ {$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
+ document.size
+ )}
+ {/if}
+
+
+
+
+
+ {/each}
+
+{/if}
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/Index.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/Index.svelte
new file mode 100644
index 00000000..59a94eb9
--- /dev/null
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/Index.svelte
@@ -0,0 +1,361 @@
+
+
+
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.upload_title')}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.upload_description')}
+
+
+
+
+
+
+ {#if selectedFile}
+
+
+
+
{selectedFile.name}
+
{formatFileSize(selectedFile.size)}
+
+
+
+ {:else}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.or')}
+
+
+
+
fileInput?.click()}
+ />
+
+ {/if}
+
+
+
+ {#if $uploadCourseDocumentStore.error}
+
+
{$uploadCourseDocumentStore.error}
+
+ {/if}
+
+
+ {#if $uploadCourseDocumentStore.isUploading}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.uploading')}
+ {$uploadCourseDocumentStore.uploadProgress}%
+
+
+
+
+ {/if}
+
+
+ {#if selectedFile && !$uploadCourseDocumentStore.isUploading}
+
+ {/if}
+
+
+ {#if $uploadCourseDocumentStore.uploadedDocument}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.upload_success')}
+
+
+ {/if}
+
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/store.ts b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/store.ts
new file mode 100644
index 00000000..c74d996f
--- /dev/null
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Document/store.ts
@@ -0,0 +1,32 @@
+import { writable } from 'svelte/store';
+
+export interface DocumentUploadState {
+ isUploading: boolean;
+ isModalOpen: boolean;
+ uploadProgress: number;
+ uploadedDocument: {
+ name: string;
+ link: string;
+ type: 'pdf' | 'docx' | 'doc';
+ size: number;
+ } | null;
+ error: string | null;
+}
+
+export const uploadCourseDocumentStore = writable({
+ isUploading: false,
+ isModalOpen: false,
+ uploadProgress: 0,
+ uploadedDocument: null,
+ error: null
+});
+
+export function resetDocumentUploadStore() {
+ uploadCourseDocumentStore.set({
+ isUploading: false,
+ isModalOpen: false,
+ uploadProgress: 0,
+ uploadedDocument: null,
+ error: null
+ });
+}
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte
index 81b3b2d9..e2fb82e8 100644
--- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte
@@ -5,11 +5,13 @@
import {
lesson,
deleteLessonVideo,
- uploadCourseVideoStore
+ uploadCourseVideoStore,
+ cancelVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { type ComponentProps } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte';
import { isFreePlan } from '$lib/utils/store/org';
+ import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { apiClient } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations';
@@ -27,11 +29,16 @@
let fileInput;
let submit;
let fileName = '';
+ let abortController: AbortController | null = null;
async function onUpload() {
if (!fileInput) return;
+ // Create new AbortController for this upload
+ abortController = new AbortController();
+
$uploadCourseVideoStore.isUploading = true;
+ $uploadCourseVideoStore.isCancelled = false;
const videoFile = fileInput.files[0];
fileSize = videoFile?.size / (1024 * 1024);
@@ -50,7 +57,13 @@
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
+ signal: abortController.signal,
onUploadProgress: (progressEvent) => {
+ // Check if upload was cancelled during progress
+ if ($uploadCourseVideoStore.isCancelled) {
+ abortController?.abort();
+ return;
+ }
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
}
});
@@ -78,8 +91,6 @@
status: uploadResponse.status
};
- console.log('formRes', formRes);
-
$lesson.materials.videos = [
...$lesson.materials.videos,
{
@@ -89,15 +100,28 @@
}
];
- 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);
- if (err.response) {
+
+ if ($uploadCourseVideoStore.isCancelled) {
+ formRes = {
+ type: 'CANCELLED',
+ status: 0,
+ message: $t('course.navItem.lessons.materials.tabs.video.add_video.upload_cancelled')
+ };
+ } else if (err.response) {
formRes = err.response.data;
- console.log('formRes', formRes);
+ } else {
+ formRes = {
+ type: 'INTERNAL_ERROR',
+ status: 500,
+ message: 'An error occurred while uploading the video'
+ };
}
+ } finally {
+ abortController = null;
}
isLoaded = true;
@@ -110,6 +134,16 @@
value = 0;
}
+ function cancelUpload() {
+ if (abortController) {
+ abortController.abort();
+ }
+ cancelVideoUpload();
+ formRes = null;
+ isLoaded = false;
+ value = 0;
+ }
+
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) {
helperText = 'Done';
@@ -141,6 +175,13 @@
{helperText}
+
{:else}
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte
index 7a33c3b3..57b14bea 100644
--- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte
@@ -266,7 +266,7 @@
-
+
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte
new file mode 100644
index 00000000..37670a00
--- /dev/null
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentDocument.svelte
@@ -0,0 +1,454 @@
+
+
+
+
+
+
+
+{#if pdfViewerOpen}
+
+
+
+
+
+ {viewingPDF?.name}
+
+ {#if !isLoading && !error}
+
+ {$t('course.navItem.lessons.materials.tabs.document.page_of', {
+ page: pageNum,
+ total: pageCount
+ })}
+
+ {/if}
+
+
+
+
+ {#if !isLoading && !error}
+
+
+
+
+
+ = pageCount}
+ toolTipProps={{ title: 'Next page (→)', hotkeys: ['ArrowRight'] }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(scale * 100)}%
+
+
+ = 3.0}
+ toolTipProps={{ title: 'Zoom in (+)', hotkeys: ['+'] }}
+ >
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if isLoading}
+
+
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
+
+
+
+ {:else if error}
+
+
+
+
{$t(error)}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+ {#if !isLoading && !error}
+
+
+ {$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
+
+
+ {/if}
+
+{/if}
+
+
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte
index b6855bd8..1b5e20db 100644
--- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte
+++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/ComponentVideo.svelte
@@ -72,9 +72,10 @@
{:else}