feat: add documents to Lesson materials (#572)

* feat: Initial logic for the pdf upload

* feat: uploa logic, translations and ui flow complete

* chore:console log cleanup

* feat: fixed upload issue and added cancel upload button

* feat: fixing the download document ish

* feat: All done and ready for use

* feat: updated the document viewing logic

* feat: some minor refactor

* feat: added migration file

* fix: dark mode, translations, restyling,

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Nwosu Emmanuel
2025-09-10 11:39:06 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 7a77bd6c79
commit 937f1d8155
32 changed files with 1682 additions and 50 deletions
+12
View File
@@ -5,4 +5,16 @@ export const ALLOWED_CONTENT_TYPES = [
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
} as const;
+99 -3
View File
@@ -1,6 +1,11 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { ZCourseDownloadPresignedUrl, ZCoursePresignUrlUpload } from '$src/types/course';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
@@ -20,7 +25,7 @@ presignRouter.post('/upload', authMiddleware, async (c) => {
try {
const command = new PutObjectCommand({
Bucket: CLOUDFLARE.R2.BUCKET,
Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
@@ -60,6 +65,55 @@ presignRouter.post('/upload', authMiddleware, async (c) => {
}
});
presignRouter.post('/document/upload', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDocumentPresignUrlUpload.parse(body);
const { fileName, fileType } = result;
const fileKey = generateFileKey(fileName);
try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Document pre-signed URL generated successfully'
});
} catch (error) {
console.error('Error generating document pre-signed URL:', error);
if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
return c.json(
{
success: false,
type: 'SIGNATURE_ERROR',
message: 'Invalid signature for the request'
},
403
);
}
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error generating document pre-signed URL'
},
500
);
}
});
presignRouter.post('/download', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
@@ -72,7 +126,7 @@ presignRouter.post('/download', authMiddleware, async (c) => {
await Promise.all(
keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: CLOUDFLARE.R2.BUCKET,
Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
@@ -101,3 +155,45 @@ presignRouter.post('/download', authMiddleware, async (c) => {
);
}
});
presignRouter.post('/download/document', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
const { keys } = result;
try {
const signedUrls: Record<string, string> = {};
await Promise.all(
keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
signedUrls[key] = presignedUrl;
})
);
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
} catch (error) {
console.error('Error Retrieving Document:', error);
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error Retrieving Document'
},
500
);
}
});
+16 -1
View File
@@ -1,4 +1,8 @@
import { ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE } from '$src/constants/upload';
import {
ALLOWED_CONTENT_TYPES,
ALLOWED_DOCUMENT_TYPES,
MAX_FILE_SIZE
} from '$src/constants/upload';
import { z } from 'zod';
@@ -32,6 +36,17 @@ export const ZCoursePresignUrlUpload = z.object({
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDocumentPresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
errorMap: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
});
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
@@ -0,0 +1,159 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
import EyeIcon from 'carbon-icons-svelte/lib/View.svelte';
import isEmpty from 'lodash/isEmpty';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let mode = MODES.view;
export let displayDocuments: any[] = [];
export let downloadingDocuments: Set<string>;
export let formatFileSize: (bytes: number) => string;
export let openDocumentUploadModal: () => void;
export let deleteDocument: (index: number) => void;
export let downloadDocument: (doc: any) => Promise<void>;
function getFileIcon(type: string) {
switch (type) {
case 'pdf':
return PdfIcon;
default:
return DocumentIcon;
}
}
function handleViewPDF(document: any) {
dispatch('viewPDF', document);
}
</script>
{#if mode === MODES.edit}
<div class="mb-4">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.add_document')}
onClick={openDocumentUploadModal}
className="mb-4"
/>
</div>
{#if !isEmpty(displayDocuments)}
<div class="space-y-4">
{#each displayDocuments as document, index}
<div class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center space-x-3">
<svelte:component
this={getFileIcon(document.type)}
size={32}
class="text-primary-600 w-[10%]"
/>
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
<div
class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400"
>
<span>
{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}
</span>
{#if document.size}
<span>
{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
document.size
)}
</span>
{/if}
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<button
on:click={() => handleViewPDF(document)}
class="text-sm text-blue-600 underline hover:text-blue-800"
>
<EyeIcon size={16} class="mr-1 inline" />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</button>
{:else}
<a
href={document.link}
target="_blank"
class="text-sm text-blue-600 underline hover:text-blue-800"
>
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
<button
on:click={() => downloadDocument(document)}
disabled={downloadingDocuments.has(document.name)}
class="cursor-pointer text-sm text-green-600 underline hover:text-green-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{downloadingDocuments.has(document.name)
? $t('course.navItem.lessons.materials.tabs.document.downloading')
: $t('course.navItem.lessons.materials.tabs.document.download')}
</button>
<button
on:click={() => deleteDocument(index)}
class="text-sm text-red-600 underline hover:text-red-800"
>
{$t('course.navItem.lessons.materials.tabs.document.delete')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else if !isEmpty(displayDocuments)}
<div class="space-y-4">
{#each displayDocuments as document}
<div class="rounded-md border border-gray-200 p-4 dark:border-neutral-600">
<div class="flex items-center space-x-3">
<svelte:component
this={getFileIcon(document.type)}
size={32}
class="text-primary-600 w-[10%]"
/>
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-300">{document.name}</h4>
<div class="mb-3 flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
<span
>{$t('course.navItem.lessons.materials.tabs.document.type')}: {document.type.toUpperCase()}</span
>
{#if document.size}
<span
>{$t('course.navItem.lessons.materials.tabs.document.size')}: {formatFileSize(
document.size
)}</span
>
{/if}
</div>
<div class="flex gap-3">
{#if document.type === 'pdf'}
<PrimaryButton
variant={VARIANTS.CONTAINED_DARK}
onClick={() => handleViewPDF(document)}
>
<EyeIcon size={16} class="mr-1 inline" />
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
</PrimaryButton>
{:else}
<a
href={document.link}
target="_blank"
class="text-sm text-blue-600 underline hover:text-blue-800"
>
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
</a>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,361 @@
<script lang="ts">
import {
uploadCourseDocumentStore,
resetDocumentUploadStore,
cancelDocumentUpload
} 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';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
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 { onDestroy } from 'svelte';
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;
const ALLOWED_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
];
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc'];
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
if (file.type === 'application/pdf') return 'pdf';
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
return 'docx';
if (file.type === 'application/msword') return 'doc';
return 'pdf'; // fallback
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function validateFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return $t('course.navItem.lessons.materials.tabs.document.file_type_error');
}
if (file.size > MAX_DOCUMENT_SIZE) {
return $t('course.navItem.lessons.materials.tabs.document.file_size_error', {
size: formatFileSize(MAX_DOCUMENT_SIZE)
});
}
return null;
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
selectFile(file);
}
}
function selectFile(file: File) {
const error = validateFile(file);
if (error) {
snackbar.error(error);
return;
}
selectedFile = file;
$uploadCourseDocumentStore.error = null;
}
function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
selectFile(files[0]);
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
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;
try {
// Get presigned URL for upload
const uploadPresignResponse = await apiClient.post('/course/presign/document/upload', {
fileName: selectedFile.name,
fileType: selectedFile.type
});
// Check if upload was cancelled
if ($uploadCourseDocumentStore.isCancelled) {
throw new Error('Upload cancelled');
}
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,
link: presignedUrls[fileKey],
key: fileKey,
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;
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
// Reset after a short delay
setTimeout(() => {
resetDocumentUploadStore();
selectedFile = null;
if (fileInput) fileInput.value = '';
}, 1500);
} catch (error) {
console.error('Upload error:', error);
if ($uploadCourseDocumentStore.isCancelled) {
$uploadCourseDocumentStore.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';
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$uploadCourseDocumentStore.isUploading = false;
abortController = null;
}
}
function removeSelectedFile() {
selectedFile = null;
if (fileInput) fileInput.value = '';
$uploadCourseDocumentStore.error = null;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
cancelDocumentUpload();
selectedFile = null;
if (fileInput) fileInput.value = '';
}
function getFileIcon(type: string) {
switch (type) {
case 'application/pdf':
return PdfIcon;
default:
return DocumentIcon;
}
}
// Auto-clear error after 5 seconds
$: if ($uploadCourseDocumentStore.error) {
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
$uploadCourseDocumentStore.error = null;
}, 5000);
}
// Clear error timeout when component is destroyed
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
</script>
<div class="p-6">
<div class="mb-6">
<h3 class="mb-2 text-lg font-semibold">
{$t('course.navItem.lessons.materials.tabs.document.upload_title')}
</h3>
<p class="mb-4 text-sm text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_description')}
</p>
</div>
<!-- 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
? 'border-blue-500 bg-blue-50'
: 'hover:border-gray-400'}"
on:drop={handleDrop}
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
>
{#if selectedFile}
<div class="mb-4 flex items-center justify-center space-x-3">
<svelte:component this={getFileIcon(selectedFile.type)} size={32} class="text-blue-600" />
<div class="text-left">
<p class="font-medium text-gray-900">{selectedFile.name}</p>
<p class="text-sm text-gray-500">{formatFileSize(selectedFile.size)}</p>
</div>
<button on:click={removeSelectedFile} class="rounded-full p-1 hover:bg-gray-200">
<CloseIcon size={20} class="text-gray-500" />
</button>
</div>
{:else}
<DocumentIcon size={32} class="mx-auto mb-4 text-gray-400" />
<p class="mb-2 text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.drag_drop')}
</p>
<p class="mb-4 text-sm text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.or')}
</p>
<input
bind:this={fileInput}
type="file"
accept=".pdf,.docx,.doc"
on:change={handleFileSelect}
class="hidden"
/>
<div class="flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
variant={VARIANTS.OUTLINED}
onClick={() => fileInput?.click()}
/>
</div>
{/if}
</div>
<!-- Error Message -->
{#if $uploadCourseDocumentStore.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>
</div>
{/if}
<!-- Upload Progress -->
{#if $uploadCourseDocumentStore.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>
</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}%"
></div>
</div>
<div class="mt-3 flex justify-center">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
</div>
</div>
{/if}
<!-- Upload Button -->
{#if selectedFile && !$uploadCourseDocumentStore.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
variant={VARIANTS.OUTLINED}
onClick={removeSelectedFile}
/>
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
onClick={uploadDocument}
isLoading={$uploadCourseDocumentStore.isUploading}
/>
</div>
{/if}
<!-- Success Message -->
{#if $uploadCourseDocumentStore.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')}
</p>
</div>
{/if}
</div>
@@ -0,0 +1,32 @@
import { writable } from 'svelte/store';
export interface DocumentUploadState {
isUploading: boolean;
isModalOpen: boolean;
uploadProgress: number;
uploadedDocument: {
name: string;
link: string;
type: 'pdf' | 'docx' | 'doc';
size: number;
} | null;
error: string | null;
}
export const uploadCourseDocumentStore = writable<DocumentUploadState>({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
});
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
});
}
@@ -5,11 +5,13 @@
import {
lesson,
deleteLessonVideo,
uploadCourseVideoStore
uploadCourseVideoStore,
cancelVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { type ComponentProps } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte';
import { isFreePlan } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { apiClient } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations';
@@ -27,11 +29,16 @@
let fileInput;
let submit;
let fileName = '';
let abortController: AbortController | null = null;
async function onUpload() {
if (!fileInput) return;
// Create new AbortController for this upload
abortController = new AbortController();
$uploadCourseVideoStore.isUploading = true;
$uploadCourseVideoStore.isCancelled = false;
const videoFile = fileInput.files[0];
fileSize = videoFile?.size / (1024 * 1024);
@@ -50,7 +57,13 @@
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: abortController.signal,
onUploadProgress: (progressEvent) => {
// Check if upload was cancelled during progress
if ($uploadCourseVideoStore.isCancelled) {
abortController?.abort();
return;
}
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
}
});
@@ -78,8 +91,6 @@
status: uploadResponse.status
};
console.log('formRes', formRes);
$lesson.materials.videos = [
...$lesson.materials.videos,
{
@@ -89,15 +100,28 @@
}
];
console.log('Upload res', uploadResponse, 'formRes', formRes);
$uploadCourseVideoStore.isUploading = false;
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if (err.response) {
if ($uploadCourseVideoStore.isCancelled) {
formRes = {
type: 'CANCELLED',
status: 0,
message: $t('course.navItem.lessons.materials.tabs.video.add_video.upload_cancelled')
};
} else if (err.response) {
formRes = err.response.data;
console.log('formRes', formRes);
} else {
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: 'An error occurred while uploading the video'
};
}
} finally {
abortController = null;
}
isLoaded = true;
@@ -110,6 +134,16 @@
value = 0;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
cancelVideoUpload();
formRes = null;
isLoaded = false;
value = 0;
}
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) {
helperText = 'Done';
@@ -141,6 +175,13 @@
</p>
<ProgressBar class="w-full" {value} {max} {status} />
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.video.add_video.cancel_upload')}
variant={VARIANTS.OUTLINED}
onClick={cancelUpload}
/>
</div>
</div>
{:else}
<img src="/upload-video.svg" alt="upload" />
@@ -266,7 +266,7 @@
<DeleteModal bind:open={openDeleteModal} onDelete={handleDeleteComment} />
<div class="mx-auto mt-10 max-w-[65ch]">
<div class="mx-auto w-full max-w-[65ch]">
<!-- <hr class="my-5" /> -->
<div class="mb-5">
<p class="text-xl font-bold capitalize">
@@ -0,0 +1,454 @@
<script lang="ts">
import {
lesson,
deleteLessonDocument
} from '$lib/components/Course/components/Lesson/store/lessons';
import { uploadCourseDocumentStore } 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';
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
import ZoomInIcon from 'carbon-icons-svelte/lib/ZoomIn.svelte';
import ZoomOutIcon from 'carbon-icons-svelte/lib/ZoomOut.svelte';
import { onMount } from 'svelte';
import DocumentList from '../Document/DocumentList.svelte';
import { t } from '$lib/utils/functions/translations';
export let mode = MODES.view;
// Use real documents from store
$: displayDocuments = $lesson?.materials?.documents || [];
let downloadingDocuments = new Set<string>();
let viewingPDF: any = null;
let pdfViewerOpen = false;
let pdfCanvas: HTMLCanvasElement;
let pdfDoc: any = null;
let pageNum = 1;
let pageCount = 0;
let scale = 1.0;
let isLoading = false;
let error: string | null = null;
let pdfjsLib: any = null;
let renderTimeout: any = null;
let currentRenderTask: any = null;
onMount(() => {
// Load PDF.js dynamically
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
script.onload = () => {
pdfjsLib = (window as any).pdfjsLib;
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
};
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
});
function openDocumentUploadModal() {
$uploadCourseDocumentStore.isModalOpen = true;
}
function deleteDocument(index: number) {
deleteLessonDocument(index);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function downloadDocument(doc: any) {
downloadingDocuments.add(doc.name);
downloadingDocuments = downloadingDocuments;
try {
const response = await fetch(doc.link);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading document:', error);
window.open(doc.link, '_blank');
} finally {
downloadingDocuments.delete(doc.name);
downloadingDocuments = downloadingDocuments;
}
}
function debouncedRender() {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderTimeout = setTimeout(() => {
renderPage();
renderTimeout = null;
}, 150); // 150ms debounce
}
async function viewPDF(document: any) {
// Wait for PDF.js to be fully loaded
if (!pdfjsLib) {
// Show loading state while waiting for PDF.js
viewingPDF = document;
pdfViewerOpen = true;
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();
}
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = null;
try {
const response = await fetch(document.link);
if (!response.ok) {
throw new Error('Failed to fetch PDF');
}
const arrayBuffer = await response.arrayBuffer();
pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
pageCount = pdfDoc.numPages;
pageNum = 1;
scale = 1.0;
isLoading = false;
// Wait for the canvas to be available in the DOM
await new Promise((resolve) => setTimeout(resolve, 100));
await renderPage();
} catch (err) {
console.error('Error loading PDF:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_load_pdf';
isLoading = false;
}
}
async function renderPage() {
if (!pdfDoc) return;
// Wait for canvas to be available
let attempts = 0;
while (!pdfCanvas && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (!pdfCanvas) {
console.error('Canvas not available after waiting');
return;
}
try {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
pdfCanvas.height = viewport.height;
pdfCanvas.width = viewport.width;
const ctx = pdfCanvas.getContext('2d');
const renderContext = {
canvasContext: ctx,
viewport: viewport
};
currentRenderTask = page.render(renderContext);
await currentRenderTask.promise;
currentRenderTask = null;
} catch (err) {
console.error('Error rendering page:', err);
error = 'course.navItem.lessons.materials.tabs.document.failed_to_render_pdf';
currentRenderTask = null;
}
}
function nextPage() {
if (pageNum < pageCount) {
pageNum++;
debouncedRender();
}
}
function prevPage() {
if (pageNum > 1) {
pageNum--;
debouncedRender();
}
}
function zoomIn() {
scale = Math.min(scale + 0.25, 3.0);
debouncedRender();
}
function zoomOut() {
scale = Math.max(scale - 0.25, 0.5);
debouncedRender();
}
function handleViewPDF(event: CustomEvent) {
viewPDF(event.detail);
}
function closePDFViewer() {
// Cancel any ongoing render task
if (currentRenderTask) {
currentRenderTask.cancel();
}
// Clear any pending render timeout
if (renderTimeout) {
clearTimeout(renderTimeout);
}
pdfViewerOpen = false;
viewingPDF = null;
pdfDoc = null;
pageNum = 1;
pageCount = 0;
scale = 1.0;
error = null;
currentRenderTask = null;
renderTimeout = null;
}
function handleKeydown(event: KeyboardEvent) {
if (!pdfViewerOpen) return;
switch (event.key) {
case 'ArrowLeft':
prevPage();
break;
case 'ArrowRight':
nextPage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
zoomOut();
break;
case 'Escape':
closePDFViewer();
break;
}
}
onMount(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
});
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
}
function handleDragStart(event: DragEvent) {
event.preventDefault();
}
</script>
<div class="mx-auto w-full max-w-lg">
<DocumentList
{mode}
{displayDocuments}
{downloadingDocuments}
{formatFileSize}
{openDocumentUploadModal}
{deleteDocument}
{downloadDocument}
on:viewPDF={handleViewPDF}
/>
</div>
<!-- PDF Viewer Modal -->
{#if pdfViewerOpen}
<div class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-800">
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800"
>
<div class="flex items-center space-x-4">
<h2 class="max-w-md truncate text-lg font-semibold text-gray-900 dark:text-gray-300">
{viewingPDF?.name}
</h2>
{#if !isLoading && !error}
<span class="text-sm text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.page_of', {
page: pageNum,
total: pageCount
})}
</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<!-- Navigation Controls -->
{#if !isLoading && !error}
<div class="flex items-center space-x-1">
<IconButton
onClick={prevPage}
disabled={pageNum <= 1}
toolTipProps={{ title: 'Previous page (←)', hotkeys: ['ArrowLeft'] }}
>
<ChevronLeftIcon size={20} class="carbon-icon" />
</IconButton>
<IconButton
onClick={nextPage}
disabled={pageNum >= pageCount}
toolTipProps={{ title: 'Next page (→)', hotkeys: ['ArrowRight'] }}
>
<ChevronRightIcon size={20} class="carbon-icon" />
</IconButton>
</div>
<!-- Zoom Controls -->
<div class="flex items-center space-x-1">
<IconButton
onClick={zoomOut}
disabled={scale <= 0.5}
toolTipProps={{ title: 'Zoom out (-)', hotkeys: ['-'] }}
>
<ZoomOutIcon size={20} class="carbon-icon" />
</IconButton>
<span class="min-w-[3rem] text-center text-sm text-gray-600">
{Math.round(scale * 100)}%
</span>
<IconButton
onClick={zoomIn}
disabled={scale >= 3.0}
toolTipProps={{ title: 'Zoom in (+)', hotkeys: ['+'] }}
>
<ZoomInIcon size={20} class="carbon-icon" />
</IconButton>
</div>
{/if}
<!-- Close Button -->
<IconButton
onClick={closePDFViewer}
toolTipProps={{ title: 'Close (Esc)', hotkeys: ['Esc'] }}
>
<CloseIcon size={20} class="carbon-icon" />
</IconButton>
</div>
</div>
<!-- PDF Content -->
<div class="flex-1 overflow-auto bg-gray-100 p-4">
{#if isLoading}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"
></div>
<p class="text-gray-600">
{$t('course.navItem.lessons.materials.tabs.document.loading_pdf')}
</p>
</div>
</div>
{:else if error}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<div class="mb-4 text-red-500">
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p class="mb-2 text-red-600">{$t(error)}</p>
<button
on:click={() => viewPDF(viewingPDF)}
class="text-blue-600 underline hover:text-blue-800"
>
{$t('course.navItem.lessons.materials.tabs.document.try_again')}
</button>
</div>
</div>
{:else}
<div class="flex justify-center">
<canvas
bind:this={pdfCanvas}
on:contextmenu={handleContextMenu}
on:dragstart={handleDragStart}
class="shadow-lg"
></canvas>
</div>
{/if}
</div>
<!-- Footer with instructions -->
{#if !isLoading && !error}
<div class="border-t border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-center text-xs text-gray-500">
{$t('course.navItem.lessons.materials.tabs.document.use_arrow_keys')}
</p>
</div>
{/if}
</div>
{/if}
<style>
/* Disable text selection on the canvas */
canvas {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Disable drag and drop */
canvas {
pointer-events: auto;
}
</style>
@@ -72,9 +72,10 @@
{:else}
<video
bind:this={videoElements[index]}
class="plyr-video-trigger h-full w-full"
class="plyr-video-trigger iframe h-full w-full"
playsinline
controls
style="aspect-ratio: 16/9;"
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
@@ -1,5 +1,5 @@
import type { Tabs } from '$lib/utils/types';
import { Notebook, PresentationFile, Video } from 'carbon-icons-svelte';
import { Notebook, PresentationFile, Video, Document } from 'carbon-icons-svelte';
interface MaterialTab {
label: string;
@@ -26,6 +26,12 @@ export let tabs: MaterialTab[] = [
icon: Video,
value: 3,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.document.title',
icon: Document,
value: 4,
badgeValue: 0
}
];
@@ -1,6 +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 {
deleteLessonVideo,
handleUpdateLessonMaterials,
@@ -8,7 +9,8 @@
lesson,
lessonByTranslation,
lessons,
uploadCourseVideoStore
uploadCourseVideoStore,
uploadCourseDocumentStore
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import TextField from '$lib/components/Form/TextField.svelte';
@@ -41,10 +43,10 @@
import ComponentNote from './components/ComponentNote.svelte';
import ComponentSlide from './components/ComponentSlide.svelte';
import ComponentVideo from './components/ComponentVideo.svelte';
import ComponentDocument from './components/ComponentDocument.svelte';
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 = '';
@@ -142,12 +144,13 @@
materials: LessonPage['materials'],
translation: Record<LOCALE, string>
) {
const { slide_url, videos, note } = materials;
const { slide_url, videos, note, documents } = materials;
return (
isHtmlValueEmpty(note) &&
!slide_url &&
isEmpty(videos) &&
isEmpty(documents) &&
Object.values(translation || {}).every((t) => isHtmlValueEmpty(t))
);
}
@@ -158,18 +161,20 @@
}
}
function addBadgeValueToTab(materials: LessonPage['materials']) {
const { slide_url, videos, note } = materials;
function addBadgeValueToTab(materials: LessonPage['materials'], localeContent: string) {
const { slide_url, videos, note, documents } = materials;
tabs = tabs.map((tab) => {
let badgeValue = 0;
if (tab.value === 1 && !isHtmlValueEmpty(note)) {
if (tab.value === 1 && (!isHtmlValueEmpty(note) || !isHtmlValueEmpty(localeContent))) {
badgeValue = 1;
} else if (tab.value === 2 && !!slide_url) {
badgeValue = 1;
} else if (tab.value === 3 && !isEmpty(videos)) {
badgeValue = 1;
badgeValue = videos.length;
} else if (tab.value === 4 && !isEmpty(documents)) {
badgeValue = documents.length;
}
tab.badgeValue = badgeValue;
return tab;
@@ -272,11 +277,21 @@
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
const onDocumentClose = () => {
if ($uploadCourseDocumentStore.isUploading) return;
$uploadCourseDocumentStore.isModalOpen = false;
// Clear any error messages when modal closes
$uploadCourseDocumentStore.error = null;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
function getComponentOrder(tabs = CONSTANTS.tabs) {
const componentMap = {
'1': ComponentNote,
'2': ComponentSlide,
'3': ComponentVideo
'3': ComponentVideo,
'4': ComponentDocument
};
const componentNames = tabs
@@ -295,7 +310,7 @@
$: handleSave(prevMode);
$: addBadgeValueToTab($lesson.materials);
$: addBadgeValueToTab($lesson.materials, $lessonByTranslation[lessonId]?.[$lesson.locale] || '');
$: updateNoteByCompletion($completion);
@@ -319,6 +334,15 @@
<VideoUploader {lessonId} />
</Modal>
<Modal
onClose={onDocumentClose}
bind:open={$uploadCourseDocumentStore.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
>
<DocumentUploader {lessonId} />
</Modal>
<HtmlRender className="m-auto text-center">
<svelte:fragment slot="content">
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
@@ -481,11 +505,17 @@
</div>
{/if}
</TabContent>
<TabContent
value={getValue('course.navItem.lessons.materials.tabs.document.title')}
index={currentTab}
>
<ComponentDocument {mode} />
</TabContent>
</slot:fragment>
</Tabs>
{:else if !isMaterialsEmpty($lesson.materials, $lessonByTranslation[lessonId])}
{#key lessonId}
<div class="mb-20 w-full" in:fade={{ delay: 500 }} out:fade>
<div class="mb-20 flex w-full flex-col gap-6" in:fade={{ delay: 500 }} out:fade>
{#each componentsToRender as Component}
<svelte:component this={Component} {lessonId} />
{/each}
@@ -17,7 +17,17 @@ import { writable } from 'svelte/store';
export const uploadCourseVideoStore = writable({
isUploading: false,
isModalOpen: false
isModalOpen: false,
isCancelled: false
});
export const uploadCourseDocumentStore = writable({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null as any,
error: null as string | null,
isCancelled: false
});
export const lessons: Writable<Lesson[]> = writable([]);
@@ -37,7 +47,8 @@ export const lesson = writable<LessonPage>({
materials: {
note: '',
slide_url: '',
videos: []
videos: [],
documents: []
},
exercises: [],
lesson_completion: []
@@ -192,3 +203,41 @@ export const deleteLessonVideo = (index: any) => {
}
}));
};
export const deleteLessonDocument = (index: any) => {
lesson.update((currentLesson) => ({
...currentLesson,
materials: {
...currentLesson.materials,
documents: (currentLesson.materials.documents || []).filter((document, i) => i !== index)
}
}));
};
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null,
isCancelled: false
});
}
export function cancelDocumentUpload() {
uploadCourseDocumentStore.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
error: 'Upload cancelled by user'
}));
}
export function cancelVideoUpload() {
uploadCourseVideoStore.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
}
@@ -11,7 +11,8 @@ export const settings = writable({
tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
lesson_download: false,
is_published: false
@@ -43,7 +43,8 @@ export const defaultCourse: Course = {
lessonTabsOrder: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' }
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
grading: false,
lessonDownload: true,
@@ -156,6 +157,29 @@ export async function setCourse(data: Course, setLesson = true) {
allowNewStudent: false
};
}
// Ensure lessonTabsOrder includes all tabs (backward compatibility)
if (data.metadata && data.metadata.lessonTabsOrder) {
const existingTabIds = data.metadata.lessonTabsOrder.map((tab) => tab.id);
const allTabs = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
];
let hasChanges = false;
// Add missing tabs to existing lessonTabsOrder
allTabs.forEach((tab) => {
if (!existingTabIds.includes(tab.id) && data.metadata.lessonTabsOrder) {
data.metadata.lessonTabsOrder.push(tab);
hasChanges = true;
}
});
if (hasChanges) {
// Document tab has been added to lessonTabsOrder
}
}
if (!data.certificate_theme) {
data.certificate_theme = 'professional';
@@ -265,6 +265,7 @@ export async function getMarks(courseId) {
}
export async function fetchLesson(lessonId: Lesson['id']) {
// TODO: add documents to the query
const { data, error } = await supabase
.from('lesson')
.select(
@@ -273,6 +274,7 @@ export async function fetchLesson(lessonId: Lesson['id']) {
note,
videos,
slide_url,
documents,
call_url,
totalExercises:exercise(count),
totalComments:lesson_comment(count),
@@ -737,7 +737,9 @@
"upgrade": "Aktualisieren Sie Ihren Plan, um Videos hochzuladen",
"uploading": "Hochladen...",
"invalid_youtube": "Ungültige YouTube-Link(s)",
"invalid_link": "Ungültige Links"
"invalid_link": "Ungültige Links",
"cancel_upload": "Upload abbrechen",
"upload_cancelled": "Vom Benutzer abgebrochen hochladen"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Unterrichtsnotiz erstellen",
"activities": "Unterrichtsaktivitäten erstellen"
}
},
"document": {
"title": "Dokumentieren",
"add_document": "Dokument hinzufügen",
"no_document": "Noch keine Dokumente hochgeladen",
"view_document": "Dokument anzeigen",
"download": "Herunterladen",
"upload_document": "Dokument hochladen",
"upload_title": "Dokument hochladen",
"upload_description": "Laden Sie PDF-, DOCX- oder DOC -Dateien hoch. Maximale Größe: 5 MB",
"drag_drop": "Ziehen Sie Ihr Dokument hier und lassen Sie sie fallen",
"or": "oder",
"choose_file": "Datei wählen",
"cancel": "Stornieren",
"uploading": "Hochladen ...",
"upload_success": "Dokument erfolgreich hochgeladen!",
"upload_error": "Dokument nicht hochladen",
"file_type_error": "Dateityp nicht unterstützt. Bitte laden Sie PDF-, DOCX- oder DOC -Dateien hoch.",
"file_size_error": "Dateigröße zu groß. Die maximale Größe ist {Größe}.",
"type": "Typ",
"size": "Größe",
"upload_error_title": "Fehler hochladen",
"upload_error_message": "Nicht unterstütztes Dateiformat oder Upload ist fehlgeschlagen",
"upload_error_try_again": "Bitte versuchen Sie es erneut mit einer gültigen Dokumentdatei",
"try_again": "Versuchen Sie es erneut",
"cancel_upload": "Upload abbrechen",
"upload_cancelled": "Vom Benutzer abgebrochen hochladen",
"downloading": "Herunterladen ...",
"view_pdf": "PDF anzeigen",
"delete": "Löschen",
"page_of": "Seite {page} von {total}",
"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."
}
}
},
@@ -663,8 +663,46 @@
"upgrade": "Upgrade your plan to upload vidoes",
"uploading": "Uploading...",
"invalid_youtube": "Invalid YouTube link(s)",
"invalid_link": "Invalid link(s)"
"invalid_link": "Invalid link(s)",
"cancel_upload": "Cancel Upload",
"upload_cancelled": "Upload cancelled by user"
}
},
"document": {
"title": "Docs",
"add_document": "Add Document",
"no_document": "No documents uploaded yet",
"view_document": "View Document",
"download": "Download",
"upload_document": "Upload Document",
"upload_title": "Upload Document",
"upload_description": "Upload PDF, DOCX, or DOC files. Maximum size: 5MB",
"drag_drop": "Drag and drop your document here",
"or": "or",
"choose_file": "Choose File",
"cancel": "Cancel",
"uploading": "Uploading...",
"upload_success": "Document uploaded successfully!",
"upload_error": "Failed to upload document",
"file_type_error": "File type not supported. Please upload PDF, DOCX, or DOC files.",
"file_size_error": "File size too large. Maximum size is {size}.",
"type": "Type",
"size": "Size",
"upload_error_title": "Upload Error",
"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",
"cancel_upload": "Cancel Upload",
"upload_cancelled": "Upload cancelled by user",
"downloading": "Downloading...",
"view_pdf": "View PDF",
"delete": "Delete",
"page_of": "Page {page} of {total}",
"loading_pdf": "Loading PDF...",
"failed_to_load_pdf": "Failed to load PDF document",
"failed_to_render_pdf": "Failed to render PDF page",
"use_arrow_keys": "Use arrow keys to navigate, +/- to zoom, or click the controls above. Right-click is disabled."
}
},
"button_done": "Done"
@@ -737,7 +737,9 @@
"upgrade": "Actualiza tu plan para subir videos",
"uploading": "Subiendo...",
"invalid_youtube": "Enlace(s) de YouTube no válido(s)",
"invalid_link": "Enlace(s) no válido(s)"
"invalid_link": "Enlace(s) no válido(s)",
"cancel_upload": "Cancelar la carga",
"upload_cancelled": "Carga cancelada por el usuario"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Generar nota de lección",
"activities": "Generar actividades lectivas"
}
},
"document": {
"title": "Documento",
"add_document": "Agregar documento",
"no_document": "No hay documentos cargados todavía",
"view_document": "Ver documento",
"download": "Descargar",
"upload_document": "Cargar documento",
"upload_title": "Cargar documento",
"upload_description": "Sube archivos PDF, DOCX o DOC. Tamaño máximo: 5 MB",
"drag_drop": "Arrastre y suelte su documento aquí",
"or": "o",
"choose_file": "Elija archivo",
"cancel": "Cancelar",
"uploading": "Subiendo ...",
"upload_success": "¡Documento cargado con éxito!",
"upload_error": "No se pudo subir al documento",
"file_type_error": "Tipo de archivo no compatible. Sube archivos PDF, DOCX o DOC.",
"file_size_error": "Tamaño de archivo demasiado grande. El tamaño máximo es {tamaño}.",
"type": "Tipo",
"size": "Tamaño",
"upload_error_title": "Error de carga",
"upload_error_message": "Falló el formato de archivo o la carga no compatible",
"upload_error_try_again": "Vuelva a intentarlo con un archivo de documento válido",
"try_again": "Intentar otra vez",
"cancel_upload": "Cancelar la carga",
"upload_cancelled": "Carga cancelada por el usuario",
"downloading": "Descarga ...",
"view_pdf": "Ver PDF",
"delete": "Borrar",
"page_of": "Página {page} de {total}",
"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."
}
}
},
@@ -737,7 +737,9 @@
"upgrade": "Mettez à niveau votre forfait pour télécharger des vidéos",
"uploading": "Téléchargement...",
"invalid_youtube": "Lien(s) YouTube invalide(s)",
"invalid_link": "Lien(s) invalide(s)"
"invalid_link": "Lien(s) invalide(s)",
"cancel_upload": "Annuler le téléchargement",
"upload_cancelled": "Télécharger annulé par l'utilisateur"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Générer une note de cours",
"activities": "Générer des activités de cours"
}
},
"document": {
"title": "Document",
"add_document": "Ajouter un document",
"no_document": "Aucun document encore téléchargé",
"view_document": "Afficher le document",
"download": "Télécharger",
"upload_document": "Télécharger le document",
"upload_title": "Télécharger le document",
"upload_description": "Téléchargez les fichiers PDF, DOCX ou DOC. Taille maximale: 5 Mo",
"drag_drop": "Faites glisser et déposez votre document ici",
"or": "ou",
"choose_file": "Choisir le fichier",
"cancel": "Annuler",
"uploading": "Téléchargement ...",
"upload_success": "Document téléchargé avec succès!",
"upload_error": "Échec de la téléchargement du document",
"file_type_error": "Type de fichier non pris en charge. Veuillez télécharger des fichiers PDF, DOCX ou DOC.",
"file_size_error": "Taille de fichier trop grande. La taille maximale est {taille}.",
"type": "Taper",
"size": "Taille",
"upload_error_title": "Télécharger une erreur",
"upload_error_message": "Le format de fichier non pris en charge ou le téléchargement a échoué",
"upload_error_try_again": "Veuillez réessayer avec un fichier de document valide",
"try_again": "Essayer à nouveau",
"cancel_upload": "Annuler le téléchargement",
"upload_cancelled": "Télécharger annulé par l'utilisateur",
"downloading": "Téléchargement...",
"view_pdf": "Afficher le PDF",
"delete": "Supprimer",
"page_of": "Page {page} de {total}",
"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é."
}
}
},
@@ -737,7 +737,9 @@
"upgrade": "वीडियो अपलोड करने की अपनी योजना को अपग्रेड करें",
"uploading": "अपलोड हो रहा है...",
"invalid_youtube": "अमान्य यूट्यूब लिंक",
"invalid_link": "अमान्य लिंक"
"invalid_link": "अमान्य लिंक",
"cancel_upload": "अपलोड रद्द करें",
"upload_cancelled": "उपयोगकर्ता द्वारा रद्द किए गए अपलोड"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "पाठ नोट जनरेट करें",
"activities": "पाठ गतिविधियाँ उत्पन्न करें"
}
},
"document": {
"title": "दस्तावेज़",
"add_document": "दस्तावेज़ जोड़ें",
"no_document": "कोई दस्तावेज अभी तक अपलोड नहीं किया गया है",
"view_document": "दस्तावेज़ देखें",
"download": "डाउनलोड करना",
"upload_document": "अपलोड दस्तावेज़",
"upload_title": "अपलोड दस्तावेज़",
"upload_description": "PDF, DOCX, या DOC फ़ाइलों को अपलोड करें। अधिकतम आकार: 5 एमबी",
"drag_drop": "अपने दस्तावेज़ को यहां खींचें और ड्रॉप करें",
"or": "या",
"choose_file": "फाइलें चुनें",
"cancel": "रद्द करना",
"uploading": "अपलोड करना ...",
"upload_success": "दस्तावेज़ सफलतापूर्वक अपलोड किया गया!",
"upload_error": "दस्तावेज़ अपलोड करने में विफल",
"file_type_error": "फ़ाइल प्रकार समर्थित नहीं है। कृपया PDF, DOCX, या DOC फ़ाइलें अपलोड करें।",
"file_size_error": "फ़ाइल का आकार बहुत बड़ा है। अधिकतम आकार {आकार} है।",
"type": "प्रकार",
"size": "आकार",
"upload_error_title": "अपलोड त्रुटि",
"upload_error_message": "असमर्थित फ़ाइल प्रारूप या अपलोड विफल",
"upload_error_try_again": "कृपया एक मान्य दस्तावेज़ फ़ाइल के साथ पुनः प्रयास करें",
"try_again": "पुनः प्रयास करें",
"cancel_upload": "अपलोड रद्द करें",
"upload_cancelled": "उपयोगकर्ता द्वारा रद्द किए गए अपलोड",
"downloading": "डाउनलोड करना ...",
"view_pdf": "पीडीएफ देखें",
"delete": "मिटाना",
"page_of": "पृष्ठ {page} {total} का",
"loading_pdf": "लोडिंग पीडीएफ ...",
"failed_to_load_pdf": "पीडीएफ दस्तावेज़ लोड करने में विफल",
"failed_to_render_pdf": "पीडीएफ पेज को प्रस्तुत करने में विफल",
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।"
}
}
},
@@ -889,7 +889,9 @@
"upgrade": "Uaktualnij swój plan przesłania vidoes",
"uploading": "Przesyłanie ...",
"invalid_youtube": "Nieprawidłowe linki do YouTube",
"invalid_link": "Nieprawidłowe linki (y)"
"invalid_link": "Nieprawidłowe linki (y)",
"cancel_upload": "Anuluj przesłanie",
"upload_cancelled": "Prześlij anulowany przez użytkownika"
}
},
"slide": {
@@ -905,6 +907,41 @@
"note": "Wygeneruj notatkę lekcji",
"activities": "Generować zajęcia lekcji"
}
},
"document": {
"title": "Dokument",
"add_document": "Dodaj dokument",
"no_document": "Brak przesłanych dokumentów",
"view_document": "Wyświetl dokument",
"download": "Pobierać",
"upload_document": "Prześlij dokument",
"upload_title": "Prześlij dokument",
"upload_description": "Prześlij pliki PDF, DOCX lub DOC. Maksymalny rozmiar: 5 MB",
"drag_drop": "Przeciągnij i upuść swój dokument tutaj",
"or": "Lub",
"choose_file": "Wybierz plik",
"cancel": "Anulować",
"uploading": "Przesyłanie ...",
"upload_success": "Dokument przesłany pomyślnie!",
"upload_error": "Nie udało się przesłać dokumentu",
"file_type_error": "Typ pliku nie jest obsługiwany. Prześlij pliki PDF, DOCX lub DOC.",
"file_size_error": "Rozmiar pliku za duży. Maksymalny rozmiar to {rozmiar}.",
"type": "Typ",
"size": "Rozmiar",
"upload_error_title": "Prześlij błąd",
"upload_error_message": "Nieprzepustowy format pliku lub przesyłanie nie powiodło się",
"upload_error_try_again": "Spróbuj ponownie z prawidłowym plikiem dokumentu",
"try_again": "Spróbuj ponownie",
"cancel_upload": "Anuluj przesłanie",
"upload_cancelled": "Prześlij anulowany przez użytkownika",
"downloading": "Ściąganie...",
"view_pdf": "Zobacz PDF",
"delete": "Usuwać",
"page_of": "Strona {page} z {total}",
"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."
}
}
},
@@ -737,7 +737,9 @@
"upgrade": "Atualize seu plano para enviar vídeos",
"uploading": "Enviando...",
"invalid_youtube": "Links inválidos do YouTube",
"invalid_link": "Links inválidos"
"invalid_link": "Links inválidos",
"cancel_upload": "Cancelar upload",
"upload_cancelled": "Upload cancelado pelo usuário"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Gerar nota de aula",
"activities": "Gerar atividades de aula"
}
},
"document": {
"title": "Documento",
"add_document": "Adicione documento",
"no_document": "Nenhum documento enviado ainda",
"view_document": "Exibir documento",
"download": "Download",
"upload_document": "Documento de upload",
"upload_title": "Documento de upload",
"upload_description": "Faça o upload de arquivos PDF, DOCX ou DOC. Tamanho máximo: 5 MB",
"drag_drop": "Arraste e solte seu documento aqui",
"or": "ou",
"choose_file": "Escolha o arquivo",
"cancel": "Cancelar",
"uploading": "Enviando ...",
"upload_success": "Documento enviado com sucesso!",
"upload_error": "Falha ao fazer upload do documento",
"file_type_error": "Tipo de arquivo não suportado. Faça o upload de arquivos PDF, DOCX ou DOC.",
"file_size_error": "Tamanho do arquivo muito grande. O tamanho máximo é {size}.",
"type": "Tipo",
"size": "Tamanho",
"upload_error_title": "Erro de upload",
"upload_error_message": "Formato de arquivo ou upload não suportado",
"upload_error_try_again": "Tente novamente com um arquivo de documento válido",
"try_again": "Tente novamente",
"cancel_upload": "Cancelar upload",
"upload_cancelled": "Upload cancelado pelo usuário",
"downloading": "Download ...",
"view_pdf": "Veja PDF",
"delete": "Excluir",
"page_of": "Página {page} de {total}",
"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."
}
}
},
@@ -737,7 +737,9 @@
"upgrade": "Обновите свой план, чтобы загружать видео",
"uploading": "Загрузка...",
"invalid_youtube": "Недействительная ссылка(и) на YouTube",
"invalid_link": "Неверная ссылка(и)"
"invalid_link": "Неверная ссылка(и)",
"cancel_upload": "Отменить загрузку",
"upload_cancelled": "Загрузить отменен пользователем"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Создать заметку к уроку",
"activities": "Создание заданий урока"
}
},
"document": {
"title": "Документ",
"add_document": "Добавить документ",
"no_document": "Документы еще не загружены",
"view_document": "Просмотреть документ",
"download": "Скачать",
"upload_document": "Загрузить документ",
"upload_title": "Загрузить документ",
"upload_description": "Загрузите PDF, DOCX или DOC -файлы. Максимальный размер: 5 МБ",
"drag_drop": "Перетащите здесь свой документ",
"or": "или",
"choose_file": "Выберите файл",
"cancel": "Отмена",
"uploading": "Загрузка ...",
"upload_success": "Документ загружен успешно!",
"upload_error": "Не удалось загрузить документ",
"file_type_error": "Тип файла не поддерживается. Пожалуйста, загрузите файлы PDF, DOCX или DOC.",
"file_size_error": "Размер файла слишком большой. Максимальный размер - {размер}.",
"type": "Тип",
"size": "Размер",
"upload_error_title": "Ошибка загрузки",
"upload_error_message": "Неупречный формат файла или ошибка загрузки",
"upload_error_try_again": "Пожалуйста, попробуйте еще раз с действительным файлом документа",
"try_again": "Попробуйте еще раз",
"cancel_upload": "Отменить загрузку",
"upload_cancelled": "Загрузить отменен пользователем",
"downloading": "Загрузка ...",
"view_pdf": "Просмотреть PDF",
"delete": "Удалить",
"page_of": "Page {page} {total}",
"loading_pdf": "Загрузка PDF ...",
"failed_to_load_pdf": "Не удалось загрузить документ PDF",
"failed_to_render_pdf": "Не удалось отобразить страницу PDF",
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено."
}
}
},
@@ -737,7 +737,9 @@
"upgrade": "Nâng cấp kế hoạch tải video lên của bạn",
"uploading": "Đang tải lên...",
"invalid_youtube": "(Các) liên kết YouTube không hợp lệ",
"invalid_link": "(Các) liên kết không hợp lệ"
"invalid_link": "(Các) liên kết không hợp lệ",
"cancel_upload": "Hủy tải lên",
"upload_cancelled": "Tải lên bị hủy bởi người dùng"
}
},
"slide": {
@@ -753,6 +755,41 @@
"note": "Tạo ghi chú bài học",
"activities": "Tạo hoạt động bài học"
}
},
"document": {
"title": "Tài liệu",
"add_document": "Thêm tài liệu",
"no_document": "Chưa có tài liệu nào được tải lên",
"view_document": "Xem tài liệu",
"download": "Tải xuống",
"upload_document": "Tải lên tài liệu",
"upload_title": "Tải lên tài liệu",
"upload_description": "Tải lên các tệp PDF, DOCX hoặc DOC. Kích thước tối đa: 5MB",
"drag_drop": "Kéo và thả tài liệu của bạn tại đây",
"or": "hoặc",
"choose_file": "Chọn Tệp",
"cancel": "Hủy bỏ",
"uploading": "Tải lên ...",
"upload_success": "Tài liệu được tải lên thành công!",
"upload_error": "Không tải lên tài liệu",
"file_type_error": "Loại tệp không được hỗ trợ. Vui lòng tải lên các tệp PDF, DOCX hoặc DOC.",
"file_size_error": "Kích thước tập tin quá lớn. Kích thước tối đa là {kích thước}.",
"type": "Kiểu",
"size": "Kích cỡ",
"upload_error_title": "Lỗi tải lên",
"upload_error_message": "Định dạng tệp không được hỗ trợ hoặc tải lên không thành công",
"upload_error_try_again": "Vui lòng thử lại với một tệp tài liệu hợp lệ",
"try_again": "Hãy thử lại",
"cancel_upload": "Hủy tải lên",
"upload_cancelled": "Tải lên bị hủy bởi người dùng",
"downloading": "Tải xuống ...",
"view_pdf": "Xem PDF",
"delete": "Xóa bỏ",
"page_of": "Trang {page} của {total}",
"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."
}
}
},
@@ -281,6 +281,7 @@ export interface Group_attendance {
}
export type LessonVideoType = 'youtube' | 'generic' | 'upload';
export type LessonDocumentType = any;
export interface LessonPage {
id?: string | null;
@@ -301,6 +302,13 @@ export interface LessonPage {
svid?: string;
};
}>;
documents: Array<{
type: LessonDocumentType;
name: string;
link: string;
size?: number;
key?: string;
}>;
};
exercises: [];
lesson_completion: LessonCompletion[];
@@ -33,7 +33,9 @@ export async function POST({ request }) {
},
{
role: 'user',
content: `Generate ${instruction[type]} given the course title is "${courseTitle}" and the title of the lesson is "${lessonTitle}". Format in HTML without any styling. MOST IMPORTANT DON'T include the title of the course and don't include the lesson title: "${lessonTitle}" in your output. Please make sure the content is well detailed and you output the content in this locale: ${locale}`
content: `Generate ${instruction[type]} given the course title is "${courseTitle}" and the title of the lesson is "${lessonTitle}". Format in HTML without any styling. MOST IMPORTANT DON'T include the title of the course and don't include the lesson title: "${lessonTitle}" in your output. Please make sure the content is well detailed and you output the content in this locale: ${locale}.
VERY IMPORTANT: DON'T INCLUDE THREE BACKTICKS AND html IN YOUR OUTPUT.
`
}
],
stream: true
@@ -1,6 +1,7 @@
import { env } from '$env/dynamic/private';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { env } from '$env/dynamic/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
@@ -25,7 +26,7 @@ export async function POST({ request }) {
},
{
role: 'user',
content: `${prompt}. DONT ADD ANY CSS STYLE TO THE HTML FORMATTING AND DON'T USE CODE HTML TAG`
content: `${prompt}. DONT ADD ANY CSS STYLE TO THE HTML FORMATTING AND DON'T USE CODE HTML TAG. ALSO DON'T INLUDE THREE BACKTICKS AND html`
}
],
stream: true
@@ -213,7 +213,8 @@
materials: {
videos: lessonData.videos,
note: lessonData.note,
slide_url: lessonData.slide_url
slide_url: lessonData.slide_url,
documents: lessonData.documents || null
},
lesson_completion,
exercises: [],
@@ -359,7 +360,7 @@
<div
class={`flex-row ${
$apps.dropdown && $apps.open
? 'absolute right-14 top-[85px] z-40 rounded-md bg-gray-100 p-3 dark:bg-neutral-800 lg:relative lg:right-0 lg:top-0 lg:p-0'
? 'absolute right-14 top-[85px] z-40 rounded-md bg-gray-100 p-3 lg:relative lg:right-0 lg:top-0 lg:p-0 dark:bg-neutral-800'
: 'hidden'
} items-center lg:flex`}
>
-1
View File
@@ -9,7 +9,6 @@
"format": "prettier . --write",
"release": "standard-version",
"ci": "cypress run",
"supabase": "pnpx supabase",
"dashboard:build": "cd apps/dashboard && pnpm i && PUBLIC_IS_SELFHOSTED=true NODE_OPTIONS='--max-old-space-size=20384' DEPLOYMENT_PROVIDER=docker pnpm build",
"dashboard:start": "cd apps/dashboard && node build"
},
@@ -0,0 +1,4 @@
-- Migration: Add documents column to lesson table
-- Description: Add a JSONB column to store document metadata for lessons
alter table "public"."lesson" add column documents jsonb null default '[]'::jsonb;
+1 -1
View File
@@ -19,7 +19,7 @@ INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encryp
-- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
--
INSERT INTO "auth"."identities" ("id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at", "updated_at") VALUES
INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at", "updated_at") VALUES
('01676a50-bb56-4c5e-8a61-fb9e9190fb10', '01676a50-bb56-4c5e-8a61-fb9e9190fb10', '{"sub": "01676a50-bb56-4c5e-8a61-fb9e9190fb10", "email": "test@test.com"}', 'email', '2023-11-22 10:09:32.225444+00', '2023-11-22 10:09:32.225521+00', '2023-11-22 10:09:32.225521+00'),
('7ac00503-8519-43c8-a5ea-b79aeca900b1', '7ac00503-8519-43c8-a5ea-b79aeca900b1', '{"sub": "7ac00503-8519-43c8-a5ea-b79aeca900b1", "email": "admin@test.com"}', 'email', '2023-12-16 14:04:38.302264+00', '2023-12-16 14:04:38.302326+00', '2023-12-16 14:04:38.302326+00'),
('0c256e75-aa40-4f62-8d30-0217ca1c60d9', '0c256e75-aa40-4f62-8d30-0217ca1c60d9', '{"sub": "0c256e75-aa40-4f62-8d30-0217ca1c60d9", "email": "student@test.com"}', 'email', '2023-12-18 17:08:57.410285+00', '2023-12-18 17:08:57.410344+00', '2023-12-18 17:08:57.410344+00');