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} +
+
+ {#if document.type === 'pdf'} + handleViewPDF(document)} + > + + {$t('course.navItem.lessons.materials.tabs.document.view_pdf')} + + {:else} + + {$t('course.navItem.lessons.materials.tabs.document.view_document')} + + {/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} upload 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}