feat: end to end api type safety with redis rate limiting (#573)

* feat: support hono rpc

* add wrangler file

* update name of wrangler file

* add wrangler as a dependency

* add compatibility_flags

* trigger ci

* core: add health check to docker file

* expose port 80

* update custom healthcheck

* feat: implement openapi

* install dependencies in root

* debug yml

* update yml

* update port

* update dockerfile with envs

* completely move away from apiClient

* rename backend to api

* refactor: update server descriptions and comment out local spec saving

* update lock file

* use api.classroomio.com

* add support for redis

* fix: add missing line break in WelcomeModal.svelte

* refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
@@ -4,7 +4,7 @@
import { env } from '$env/dynamic/public';
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
@@ -13,7 +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';
import { classroomio } from '$lib/utils/services/api';
let isLoading = false;
let isCourseComplete = false;
@@ -24,22 +24,20 @@
isLoading = true;
try {
const response = await apiClient.post(
'/course/download/certificate',
{
const response = await classroomio.course.download.certificate.$post({
json: {
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgLogoUrl: $currentOrg.avatar_url || `${$currentOrgDomain}/logo-512.png`,
orgName: `${$currentOrg.name}`
},
{
responseType: 'blob'
}
);
});
const file = new Blob([response.data], { type: 'application/pdf' });
const blobResponse = await response.blob();
const file = new Blob([blobResponse], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
@@ -1,9 +1,5 @@
<script lang="ts">
import {
uploadCourseDocumentStore,
resetDocumentUploadStore,
cancelDocumentUpload
} from '../../store/lessons';
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -11,18 +7,19 @@
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
import { lesson } from '../../store/lessons';
import { apiClient } from '$lib/utils/services/api';
import axios from 'axios';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy } from 'svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
export let lessonId = '';
let fileInput: HTMLInputElement;
let selectedFile: File | null = null;
let dragOver = false;
let abortController: AbortController | null = null;
let errorTimeout: NodeJS.Timeout | null = null;
let isDisabled = false;
const ALLOWED_TYPES = [
'application/pdf',
@@ -30,9 +27,10 @@
'application/msword'
];
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc'];
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
const documentUploader = new DocumentUploader();
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
if (file.type === 'application/pdf') return 'pdf';
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
@@ -79,10 +77,12 @@
}
selectedFile = file;
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}
function handleDrop(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = false;
@@ -93,76 +93,32 @@
}
function handleDragOver(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
if (isDisabled) return;
dragOver = false;
}
async function uploadDocument() {
if (!selectedFile) return;
// Create new AbortController for this upload
abortController = new AbortController();
$uploadCourseDocumentStore.isUploading = true;
$uploadCourseDocumentStore.uploadProgress = 0;
$uploadCourseDocumentStore.error = null;
$uploadCourseDocumentStore.isCancelled = false;
$lessonDocUpload.isUploading = true;
try {
// Get presigned URL for upload
const uploadPresignResponse = await apiClient.post('/course/presign/document/upload', {
fileName: selectedFile.name,
fileType: selectedFile.type
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
await documentUploader.uploadFile({
url: presignedUrl,
file: selectedFile
});
// Check if upload was cancelled
if ($uploadCourseDocumentStore.isCancelled) {
throw new Error('Upload cancelled');
}
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
const presignedUrl = uploadPresignResponse.data.url;
// Upload file to Cloudflare R2 using presigned URL
const uploadResponse = await axios.put(presignedUrl, selectedFile, {
headers: {
'Content-Type': selectedFile.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: abortController.signal,
onUploadProgress: (progressEvent) => {
// Check if upload was cancelled during progress
if ($uploadCourseDocumentStore.isCancelled) {
abortController?.abort();
return;
}
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
$uploadCourseDocumentStore.uploadProgress = progress;
}
});
// Check if upload was cancelled
if ($uploadCourseDocumentStore.isCancelled) {
throw new Error('Upload cancelled');
}
// Get download URL for the uploaded file
const downloadPresignedResponse = await apiClient.post('/course/presign/download/document', {
keys: [uploadPresignResponse.data.fileKey]
});
if (!downloadPresignedResponse.data.success) {
throw new Error(downloadPresignedResponse.data.message);
}
const fileKey = uploadPresignResponse.data.fileKey;
const presignedUrls = downloadPresignedResponse.data.urls;
// Create document object
const document = {
type: getFileType(selectedFile),
name: selectedFile.name,
@@ -171,18 +127,18 @@
size: selectedFile.size
};
// Add to lesson materials - ensure materials and documents are properly initialized
if (!$lesson.materials) {
$lesson.materials = { note: '', slide_url: '', videos: [], documents: [] };
}
if (!$lesson.materials.documents) {
$lesson.materials.documents = [];
}
$lesson.materials.documents = [...$lesson.materials.documents, document];
// Update store
$uploadCourseDocumentStore.uploadedDocument = document;
$uploadCourseDocumentStore.uploadProgress = 100;
lesson.update((state) => ({
...state,
materials: {
...(state.materials || {}),
documents: [...(state.materials.documents || []), document]
}
}));
lessonDocUpload.update((state) => ({
...state,
uploadedDocument: document,
uploadProgress: 100
}));
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
@@ -195,32 +151,29 @@
} catch (error) {
console.error('Upload error:', error);
if ($uploadCourseDocumentStore.isCancelled) {
$uploadCourseDocumentStore.error = $t(
if ($lessonDocUpload.isCancelled) {
$lessonDocUpload.error = $t(
'course.navItem.lessons.materials.tabs.document.upload_cancelled'
);
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
} else {
$uploadCourseDocumentStore.error = error instanceof Error ? error.message : 'Upload failed';
$lessonDocUpload.error = error instanceof Error ? error.message : 'Upload failed';
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$uploadCourseDocumentStore.isUploading = false;
abortController = null;
$lessonDocUpload.isUploading = false;
}
}
function removeSelectedFile() {
selectedFile = null;
if (fileInput) fileInput.value = '';
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
cancelDocumentUpload();
documentUploader.cancelUpload();
selectedFile = null;
if (fileInput) fileInput.value = '';
}
@@ -235,10 +188,10 @@
}
// Auto-clear error after 5 seconds
$: if ($uploadCourseDocumentStore.error) {
$: if ($lessonDocUpload.error) {
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}, 5000);
}
@@ -246,8 +199,14 @@
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
$: isDisabled = $lessonDocUpload.isUploading || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
</UpgradeBanner>
<div class="p-6">
<div class="mb-6">
<h3 class="mb-2 text-lg font-semibold">
@@ -261,7 +220,8 @@
<!-- File Upload Area -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {dragOver
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} {dragOver
? 'border-blue-500 bg-blue-50'
: 'hover:border-gray-400'}"
on:drop={handleDrop}
@@ -289,6 +249,7 @@
</p>
<input
bind:this={fileInput}
disabled={isDisabled}
type="file"
accept=".pdf,.docx,.doc"
on:change={handleFileSelect}
@@ -298,6 +259,7 @@
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
variant={VARIANTS.OUTLINED}
{isDisabled}
onClick={() => fileInput?.click()}
/>
</div>
@@ -305,23 +267,23 @@
</div>
<!-- Error Message -->
{#if $uploadCourseDocumentStore.error}
{#if $lessonDocUpload.error}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{$uploadCourseDocumentStore.error}</p>
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
</div>
{/if}
<!-- Upload Progress -->
{#if $uploadCourseDocumentStore.isUploading}
{#if $lessonDocUpload.isUploading}
<div class="mt-4">
<div class="mb-1 flex justify-between text-sm text-gray-600">
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
<span>{$uploadCourseDocumentStore.uploadProgress}%</span>
<span>{$lessonDocUpload.uploadProgress}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {$uploadCourseDocumentStore.uploadProgress}%"
style="width: {$lessonDocUpload.uploadProgress}%"
></div>
</div>
<div class="mt-3 flex justify-center">
@@ -335,7 +297,7 @@
{/if}
<!-- Upload Button -->
{#if selectedFile && !$uploadCourseDocumentStore.isUploading}
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
@@ -345,13 +307,13 @@
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
onClick={uploadDocument}
isLoading={$uploadCourseDocumentStore.isUploading}
isLoading={$lessonDocUpload.isUploading}
/>
</div>
{/if}
<!-- Success Message -->
{#if $uploadCourseDocumentStore.uploadedDocument}
{#if $lessonDocUpload.uploadedDocument}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
@@ -11,22 +11,25 @@ export interface DocumentUploadState {
size: number;
} | null;
error: string | null;
isCancelled: boolean;
}
export const uploadCourseDocumentStore = writable<DocumentUploadState>({
export const lessonDocUpload = writable<DocumentUploadState>({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
error: null,
isCancelled: false
});
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
error: null,
isCancelled: false
});
}
@@ -1,26 +1,21 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import axios from 'axios';
import {
lesson,
deleteLessonVideo,
uploadCourseVideoStore,
lessonVideoUpload,
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';
import { VideoUploader } from '$lib/utils/services/courses/presign';
export let lessonId = '';
let value = 0;
let max = 100;
let status: ComponentProps<ProgressBar>['status'] = 'active';
let fileSize;
let isDisabled = false;
@@ -29,66 +24,32 @@
let fileInput;
let submit;
let fileName = '';
let abortController: AbortController | null = null;
const videoUploader = new VideoUploader();
async function onUpload() {
if (!fileInput) return;
// Create new AbortController for this upload
abortController = new AbortController();
$uploadCourseVideoStore.isUploading = true;
$uploadCourseVideoStore.isCancelled = false;
videoUploader.initUpload();
const videoFile = fileInput.files[0];
fileSize = videoFile?.size / (1024 * 1024);
fileName = videoFile?.name;
try {
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
fileName: videoFile.name,
fileType: videoFile.type
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
await videoUploader.uploadFile({
url: presignedUrl,
file: videoFile
});
const presignedUrl = uploadPresignResponse.data.url;
const uploadResponse = await axios.put(presignedUrl, videoFile, {
headers: {
'Content-Type': videoFile.type
},
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);
}
});
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;
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
formRes = {
url: presignedUrls[fileKey],
fileKey: uploadPresignResponse.data.fileKey,
status: uploadResponse.status
fileKey: fileKey,
status: 200
};
$lesson.materials.videos = [
@@ -100,12 +61,11 @@
}
];
$uploadCourseVideoStore.isUploading = false;
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if ($uploadCourseVideoStore.isCancelled) {
if ($lessonVideoUpload.isCancelled) {
formRes = {
type: 'CANCELLED',
status: 0,
@@ -114,53 +74,54 @@
} else if (err.response) {
formRes = err.response.data;
} else {
let message = 'An error occurred while uploading the video';
if (err instanceof Error) {
message = err.message;
}
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: 'An error occurred while uploading the video'
message
};
}
} finally {
abortController = null;
videoUploader.abortController = null;
}
isLoaded = true;
$lessonVideoUpload.isUploading = false;
}
function tryAgain() {
formRes = null;
isLoaded = false;
$uploadCourseVideoStore.isUploading = false;
value = 0;
$lessonVideoUpload.isUploading = false;
$lessonVideoUpload.uploadProgress = 0;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
videoUploader.abortController?.abort();
cancelVideoUpload();
formRes = null;
isLoaded = false;
value = 0;
$lessonVideoUpload.uploadProgress = 0;
}
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) {
helperText = 'Done';
status = 'finished';
}
$: helperText = $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize) + 'MB';
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
$: isDisabled = $lessonVideoUpload.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.upgrade')}
</UpgradeBanner>
{#if !isLoaded}
<button
type="button"
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
on:click={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
disabled={isDisabled}
>
@@ -168,12 +129,17 @@
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 $uploadCourseVideoStore.isUploading}
{#if $lessonVideoUpload.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>
<ProgressBar class="w-full" {value} {max} {status} />
<ProgressBar
class="w-full"
value={$lessonVideoUpload.uploadProgress}
max={100}
status={$lessonVideoUpload.uploadProgress === 100 ? 'finished' : 'active'}
/>
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<PrimaryButton
@@ -242,25 +208,19 @@
/>
</div>
{:else}
<div class="flex h-full w-full flex-col items-start justify-between">
<div class="h-auto w-full rounded-md border px-8 py-3">
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="overflow-hidden rounded-sm">
<video class="w-[200px]">
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
</video>
</div>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</span>
<button on:click={deleteLessonVideo}>
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
</button>
<div class=" w-full rounded-md border px-8 py-3">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-2">
<video class="border-primary-300 w-[200px] rounded-md border">
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
<p>{$t('generic.loading')}</p>
</video>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</div>
<PrimaryButton
label={$t('course.navItem.lessons.materials.button_done')}
onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}
onClick={() => ($lessonVideoUpload.isModalOpen = false)}
/>
</div>
</div>
@@ -3,10 +3,7 @@
lesson,
deleteLessonDocument
} from '$lib/components/Course/components/Lesson/store/lessons';
import {
uploadCourseDocumentStore,
presignedDocUrls
} from '$lib/components/Course/components/Lesson/store/lessons';
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import MODES from '$lib/utils/constants/mode';
import IconButton from '$lib/components/IconButton/index.svelte';
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
@@ -17,7 +14,6 @@
import { onMount } from 'svelte';
import DocumentList from '../Document/DocumentList.svelte';
import { t } from '$lib/utils/functions/translations';
import { apiClient } from '$lib/utils/services/api';
import type { LessonDocument } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -56,7 +52,7 @@
});
function openDocumentUploadModal() {
$uploadCourseDocumentStore.isModalOpen = true;
$lessonDocUpload.isModalOpen = true;
}
function deleteDocument(index: number) {
@@ -71,36 +67,6 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function getPresignedDocs(docs: LessonDocument[]) {
if (!docs.length) return {};
if (docs.every((d) => $presignedDocUrls[d.key || ''])) return;
isLoading = true;
try {
const { data } = await apiClient.post('/course/presign/download/document', {
keys: docs.map((d) => d.key)
});
if (!data.success) {
throw new Error(data.message);
}
for (const key in data.urls) {
presignedDocUrls.update((urls) => ({
...urls,
[key]: data.urls[key]
}));
}
} catch (error) {
isLoading = false;
console.error('Error getting presigned docs:', error);
snackbar.error('Error getting presigned docs');
} finally {
isLoading = false;
}
}
async function downloadDocument(doc: LessonDocument) {
downloadingDocuments.add(doc.name);
downloadingDocuments = downloadingDocuments;
@@ -111,7 +77,7 @@
}
try {
const response = await fetch($presignedDocUrls[doc.key]);
const response = await fetch(doc.link);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
@@ -142,6 +108,19 @@
renderTimeout = null;
}, 150); // 150ms debounce
}
// Wait for PDF.js to load
const checkPDFJS = () => {
return new Promise((resolve) => {
const check = () => {
if (pdfjsLib) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
};
async function viewPDF(document: LessonDocument) {
// Wait for PDF.js to be fully loaded
@@ -152,20 +131,6 @@
isLoading = true;
error = 'Loading PDF viewer...';
// Wait for PDF.js to load
const checkPDFJS = () => {
return new Promise((resolve) => {
const check = () => {
if (pdfjsLib) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
};
await checkPDFJS();
}
@@ -180,7 +145,7 @@
return;
}
const response = await fetch($presignedDocUrls[document.key]);
const response = await fetch(document.link);
if (!response.ok) {
throw new Error('Failed to fetch PDF');
@@ -335,23 +300,19 @@
}
$: displayDocuments = $lesson?.materials?.documents || [];
$: getPresignedDocs(displayDocuments);
</script>
<div class="mx-auto w-full max-w-lg">
<DocumentList
{isLoading}
{mode}
{displayDocuments}
{downloadingDocuments}
{formatFileSize}
{openDocumentUploadModal}
{deleteDocument}
{downloadDocument}
on:viewPDF={handleViewPDF}
/>
</div>
<DocumentList
{isLoading}
{mode}
{displayDocuments}
{downloadingDocuments}
{formatFileSize}
{openDocumentUploadModal}
{deleteDocument}
{downloadDocument}
on:viewPDF={handleViewPDF}
/>
<!-- PDF Viewer Modal -->
{#if pdfViewerOpen}
@@ -1,7 +1,7 @@
<script lang="ts">
import Box from '$lib/components/Box/index.svelte';
import VideoUploader from '$lib/components/Course/components/Lesson/Materials/Video/Index.svelte';
import DocumentUploader from '$lib/components/Course/components/Lesson/Materials/Document/Index.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
import {
deleteLessonVideo,
handleUpdateLessonMaterials,
@@ -9,8 +9,8 @@
lesson,
lessonByTranslation,
lessons,
uploadCourseVideoStore,
uploadCourseDocumentStore
lessonVideoUpload,
lessonDocUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import TextField from '$lib/components/Form/TextField.svelte';
@@ -47,6 +47,7 @@
import * as CONSTANTS from './constants';
import { orderedTabs } from './constants';
import Loader from './Loader.svelte';
export let mode = MODES.view;
export let prevMode = '';
export let lessonId = '';
@@ -57,7 +58,6 @@
let localeExists: Record<string, boolean> = {};
let lessonTitle = '';
let prevContent = '';
let initAutoSave = false;
let timeoutId: NodeJS.Timeout;
let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value;
@@ -182,7 +182,7 @@
}
const openAddVideoModal = () => {
$uploadCourseVideoStore.isModalOpen = true;
$lessonVideoUpload.isModalOpen = true;
};
const { input, handleSubmit, completion, isLoading } = useCompletion({
@@ -244,11 +244,6 @@
if (timeoutId) clearTimeout(timeoutId);
if (!initAutoSave) {
initAutoSave = true;
return;
}
isSaving = true;
timeoutId = setTimeout(async () => {
const { error } = await saveLesson(updatedMaterials);
@@ -262,7 +257,6 @@
}
async function onLessonIdChange(_lid: string) {
initAutoSave = false;
isSaving = false;
tabs = orderedTabs(tabs, $course.metadata?.lessonTabsOrder);
@@ -271,18 +265,18 @@
}
const onClose = () => {
if ($uploadCourseVideoStore.isUploading) return;
if ($lessonVideoUpload.isUploading) return;
$uploadCourseVideoStore.isModalOpen = false;
$lessonVideoUpload.isModalOpen = false;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
const onDocumentClose = () => {
if ($uploadCourseDocumentStore.isUploading) return;
if ($lessonDocUpload.isUploading) return;
$uploadCourseDocumentStore.isModalOpen = false;
$lessonDocUpload.isModalOpen = false;
// Clear any error messages when modal closes
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
@@ -327,20 +321,20 @@
<Modal
{onClose}
bind:open={$uploadCourseVideoStore.isModalOpen}
bind:open={$lessonVideoUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.video.add_video.title')}
>
<VideoUploader {lessonId} />
<AddVideoToLesson {lessonId} />
</Modal>
<Modal
onClose={onDocumentClose}
bind:open={$uploadCourseDocumentStore.isModalOpen}
bind:open={$lessonDocUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
>
<DocumentUploader {lessonId} />
<AddDocumentToLesson {lessonId} />
</Modal>
<HtmlRender className="m-auto text-center">
@@ -493,7 +487,13 @@
/>
</div>
{:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
<video
bind:this={player}
class="plyr-video-trigger"
style="max-height: 569px;"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
@@ -15,22 +15,22 @@ 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,
isCancelled: false
});
export const uploadCourseDocumentStore = writable({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null as any,
export const lessonVideoUpload = writable({
error: null as string | null,
isCancelled: false
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export const presignedDocUrls = writable<Record<string, string>>({});
export const lessonDocUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadedDocument: null as any,
uploadProgress: 0
});
export const lessons: Writable<Lesson[]> = writable([]);
@@ -217,7 +217,7 @@ export const deleteLessonDocument = (index: any) => {
};
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
@@ -228,7 +228,7 @@ export function resetDocumentUploadStore() {
}
export function cancelDocumentUpload() {
uploadCourseDocumentStore.update((store) => ({
lessonDocUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
@@ -237,7 +237,7 @@ export function cancelDocumentUpload() {
}
export function cancelVideoUpload() {
uploadCourseVideoStore.update((store) => ({
lessonVideoUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false
@@ -18,6 +18,7 @@
to: $profile.email,
name: $profile.fullname
});
goto($currentOrgPath + '/courses?create=true');
};
</script>
@@ -29,7 +30,7 @@
maxWidth="w-[800px]"
modalHeading="Welcome"
>
<p class="text-black dark:text-white text-sm md:text-base lg:text-lg">
<p class="text-sm text-black md:text-base lg:text-lg dark:text-white">
{$t('welcome_modal.we_at')}
<a href="https://app.classroomio.com/" class="text-primary-700 no-underline hover:no-underline"
>ClassroomIO</a
@@ -38,5 +39,5 @@
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
{$t('welcome_modal.deeply_appreciate')}
</p>
<img src="/images/welcome-img.svg" alt="A welcome banner" class="w-full my-6" />
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
</Modal>