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>
@@ -80,7 +80,7 @@ export const addMathPlugin = (tinymce: any) => {
onSubmit: async function (w) {
let content = output.exportAs('html');
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our backend service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our api service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// https://latex.codecogs.com/svg.image
// 1. Get only the URL in the img tag generated by codecogs library
@@ -1,19 +0,0 @@
import axios from 'axios';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
export const apiClient = axios.create({
baseURL: `${env.PUBLIC_SERVER_URL}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
apiClient.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
@@ -0,0 +1,80 @@
# API Client
## Basic Usage
```typescript
import { apiClient, safeRequest, classroomio } from '$lib/utils/services/api';
// GET request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', { method: 'GET' });
});
// POST request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', {
method: 'POST',
body: { name: 'John', email: '[email protected]' }
});
});
// Handle response
if (result.success) {
console.log('Data:', result.data);
} else {
console.error('Error:', result.error.message);
}
```
## Error Handling
```typescript
if (result.success) {
// Success
console.log(result.data);
} else {
// Check error type
if (result.error.error_code === 'auth') {
window.location.href = '/login';
} else if (result.error.error_code === 'client') {
showError('Please check your input');
} else if (result.error.error_code === 'server') {
showError('Server error, please try again');
}
}
```
## Custom Fetch
```typescript
import { createApiClient } from '$lib/utils/services/api';
const customClient = createApiClient({
customFetch: (input, init) => {
// Your custom fetch logic
return fetch(input, init);
}
});
const result = await safeRequest(async () => {
return customClient.request('/api/data', { method: 'GET' });
});
```
## RPC Usage
```typescript
import { classroomio } from '$lib/utils/services/api';
// Use with Hono RPC
const data = await classroomio.users.$get();
const user = await classroomio.users.$post({ json: { name: 'John' } });
```
## Error Codes
- `'auth'` - 401 Unauthorized
- `'client'` - 400-499 Client errors
- `'server'` - 500-599 Server errors
- `'timeout'` - 408 Request timeout
- `'network'` - 0 Network issues
@@ -0,0 +1,24 @@
import type { ApiClientConfig } from './types';
import { env } from '$env/dynamic/public';
// Default configuration
export const DEFAULT_CONFIG: Required<ApiClientConfig> = {
baseURL: env.PUBLIC_SERVER_URL || '',
timeout: 30000, // 30 seconds
retries: 1,
retryDelay: 1000, // 1 second
customFetch: fetch,
onAuthError: async () => {
// Default: redirect to login or refresh token
console.warn('Authentication error occurred. Consider redirecting to login.');
},
onNetworkError: async (error: Error) => {
console.error('Network error:', error);
},
onResponse: async (response: Response) => {
// Default: log response for debugging
if (process.env.NODE_ENV === 'development') {
console.log(`API Response: ${response.status} ${response.statusText}`);
}
}
};
@@ -0,0 +1,212 @@
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { hcWithType } from '@cio/api/rpc-types';
class ApiClient {
private config: Required<ApiClientConfig>;
constructor(config: ApiClientConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
private async makeRequest(
input: RequestInfo | URL,
requestConfig: RequestConfig = {}
): Promise<Response> {
console.log('makeRequest', input, requestConfig);
const {
timeout = this.config.timeout,
retries = this.config.retries,
skipAuth = false,
...fetchConfig
} = requestConfig;
const url = typeof input === 'string' ? input : input.toString();
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
// Prepare headers
const headers = new Headers(fetchConfig.headers);
// Add default headers
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
// Handle body serialization and content-type
let requestBody = fetchConfig.body;
if (
fetchConfig.body &&
typeof fetchConfig.body === 'object' &&
!(fetchConfig.body instanceof FormData)
) {
// Auto-stringify objects (except FormData)
requestBody = JSON.stringify(fetchConfig.body);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
} else if (fetchConfig.body && !headers.has('Content-Type')) {
// Set content-type for other body types if not already set
if (typeof fetchConfig.body === 'string') {
headers.set('Content-Type', 'text/plain');
}
}
// Add authentication if not skipped
if (!skipAuth) {
try {
const token = await getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
} catch (error) {
console.warn('Failed to get access token:', error);
// Continue without auth if token retrieval fails
}
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = {
...fetchConfig,
headers,
body: requestBody,
signal: controller.signal
};
let lastError: Error | null = null;
// Retry logic with exponential backoff
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.config.customFetch(fullUrl, requestInit);
clearTimeout(timeoutId);
await this.config.onResponse(response);
if (response.ok) {
return response;
}
if (response.status === 401) {
await this.config.onAuthError();
throw new ApiError(
'Authentication failed',
response.status,
response.statusText,
response
);
}
if (response.status >= 400 && response.status < 500) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(
`Client error: ${errorText}`,
response.status,
response.statusText,
response
);
}
if (response.status >= 500) {
const error = new ApiError(
`Server error: ${response.statusText}`,
response.status,
response.statusText,
response
);
if (attempt === retries) {
throw error;
}
lastError = error;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Other status codes
throw new ApiError(
`Unexpected status: ${response.statusText}`,
response.status,
response.statusText,
response
);
} catch (error) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError('Request timeout', 408, 'Request Timeout');
}
// Handle network errors - retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
const networkError = new ApiError('Network error', 0, 'Network Error');
if (attempt === retries) {
await this.config.onNetworkError(networkError);
throw networkError;
}
lastError = networkError;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Re-throw non-retryable errors
throw error;
}
}
// If we get here, all retries failed
throw lastError || new ApiError('Request failed after all retries');
}
// Generic request method - handles all HTTP methods
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
return this.makeRequest(input, config);
}
// Update configuration
updateConfig(newConfig: Partial<ApiClientConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}
// Create default instance
export const apiClient = new ApiClient();
// RPC client using the new fetch wrapper
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
return apiClient.request(input, requestInit);
}
});
// Utility functions for common use cases
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
// Re-export utility functions for convenience
export {
handleJsonResponse,
handleTextResponse,
isApiError,
isNetworkError,
isAuthError,
isClientError,
isServerError,
isTimeoutError,
safeRequest,
safeRequestText,
safeRequestRaw,
type Result
} from './utils';
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
@@ -0,0 +1,46 @@
// Types for better type safety
export interface ApiClientConfig {
baseURL?: string;
timeout?: number;
retries?: number;
retryDelay?: number;
customFetch?: typeof fetch;
onAuthError?: () => Promise<void> | void;
onNetworkError?: (error: Error) => Promise<void> | void;
onResponse?: (response: Response) => Promise<void> | void;
}
export interface RequestConfig extends RequestInit {
timeout?: number;
retries?: number;
skipAuth?: boolean;
}
export class ApiError extends Error {
public readonly error_code: string;
constructor(
message: string,
public status?: number,
public statusText?: string,
public response?: Response
) {
super(message);
this.name = 'ApiError';
// Determine error code based on status
if (status === 401) {
this.error_code = 'auth';
} else if (status && status >= 400 && status < 500) {
this.error_code = 'client';
} else if (status && status >= 500) {
this.error_code = 'server';
} else if (status === 408) {
this.error_code = 'timeout';
} else if (status === 0 || !status) {
this.error_code = 'network';
} else {
this.error_code = 'unknown';
}
}
}
@@ -0,0 +1,125 @@
import { ApiError } from './types';
// Utility function for exponential backoff
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Helper function to handle JSON responses
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
try {
return await response.json();
} catch (error) {
throw new ApiError(
'Failed to parse JSON response',
response.status,
response.statusText,
response
);
}
};
// Helper function to handle text responses
export const handleTextResponse = async (response: Response): Promise<string> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
return response.text();
};
// Error handling utilities
export const isApiError = (error: unknown): error is ApiError => {
return error instanceof ApiError;
};
export const isNetworkError = (error: unknown): boolean => {
return isApiError(error) && error.status === 0;
};
export const isAuthError = (error: unknown): boolean => {
return isApiError(error) && error.status === 401;
};
export const isClientError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
};
export const isServerError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 500;
};
export const isTimeoutError = (error: unknown): boolean => {
return isApiError(error) && error.status === 408;
};
// Safe request wrapper that returns Result pattern
export type Result<T, E = ApiError> = { success: true; data: T } | { success: false; error: E };
// Generic safe request wrapper
export const safeRequest = async <T = any>(
requestFn: () => Promise<Response>
): Promise<Result<T>> => {
try {
const response = await requestFn();
const data = await handleJsonResponse<T>(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper for text responses
export const safeRequestText = async (
requestFn: () => Promise<Response>
): Promise<Result<string>> => {
try {
const response = await requestFn();
const data = await handleTextResponse(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper that returns the raw response
export const safeRequestRaw = async (
requestFn: () => Promise<Response>
): Promise<Result<Response>> => {
try {
const response = await requestFn();
return { success: true, data: response };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
@@ -11,9 +11,9 @@ import type {
} from '$lib/utils/types';
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
import { GenericUploader } from './presign';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from '$lib/utils/constants/course';
import { apiClient } from '$lib/utils/services/api';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
@@ -53,12 +53,10 @@ export async function fetchProfileCourseProgress(
data: ProfileCourseProgress[] | null;
error: PostgrestError | null;
}> {
const { data, error } = await supabase
.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
})
.returns<ProfileCourseProgress[]>();
const { data, error } = await supabase.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
});
return { data, error };
}
@@ -286,23 +284,30 @@ export async function fetchLesson(lessonId: Lesson['id']) {
.single();
if (data) {
const keys = data.videos.filter((video) => video.type === 'upload').map((video) => video.key);
const videoKeys =
data.videos?.filter((video) => video.type === 'upload')?.map((video) => video.key) || [];
if (!keys.length) {
return { data, error };
}
const docKeys = data.documents?.map((doc) => doc.key) || [];
try {
const response = await apiClient.post('/course/presign/download', { keys });
// Get presigned URLs for videos and documents
const genericUploader = new GenericUploader('generic');
const urls = await genericUploader.getAllDownloadPresignedUrl(videoKeys, docKeys);
data.videos = data.videos.map((video) => {
if (video.type === 'upload') {
video.link = response.data.urls[video.key];
if (urls.videos[video.key]) {
video.link = urls.videos[video.key];
}
return video;
});
data.documents = data.documents.map((doc) => {
doc.link = urls.documents[doc.key];
return doc;
});
} catch (error) {
console.error('Error retrieving videos:', error);
console.error('Error retrieving presigned assets (videos and documents):', error);
}
}
@@ -0,0 +1,134 @@
import { type Writable, get } from 'svelte/store';
import {
lessonDocUpload,
lessonVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import axios from 'axios';
import { classroomio } from '$lib/utils/services/api';
export type UploadType = 'document' | 'video' | 'generic';
export class GenericUploader {
public abortController: AbortController | null = null;
private uploadType: UploadType;
private uploadStore: Writable<any>;
constructor(uploadType: UploadType) {
this.uploadType = uploadType;
this.uploadStore = uploadType === 'document' ? lessonDocUpload : lessonVideoUpload;
this.abortController = new AbortController();
}
async getDownloadPresignedUrl(keys: string[], type = this.uploadType) {
const endpoint =
type === 'document'
? classroomio.course.presign.document.download
: classroomio.course.presign.video.download;
const response = await endpoint.$post({
json: {
keys
}
});
return response.json();
}
async getAllDownloadPresignedUrl(videoKeys: string[], docKeys: string[]) {
const urls = {
videos: {},
documents: {}
};
try {
if (videoKeys.length) {
const videoResponse = await this.getDownloadPresignedUrl(videoKeys, 'video');
urls.videos = videoResponse?.urls || {};
}
if (docKeys.length) {
const docResponse = await this.getDownloadPresignedUrl(docKeys, 'document');
urls.documents = docResponse?.urls || {};
}
} catch (error) {
console.error('Error getting download presigned url:', error);
}
return urls;
}
async getPresignedUrl(file: File) {
const endpoint =
this.uploadType === 'document'
? classroomio.course.presign.document.upload
: classroomio.course.presign.video.upload;
const response = await endpoint.$post({
json: {
fileName: file?.name,
fileType: file?.type
}
});
return response.json();
}
async uploadFile(params: { url: string; file: File }) {
await axios.put(params.url, params.file, {
headers: {
'Content-Type': params.file.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: this.abortController?.signal,
onUploadProgress: (progressEvent) => {
if (get(this.uploadStore).isCancelled) {
this.abortController?.abort();
return;
}
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
this.uploadStore.update((state) => ({
...state,
uploadProgress: progress
}));
}
});
}
initUpload() {
this.uploadStore.update((state) => ({
...state,
isUploading: true,
uploadProgress: 0,
error: null,
isCancelled: false
}));
this.abortController = new AbortController();
}
cancelUpload() {
this.uploadStore.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
this.abortController?.abort();
this.abortController = null;
}
}
export class DocumentUploader extends GenericUploader {
constructor() {
super('document');
}
}
export class VideoUploader extends GenericUploader {
constructor() {
super('video');
}
}
@@ -789,7 +789,8 @@
"loading_pdf": "PDF laden ...",
"failed_to_load_pdf": "Das PDF -Dokument konnte nicht geladen werden",
"failed_to_render_pdf": "Die PDF -Seite versäumte es, PDF -Seite zu rendern",
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert."
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert.",
"upgrade": "Aktualisieren Sie Ihren Plan, um Dokumente hochzuladen"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Gesperrt",
"unlocked": "Entsperrt"
"unlocked": "Entsperrt",
"loading": "Laden"
},
"specialization": {
"course_tag": "Spezialisierung"
@@ -693,6 +693,7 @@
"upload_error_message": "Unsupported file format or upload failed",
"upload_error_try_again": "Please try again with a valid document file",
"try_again": "Try Again",
"upgrade": "Upgrade your plan to upload documents",
"cancel_upload": "Cancel Upload",
"upload_cancelled": "Upload cancelled by user",
@@ -1521,7 +1522,8 @@
"generic": {
"locked": "Locked",
"unlocked": "Unlocked"
"unlocked": "Unlocked",
"loading": "Loading"
},
"validations": {
@@ -789,7 +789,8 @@
"loading_pdf": "Cargando PDF ...",
"failed_to_load_pdf": "No se pudo cargar el documento PDF",
"failed_to_render_pdf": "No se pudo renderizar la página de PDF",
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado."
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado.",
"upgrade": "Actualice su plan para cargar documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "bloqueado",
"unlocked": "desbloqueado"
"unlocked": "desbloqueado",
"loading": "Cargando"
},
"specialization": {
"course_tag": "Especialización"
@@ -789,7 +789,8 @@
"loading_pdf": "Chargement du PDF ...",
"failed_to_load_pdf": "Échec du chargement du document PDF",
"failed_to_render_pdf": "Échec de la page PDF",
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé."
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
"upgrade": "Mettez à niveau votre plan pour télécharger des documents"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Fermé à clé",
"unlocked": "Débloqué"
"unlocked": "Débloqué",
"loading": "Chargement"
},
"specialization": {
"course_tag": "Spécialisation"
@@ -789,7 +789,8 @@
"loading_pdf": "लोडिंग पीडीएफ ...",
"failed_to_load_pdf": "पीडीएफ दस्तावेज़ लोड करने में विफल",
"failed_to_render_pdf": "पीडीएफ पेज को प्रस्तुत करने में विफल",
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।"
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।",
"upgrade": "दस्तावेज़ अपलोड करने के लिए अपनी योजना को अपग्रेड करें"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "बंद",
"unlocked": "अनलॉक किया"
"unlocked": "अनलॉक किया",
"loading": "लोड करना"
},
"specialization": {
"course_tag": "विशेषज्ञता"
@@ -99,7 +99,8 @@
},
"generic": {
"locked": "Zamknięty",
"unlocked": "Odblokowany"
"unlocked": "Odblokowany",
"loading": "Załadunek"
},
"content": {
"heading": "Internacjonalizacja w SvelTekit",
@@ -957,7 +958,8 @@
"loading_pdf": "Ładowanie PDF ...",
"failed_to_load_pdf": "Nie udało się załadować dokumentu PDF",
"failed_to_render_pdf": "Nie udało się renderować strony PDF",
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone."
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
"upgrade": "Uaktualnij swój plan przesyłania dokumentów"
}
}
},
@@ -789,7 +789,8 @@
"loading_pdf": "Carregando PDF ...",
"failed_to_load_pdf": "Falha ao carregar o documento PDF",
"failed_to_render_pdf": "Falhou em renderizar a página em PDF",
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado."
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado.",
"upgrade": "Atualize seu plano para fazer upload de documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Bloqueado",
"unlocked": "Desbloqueado"
"unlocked": "Desbloqueado",
"loading": "Carregando"
},
"specialization": {
"course_tag": "Especialização"
@@ -789,7 +789,8 @@
"loading_pdf": "Загрузка PDF ...",
"failed_to_load_pdf": "Не удалось загрузить документ PDF",
"failed_to_render_pdf": "Не удалось отобразить страницу PDF",
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено."
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено.",
"upgrade": "Обновите свой план для загрузки документов"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Заблокировано",
"unlocked": "Разблокировано"
"unlocked": "Разблокировано",
"loading": "Загрузка"
},
"specialization": {
"course_tag": "Специализация"
@@ -789,7 +789,8 @@
"loading_pdf": "Tải PDF ...",
"failed_to_load_pdf": "Không tải tài liệu PDF",
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa."
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
"upgrade": "Nâng cấp kế hoạch của bạn để tải lên tài liệu"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Đã khóa",
"unlocked": "Đã mở khóa"
"unlocked": "Đã mở khóa",
"loading": "Đang tải"
},
"specialization": {
"course_tag": "Chuyên môn"
+1 -1
View File
@@ -295,7 +295,7 @@ export type LessonDocument = {
name: string;
link: string;
size?: number;
key?: string;
key: string;
};
export interface LessonPage {
+5 -8
View File
@@ -1,4 +1,4 @@
import { env } from '$env/dynamic/public';
import { classroomio } from '$lib/utils/services/api';
const sendEmail = (sFetch: typeof fetch) => {
return async (
@@ -12,13 +12,10 @@ const sendEmail = (sFetch: typeof fetch) => {
}[]
) => {
try {
const response = await sFetch(`${env.PUBLIC_SERVER_URL}/mail/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailDataArray)
});
const response = await classroomio.mail.send.$post(
{ json: emailDataArray },
{ fetch: sFetch }
);
if (!response.ok) {
console.log('Failed to send emails');
@@ -41,7 +41,7 @@
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
import { apiClient } from '$lib/utils/services/api';
import { classroomio } from '$lib/utils/services/api';
export let data;
@@ -160,9 +160,8 @@
const lessonNumber = getLessonOrder(currentLesson.id);
const slideUrl = $lesson.materials.slide_url || '';
const response = await apiClient.post(
'/course/lesson/download/pdf',
{
const response = await classroomio.course.lesson.download.pdf.$post({
json: {
title: currentLesson.title,
number: lessonNumber,
orgName: $currentOrg.name,
@@ -170,13 +169,11 @@
slideUrl: slideUrl,
video: lessonVideo,
courseTitle: $course.title
},
{
responseType: 'blob'
}
);
});
const blob = await response.blob();
const file = new Blob([response.data], { type: 'application/pdf' });
const file = new Blob([blob], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');