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:
co-authored by
Rotimi Best
parent
7a77bd6c79
commit
937f1d8155
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
|
||||
+159
@@ -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}
|
||||
+361
@@ -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>
|
||||
+32
@@ -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
|
||||
});
|
||||
}
|
||||
+47
-6
@@ -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" />
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+454
@@ -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>
|
||||
+2
-1
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user