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:
+9
-11
@@ -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');
|
||||
|
||||
+61
-99
@@ -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')}
|
||||
+7
-4
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
+50
-90
@@ -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>
|
||||
|
||||
+28
-67
@@ -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: 'john@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -295,7 +295,7 @@ export type LessonDocument = {
|
||||
name: string;
|
||||
link: string;
|
||||
size?: number;
|
||||
key?: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export interface LessonPage {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user