Merge branch 'main' of https://github.com/classroomio/classroomio into feat/release-v2
This commit is contained in:
@@ -16,10 +16,10 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
|
||||
|
||||
<script defer src="https://umami.hz.oncws.com/script.js"
|
||||
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js"
|
||||
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
|
||||
|
||||
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.description = 'Certificate customization is only available on paid plans';
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
const result = saveCertificateValidation({
|
||||
description: $course.description || '',
|
||||
is_certificate_downloadable: $course.is_certificate_downloadable || false,
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { questionnaire } from '../store/exercise';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
@@ -80,7 +81,9 @@
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
|
||||
{@html sanitizeHtml(
|
||||
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
|
||||
)}
|
||||
</article>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { COURSE_TYPE } from '$lib/utils/types';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -297,7 +298,7 @@
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html $questionnaire.description || 'No desription'}
|
||||
{@html sanitizeHtml($questionnaire.description || 'No desription')}
|
||||
</article>
|
||||
|
||||
<Button onclick={handleStart} type="button" class="float-right my-5">Start</Button>
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@
|
||||
import { diffLines } from 'diff';
|
||||
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
@@ -194,7 +195,7 @@
|
||||
{#key lessonId}
|
||||
<HtmlRender id="display" className="m-auto">
|
||||
<div class="amen">
|
||||
{@html content}
|
||||
{@html sanitizeHtml(content)}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{/key}
|
||||
|
||||
+8
@@ -101,6 +101,14 @@
|
||||
|
||||
async function uploadDocument() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
$lessonDocUpload.error = $t('upgrade.required');
|
||||
snackbar.error($lessonDocUpload.error);
|
||||
return;
|
||||
}
|
||||
|
||||
$lessonDocUpload.isUploading = true;
|
||||
|
||||
try {
|
||||
|
||||
+11
@@ -36,6 +36,17 @@
|
||||
async function onUpload() {
|
||||
if (!fileInput) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
formRes = {
|
||||
type: 'AUTHORIZATION_ERROR',
|
||||
status: 403,
|
||||
message: $t('upgrade.required')
|
||||
};
|
||||
isLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
videoUploader.initUpload();
|
||||
|
||||
const videoFile = fileInput.files?.[0];
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { lesson, lessonByTranslation } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -22,7 +23,7 @@
|
||||
{#if !isHtmlValueEmpty(content)}
|
||||
<HtmlRender className="m-auto">
|
||||
<div>
|
||||
{@html content}
|
||||
{@html sanitizeHtml(content)}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{:else if hasAtLeastOneTranslation}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import type { Author, Feed } from '$lib/utils/types/feed';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
|
||||
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
|
||||
|
||||
@@ -162,7 +163,7 @@
|
||||
{#if !isHtmlValueEmpty(feed.content)}
|
||||
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
|
||||
<div>
|
||||
{@html feed.content}
|
||||
{@html sanitizeHtml(feed.content)}
|
||||
</div>
|
||||
</HtmlRender>
|
||||
{/if}
|
||||
|
||||
@@ -124,12 +124,13 @@ export async function setCourse(data: Course, setLesson = true) {
|
||||
if (!data || !(Object.values(data) && Object.values(data).length)) return;
|
||||
|
||||
if (data.group) {
|
||||
const groupData: GroupStore = {
|
||||
id: data.group.id,
|
||||
const copiedGroup = JSON.parse(JSON.stringify(data.group));
|
||||
const groupData = Object.assign(copiedGroup, {
|
||||
id: copiedGroup.id,
|
||||
tutors: [],
|
||||
students: [],
|
||||
people: []
|
||||
};
|
||||
});
|
||||
|
||||
if (Array.isArray(data.group.members)) {
|
||||
for (const member of data.group.members) {
|
||||
|
||||
@@ -30,17 +30,18 @@
|
||||
isLoading = true;
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch('/api/analytics/course', {
|
||||
method: 'POST',
|
||||
const response = await fetch(`/api/courses/analytics?courseId=${courseId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ courseId })
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
courseAnalytics = (await response.json()) as CourseAnalytics;
|
||||
const { data } = await response.json();
|
||||
|
||||
courseAnalytics = data as CourseAnalytics;
|
||||
} else {
|
||||
console.error('Failed to fetch course analytics:', response);
|
||||
snackbar.error('Failed to load analytics data');
|
||||
@@ -133,4 +134,3 @@
|
||||
className="h-screen"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
let { courseId }: Props = $props();
|
||||
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
@@ -8,6 +8,9 @@
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { setupDOMProtection, updateBodyClass } from '../utils/dom-protection';
|
||||
|
||||
const WAIT_SEC = 120; // 2 minutes
|
||||
const WAIT_TIME = WAIT_SEC * 1000;
|
||||
@@ -21,6 +24,8 @@
|
||||
|
||||
const open = $derived(Boolean(!$profile.isEmailVerified && !!$profile.id && !!$currentOrg.id));
|
||||
|
||||
let domProtectionCleanup: ReturnType<typeof setupDOMProtection> | undefined;
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!$profile.email) {
|
||||
snackbar.error('verify_email_modal.snackbar.error');
|
||||
@@ -63,6 +68,32 @@
|
||||
}, WAIT_TIME);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (browser && $profile.id) {
|
||||
domProtectionCleanup = setupDOMProtection(() => $profile.isEmailVerified ?? false);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
if (domProtectionCleanup) {
|
||||
domProtectionCleanup.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (browser) updateBodyClass($profile.isEmailVerified ?? false);
|
||||
|
||||
if (browser && !$profile.isEmailVerified && $profile.id) {
|
||||
console.warn(
|
||||
'%c🔒 SECURITY WARNING: Email verification required',
|
||||
'color: red; font-weight: bold; font-size: 16px;',
|
||||
'\nAll user interactions are disabled until email verification is completed.',
|
||||
'\nAttempting to bypass this protection is monitored and logged.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || isSent || loading || autoTriggered) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface DOMProtectionCleanup {
|
||||
protectionInterval?: ReturnType<typeof setInterval>;
|
||||
domObserver?: MutationObserver;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up DOM protection to disable all interactions when email is not verified.
|
||||
* Returns cleanup functions to properly dispose of intervals and observers.
|
||||
*
|
||||
* @param isEmailVerified - A readable store or getter function that returns whether email is verified
|
||||
* @returns Cleanup object with intervals, observers, and a cleanup function
|
||||
*/
|
||||
export function setupDOMProtection(
|
||||
isEmailVerified: Readable<boolean> | (() => boolean | null)
|
||||
): DOMProtectionCleanup {
|
||||
if (!browser) {
|
||||
return {
|
||||
cleanup: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
let protectionInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let domObserver: MutationObserver | undefined;
|
||||
|
||||
// Helper to get current verification status
|
||||
const getIsVerified = () => {
|
||||
if (typeof isEmailVerified === 'function') {
|
||||
return isEmailVerified() ?? false;
|
||||
}
|
||||
// For stores, we need to subscribe temporarily - this is a limitation
|
||||
// In practice, the component should pass a getter function
|
||||
let value = false;
|
||||
const unsubscribe = isEmailVerified.subscribe((v) => {
|
||||
value = v ?? false;
|
||||
});
|
||||
unsubscribe();
|
||||
return value;
|
||||
};
|
||||
|
||||
// Function to disable all interactive elements
|
||||
function disableInteractions() {
|
||||
if (getIsVerified()) return;
|
||||
|
||||
// Disable all buttons except verification-related ones
|
||||
const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]');
|
||||
buttons.forEach((button) => {
|
||||
const el = button as HTMLElement;
|
||||
|
||||
// Skip if already protected or is verification-related
|
||||
if (
|
||||
el.dataset.verificationProtected === 'true' ||
|
||||
el.closest('[data-verification-modal]') ||
|
||||
el.textContent?.toLowerCase().includes('verify') ||
|
||||
el.textContent?.toLowerCase().includes('resend')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.cursor = 'not-allowed';
|
||||
el.setAttribute('title', 'Email verification required');
|
||||
|
||||
// Add click prevention
|
||||
el.addEventListener('click', preventInteraction, true);
|
||||
el.addEventListener('mousedown', preventInteraction, true);
|
||||
});
|
||||
|
||||
// Disable all form inputs
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach((input) => {
|
||||
const el = input as HTMLInputElement;
|
||||
|
||||
// Skip if in verification modal
|
||||
if (el.closest('[data-verification-modal]')) return;
|
||||
|
||||
if (!el.dataset.verificationProtected) {
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
|
||||
el.disabled = true;
|
||||
el.style.opacity = '0.5';
|
||||
el.addEventListener('focus', preventInteraction, true);
|
||||
el.addEventListener('input', preventInteraction, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable contenteditable elements
|
||||
const editables = document.querySelectorAll('[contenteditable="true"]');
|
||||
editables.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
if (!element.dataset.verificationProtected) {
|
||||
element.dataset.verificationProtected = 'true';
|
||||
element.contentEditable = 'false';
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function preventInteraction(event: Event) {
|
||||
if (!getIsVerified()) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
// Show warning
|
||||
if (event.target) {
|
||||
const target = event.target as HTMLElement;
|
||||
target.setAttribute('title', 'Please verify your email to use this feature');
|
||||
}
|
||||
|
||||
// Optional: Show alert
|
||||
console.warn('🔒 Email verification required to interact with this element');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to restore interactions when verified
|
||||
function enableInteractions() {
|
||||
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
|
||||
protectedElements.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
|
||||
// Restore styles
|
||||
if (element.dataset.originalPointerEvents) {
|
||||
element.style.pointerEvents = element.dataset.originalPointerEvents;
|
||||
} else {
|
||||
element.style.pointerEvents = '';
|
||||
}
|
||||
element.style.opacity = '';
|
||||
element.style.cursor = '';
|
||||
element.removeAttribute('title');
|
||||
|
||||
// Restore functionality for inputs
|
||||
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
|
||||
const input = element as HTMLInputElement;
|
||||
input.disabled = input.dataset.originalDisabled === 'true';
|
||||
}
|
||||
|
||||
// Restore contenteditable
|
||||
if (element.hasAttribute('contenteditable')) {
|
||||
element.contentEditable = 'true';
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
element.removeEventListener('click', preventInteraction, true);
|
||||
element.removeEventListener('mousedown', preventInteraction, true);
|
||||
element.removeEventListener('focus', preventInteraction, true);
|
||||
element.removeEventListener('input', preventInteraction, true);
|
||||
|
||||
// Clean up data attributes
|
||||
delete element.dataset.verificationProtected;
|
||||
delete element.dataset.originalPointerEvents;
|
||||
delete element.dataset.originalDisabled;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up mutation observer to protect new elements
|
||||
if (window.MutationObserver) {
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldReapply = false;
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
shouldReapply = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReapply && !getIsVerified()) {
|
||||
setTimeout(disableInteractions, 100);
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Run protection every second
|
||||
protectionInterval = setInterval(() => {
|
||||
if (getIsVerified()) {
|
||||
enableInteractions();
|
||||
cleanup();
|
||||
} else {
|
||||
disableInteractions();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Initial protection
|
||||
if (!getIsVerified()) {
|
||||
disableInteractions();
|
||||
}
|
||||
|
||||
// Add global CSS to prevent any missed interactions
|
||||
if (document) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'email-verification-protection';
|
||||
style.textContent = `
|
||||
body:not(.email-verified) button:not([data-verification-modal] button),
|
||||
body:not(.email-verified) a:not([data-verification-modal] a),
|
||||
body:not(.email-verified) input:not([data-verification-modal] input),
|
||||
body:not(.email-verified) textarea:not([data-verification-modal] textarea),
|
||||
body:not(.email-verified) [contenteditable="true"]:not([data-verification-modal] [contenteditable]) {
|
||||
pointer-events: none !important;
|
||||
opacity: 0.5 !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (protectionInterval) {
|
||||
clearInterval(protectionInterval);
|
||||
protectionInterval = undefined;
|
||||
}
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = undefined;
|
||||
}
|
||||
|
||||
// Clean up protection styles
|
||||
if (browser) {
|
||||
const style = document?.getElementById('email-verification-protection');
|
||||
if (style) style.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
protectionInterval,
|
||||
domObserver,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the body class based on email verification status
|
||||
*/
|
||||
export function updateBodyClass(isEmailVerified: boolean | null) {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmailVerified) {
|
||||
document.body.classList.add('email-verified');
|
||||
} else {
|
||||
document.body.classList.remove('email-verified');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@
|
||||
async function handleSaveCustomDomain() {
|
||||
if (!isDomainValid) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.customDomain = 'Custom domains are only available on paid plans';
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedDomain = sanitizeDomain(customDomain);
|
||||
|
||||
const details = parse(sanitizedDomain);
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
let isRemoving: number | null = $state(null);
|
||||
|
||||
async function onSendInvite() {
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
|
||||
|
||||
if (hasError) {
|
||||
@@ -48,6 +54,12 @@
|
||||
}
|
||||
|
||||
async function onRemove(id: number) {
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
|
||||
isRemoving = id;
|
||||
await orgApi.removeTeamMember($currentOrg.id, id);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
interface Props {
|
||||
code?: string;
|
||||
}
|
||||
@@ -7,5 +8,5 @@
|
||||
</script>
|
||||
|
||||
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
|
||||
{@html code}
|
||||
{@html sanitizeHtml(code)}
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { goto } from '$app/navigation';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import PaymentModal from './payment-modal.svelte';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
@@ -184,7 +185,7 @@
|
||||
<!-- Gift Container -->
|
||||
{#if courseData?.metadata?.reward?.show}
|
||||
<div class="flex flex-col items-center border-b border-t border-gray-300 p-10">
|
||||
<HtmlRender>{@html get(courseData, 'metadata.reward.description', '')}</HtmlRender>
|
||||
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', ''))}</HtmlRender>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
|
||||
import { getLectureNo } from '$lib/components/Course/function';
|
||||
@@ -216,7 +217,7 @@
|
||||
</h3>
|
||||
|
||||
<ul class="list font-light">
|
||||
<HtmlRender>{@html get(courseData, 'metadata.requirements', '')}</HtmlRender>
|
||||
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HtmlRender>
|
||||
</ul>
|
||||
</NavSection>
|
||||
{/if}
|
||||
@@ -229,7 +230,7 @@
|
||||
</h3>
|
||||
|
||||
<HtmlRender className="dark:text-white text-sm font-light">
|
||||
{@html get(courseData, 'metadata.description', '')}
|
||||
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
|
||||
</HtmlRender>
|
||||
</NavSection>
|
||||
{/if}
|
||||
@@ -239,7 +240,7 @@
|
||||
<NavSection id="goals">
|
||||
<h3 class="mb-3 mt-0 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<ul class="list font-light">
|
||||
<HtmlRender>{@html get(courseData, 'metadata.goals', '')}</HtmlRender>
|
||||
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HtmlRender>
|
||||
</ul>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import * as ImageCropper from '@cio/ui/custom/image-cropper';
|
||||
import CameraIcon from '@lucide/svelte/icons/camera';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { validateImageUpload } from '$lib/utils/functions/fileValidation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -44,6 +46,16 @@
|
||||
// getCroppedImg outputs PNG format, so use blob.type (which will be 'image/png') and matching filename
|
||||
const file = new File([blob], 'cropped-image.png', { type: blob.type });
|
||||
|
||||
const validation = validateImageUpload(file);
|
||||
if (!validation.isValid) {
|
||||
snackbar.error(validation.error || 'Invalid file type');
|
||||
errorMessage = $t('snackbar.landing_page_settings.error.try_again');
|
||||
|
||||
cropperSrc = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update avatar with the cropped file
|
||||
avatar = file;
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ export const ROUTE = {
|
||||
FORGOT: '/forgot',
|
||||
RESET: '/reset',
|
||||
LOGOUT: '/logout',
|
||||
AUTH_FAILED: '/auth-failed'
|
||||
AUTH_FAILED: '/auth-failed',
|
||||
VERIFY_EMAIL_ERROR: '/verify-email-error'
|
||||
};
|
||||
|
||||
export const PUBLIC_ROUTES = [
|
||||
@@ -30,6 +31,7 @@ export const PUBLIC_ROUTES = [
|
||||
`^${ROUTE.PAGES}/.*`,
|
||||
`^${ROUTE.COURSE}/.*`,
|
||||
'/404',
|
||||
`^${ROUTE.VERIFY_EMAIL_ERROR}$`,
|
||||
ROUTE.AUTH_FAILED
|
||||
];
|
||||
|
||||
@@ -50,5 +52,6 @@ export const ROUTES_TO_HIDE_NAV = [
|
||||
`^${ROUTE.INVITE_STUDENT}/.*`,
|
||||
`^${ROUTE.FORGOT}$`,
|
||||
`^${ROUTE.RESET}$`,
|
||||
`^${ROUTE.ONBOARDING}$`
|
||||
`^${ROUTE.ONBOARDING}$`,
|
||||
`^${ROUTE.VERIFY_EMAIL_ERROR}$`
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Server-side file type validation utilities for preventing malicious uploads
|
||||
* Specifically designed to prevent SVG XSS attacks and other security issues
|
||||
*/
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
] as const;
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type
|
||||
* @param file - File object to validate
|
||||
* @returns boolean indicating if file type is allowed
|
||||
*/
|
||||
export function validateImageType(file: File): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension
|
||||
* @param filename - Filename to validate
|
||||
* @returns boolean indicating if extension is allowed
|
||||
*/
|
||||
export function validateImageExtension(filename: string): boolean {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ALLOWED_EXTENSIONS.includes(extension as (typeof ALLOWED_EXTENSIONS)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive file validation
|
||||
* @param file - File object to validate
|
||||
* @returns object with validation result and error message
|
||||
*/
|
||||
export function validateImageUpload(file: File): { isValid: boolean; error?: string } {
|
||||
// Check file type
|
||||
if (!validateImageType(file)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP files are allowed.'
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension (double check for security)
|
||||
if (!validateImageExtension(file.name)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid file extension. Only .jpg, .jpeg, .png, .gif, and .webp files are allowed.'
|
||||
};
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
if (file.name.toLowerCase().includes('.svg')) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'SVG files are not allowed for security reasons.'
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent directory traversal and other issues
|
||||
* @param filename - Original filename
|
||||
* @returns sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts
|
||||
let sanitized = filename.replace(/[/\\]/g, '');
|
||||
|
||||
// Remove special characters that could cause issues
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitized.length > 255) {
|
||||
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
|
||||
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
|
||||
sanitized = name.substring(0, 255 - ext.length) + ext;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export interface UserPermissionResult {
|
||||
hasAccess: boolean;
|
||||
isOrgAdmin: boolean;
|
||||
userMembership: any;
|
||||
isStudent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a course and determine their role
|
||||
*/
|
||||
export async function checkUserCoursePermissions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
courseGroupId: string
|
||||
): Promise<UserPermissionResult> {
|
||||
// Check if user is a member of the course group
|
||||
const { data: userMembership } = await supabase
|
||||
.from('groupmember')
|
||||
.select('role_id, id, profile_id, email, created_at, assigned_student_id, profile(*)')
|
||||
.eq('group_id', courseGroupId)
|
||||
.eq('profile_id', userId)
|
||||
.single();
|
||||
|
||||
// Check if user is org admin
|
||||
const { data: orgData } = await supabase
|
||||
.from('group')
|
||||
.select('organization_id')
|
||||
.eq('id', courseGroupId)
|
||||
.single();
|
||||
|
||||
let isOrgAdmin = false;
|
||||
if (orgData?.organization_id) {
|
||||
const { data: orgMembership } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('role_id')
|
||||
.eq('organization_id', orgData.organization_id)
|
||||
.eq('profile_id', userId)
|
||||
.eq('role_id', 1) // Admin role
|
||||
.single();
|
||||
|
||||
isOrgAdmin = !!orgMembership;
|
||||
}
|
||||
|
||||
// Check if user has access (either is course member or org admin)
|
||||
const hasAccess = !!userMembership || isOrgAdmin;
|
||||
const isStudent = userMembership?.role_id === 3 && !isOrgAdmin;
|
||||
|
||||
return {
|
||||
hasAccess,
|
||||
isOrgAdmin,
|
||||
userMembership,
|
||||
isStudent
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProfileCourseProgress
|
||||
} from '$lib/utils/types';
|
||||
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
|
||||
import { getAccessToken, supabase } from '$lib/utils/functions/supabase';
|
||||
|
||||
import { GenericUploader } from './presign';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
@@ -18,7 +19,6 @@ import { STATUS } from '$lib/utils/constants/course';
|
||||
import { get } from 'svelte/store';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
export async function fetchCourses(profileId, orgId) {
|
||||
@@ -155,6 +155,33 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCourseFromAPI(courseId: Course['id']) {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await fetch('/api/courses/data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ courseId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('fetchCourseFromAPI error:', result.message);
|
||||
return { data: null, error: result.error || result.message };
|
||||
}
|
||||
|
||||
return { data: result.data, error: null };
|
||||
} catch (error) {
|
||||
console.error('fetchCourseFromAPI error:', error);
|
||||
return { data: null, error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExploreCourses(profileId, orgId) {
|
||||
if (!orgId || !profileId) return;
|
||||
|
||||
@@ -345,8 +372,29 @@ export function createExercise(exercise: any) {
|
||||
return supabase.from('exercise').insert(exercise).select();
|
||||
}
|
||||
|
||||
export function fetchExercisesByMarks(courseId: Course['id']) {
|
||||
return supabase.rpc('get_exercises').eq('course_id', courseId);
|
||||
export async function fetchExercisesByMarks(courseId: Course['id']) {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await fetch('/api/courses/exercises', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ courseId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return { data: null, error: result.message };
|
||||
}
|
||||
|
||||
return { data: result.data, error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error };
|
||||
}
|
||||
}
|
||||
|
||||
function isNew(item: any) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import type { Reaction, FeedApi, Feed } from '$lib/utils/types/feed';
|
||||
|
||||
export async function fetchNewsFeedReaction(feedId: Feed['id']) {
|
||||
@@ -7,38 +7,34 @@ export async function fetchNewsFeedReaction(feedId: Feed['id']) {
|
||||
}
|
||||
|
||||
export async function fetchNewsFeeds(courseId?: Course['id']) {
|
||||
const response = await supabase
|
||||
.from('course_newsfeed')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
created_at,
|
||||
content,
|
||||
course_id,
|
||||
author:groupmember(
|
||||
profile(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
),
|
||||
reaction,
|
||||
is_pinned,
|
||||
comment:course_newsfeed_comment(
|
||||
id,
|
||||
created_at,
|
||||
author:groupmember( profile(id, fullname, avatar_url) ),
|
||||
content,
|
||||
course_newsfeed_id)
|
||||
`
|
||||
)
|
||||
.match({ course_id: courseId })
|
||||
.order('created_at', { ascending: false })
|
||||
.returns<FeedApi[]>();
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const { data, error } = response;
|
||||
const response = await fetch(`/api/courses/newsfeed?courseId=${courseId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
}
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error }
|
||||
};
|
||||
}
|
||||
|
||||
const { success, data, message } = await response.json();
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message }
|
||||
};
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
export async function createNewFeed(post: {
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
|
||||
export function fetchSubmissionStatus() {
|
||||
return supabase.from('submissionstatus').select(`*`);
|
||||
}
|
||||
|
||||
export function fetchSubmissions(course_id: string) {
|
||||
return supabase
|
||||
.from('submission')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
created_at,
|
||||
answers:question_answer(*),
|
||||
exercise:exercise_id(
|
||||
id, title, due_by,
|
||||
lesson:lesson_id(id, title),
|
||||
questions:question(
|
||||
*,
|
||||
options:option(*),
|
||||
question_type:question_type_id(id, label)
|
||||
)
|
||||
),
|
||||
status_id,
|
||||
feedback,
|
||||
course:course_id(*),
|
||||
groupmember:submitted_by(
|
||||
profile(*)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({
|
||||
course_id
|
||||
export async function fetchSubmissions(course_id: string) {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const response = await fetch(`/api/courses/submissions?courseId=${course_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error }
|
||||
};
|
||||
}
|
||||
|
||||
const { success, data, message } = await response.json();
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message }
|
||||
};
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
export async function fetchSubmission({
|
||||
@@ -44,40 +45,38 @@ export async function fetchSubmission({
|
||||
courseId?: string;
|
||||
submittedBy?: string;
|
||||
}) {
|
||||
const query: {
|
||||
exercise_id: string;
|
||||
course_id?: string;
|
||||
submitted_by?: string;
|
||||
} = {
|
||||
exercise_id: exerciseId
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const params = new URLSearchParams({ exerciseId });
|
||||
if (courseId) params.append('courseId', courseId);
|
||||
if (submittedBy) params.append('submittedBy', submittedBy);
|
||||
|
||||
const response = await fetch(`/api/courses/submission?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error }
|
||||
};
|
||||
|
||||
if (courseId) {
|
||||
query.course_id = courseId;
|
||||
}
|
||||
if (submittedBy) {
|
||||
query.submitted_by = submittedBy;
|
||||
}
|
||||
|
||||
return supabase
|
||||
.from('submission')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
answers:question_answer(*),
|
||||
status_id,
|
||||
feedback,
|
||||
submitted_by:groupmember!inner(
|
||||
profile!inner(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match(query)
|
||||
.returns<ExerciseSubmissions[]>();
|
||||
const { success, data, message } = await response.json();
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message }
|
||||
};
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
export async function updateSubmission(
|
||||
|
||||
@@ -99,21 +99,6 @@
|
||||
"email": "Ugyldig e -mail -adresse",
|
||||
"username": "Brugernavn kan ikke være tomt",
|
||||
"fullname": "Navnet skal være mindst 5 tegn"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Ugyldig type{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"too_big": "Værdien er for stor{maksimum, vælg, udefineret{} andet{. Maksimum {maksimum}}}",
|
||||
"too_small": "Værdien er for lille{minimum, vælg, udefineret{} andet{. Minimum {minimum}}}",
|
||||
"invalid_format": "Ugyldigt format{validering, vælg, udefineret{} andet{ for {validation}}}",
|
||||
"not_multiple_of": "Værdien skal være et multiplum af {multipleOf}",
|
||||
"unrecognized_keys": "Indeholder ikke-genkendte nøgler{nøgler, vælg, udefineret{} andet{: {nøgler}}}",
|
||||
"invalid_union": "Ugyldig værdi for angivne muligheder{unionErrors, select, undefined{} other{. {unionErrors} valgmulighed(er) mislykkedes}}",
|
||||
"invalid_key": "Ugyldig nøgle{nøgle, vælg, udefineret{} anden{: {nøgle}}}",
|
||||
"invalid_element": "Ugyldigt element{indeks, vælg, udefineret{} andet{ ved indeks {indeks}}}",
|
||||
"invalid_value": "Ugyldig værdi{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"custom": "{besked}",
|
||||
"required_field": "Dette felt er påkrævet",
|
||||
"invalid_field": "Ugyldig værdi"
|
||||
}
|
||||
},
|
||||
"generic": {
|
||||
@@ -327,7 +312,8 @@
|
||||
"team_members": "Teammedlemmer",
|
||||
"back": "Tilbage til indstillinger",
|
||||
"download_lessons": "Opgrader for at downloade lektioner",
|
||||
"download_course": "Opgrader for at downloade kursus"
|
||||
"download_course": "Opgrader for at downloade kursus",
|
||||
"required": "Opgradering påkrævet"
|
||||
},
|
||||
"analytics": {
|
||||
"courses": "Kurser",
|
||||
@@ -869,8 +855,7 @@
|
||||
"heading": "Beskrivelse",
|
||||
"describe": "Beskriv øvelsen til din studerende",
|
||||
"no": "Ingen beskrivelse"
|
||||
},
|
||||
"add_question": "Tilføj et spørgsmål"
|
||||
}
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Ny øvelse",
|
||||
@@ -1085,6 +1070,7 @@
|
||||
"students": "studerende",
|
||||
"published": "Offentliggjort",
|
||||
"unpublished": "Upubliceret",
|
||||
"continue_course": "Fortsæt kursus",
|
||||
"learn_more": "Lær mere",
|
||||
"list_view": {
|
||||
"title": "Titel",
|
||||
@@ -1140,6 +1126,7 @@
|
||||
"no_courses": "Ingen kurser i gang endnu",
|
||||
"start_course": "Når du starter et kursus, afspejler dine fremskridt her",
|
||||
"lms_dashboard_hero": "I dag er en anden dag for at bringe dig tættere på dine læringsmål. Giv ikke op, jo mere du lærer, jo bedre bliver du.",
|
||||
"dont": "Giv ikke op",
|
||||
"lessons_completed": "Lektioner afsluttet",
|
||||
"no_courses_started": "Ingen kurser startede",
|
||||
"revenue": "Indtægter",
|
||||
@@ -1595,8 +1582,46 @@
|
||||
"seconds": "sekunder",
|
||||
"snackbar_error": "Fejl under afsendelse af kode"
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Live klasse",
|
||||
"self_paced_label": "Selv tempo"
|
||||
"email_verification": {
|
||||
"security_footer": "Din kontosikkerhed er vores topprioritet. Alle verifikationsforsøg logges til sikkerhedsovervågning.",
|
||||
"actions": {
|
||||
"request_new": "Anmod om ny bekræftelses-e-mail",
|
||||
"contact_support": "Kontakt support",
|
||||
"back_to_dashboard": "Tilbage til Dashboard"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Sikkerhedsforbedring",
|
||||
"message": "Vi har opgraderet vores e-mailbekræftelsessystem for bedre sikkerhed. Gamle bekræftelseslinks er ikke længere gyldige for at beskytte din konto."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Bekræftelse mislykkedes",
|
||||
"message": "Der opstod en uventet fejl under e-mailbekræftelse. Prøv venligst igen, eller kontakt vores supportteam for at få hjælp."
|
||||
},
|
||||
"database": {
|
||||
"title": "Systemfejl",
|
||||
"message": "Vi oplever tekniske problemer. Prøv venligst igen senere, eller kontakt vores supportteam, hvis problemet fortsætter."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Ikke-understøttet bekræftelsesmetode",
|
||||
"message": "Denne bekræftelsesmetode understøttes ikke længere af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail ved hjælp af vores sikre bekræftelsessystem."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "E-mail-adresse ændret",
|
||||
"message": "Din e-mailadresse er blevet ændret, siden dette bekræftelseslink blev genereret. Anmod om et nyt bekræftelseslink til din nuværende e-mailadresse."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Bekræftelseslink er udløbet",
|
||||
"message": "Dette bekræftelseslink er udløbet af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail for at fortsætte."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link er allerede brugt",
|
||||
"message": "Dette bekræftelseslink er allerede blevet brugt. Hvis du har brug for at bekræfte din e-mail igen, bedes du anmode om et nyt bekræftelseslink."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Ugyldigt bekræftelseslink",
|
||||
"message": "Dette bekræftelseslink er ugyldigt eller forkert udformet. Tjek venligst din e-mail for det korrekte link, eller anmod om en ny bekræftelses-e-mail."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Zurück zu den Einstellungen",
|
||||
"download_lessons": "Führen Sie ein Upgrade durch, um Lektionen herunterzuladen",
|
||||
"download_course": "Upgrade zum Download-Kurs",
|
||||
"domain": "Aktualisieren Sie Ihren Plan, um benutzerdefinierte Domänen hinzuzufügen"
|
||||
"domain": "Aktualisieren Sie Ihren Plan, um benutzerdefinierte Domänen hinzuzufügen",
|
||||
"required": "Upgrade erforderlich"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Publikum",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "Es wurde keine Antwort gegeben",
|
||||
"check": "Überprüfen",
|
||||
"address": "Geben Sie hier Ihre Adresse ein",
|
||||
"add_question": "Fügen Sie eine Frage hinzu"
|
||||
"address": "Geben Sie hier Ihre Adresse ein"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Neue Übung",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "Studenten",
|
||||
"published": "Veröffentlicht",
|
||||
"unpublished": "Unveröffentlicht",
|
||||
"continue_course": "Kurs fortsetzen",
|
||||
"list_view": {
|
||||
"title": "Titel",
|
||||
"description": "Beschreibung",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Es sind noch keine Kurse im Gange",
|
||||
"start_course": "Sobald Sie einen Kurs beginnen, werden Ihre Fortschritte hier angezeigt",
|
||||
"lms_dashboard_hero": "Heute ist ein weiterer Tag, um Sie Ihren Lernzielen näher zu bringen. Geben Sie nicht auf, je mehr Sie lernen, desto besser werden Sie.",
|
||||
"dont": "Gib nicht auf",
|
||||
"lessons_completed": "Lektionen abgeschlossen",
|
||||
"no_courses_started": "Es wurden keine Kurse gestartet",
|
||||
"revenue": "Einnahmen",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "Ungültige E-Mail-Adresse",
|
||||
"username": "Benutzername darf nicht leer sein",
|
||||
"fullname": "Der Name sollte mindestens 5 Zeichen lang sein"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Ungültiger Typ {erwartet, ausgewählt, undefiniert{} andere{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
|
||||
"too_big": "Der Wert ist zu groß{maximum, select, undefiniert{} other{. Maximum {maximum}}}",
|
||||
"too_small": "Der Wert ist zu klein{minimum, select, undefiniert{} other{. Minimum {Minimum}}}",
|
||||
"invalid_format": "Ungültiges Format{validation, select, undefiniert{} other{ für {validation}}}",
|
||||
"not_multiple_of": "Der Wert muss ein Vielfaches von {multipleOf} sein",
|
||||
"unrecognized_keys": "Enthält nicht erkannte Schlüssel{keys, select, undefiniert{} other{: {keys}}}",
|
||||
"invalid_union": "Ungültiger Wert für die bereitgestellten Optionen {unionErrors, select, undefined{} other{. {unionErrors}-Option(en) fehlgeschlagen}}",
|
||||
"invalid_key": "Ungültiger Schlüssel{key, select, undefiniert{} other{: {key}}}",
|
||||
"invalid_element": "Ungültiges Element{index, select, undefiniert{} other{ am Index {index}}}",
|
||||
"invalid_value": "Invalid value{expected, select, undefined{} other{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
|
||||
"custom": "{Nachricht}",
|
||||
"required_field": "Dieses Feld ist erforderlich",
|
||||
"invalid_field": "Ungültiger Wert"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Live-Klasse",
|
||||
"self_paced_label": "Eigenständig"
|
||||
"email_verification": {
|
||||
"security_footer": "Die Sicherheit Ihres Kontos hat für uns oberste Priorität. Alle Verifizierungsversuche werden zur Sicherheitsüberwachung protokolliert.",
|
||||
"actions": {
|
||||
"request_new": "Fordern Sie eine neue Bestätigungs-E-Mail an",
|
||||
"contact_support": "Kontaktieren Sie den Support",
|
||||
"back_to_dashboard": "Zurück zum Dashboard"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Sicherheitsverbesserung",
|
||||
"message": "Wir haben unser E-Mail-Verifizierungssystem für mehr Sicherheit aktualisiert. Alte Bestätigungslinks sind zum Schutz Ihres Kontos nicht mehr gültig."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Überprüfung fehlgeschlagen",
|
||||
"message": "Bei der E-Mail-Verifizierung ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an unser Support-Team, um Hilfe zu erhalten."
|
||||
},
|
||||
"database": {
|
||||
"title": "Systemfehler",
|
||||
"message": "Wir haben technische Schwierigkeiten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie unser Support-Team, wenn das Problem weiterhin besteht."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Nicht unterstützte Verifizierungsmethode",
|
||||
"message": "Diese Verifizierungsmethode wird aus Sicherheitsgründen nicht mehr unterstützt. Bitte fordern Sie über unser sicheres Verifizierungssystem eine neue Verifizierungs-E-Mail an."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "E-Mail-Adresse geändert",
|
||||
"message": "Ihre E-Mail-Adresse wurde seit der Erstellung dieses Bestätigungslinks geändert. Bitte fordern Sie einen neuen Bestätigungslink für Ihre aktuelle E-Mail-Adresse an."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Bestätigungslink abgelaufen",
|
||||
"message": "Dieser Bestätigungslink ist aus Sicherheitsgründen abgelaufen. Bitte fordern Sie eine neue Bestätigungs-E-Mail an, um fortzufahren."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link bereits verwendet",
|
||||
"message": "Dieser Bestätigungslink wurde bereits verwendet. Wenn Sie Ihre E-Mail-Adresse erneut bestätigen müssen, fordern Sie bitte einen neuen Bestätigungslink an."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Ungültiger Bestätigungslink",
|
||||
"message": "Dieser Bestätigungslink ist ungültig oder fehlerhaft. Bitte überprüfen Sie Ihre E-Mails auf den richtigen Link oder fordern Sie eine neue Bestätigungs-E-Mail an."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,49 @@
|
||||
"snackbar_error": "error while sending code"
|
||||
},
|
||||
|
||||
"email_verification": {
|
||||
"errors": {
|
||||
"invalid_token": {
|
||||
"title": "Invalid Verification Link",
|
||||
"message": "This verification link is invalid or malformed. Please check your email for the correct link or request a new verification email."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link Already Used",
|
||||
"message": "This verification link has already been used. If you need to verify your email again, please request a new verification link."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Verification Link Expired",
|
||||
"message": "This verification link has expired for security reasons. Please request a new verification email to continue."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Email Address Changed",
|
||||
"message": "Your email address has been changed since this verification link was generated. Please request a new verification link for your current email address."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Unsupported Verification Method",
|
||||
"message": "This verification method is no longer supported for security reasons. Please request a new verification email using our secure verification system."
|
||||
},
|
||||
"database": {
|
||||
"title": "System Error",
|
||||
"message": "We're experiencing technical difficulties. Please try again later or contact our support team if the issue persists."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Verification Failed",
|
||||
"message": "An unexpected error occurred during email verification. Please try again or contact our support team for assistance."
|
||||
}
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Security Enhancement",
|
||||
"message": "We've upgraded our email verification system for better security. Old verification links are no longer valid to protect your account."
|
||||
},
|
||||
"actions": {
|
||||
"request_new": "Request New Verification Email",
|
||||
"contact_support": "Contact Support",
|
||||
"back_to_dashboard": "Back to Dashboard"
|
||||
},
|
||||
"security_footer": "Your account security is our top priority. All verification attempts are logged for security monitoring."
|
||||
},
|
||||
|
||||
"delete_modal": {
|
||||
"label": "Delete",
|
||||
"content": "Are you sure?",
|
||||
@@ -1343,7 +1386,8 @@
|
||||
"team_members": "Team members",
|
||||
"back": "Back to Settings",
|
||||
"download_lessons": "Upgrade to download lessons",
|
||||
"download_course": "Upgrade to download course"
|
||||
"download_course": "Upgrade to download course",
|
||||
"required": "Upgrade Required"
|
||||
},
|
||||
|
||||
"lms_navigation": {
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Volver a configuración",
|
||||
"download_lessons": "Actualice para descargar lecciones",
|
||||
"download_course": "Actualizar para descargar el curso",
|
||||
"domain": "Actualice su plan para agregar dominios personalizados"
|
||||
"domain": "Actualice su plan para agregar dominios personalizados",
|
||||
"required": "Actualización requerida"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Audiencia",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "No se proporcionó respuesta",
|
||||
"check": "Controlar",
|
||||
"address": "Escribe tu dirección aquí",
|
||||
"add_question": "Añadir una pregunta"
|
||||
"address": "Escribe tu dirección aquí"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Nuevo ejercicio",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "estudiantes",
|
||||
"published": "Publicado",
|
||||
"unpublished": "Inédito",
|
||||
"continue_course": "Continuar curso",
|
||||
"list_view": {
|
||||
"title": "Título",
|
||||
"description": "Descripción",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Aún no hay cursos en curso",
|
||||
"start_course": "Una vez que comiences un curso, tu progreso se reflejará aquí.",
|
||||
"lms_dashboard_hero": "Hoy es otro día para acercarte a tus objetivos de aprendizaje. No te rindas, cuanto más aprendas, mejor serás.",
|
||||
"dont": "no te rindas",
|
||||
"lessons_completed": "Lecciones completadas",
|
||||
"no_courses_started": "No se iniciaron cursos",
|
||||
"revenue": "Ganancia",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "Dirección de correo electrónico no válida",
|
||||
"username": "El nombre de usuario no puede estar vacío",
|
||||
"fullname": "El nombre debe tener un mínimo de 5 caracteres."
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Invalid type{expected, select, undefined{} other{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
|
||||
"too_big": "El valor es demasiado grande{máximo, selección, undefinido{} otro{. Máximo {máximo}}}",
|
||||
"too_small": "El valor es demasiado pequeño{mínimo, seleccionado, undefinido{} otro{. Mínimo {mínimo}}}",
|
||||
"invalid_format": "Formato no válido{validación, selección, indefinido{} otro{ para {validación}}}",
|
||||
"not_multiple_of": "El valor debe ser múltiplo de {multipleOf}",
|
||||
"unrecognized_keys": "Contiene claves no reconocidas{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valor no válido para las opciones proporcionadas{unionErrors, select, undefined{} other{. {unionErrors} opción(es) fallida(s)}}",
|
||||
"invalid_key": "Clave no válida{clave, selección, undefinido{} otro{: {clave}}}",
|
||||
"invalid_element": "Elemento no válido{índice, selección, undefinido{} otro{ en el índice {índice}}}",
|
||||
"invalid_value": "Valor no válido{esperado, seleccionado, indefinido{} otro{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
|
||||
"custom": "{mensaje}",
|
||||
"required_field": "Este campo es obligatorio",
|
||||
"invalid_field": "Invalid value"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Clase en vivo",
|
||||
"self_paced_label": "a tu propio ritmo"
|
||||
"email_verification": {
|
||||
"security_footer": "La seguridad de su cuenta es nuestra principal prioridad. Todos los intentos de verificación se registran para el control de seguridad.",
|
||||
"actions": {
|
||||
"request_new": "Solicitar nuevo correo electrónico de verificación",
|
||||
"contact_support": "Contactar con soporte",
|
||||
"back_to_dashboard": "Volver al panel"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Mejora de la seguridad",
|
||||
"message": "Hemos actualizado nuestro sistema de verificación de correo electrónico para mayor seguridad. Los enlaces de verificación antiguos ya no son válidos para proteger su cuenta."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Verificación fallida",
|
||||
"message": "Se produjo un error inesperado durante la verificación del correo electrónico. Inténtelo de nuevo o comuníquese con nuestro equipo de soporte para obtener ayuda."
|
||||
},
|
||||
"database": {
|
||||
"title": "Error del sistema",
|
||||
"message": "Estamos experimentando dificultades técnicas. Inténtelo de nuevo más tarde o comuníquese con nuestro equipo de soporte si el problema persiste."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Método de verificación no admitido",
|
||||
"message": "Este método de verificación ya no es compatible por razones de seguridad. Solicite un nuevo correo electrónico de verificación utilizando nuestro sistema de verificación seguro."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Dirección de correo electrónico cambiada",
|
||||
"message": "Su dirección de correo electrónico ha cambiado desde que se generó este enlace de verificación. Solicite un nuevo enlace de verificación para su dirección de correo electrónico actual."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Enlace de verificación caducado",
|
||||
"message": "Este enlace de verificación ha caducado por motivos de seguridad. Solicite un nuevo correo electrónico de verificación para continuar."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Enlace ya utilizado",
|
||||
"message": "Este enlace de verificación ya se ha utilizado. Si necesita verificar su correo electrónico nuevamente, solicite un nuevo enlace de verificación."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Enlace de verificación no válido",
|
||||
"message": "Este enlace de verificación no es válido o tiene un formato incorrecto. Revise su correo electrónico para ver el enlace correcto o solicite un nuevo correo electrónico de verificación."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Retour aux paramètres",
|
||||
"download_lessons": "Mettre à niveau pour télécharger les leçons",
|
||||
"download_course": "Mettre à niveau pour télécharger le cours",
|
||||
"domain": "Mettez à niveau votre forfait pour ajouter des domaines personnalisés"
|
||||
"domain": "Mettez à niveau votre forfait pour ajouter des domaines personnalisés",
|
||||
"required": "Mise à niveau requise"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Public",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "Aucune réponse n'a été fournie",
|
||||
"check": "Vérifier",
|
||||
"address": "Écrivez votre adresse ici",
|
||||
"add_question": "Ajouter une question"
|
||||
"address": "Écrivez votre adresse ici"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Nouvel exercice",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "étudiants",
|
||||
"published": "Publié",
|
||||
"unpublished": "Inédit",
|
||||
"continue_course": "Continuer le cours",
|
||||
"list_view": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Aucun cours en cours pour le moment",
|
||||
"start_course": "Une fois que vous aurez commencé un cours, vos progrès seront reflétés ici",
|
||||
"lms_dashboard_hero": "Aujourd'hui est un autre jour pour vous rapprocher de vos objectifs d'apprentissage. N'abandonnez pas, plus vous apprenez, mieux vous vous améliorez.",
|
||||
"dont": "N'abandonne pas",
|
||||
"lessons_completed": "Leçons terminées",
|
||||
"no_courses_started": "Aucun cours n'a commencé",
|
||||
"revenue": "Revenu",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "Adresse e-mail invalide",
|
||||
"username": "Le nom d'utilisateur ne peut pas être vide",
|
||||
"fullname": "Le nom doit comporter au minimum 5 caractères"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Type non valide{attendu, sélectionné, non défini{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
|
||||
"too_big": "La valeur est trop grande{maximum, select, undefined{} autre{. Maximum {maximum}}}",
|
||||
"too_small": "La valeur est trop petite{minimum, select, undefined{} autre{. Minimum {minimum}}}",
|
||||
"invalid_format": "Format invalide{validation, select, non défini{} autre{ pour {validation}}}",
|
||||
"not_multiple_of": "La valeur doit être un multiple de {multipleOf}",
|
||||
"unrecognized_keys": "Contient des clés non reconnues{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valeur non valide pour les options fournies {unionErrors, select, undefined{} other{. {unionErrors} option(s) a échoué}}",
|
||||
"invalid_key": "Clé invalide{key, select, undefined{} other{: {key}}}",
|
||||
"invalid_element": "Élément invalide{index, select, undefined{} autre{ à l'index {index}}}",
|
||||
"invalid_value": "Valeur non valide{attendue, sélectionnée, non définie{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
|
||||
"custom": "{message}",
|
||||
"required_field": "Ce champ est obligatoire",
|
||||
"invalid_field": "Valeur invalide"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "Vous avez des changements non sauvés. Êtes-vous sûr de vouloir partir?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Cours en direct",
|
||||
"self_paced_label": "À votre rythme"
|
||||
"email_verification": {
|
||||
"security_footer": "La sécurité de votre compte est notre priorité absolue. Toutes les tentatives de vérification sont enregistrées pour la surveillance de la sécurité.",
|
||||
"actions": {
|
||||
"request_new": "Demander un nouvel e-mail de vérification",
|
||||
"contact_support": "Contacter l'assistance",
|
||||
"back_to_dashboard": "Retour au tableau de bord"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Amélioration de la sécurité",
|
||||
"message": "Nous avons mis à niveau notre système de vérification des e-mails pour une meilleure sécurité. Les anciens liens de vérification ne sont plus valides pour protéger votre compte."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Échec de la vérification",
|
||||
"message": "Une erreur inattendue s'est produite lors de la vérification du courrier électronique. Veuillez réessayer ou contacter notre équipe d'assistance pour obtenir de l'aide."
|
||||
},
|
||||
"database": {
|
||||
"title": "Erreur système",
|
||||
"message": "Nous rencontrons des difficultés techniques. Veuillez réessayer plus tard ou contacter notre équipe d'assistance si le problème persiste."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Méthode de vérification non prise en charge",
|
||||
"message": "Cette méthode de vérification n'est plus prise en charge pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification en utilisant notre système de vérification sécurisé."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Adresse e-mail modifiée",
|
||||
"message": "Votre adresse e-mail a été modifiée depuis la génération de ce lien de vérification. Veuillez demander un nouveau lien de vérification pour votre adresse e-mail actuelle."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Lien de vérification expiré",
|
||||
"message": "Ce lien de vérification a expiré pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification pour continuer."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Lien déjà utilisé",
|
||||
"message": "Ce lien de vérification a déjà été utilisé. Si vous devez vérifier à nouveau votre adresse e-mail, veuillez demander un nouveau lien de vérification."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Lien de vérification invalide",
|
||||
"message": "Ce lien de vérification est invalide ou mal formé. Veuillez vérifier votre courrier électronique pour trouver le lien correct ou demander un nouvel e-mail de vérification."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "सेटिंग्स पर वापस जाएँ",
|
||||
"download_lessons": "पाठ डाउनलोड करने के लिए अपग्रेड करें",
|
||||
"download_course": "पाठ्यक्रम डाउनलोड करने के लिए अपग्रेड करें",
|
||||
"domain": "कस्टम डोमेन जोड़ने के लिए अपनी योजना को अपग्रेड करें"
|
||||
"domain": "कस्टम डोमेन जोड़ने के लिए अपनी योजना को अपग्रेड करें",
|
||||
"required": "अपग्रेड आवश्यक"
|
||||
},
|
||||
"audience": {
|
||||
"title": "श्रोता",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "कोई उत्तर नहीं दिया गया",
|
||||
"check": "जाँच करना",
|
||||
"address": "अपना पता यहां लिखें",
|
||||
"add_question": "एक प्रश्न जोड़ें"
|
||||
"address": "अपना पता यहां लिखें"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "नया व्यायाम",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "छात्र",
|
||||
"published": "प्रकाशित",
|
||||
"unpublished": "अप्रकाशित",
|
||||
"continue_course": "पाठ्यक्रम जारी रखें",
|
||||
"list_view": {
|
||||
"title": "शीर्षक",
|
||||
"description": "विवरण",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "अभी तक कोई पाठ्यक्रम प्रगति पर नहीं है",
|
||||
"start_course": "एक बार जब आप कोई कोर्स शुरू कर देंगे, तो आपकी प्रगति यहां दिखाई देगी",
|
||||
"lms_dashboard_hero": "आज का दिन आपको आपके सीखने के लक्ष्यों के करीब लाने का एक और दिन है। हार मत मानो, जितना अधिक सीखोगे उतना बेहतर हो जाओगे।",
|
||||
"dont": "हिम्मत मत हारो",
|
||||
"lessons_completed": "पाठ पूरा हुआ",
|
||||
"no_courses_started": "कोई पाठ्यक्रम शुरू नहीं हुआ",
|
||||
"revenue": "आय",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "अमान्य ईमेल पता",
|
||||
"username": "उपयोक्तानाम खाली नहीं हो सकता",
|
||||
"fullname": "नाम कम से कम 5 अक्षर का होना चाहिए"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "अमान्य प्रकार{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
|
||||
"too_big": "मान बहुत बड़ा है {अधिकतम, चयन करें, अपरिभाषित {} अन्य {। अधिकतम {अधिकतम}}}",
|
||||
"too_small": "मान बहुत छोटा है {न्यूनतम, चयन करें, अपरिभाषित {} अन्य {। न्यूनतम {न्यूनतम}}}",
|
||||
"invalid_format": "अमान्य प्रारूप{सत्यापन, चयन, अपरिभाषित{} अन्य{ {सत्यापन}}} के लिए",
|
||||
"not_multiple_of": "मान {multipleOf} का गुणज होना चाहिए",
|
||||
"unrecognized_keys": "अज्ञात कुंजियाँ शामिल हैं {कुंजियाँ, चयन करें, अपरिभाषित{} अन्य{: {कुंजियाँ}}}",
|
||||
"invalid_union": "प्रदत्त विकल्पों के लिए अमान्य मान {संघत्रुटि, चयन, अपरिभाषित{} अन्य{। {unionErrors} विकल्प विफल रहे}}",
|
||||
"invalid_key": "अमान्य कुंजी{कुंजी, चयन, अपरिभाषित{} अन्य{: {कुंजी}}}",
|
||||
"invalid_element": "अमान्य तत्व {सूचकांक, चयन, अपरिभाषित {} अन्य {सूचकांक {सूचकांक}}} पर",
|
||||
"invalid_value": "अमान्य मान{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
|
||||
"custom": "{संदेश}",
|
||||
"required_field": "यह फ़ील्ड आवश्यक है",
|
||||
"invalid_field": "अमान्य मान"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "लाइव क्लास",
|
||||
"self_paced_label": "स्वयं गति"
|
||||
"email_verification": {
|
||||
"security_footer": "आपके खाते की सुरक्षा हमारी सर्वोच्च प्राथमिकता है। सुरक्षा निगरानी के लिए सभी सत्यापन प्रयास लॉग किए जाते हैं।",
|
||||
"actions": {
|
||||
"request_new": "नए सत्यापन ईमेल का अनुरोध करें",
|
||||
"contact_support": "सहायता से संपर्क करें",
|
||||
"back_to_dashboard": "डैशबोर्ड पर वापस जाएँ"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "सुरक्षा संवर्धन",
|
||||
"message": "हमने बेहतर सुरक्षा के लिए अपनी ईमेल सत्यापन प्रणाली को उन्नत किया है। पुराने सत्यापन लिंक अब आपके खाते की सुरक्षा के लिए मान्य नहीं हैं।"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "सत्यापन विफल",
|
||||
"message": "ईमेल सत्यापन के दौरान एक अप्रत्याशित त्रुटि उत्पन्न हुई. कृपया पुनः प्रयास करें या सहायता के लिए हमारी सहायता टीम से संपर्क करें।"
|
||||
},
|
||||
"database": {
|
||||
"title": "सिस्टम त्रुटि",
|
||||
"message": "हम तकनीकी कठिनाइयों का सामना कर रहे हैं. कृपया बाद में पुनः प्रयास करें या यदि समस्या बनी रहती है तो हमारी सहायता टीम से संपर्क करें।"
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "असमर्थित सत्यापन विधि",
|
||||
"message": "सुरक्षा कारणों से यह सत्यापन विधि अब समर्थित नहीं है। कृपया हमारी सुरक्षित सत्यापन प्रणाली का उपयोग करके एक नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "ईमेल पता बदल गया",
|
||||
"message": "इस सत्यापन लिंक के जनरेट होने के बाद से आपका ईमेल पता बदल दिया गया है। कृपया अपने वर्तमान ईमेल पते के लिए एक नए सत्यापन लिंक का अनुरोध करें।"
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "सत्यापन लिंक समाप्त हो गया",
|
||||
"message": "सुरक्षा कारणों से यह सत्यापन लिंक समाप्त हो गया है। जारी रखने के लिए कृपया एक नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
},
|
||||
"token_used": {
|
||||
"title": "लिंक पहले से ही प्रयुक्त है",
|
||||
"message": "इस सत्यापन लिंक का उपयोग पहले ही किया जा चुका है. यदि आपको अपना ईमेल दोबारा सत्यापित करने की आवश्यकता है, तो कृपया एक नए सत्यापन लिंक का अनुरोध करें।"
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "अमान्य सत्यापन लिंक",
|
||||
"message": "यह सत्यापन लिंक अमान्य या विकृत है. कृपया सही लिंक के लिए अपना ईमेल जांचें या नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,21 +94,6 @@
|
||||
"email": "Nieprawidłowy adres e -mail",
|
||||
"username": "Nazwa użytkownika nie może być pusta",
|
||||
"fullname": "Nazwa powinna mieć minimum 5 znaków"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Nieprawidłowy typ{oczekiwany, wybierz, niezdefiniowany{} inny{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
|
||||
"too_big": "Wartość jest za duża{maksymalna, wybierz, niezdefiniowana{} inna{. Maksymalnie {maksimum}}}",
|
||||
"too_small": "Wartość jest za mała{minimum, wybierz, niezdefiniowane{} inne{. Minimum {minimum}}}",
|
||||
"invalid_format": "Nieprawidłowy format{validacja, wybierz, niezdefiniowany{} other{ for {validation}}}",
|
||||
"not_multiple_of": "Wartość musi być wielokrotnością {multipleOf}",
|
||||
"unrecognized_keys": "Zawiera nierozpoznane klucze{klucze, wybierz, niezdefiniowane{} inne{: {klucze}}}",
|
||||
"invalid_union": "Nieprawidłowa wartość podanych opcji{unionErrors, wybierz, niezdefiniowane{} inne{. Niepowodzenie opcji: {unionErrors}}}",
|
||||
"invalid_key": "Nieprawidłowy klucz{klucz, wybierz, niezdefiniowany{} inny{: {klucz}}}",
|
||||
"invalid_element": "Nieprawidłowy element{index, wybierz, niezdefiniowany{} other{ w indeksie {index}}}",
|
||||
"invalid_value": "Nieprawidłowa wartość{oczekiwana, wybierz, niezdefiniowana{} inna{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
|
||||
"custom": "{wiadomość}",
|
||||
"required_field": "To pole jest wymagane",
|
||||
"invalid_field": "Nieprawidłowa wartość"
|
||||
}
|
||||
},
|
||||
"generic": {
|
||||
@@ -322,7 +307,8 @@
|
||||
"team_members": "Członkowie zespołu",
|
||||
"back": "Powrót do ustawień",
|
||||
"download_lessons": "Upgrade to pobieranie lekcji",
|
||||
"download_course": "Upgrade to Pobierz"
|
||||
"download_course": "Upgrade to Pobierz",
|
||||
"required": "Wymagana aktualizacja"
|
||||
},
|
||||
"analytics": {
|
||||
"courses": "Kursy",
|
||||
@@ -862,8 +848,7 @@
|
||||
"describe": "Opisz ćwiczenie swoim uczniowi",
|
||||
"no": "Brak opisu"
|
||||
},
|
||||
"address": "Wpisz tutaj swój adres",
|
||||
"add_question": "Dodaj pytanie"
|
||||
"address": "Wpisz tutaj swój adres"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Nowe ćwiczenie",
|
||||
@@ -1081,6 +1066,7 @@
|
||||
"students": "studenci",
|
||||
"published": "Opublikowany",
|
||||
"unpublished": "Niepublikowane",
|
||||
"continue_course": "Kontynuuj kurs",
|
||||
"learn_more": "Dowiedz się więcej",
|
||||
"list_view": {
|
||||
"title": "Tytuł",
|
||||
@@ -1136,6 +1122,7 @@
|
||||
"no_courses": "Nie ma jeszcze żadnych kursów",
|
||||
"start_course": "Po rozpoczęciu kursu twoje postępy będą się tutaj odzwierciedlać",
|
||||
"lms_dashboard_hero": "Dzisiaj jest kolejny dzień, aby zbliżyć cię do celów uczenia się. Nie poddawaj się, im więcej uczysz się, tym lepiej.",
|
||||
"dont": "Nie poddawaj się",
|
||||
"lessons_completed": "Ukończone lekcje",
|
||||
"no_courses_started": "Żadne kursy nie rozpoczęły się",
|
||||
"revenue": "Przychód",
|
||||
@@ -1596,8 +1583,46 @@
|
||||
"message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Zajęcia na żywo",
|
||||
"self_paced_label": "Samodzielne tempo"
|
||||
"email_verification": {
|
||||
"security_footer": "Bezpieczeństwo Twojego konta jest naszym najwyższym priorytetem. Wszystkie próby weryfikacji są rejestrowane w celu monitorowania bezpieczeństwa.",
|
||||
"actions": {
|
||||
"request_new": "Poproś o nowy e-mail weryfikacyjny",
|
||||
"contact_support": "Skontaktuj się z pomocą techniczną",
|
||||
"back_to_dashboard": "Powrót do Panelu"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Zwiększenie bezpieczeństwa",
|
||||
"message": "Dla większego bezpieczeństwa ulepszyliśmy nasz system weryfikacji e-mailem. Stare linki weryfikacyjne nie są już ważne i chronią Twoje konto."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Weryfikacja nie powiodła się",
|
||||
"message": "Wystąpił nieoczekiwany błąd podczas weryfikacji e-mailowej. Spróbuj ponownie lub skontaktuj się z naszym zespołem wsparcia w celu uzyskania pomocy."
|
||||
},
|
||||
"database": {
|
||||
"title": "Błąd systemu",
|
||||
"message": "Mamy problemy techniczne. Spróbuj ponownie później lub skontaktuj się z naszym zespołem wsparcia, jeśli problem będzie się powtarzał."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Nieobsługiwana metoda weryfikacji",
|
||||
"message": "Ta metoda weryfikacji nie jest już obsługiwana ze względów bezpieczeństwa. Poproś o nowy e-mail weryfikacyjny, korzystając z naszego bezpiecznego systemu weryfikacji."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Adres e-mail zmieniony",
|
||||
"message": "Twój adres e-mail został zmieniony od czasu wygenerowania tego linku weryfikacyjnego. Poproś o nowy link weryfikacyjny dla bieżącego adresu e-mail."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Link weryfikacyjny wygasł",
|
||||
"message": "Ten link weryfikacyjny wygasł ze względów bezpieczeństwa. Aby kontynuować, poproś o nowy e-mail weryfikacyjny."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link już używany",
|
||||
"message": "Ten link weryfikacyjny został już wykorzystany. Jeśli chcesz ponownie zweryfikować swój adres e-mail, poproś o nowy link weryfikacyjny."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Nieprawidłowy link weryfikacyjny",
|
||||
"message": "Ten link weryfikacyjny jest nieprawidłowy lub źle sformułowany. Sprawdź, czy w skrzynce e-mail znajduje się właściwy link, lub poproś o nowy e-mail weryfikacyjny."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Voltar para configurações",
|
||||
"download_lessons": "Atualize para baixar lições",
|
||||
"download_course": "Atualize para baixar o curso",
|
||||
"domain": "Atualize seu plano para adicionar domínios personalizados"
|
||||
"domain": "Atualize seu plano para adicionar domínios personalizados",
|
||||
"required": "Atualização necessária"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Público",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "Nenhuma resposta foi fornecida",
|
||||
"check": "Verificar",
|
||||
"address": "Escreva seu endereço aqui",
|
||||
"add_question": "Adicione uma pergunta"
|
||||
"address": "Escreva seu endereço aqui"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Novo exercício",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "estudantes",
|
||||
"published": "Publicados",
|
||||
"unpublished": "Não publicado",
|
||||
"continue_course": "Continuar curso",
|
||||
"list_view": {
|
||||
"title": "Título",
|
||||
"description": "Descrição",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Nenhum curso em andamento ainda",
|
||||
"start_course": "Depois de iniciar um curso, seu progresso será refletido aqui",
|
||||
"lms_dashboard_hero": "Hoje é mais um dia para aproximar você de seus objetivos de aprendizagem. Não desista, quanto mais você aprende melhor você fica.",
|
||||
"dont": "Não desista",
|
||||
"lessons_completed": "Lições concluídas",
|
||||
"no_courses_started": "Nenhum curso iniciado",
|
||||
"revenue": "Receita",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "Endereço de email invalido",
|
||||
"username": "O nome de usuário não pode ficar vazio",
|
||||
"fullname": "O nome deve ter no mínimo 5 caracteres"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Tipo inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
|
||||
"too_big": "O valor é muito grande{máximo, seleção, indefinido{} outro{. Máximo {máximo}}}",
|
||||
"too_small": "O valor é muito pequeno{mínimo, selecione, indefinido{} outro{. Mínimo {mínimo}}}",
|
||||
"invalid_format": "Formato inválido{validação, seleção, indefinido{} outro{ para {validação}}}",
|
||||
"not_multiple_of": "O valor precisa ser um múltiplo de {multipleOf}",
|
||||
"unrecognized_keys": "Contém chaves não reconhecidas{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valor inválido para opções fornecidas{unionErrors, select, indefinido{} other{. Falha na(s) opção(ões) {unionErrors}}}",
|
||||
"invalid_key": "Chave inválida{chave, seleção, indefinida{} outra{: {chave}}}",
|
||||
"invalid_element": "Elemento inválido{index, select, indefinido{} other{ no índice {index}}}",
|
||||
"invalid_value": "Valor inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
|
||||
"custom": "{mensagem}",
|
||||
"required_field": "Este campo é obrigatório",
|
||||
"invalid_field": "Valor inválido"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Aula ao vivo",
|
||||
"self_paced_label": "Individualizado"
|
||||
"email_verification": {
|
||||
"security_footer": "A segurança da sua conta é a nossa principal prioridade. Todas as tentativas de verificação são registradas para monitoramento de segurança.",
|
||||
"actions": {
|
||||
"request_new": "Solicitar novo e-mail de verificação",
|
||||
"contact_support": "Contate o suporte",
|
||||
"back_to_dashboard": "Voltar ao painel"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Aprimoramento de segurança",
|
||||
"message": "Atualizamos nosso sistema de verificação de e-mail para melhor segurança. Links de verificação antigos não são mais válidos para proteger sua conta."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Falha na verificação",
|
||||
"message": "Ocorreu um erro inesperado durante a verificação do email. Tente novamente ou entre em contato com nossa equipe de suporte para obter assistência."
|
||||
},
|
||||
"database": {
|
||||
"title": "Erro de sistema",
|
||||
"message": "Estamos enfrentando dificuldades técnicas. Tente novamente mais tarde ou entre em contato com nossa equipe de suporte se o problema persistir."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Método de verificação não suportado",
|
||||
"message": "Este método de verificação não é mais suportado por motivos de segurança. Solicite um novo e-mail de verificação usando nosso sistema de verificação seguro."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Endereço de e-mail alterado",
|
||||
"message": "Seu endereço de e-mail foi alterado desde que este link de verificação foi gerado. Solicite um novo link de verificação para seu endereço de e-mail atual."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Link de verificação expirou",
|
||||
"message": "Este link de verificação expirou por motivos de segurança. Solicite um novo e-mail de verificação para continuar."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link já usado",
|
||||
"message": "Este link de verificação já foi usado. Se você precisar verificar seu e-mail novamente, solicite um novo link de verificação."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Link de verificação inválido",
|
||||
"message": "Este link de verificação é inválido ou está incorreto. Verifique seu e-mail para obter o link correto ou solicite um novo e-mail de verificação."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Вернуться к настройкам",
|
||||
"download_lessons": "Обновите, чтобы скачивать уроки",
|
||||
"download_course": "Обновите, чтобы загрузить курс",
|
||||
"domain": "Обновите свой план, чтобы добавить персональные домены"
|
||||
"domain": "Обновите свой план, чтобы добавить персональные домены",
|
||||
"required": "Требуется обновление"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Аудитория",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "Ответ не был предоставлен",
|
||||
"check": "Проверять",
|
||||
"address": "Напишите здесь свой адрес",
|
||||
"add_question": "Добавить вопрос"
|
||||
"address": "Напишите здесь свой адрес"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Новое упражнение",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "студенты",
|
||||
"published": "Опубликовано",
|
||||
"unpublished": "Неопубликовано",
|
||||
"continue_course": "Продолжить курс",
|
||||
"list_view": {
|
||||
"title": "Заголовок",
|
||||
"description": "Описание",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Курсов пока нет",
|
||||
"start_course": "Здесь будет отражаться ваш прогресс после начала курса.",
|
||||
"lms_dashboard_hero": "Сегодня еще один день, который приблизит вас к вашим целям обучения. Не сдавайтесь, чем больше вы узнаете, тем лучше у вас получится.",
|
||||
"dont": "Не сдавайся",
|
||||
"lessons_completed": "Уроки завершены",
|
||||
"no_courses_started": "Курсы не начались",
|
||||
"revenue": "Доход",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "Неверный адрес электронной почты",
|
||||
"username": "Имя пользователя не может быть пустым",
|
||||
"fullname": "Имя должно состоять минимум из 5 символов."
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Неверный тип {ожидаемый, выбранный, неопределенный{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
|
||||
"too_big": "Значение слишком велико{максимум, выберите, неопределенное{} другое{. Максимум {максимум}}}",
|
||||
"too_small": "Значение слишком мало{минимальное, выберите, неопределенное{} другое{. Минимум {минимум}}}",
|
||||
"invalid_format": "Неверный формат{проверка, выбор, неопределенное{} другое{ для {проверка}}}",
|
||||
"not_multiple_of": "Значение должно быть кратно {multipleOf}.",
|
||||
"unrecognized_keys": "Содержит нераспознанные ключи{keys, select, undefine{}other{: {keys}}}",
|
||||
"invalid_union": "Недопустимое значение для предоставленных параметров{unionErrors, select, undefine{}other{. Вариант(ы) {unionErrors} не выполнен}}",
|
||||
"invalid_key": "Неверный ключ{key, select, undefine{}other{: {key}}}",
|
||||
"invalid_element": "Неверный элемент{index, select, undefined{}other{ по индексу {index}}}",
|
||||
"invalid_value": "Недопустимое значение{ожидаемое, выбранное, неопределенное{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
|
||||
"custom": "{сообщение}",
|
||||
"required_field": "Это поле обязательно для заполнения",
|
||||
"invalid_field": "Недопустимое значение"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "У вас есть неспасенные изменения. Вы уверены, что хотите уйти?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Живой урок",
|
||||
"self_paced_label": "Самостоятельный темп"
|
||||
"email_verification": {
|
||||
"security_footer": "Безопасность вашего аккаунта является нашим главным приоритетом. Все попытки проверки протоколируются для мониторинга безопасности.",
|
||||
"actions": {
|
||||
"request_new": "Запросить новое письмо для подтверждения",
|
||||
"contact_support": "Обратиться в службу поддержки",
|
||||
"back_to_dashboard": "Вернуться к панели управления"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Повышение безопасности",
|
||||
"message": "Мы обновили нашу систему проверки электронной почты для большей безопасности. Старые ссылки для проверки больше не действительны для защиты вашей учетной записи."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Проверка не удалась",
|
||||
"message": "Во время проверки электронной почты произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз или обратитесь в нашу службу поддержки за помощью."
|
||||
},
|
||||
"database": {
|
||||
"title": "Системная ошибка",
|
||||
"message": "У нас возникли технические трудности. Повторите попытку позже или свяжитесь с нашей службой поддержки, если проблема не исчезнет."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Неподдерживаемый метод проверки",
|
||||
"message": "Этот метод проверки больше не поддерживается по соображениям безопасности. Пожалуйста, запросите новое электронное письмо с подтверждением, используя нашу безопасную систему проверки."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Адрес электронной почты изменен",
|
||||
"message": "Ваш адрес электронной почты был изменен с момента создания этой ссылки для подтверждения. Пожалуйста, запросите новую ссылку для подтверждения для вашего текущего адреса электронной почты."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Срок действия ссылки для подтверждения истек",
|
||||
"message": "Срок действия этой ссылки для проверки истек по соображениям безопасности. Чтобы продолжить, запросите новое письмо с подтверждением."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Ссылка уже использована",
|
||||
"message": "Эта ссылка для проверки уже использовалась. Если вам необходимо еще раз подтвердить свой адрес электронной почты, запросите новую ссылку для подтверждения."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Неверная ссылка для подтверждения",
|
||||
"message": "Эта ссылка для проверки недействительна или имеет неправильный формат. Пожалуйста, проверьте свою электронную почту на наличие правильной ссылки или запросите новое письмо для подтверждения."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"back": "Quay lại Cài đặt",
|
||||
"download_lessons": "Nâng cấp để tải bài học",
|
||||
"download_course": "Nâng cấp để tải xuống khóa học",
|
||||
"domain": "Nâng cấp gói của bạn để thêm miền tùy chỉnh"
|
||||
"domain": "Nâng cấp gói của bạn để thêm miền tùy chỉnh",
|
||||
"required": "Yêu cầu nâng cấp"
|
||||
},
|
||||
"audience": {
|
||||
"title": "Khán giả",
|
||||
@@ -679,8 +680,7 @@
|
||||
},
|
||||
"no_answer": "Không có câu trả lời nào được đưa ra",
|
||||
"check": "Kiểm tra",
|
||||
"address": "Viết địa chỉ của bạn ở đây",
|
||||
"add_question": "Thêm câu hỏi"
|
||||
"address": "Viết địa chỉ của bạn ở đây"
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Bài tập mới",
|
||||
@@ -916,6 +916,7 @@
|
||||
"students": "sinh viên",
|
||||
"published": "Được phát hành",
|
||||
"unpublished": "Chưa xuất bản",
|
||||
"continue_course": "Tiếp tục khóa học",
|
||||
"list_view": {
|
||||
"title": "Tiêu đề",
|
||||
"description": "Sự miêu tả",
|
||||
@@ -975,6 +976,7 @@
|
||||
"no_courses": "Chưa có khóa học nào đang diễn ra",
|
||||
"start_course": "Khi bạn bắt đầu một khóa học, sự tiến bộ của bạn sẽ phản ánh ở đây",
|
||||
"lms_dashboard_hero": "Hôm nay là một ngày khác để đưa bạn đến gần hơn với mục tiêu học tập của mình. Đừng bỏ cuộc, bạn càng học nhiều thì bạn càng tiến bộ hơn.",
|
||||
"dont": "Đừng bỏ cuộc",
|
||||
"lessons_completed": "Bài học đã hoàn thành",
|
||||
"no_courses_started": "Không có khóa học nào bắt đầu",
|
||||
"revenue": "Doanh thu",
|
||||
@@ -1553,21 +1555,6 @@
|
||||
"email": "địa chỉ email không hợp lệ",
|
||||
"username": "Tên ngươi dung không được để trông",
|
||||
"fullname": "Tên phải có tối thiểu 5 ký tự"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Loại không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
|
||||
"too_big": "Giá trị quá lớn{tối đa, chọn, không xác định{} khác{. Tối đa {tối đa}}}",
|
||||
"too_small": "Giá trị quá nhỏ{tối thiểu, chọn, không xác định{} khác{. Tối thiểu {tối thiểu}}}",
|
||||
"invalid_format": "Định dạng không hợp lệ{xác thực, chọn, không xác định{} khác{ cho {xác thực}}}",
|
||||
"not_multiple_of": "Giá trị phải là bội số của {multipleOf}",
|
||||
"unrecognized_keys": "Chứa các khóa không được nhận dạng{keys, select, undefind{} other{: {keys}}}",
|
||||
"invalid_union": "Giá trị không hợp lệ cho các tùy chọn được cung cấp{unionErrors, select, unknown{} other{. {unionErrors} tùy chọn không thành công}}",
|
||||
"invalid_key": "Khóa không hợp lệ{khóa, chọn, không xác định{} khác{: {key}}}",
|
||||
"invalid_element": "Phần tử không hợp lệ{chỉ mục, chọn, không xác định{} khác{ tại chỉ mục {index}}}",
|
||||
"invalid_value": "Giá trị không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
|
||||
"custom": "{tin nhắn}",
|
||||
"required_field": "Trường này là bắt buộc",
|
||||
"invalid_field": "Giá trị không hợp lệ"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -1626,8 +1613,46 @@
|
||||
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?"
|
||||
}
|
||||
},
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Lớp học trực tiếp",
|
||||
"self_paced_label": "tự nhịp độ"
|
||||
"email_verification": {
|
||||
"security_footer": "Bảo mật tài khoản của bạn là ưu tiên hàng đầu của chúng tôi. Tất cả các nỗ lực xác minh đều được ghi lại để giám sát bảo mật.",
|
||||
"actions": {
|
||||
"request_new": "Yêu cầu email xác minh mới",
|
||||
"contact_support": "Liên hệ hỗ trợ",
|
||||
"back_to_dashboard": "Quay lại Trang tổng quan"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Tăng cường bảo mật",
|
||||
"message": "Chúng tôi đã nâng cấp hệ thống xác minh email của mình để bảo mật tốt hơn. Các liên kết xác minh cũ không còn hiệu lực để bảo vệ tài khoản của bạn."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Xác minh không thành công",
|
||||
"message": "Đã xảy ra lỗi không mong muốn trong quá trình xác minh email. Vui lòng thử lại hoặc liên hệ với nhóm hỗ trợ của chúng tôi để được hỗ trợ."
|
||||
},
|
||||
"database": {
|
||||
"title": "Lỗi hệ thống",
|
||||
"message": "Chúng tôi đang gặp khó khăn về mặt kỹ thuật. Vui lòng thử lại sau hoặc liên hệ với nhóm hỗ trợ của chúng tôi nếu sự cố vẫn tiếp diễn."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Phương thức xác minh không được hỗ trợ",
|
||||
"message": "Phương thức xác minh này không còn được hỗ trợ vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới bằng hệ thống xác minh an toàn của chúng tôi."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Địa chỉ Email đã thay đổi",
|
||||
"message": "Địa chỉ email của bạn đã được thay đổi kể từ khi liên kết xác minh này được tạo. Vui lòng yêu cầu liên kết xác minh mới cho địa chỉ email hiện tại của bạn."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Liên kết xác minh đã hết hạn",
|
||||
"message": "Liên kết xác minh này đã hết hạn vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới để tiếp tục."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Liên kết đã được sử dụng",
|
||||
"message": "Liên kết xác minh này đã được sử dụng. Nếu bạn cần xác minh lại email của mình, vui lòng yêu cầu liên kết xác minh mới."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Liên kết xác minh không hợp lệ",
|
||||
"message": "Liên kết xác minh này không hợp lệ hoặc không đúng định dạng. Vui lòng kiểm tra email của bạn để tìm liên kết chính xác hoặc yêu cầu email xác minh mới."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
try {
|
||||
// Clean up expired tokens
|
||||
const { data: cleanupResult, error } = await supabase.rpc(
|
||||
'cleanup_expired_verification_tokens'
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Token cleanup failed:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Token cleanup completed:', {
|
||||
deletedTokens: cleanupResult,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
deletedTokens: cleanupResult,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unexpected error during cleanup:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unexpected error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
export async function GET() {
|
||||
return json({
|
||||
service: 'email-verification-cleanup',
|
||||
status: 'active',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export async function GET({ url, request }) {
|
||||
// Simple auth check - in production this should be more robust
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const adminSecret = process.env.ADMIN_SECRET;
|
||||
|
||||
if (!adminSecret || authHeader !== `Bearer ${adminSecret}`) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
try {
|
||||
// Get verification token statistics
|
||||
const { data: tokenStats, error: tokenError } = await supabase
|
||||
.from('email_verification_tokens')
|
||||
.select('id, created_at, expires_at, used_at, created_by_ip, used_by_ip, email')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (tokenError) {
|
||||
console.error('Failed to fetch token stats:', tokenError);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Database error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const now = new Date();
|
||||
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
|
||||
const used = tokenStats.filter((token) => token.used_at);
|
||||
const expired = tokenStats.filter(
|
||||
(token) => new Date(token.expires_at) < now && !token.used_at
|
||||
);
|
||||
|
||||
// Count unique IPs for anomaly detection
|
||||
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip))
|
||||
.size;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: {
|
||||
total_tokens: tokenStats.length,
|
||||
recent_24h: recent.length,
|
||||
used_tokens: used.length,
|
||||
expired_unused: expired.length,
|
||||
unique_creation_ips: uniqueCreationIPs,
|
||||
unique_usage_ips: uniqueUsageIPs,
|
||||
success_rate:
|
||||
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
},
|
||||
recent_activity: recent.slice(0, 10).map((token) => ({
|
||||
created_at: token.created_at,
|
||||
email: token.email.replace(/(.{2}).*@/, '$1***@'), // Mask email for privacy
|
||||
used: !!token.used_at,
|
||||
expired: new Date(token.expires_at) < now,
|
||||
ip: token.created_by_ip
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Security monitoring error:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unexpected error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
-17
@@ -1,20 +1,40 @@
|
||||
import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics';
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
import type { UserExercisesStats } from '$lib/utils/types/analytics';
|
||||
import { calcPercentageWithRounding } from '$lib/utils/functions/number.js';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const CACHE_DURATION = 60 * 5; // 5 minutes
|
||||
|
||||
export async function POST({ setHeaders, request }) {
|
||||
const { courseId } = await request.json();
|
||||
export const GET: RequestHandler = async ({ setHeaders, request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
setHeaders({
|
||||
@@ -22,16 +42,15 @@ export async function POST({ setHeaders, request }) {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
|
||||
try {
|
||||
const analytics = await getCourseAnalytics(courseId);
|
||||
return json(analytics, { status: 200 });
|
||||
const analytics = await getCourseAnalytics(supabase, courseId);
|
||||
return json({ success: true, data: analytics });
|
||||
} catch (error) {
|
||||
console.error('Course analytics error:', error);
|
||||
return json({ error: 'Something went wrong' }, { status: 500 });
|
||||
return json({ success: false, message: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
|
||||
async function getCourseAnalytics(supabase: any, courseId: string): Promise<CourseAnalytics> {
|
||||
const analytics: CourseAnalytics = {
|
||||
totalTutors: 0,
|
||||
totalStudents: 0,
|
||||
@@ -89,7 +108,9 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(courseId, member))
|
||||
members
|
||||
.filter((member) => member.role_id === 3)
|
||||
.map((member) => getStudentOverview(supabase, courseId, member))
|
||||
);
|
||||
|
||||
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
|
||||
@@ -116,7 +137,7 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
|
||||
return analytics;
|
||||
}
|
||||
|
||||
async function getLastLogin(userId: string): Promise<string | undefined> {
|
||||
async function getLastLogin(supabase: any, userId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('analytics_login_events')
|
||||
@@ -134,7 +155,11 @@ async function getLastLogin(userId: string): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getStudentOverview(courseId: string, member: any): Promise<StudentOverview | null> {
|
||||
async function getStudentOverview(
|
||||
supabase: any,
|
||||
courseId: string,
|
||||
member: any
|
||||
): Promise<StudentOverview | null> {
|
||||
try {
|
||||
// Use the same service function that the working analytics/user API uses
|
||||
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
|
||||
@@ -154,14 +179,14 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
|
||||
}
|
||||
|
||||
// Fetch exercise stats using the same method as the people page
|
||||
const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id);
|
||||
const userExercisesStats = await fetchUserExercisesStats(supabase, courseId, member.profile_id);
|
||||
|
||||
// Calculate exercise completion using the same logic as people page
|
||||
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
|
||||
const totalExercises = userExercisesStats?.length || 0;
|
||||
|
||||
// Get last login using the same approach as analytics/user API
|
||||
const lastLoginDate = await getLastLogin(member.profile_id);
|
||||
const lastLoginDate = await getLastLogin(supabase, member.profile_id);
|
||||
|
||||
// Format last seen
|
||||
let lastSeen = 'Never';
|
||||
@@ -211,7 +236,11 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserExercisesStats(courseId: string, userId: string): Promise<UserExercisesStats[] | undefined> {
|
||||
async function fetchUserExercisesStats(
|
||||
supabase: any,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<UserExercisesStats[] | undefined> {
|
||||
try {
|
||||
const { data: courseData, error: queryError } = await supabase
|
||||
.from('course')
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { STATUS } from '$lib/utils/constants/course';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
const COURSE_SELECT_QUERY = `
|
||||
id, title, type, description, overview, logo, is_published, version, group_id,
|
||||
slug, cost, currency, metadata, is_certificate_downloadable, certificate_theme,
|
||||
lesson_section(id, title, order, created_at),
|
||||
lessons:lesson(
|
||||
id, title, public, lesson_at, is_unlocked, order, created_at, section_id,
|
||||
note, videos, slide_url, call_url, totalExercises:exercise(count),
|
||||
totalComments:lesson_comment(count), profile:teacher_id(id, avatar_url, fullname),
|
||||
lesson_completion(id, profile_id, is_complete)
|
||||
),
|
||||
attendance:group_attendance(*),
|
||||
polls:apps_poll(status)
|
||||
`;
|
||||
|
||||
const GROUP_MEMBERS_SELECT = `
|
||||
id,
|
||||
members:groupmember(
|
||||
id, role_id, profile_id, email, created_at, assigned_student_id, profile(*)
|
||||
)
|
||||
`;
|
||||
|
||||
async function getCourse(supabase: any, courseId: string) {
|
||||
const { data: course, error } = await supabase
|
||||
.from('course')
|
||||
.select(COURSE_SELECT_QUERY)
|
||||
.eq('id', courseId)
|
||||
.eq('status', STATUS[STATUS.ACTIVE])
|
||||
.single();
|
||||
|
||||
if (error || !course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async function getGroupMembers(
|
||||
supabase: any,
|
||||
groupId: string,
|
||||
isStudent: boolean,
|
||||
userMembership: any
|
||||
) {
|
||||
if (isStudent) {
|
||||
return {
|
||||
id: groupId,
|
||||
members: userMembership ? [userMembership] : []
|
||||
};
|
||||
}
|
||||
|
||||
const { data: group, error } = await supabase
|
||||
.from('group')
|
||||
.select(GROUP_MEMBERS_SELECT)
|
||||
.eq('id', groupId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching group members');
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
members: group.members
|
||||
};
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { courseId } = await request.json();
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
const course = await getCourse(supabase, courseId);
|
||||
|
||||
const { hasAccess, isStudent, userMembership } = await checkUserCoursePermissions(
|
||||
supabase,
|
||||
userId,
|
||||
course.group_id
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course.',
|
||||
data: null
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const group = await getGroupMembers(supabase, course.group_id, isStudent, userMembership);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: { ...course, group }
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
const status = message === 'Course not found' ? 404 : 500;
|
||||
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
data: null
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: exercises, error } = await supabase
|
||||
.rpc('get_exercises')
|
||||
.eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching exercises');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: exercises
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: marks, error } = await supabase.rpc('get_marks').eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
console.log('error', error);
|
||||
throw new Error('Error fetching marks');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: marks
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch newsfeeds
|
||||
const { data, error } = await supabase
|
||||
.from('course_newsfeed')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
created_at,
|
||||
content,
|
||||
course_id,
|
||||
author:groupmember(
|
||||
profile(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
),
|
||||
reaction,
|
||||
is_pinned,
|
||||
comment:course_newsfeed_comment(
|
||||
id,
|
||||
created_at,
|
||||
author:groupmember( profile(id, fullname, avatar_url) ),
|
||||
content,
|
||||
course_newsfeed_id)
|
||||
`
|
||||
)
|
||||
.match({ course_id: courseId })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching newsfeeds');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const exerciseId = url.searchParams.get('exerciseId');
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const submittedBy = url.searchParams.get('submittedBy');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!exerciseId) {
|
||||
return json({ success: false, message: 'Exercise ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// If courseId is provided, check permissions
|
||||
if (courseId) {
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build query object
|
||||
const query: {
|
||||
exercise_id: string;
|
||||
course_id?: string;
|
||||
submitted_by?: string;
|
||||
} = {
|
||||
exercise_id: exerciseId
|
||||
};
|
||||
|
||||
if (courseId) {
|
||||
query.course_id = courseId;
|
||||
}
|
||||
if (submittedBy) {
|
||||
query.submitted_by = submittedBy;
|
||||
}
|
||||
|
||||
// Fetch submission
|
||||
const { data, error } = await supabase
|
||||
.from('submission')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
answers:question_answer(*),
|
||||
status_id,
|
||||
feedback,
|
||||
submitted_by:groupmember!inner(
|
||||
profile!inner(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match(query);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching submission');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch submissions
|
||||
const { data, error } = await supabase
|
||||
.from('submission')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
created_at,
|
||||
answers:question_answer(*),
|
||||
exercise:exercise_id(
|
||||
id, title, due_by,
|
||||
lesson:lesson_id(id, title),
|
||||
questions:question(
|
||||
*,
|
||||
options:option(*),
|
||||
question_type:question_type_id(id, label)
|
||||
)
|
||||
),
|
||||
status_id,
|
||||
feedback,
|
||||
course:course_id(*),
|
||||
groupmember:submitted_by(
|
||||
profile(*)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({
|
||||
course_id: courseId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching submissions');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const orgId = url.searchParams.get('orgId');
|
||||
|
||||
if (!orgId) {
|
||||
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Check if user has access to this organization
|
||||
const { data: orgMember } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('role_id')
|
||||
.eq('organization_id', orgId)
|
||||
.eq('profile_id', userId)
|
||||
.in('role_id', [1, 2]) // Admin or Member roles
|
||||
.single();
|
||||
|
||||
if (!orgMember) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all students who are participants in any course belonging to an org
|
||||
const { data, error } = await supabase
|
||||
.from('profile')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
fullname,
|
||||
email,
|
||||
avatar_url,
|
||||
created_at,
|
||||
groupmember!inner(
|
||||
role_id,
|
||||
group_id:group!inner(
|
||||
organization_id
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('groupmember.group.organization_id', orgId)
|
||||
.eq('groupmember.role_id', 3); // STUDENT role
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching organization audience');
|
||||
}
|
||||
|
||||
const audience = (data || []).map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.fullname,
|
||||
email: profile.email,
|
||||
avatar_url: profile.avatar_url,
|
||||
date_joined: new Date(profile.created_at).toDateString()
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
audience: audience
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const orgId = url.searchParams.get('orgId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Check if user has access to this organization
|
||||
const { data: orgMember } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('role_id')
|
||||
.eq('organization_id', orgId)
|
||||
.eq('profile_id', userId)
|
||||
.in('role_id', [1]) // Admin roles only
|
||||
.single();
|
||||
|
||||
if (!orgMember) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch organization team
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
email,
|
||||
verified,
|
||||
role_id,
|
||||
profile(
|
||||
id,
|
||||
fullname,
|
||||
email
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('organization_id', orgId)
|
||||
.neq('role_id', ROLE.STUDENT)
|
||||
.order('id', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching organization team');
|
||||
}
|
||||
|
||||
const team = (data || []).map((teamMember) => ({
|
||||
id: teamMember.id,
|
||||
email: teamMember?.profile?.email || teamMember.email,
|
||||
verified: teamMember.verified,
|
||||
profileId: teamMember?.profile?.id,
|
||||
fullname: teamMember?.profile?.fullname || '',
|
||||
role: ROLE_LABEL[teamMember?.role_id] || '',
|
||||
isAdmin: teamMember?.role_id === ROLE.ADMIN
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
team: team
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<MarksPage courseId={data.courseId} />
|
||||
<MarksPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</RoleBasedSecurity>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { PeoplePage } from '$features/course/pages';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<PeoplePage courseId={data.courseId} />
|
||||
<PeoplePage />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const report = await request.json();
|
||||
|
||||
// Log the CSP violation for monitoring
|
||||
// console.warn('CSP Violation Report:', {
|
||||
// timestamp: new Date().toISOString(),
|
||||
// documentURI: report['document-uri'],
|
||||
// violatedDirective: report['violated-directive'],
|
||||
// blockedURI: report['blocked-uri'],
|
||||
// effectiveDirective: report['effective-directive'],
|
||||
// originalPolicy: report['original-policy'],
|
||||
// sourceFile: report['source-file'],
|
||||
// lineNumber: report['line-number'],
|
||||
// columnNumber: report['column-number'],
|
||||
// userAgent: request.headers.get('user-agent')
|
||||
// });
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Error processing CSP report:', error);
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
let errorType = '';
|
||||
let errorMessage = '';
|
||||
let errorTitle = '';
|
||||
let showRetry = false;
|
||||
|
||||
onMount(() => {
|
||||
errorType = $page.url.searchParams.get('error') || 'unknown';
|
||||
|
||||
switch (errorType) {
|
||||
case 'invalid_token':
|
||||
errorTitle = $t('email_verification.errors.invalid_token.title');
|
||||
errorMessage = $t('email_verification.errors.invalid_token.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'token_already_used':
|
||||
errorTitle = $t('email_verification.errors.token_used.title');
|
||||
errorMessage = $t('email_verification.errors.token_used.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'token_expired':
|
||||
errorTitle = $t('email_verification.errors.token_expired.title');
|
||||
errorMessage = $t('email_verification.errors.token_expired.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'email_mismatch':
|
||||
errorTitle = $t('email_verification.errors.email_mismatch.title');
|
||||
errorMessage = $t('email_verification.errors.email_mismatch.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'legacy_method_blocked':
|
||||
errorTitle = $t('email_verification.errors.legacy_blocked.title');
|
||||
errorMessage = $t('email_verification.errors.legacy_blocked.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'database_error':
|
||||
errorTitle = $t('email_verification.errors.database.title');
|
||||
errorMessage = $t('email_verification.errors.database.message');
|
||||
showRetry = false;
|
||||
break;
|
||||
default:
|
||||
errorTitle = $t('email_verification.errors.unknown.title');
|
||||
errorMessage = $t('email_verification.errors.unknown.message');
|
||||
showRetry = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleRetry() {
|
||||
goto('/dashboard');
|
||||
}
|
||||
|
||||
function handleSupport() {
|
||||
window.open(
|
||||
'mailto:[email protected]?subject=Email Verification Issue&body=Error Type: ' + errorType,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email Verification Error - ClassroomIO</title>
|
||||
<meta name="description" content="There was an issue verifying your email address." />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen w-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-gray-900"
|
||||
>
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="text-center">
|
||||
<!-- Error Icon -->
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{errorTitle}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{errorMessage}
|
||||
</p>
|
||||
|
||||
{#if errorType === 'legacy_method_blocked'}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
|
||||
>
|
||||
<div class="flex">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-yellow-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$t('email_verification.security_notice.title')}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{$t('email_verification.security_notice.message')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<PrimaryButton
|
||||
onClick={handleSupport}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('email_verification.actions.contact_support')}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onClick={() => goto('/')}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('email_verification.actions.back_to_dashboard')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Security Information -->
|
||||
<div class="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<p>{$t('email_verification.security_footer')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because one or more lines are too long
@@ -27,6 +27,111 @@ const config = {
|
||||
'@cio/utils': path.resolve('./node_modules/@cio/utils/dist'),
|
||||
'@cio/utils/*': path.resolve('./node_modules/@cio/utils/dist/*'),
|
||||
'@cio/db/types': path.resolve('./node_modules/@cio/db/src/types.ts')
|
||||
},
|
||||
csp: {
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': [
|
||||
'self',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://*.i.posthog.com',
|
||||
'https://*.senja.io',
|
||||
'https://www.youtube.com',
|
||||
'https://youtube.com'
|
||||
],
|
||||
'style-src': [
|
||||
'self',
|
||||
'unsafe-inline',
|
||||
'https://cdn.plyr.io',
|
||||
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
||||
'https://assets.cdn.clsrio.com/eqneditor_1.css'
|
||||
],
|
||||
'style-src-elem': [
|
||||
'self',
|
||||
'unsafe-inline',
|
||||
'https://cdn.plyr.io',
|
||||
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
||||
'https://assets.cdn.clsrio.com/eqneditor_1.css'
|
||||
],
|
||||
'font-src': ['self', 'https://fonts.gstatic.com', 'https://cdn.plyr.io'],
|
||||
'img-src': ['self', 'data:', 'https:'],
|
||||
'media-src': ['self', 'https:', 'data:'],
|
||||
'frame-src': ['self', 'https://www.youtube.com', 'https://youtube.com'],
|
||||
'connect-src': [
|
||||
'self',
|
||||
'https://*.supabase.co',
|
||||
'https://*.classroomio.com',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdn.plyr.io',
|
||||
'https://*.i.posthog.com',
|
||||
'https://umami.hz.oncws.com',
|
||||
'https://*.r2.cloudflarestorage.com',
|
||||
'http://localhost:54321',
|
||||
'ws://localhost:54321',
|
||||
'wss://*.classroomio.com',
|
||||
'wss://*.supabase.co',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'worker-src': ['self', 'blob:'],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'form-action': ['self'],
|
||||
'frame-ancestors': ['none'],
|
||||
'upgrade-insecure-requests': true
|
||||
},
|
||||
reportOnly: {
|
||||
'default-src': ['self'],
|
||||
'script-src': [
|
||||
'self',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://*.i.posthog.com',
|
||||
'https://*.senja.io',
|
||||
'https://www.youtube.com',
|
||||
'https://youtube.com'
|
||||
],
|
||||
'style-src': [
|
||||
'self',
|
||||
'unsafe-inline',
|
||||
'https://cdn.plyr.io',
|
||||
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
||||
'https://assets.cdn.clsrio.com/eqneditor_1.css'
|
||||
],
|
||||
'style-src-elem': [
|
||||
'self',
|
||||
'unsafe-inline',
|
||||
'https://cdn.plyr.io',
|
||||
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
||||
'https://assets.cdn.clsrio.com/eqneditor_1.css'
|
||||
],
|
||||
'font-src': ['self', 'https://fonts.gstatic.com', 'https://cdn.plyr.io'],
|
||||
'img-src': ['self', 'data:', 'https:'],
|
||||
'media-src': ['self', 'https:', 'data:'],
|
||||
'frame-src': ['self', 'https://www.youtube.com', 'https://youtube.com'],
|
||||
'connect-src': [
|
||||
'self',
|
||||
'https://*.supabase.co',
|
||||
'https://pgrest.classroomio.com',
|
||||
'https://api.classroomio.com',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdn.plyr.io',
|
||||
'https://*.i.posthog.com',
|
||||
'https://umami.hz.oncws.com',
|
||||
'https://*.r2.cloudflarestorage.com',
|
||||
'http://localhost:54321',
|
||||
'ws://localhost:54321',
|
||||
'wss://*.classroomio.com',
|
||||
'wss://*.supabase.co',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'worker-src': ['self', 'blob:'],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'form-action': ['self'],
|
||||
'frame-ancestors': ['none'],
|
||||
'report-uri': ['/csp-report']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -1,2 +1,19 @@
|
||||
.next
|
||||
node_modules
|
||||
|
||||
.DS_Store
|
||||
.cache
|
||||
.vercel
|
||||
.output
|
||||
.nitro
|
||||
/build/
|
||||
/api/
|
||||
/server/build
|
||||
/public/build
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.tanstack
|
||||
|
||||
src/routeTree.gen.ts
|
||||
.source
|
||||
@@ -0,0 +1 @@
|
||||
npm_config_registry=https://registry.npmjs.org
|
||||
@@ -0,0 +1 @@
|
||||
v20.19.3
|
||||
@@ -0,0 +1,137 @@
|
||||
# Migrating Docs from docs.classroomio.com to classroomio.com/docs
|
||||
|
||||
## Summary
|
||||
|
||||
This guide documents the configuration to serve the docs at `classroomio.com/docs` instead of `docs.classroomio.com` using Vercel rewrites with TanStack Start.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Docs App Configuration (`apps/docs`)
|
||||
|
||||
**a) Updated `apps/docs/vite.config.ts`:**
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000
|
||||
},
|
||||
base: '/docs', // ← Added: Vite base path for asset resolution
|
||||
plugins: [
|
||||
mdx(await import('./source.config')),
|
||||
tailwindcss(),
|
||||
tsConfigPaths({
|
||||
projects: ['./tsconfig.json']
|
||||
}),
|
||||
tanstackStart(),
|
||||
react(),
|
||||
nitro({
|
||||
baseURL: '/docs' // ← Added: Nitro base URL for server routing
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**b) Updated `apps/docs/src/router.tsx`:**
|
||||
```typescript
|
||||
export function getRouter() {
|
||||
return createTanStackRouter({
|
||||
basepath: '/docs', // ← Added: TanStack Router base path
|
||||
defaultNotFoundComponent: NotFound,
|
||||
defaultPreload: 'intent',
|
||||
routeTree,
|
||||
scrollRestoration: true
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
These three configurations work together:
|
||||
- `base: '/docs'` - Tells Vite to prefix all asset paths with `/docs`
|
||||
- `baseURL: '/docs'` - Tells Nitro to handle all routes under `/docs`
|
||||
- `basepath: '/docs'` - Tells TanStack Router that routes are under `/docs`
|
||||
|
||||
### 2. Main Site Configuration (`apps/classroomio-com`)
|
||||
|
||||
**Created `apps/classroomio-com/vercel.json`**:
|
||||
```json
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/docs/",
|
||||
"destination": "https://ciodocs.vercel.app/docs/"
|
||||
},
|
||||
{
|
||||
"source": "/docs/:path*",
|
||||
"destination": "https://ciodocs.vercel.app/docs/:path*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This proxies all `/docs/*` requests from `classroomio.com` to your docs deployment.
|
||||
|
||||
### 3. Cleanup
|
||||
|
||||
- Removed empty `apps/classroomio-com/src/routes/(redirects)/docs/` folder that would conflict with the Vercel rewrite
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **User visits:** `classroomio.com/docs/home`
|
||||
2. **Vercel receives:** Request for `/docs/home`
|
||||
3. **Vercel rewrites to:** `ciodocs.vercel.app/docs/home`
|
||||
4. **Docs app serves:** The page from its `/docs/home` route (configured with `basepath: '/docs'`)
|
||||
5. **User sees:** Content at `classroomio.com/docs/home` (URL unchanged)
|
||||
|
||||
The key is that **both the rewrite and the docs app preserve the `/docs` path**, keeping everything aligned.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### Step 1: Deploy/Redeploy the Docs App
|
||||
|
||||
The docs app needs to be built and deployed with the `basepath: '/docs'` configuration:
|
||||
|
||||
1. Make sure changes to `apps/docs/src/router.tsx` are committed
|
||||
2. Deploy or redeploy to `ciodocs.vercel.app`
|
||||
3. Vercel will automatically detect and deploy the changes
|
||||
|
||||
**Vercel Project Settings for Docs:**
|
||||
- **Root Directory:** `apps/docs`
|
||||
- **Build Command:** `pnpm build`
|
||||
- **Output Directory:** `.output`
|
||||
- **Domain:** `ciodocs.vercel.app`
|
||||
|
||||
### Step 2: Deploy/Redeploy the Main Site
|
||||
|
||||
The main site needs the `vercel.json` rewrite configuration:
|
||||
|
||||
1. Make sure `apps/classroomio-com/vercel.json` is committed
|
||||
2. Deploy or redeploy to `classroomio.com`
|
||||
|
||||
**Vercel Project Settings for Main Site:**
|
||||
- **Root Directory:** `apps/classroomio-com`
|
||||
- **Build Command:** `pnpm build`
|
||||
- **Output Directory:** `.svelte-kit`
|
||||
- **Domain:** `classroomio.com`
|
||||
|
||||
### Step 3: Test
|
||||
|
||||
Once both are deployed:
|
||||
|
||||
1. ✅ Visit `classroomio.com/docs` - should show docs homepage
|
||||
2. ✅ Visit `classroomio.com/docs/quickstart/signup` - should show specific doc pages
|
||||
3. ✅ Check that styles and JavaScript load correctly
|
||||
4. ✅ Test navigation within docs
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User Request: classroomio.com/docs/quickstart/signup
|
||||
↓
|
||||
Vercel (classroomio.com) - reads vercel.json
|
||||
↓
|
||||
Rewrite to: ciodocs.vercel.app/docs/quickstart/signup
|
||||
↓
|
||||
Docs App (TanStack Start with basepath: '/docs')
|
||||
↓
|
||||
Serves: /quickstart/signup route
|
||||
↓
|
||||
User sees content at: classroomio.com/docs/quickstart/signup
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Shu Ding
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+11
-28
@@ -1,31 +1,14 @@
|
||||
# ClassroomIO Documentation
|
||||
# docs
|
||||
|
||||
The help docs of classroomio in Next.js live on [https://classroomio.com/docs](https://classroomio.com/docs)
|
||||
This is a Tanstack Start application generated with
|
||||
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||
|
||||
## Development
|
||||
Run development server:
|
||||
|
||||
### Local setup
|
||||
|
||||
1. Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/rotimi-best/classroomio.git
|
||||
```
|
||||
|
||||
2. Go to project folder
|
||||
|
||||
```bash
|
||||
cd classroomio
|
||||
```
|
||||
|
||||
3. Install the required dependencies
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
|
||||
4. Run (in development mode)
|
||||
|
||||
```bash
|
||||
pnpm dev --filter=docs
|
||||
```
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.counter {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 2px 6px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Example from https://beta.reactjs.org/learn
|
||||
|
||||
import { useState } from 'react'
|
||||
import styles from './counters.module.css'
|
||||
|
||||
function MyButton() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
function handleClick() {
|
||||
setCount(count + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleClick} className={styles.counter}>
|
||||
Clicked {count} times
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MyApp() {
|
||||
return <MyButton />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Audience
|
||||
description: Information about ClassroomIO's target audience.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Community
|
||||
description: Join the ClassroomIO community.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
+10
-8
@@ -1,6 +1,9 @@
|
||||
# Adding Translation To Your Code - Developer's Guide
|
||||
---
|
||||
title: Adding Translation To Your Code - Developer's Guide
|
||||
description: Developer's guide to adding translations to the code.
|
||||
---
|
||||
|
||||
In this guide, we'll walk through the process of adding translations to your code. This enables multi-translation support on the platform, enhancing the experience for every users.
|
||||
In this guide, we'll walk through the process of adding translations to your code. This enables multi-translation support on the platform, enhancing the experience for every user.
|
||||
|
||||
## Obtain Your RapidAPI Key
|
||||
|
||||
@@ -18,15 +21,15 @@ Follow these steps:
|
||||
|
||||
Once you have your API Key, follow these steps to add a translation:
|
||||
|
||||
1. Open the English file named `en` located in `apps\dashboard\src\lib\utils\translations\en.json`.
|
||||
1. Open the English file named `en` located in `apps/dashboard/src/lib/utils/translations/en.json`.
|
||||
2. Create an object with a name corresponding to the translation you wish to add.
|
||||
Example: `"tabs": { "profile": "Profile", "landing_page": "Landing Page" }`.
|
||||
3. Now, navigate to the file where you want to insert the translation.
|
||||
4. Import the translation function:
|
||||
|
||||
````bash copy
|
||||
```bash
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
````
|
||||
```
|
||||
|
||||
5. Locate the line where you want to add the translation and use the following syntax: `$t('path to the value in the object')`.
|
||||
Example: `<p>{$t('tabs.profile')}</p>` (This will output "Profile").
|
||||
@@ -36,9 +39,8 @@ import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
Once you've updated the English file, you need to update the other files. To do this, run a command. Firstly ensure you're still in the `/apps/dashboard` directory, then execute:
|
||||
|
||||
````bash copy
|
||||
```zsh title="Terminal"
|
||||
pnpm run script:translate
|
||||
````
|
||||
```
|
||||
|
||||
This command runs a script that updates the other translation files.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Cloudflare Setup
|
||||
description: Guide to setting up Cloudflare for ClassroomIO.
|
||||
---
|
||||
|
||||
In this guide, you will learn how to get your Cloudflare environment variables. We use Cloudflare R2 for video uploads and the aim of this guide is to help you get the right variables to properly set up our api.
|
||||
+10
-26
@@ -1,6 +1,9 @@
|
||||
import { Callout } from 'nextra/components';
|
||||
---
|
||||
title: Login with Demo Accounts
|
||||
description: Quick login guide using demo accounts for testing.
|
||||
---
|
||||
|
||||
# Login quickly with Demo Accounts
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
This guide will help you get started quickly once you setup our local environment and you want to start using the [dashboard](https://app.classroomio.com).
|
||||
|
||||
@@ -9,7 +12,7 @@ This guide will help you get started quickly once you setup our local environmen
|
||||
and you have `dashboard` running locally.
|
||||
</Callout>
|
||||
|
||||
### Admin Login Credentials.
|
||||
## Admin Login Credentials
|
||||
|
||||
Once you have the `dashboard` service running locally and you have setup supabase, we added seed data that can allow you access an admin account to a demo organization and a student account to a student dashboard. This allows you see what ClassroomIO can do quickly without trying to figure it out.
|
||||
|
||||
@@ -17,34 +20,15 @@ Once you have the `dashboard` service running locally and you have setup supabas
|
||||
|
||||
**Password:** 123456
|
||||
|
||||
<div className="nx-w-full nx-flex nx-items-center nx-justify-center">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginY: '16px' }}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/RfE4tQ_BEds?si=uXpYKokMg4SWMkQX"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
className="nx-my-4 nx-rounded-md"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
### Student Login Credentials
|
||||
|
||||
**Login:** [email protected]
|
||||
|
||||
**Password:** 123456
|
||||
|
||||
<div className="nx-w-full nx-flex nx-items-center nx-justify-center">
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/FG7B9rKjVi4?si=TeY088zf8OsELocC"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
className="nx-my-4 nx-rounded-md"
|
||||
allowfullscreen
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '8px' }}
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Design Contribution Guide
|
||||
description: Guidelines for contributing design improvements.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
+6
-3
@@ -1,6 +1,9 @@
|
||||
# Developers Contribution Guide
|
||||
---
|
||||
title: Developers Contribution Guide
|
||||
description: Guidelines for contributing code to ClassroomIO.
|
||||
---
|
||||
|
||||
First and foremost, we're absolutely thrilled that you're considering becoming a part of the community
|
||||
First and foremost, we're absolutely thrilled that you're considering becoming a part of the community.
|
||||
|
||||
## Issue Hunters
|
||||
|
||||
@@ -14,7 +17,7 @@ We invite innovative minds to contribute to the evolution of ClassroomIO. If you
|
||||
|
||||
Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://classroomio.com/docs/contributor-guides/gitpod)
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](/docs/contributor-guides/gitpod)
|
||||
|
||||
2. **Implementation:** Code it out, test it and apply your changes.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Gitpod Setup
|
||||
description: Guide to setting up Gitpod for ClassroomIO development.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## One Click Setup
|
||||
|
||||
- This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
|
||||
- Click the button below to open this project in Gitpod.
|
||||
|
||||
[](https://gitpod.io/#https://github.com/rotimi-best/classroomio)
|
||||
|
||||
<Callout title="Note">
|
||||
Gitpod does not utilize `localhost` for accessing services. Instead, it uses its own URL after
|
||||
starting all the necessary services. The correct URLs to access the project are automatically
|
||||
inserted into the `.env` file upon initialization.
|
||||
</Callout>
|
||||
|
||||
### Viewing URLs in Terminals
|
||||
|
||||
To view the URLs, follow these steps:
|
||||
|
||||
1. Open your terminal.
|
||||
2. Click on the `port` to see the URLs.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"devs": "Developers Guide",
|
||||
"design": "Design Contribution Guide",
|
||||
"supabase-cloud": "Supabase Cloud Setup",
|
||||
"cloudflare-setup": "Cloudflare Setup",
|
||||
"gitpod": "Gitpod Setup",
|
||||
"demo-accounts": "Login with Demo Accounts",
|
||||
"student-dashboard": "Student Dashboard Guide",
|
||||
"adding-translation-to-code": "Adding Translations to Code"
|
||||
}
|
||||
+10
-7
@@ -1,6 +1,9 @@
|
||||
import { Callout } from 'nextra/components';
|
||||
---
|
||||
title: Student Dashboard - Developers Guide
|
||||
description: Guide to setting up the student dashboard locally.
|
||||
---
|
||||
|
||||
# Student Dashboard - Developers guide
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In this guide I will be showing you how to set up a local student dashboard that a student will see. This dashboard will allow students to view their grades, attendance, and other information. This dashboard will also allow students to submit assignments and view their grades for those assignments.
|
||||
|
||||
@@ -8,7 +11,7 @@ In this guide I will be showing you how to set up a local student dashboard that
|
||||
|
||||
### Requirements
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
<Callout title="ℹ️ Important">
|
||||
Make sure to use 2 different browser windows: The reason is that one will be for the admin and
|
||||
another for the students. For the student we will need to set the cookies value to hold the
|
||||
organization siteName since we are using the subdomain to access the student dashboard like on
|
||||
@@ -17,15 +20,15 @@ In this guide I will be showing you how to set up a local student dashboard that
|
||||
|
||||
### Demo
|
||||
|
||||
<div className="nx-w-full nx-flex nx-items-center nx-justify-center">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginY: '16px' }}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/QGTnK39xexs?si=ogFbyl2KK0XaFob4"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
className="nx-my-4 nx-rounded-md"
|
||||
allowfullscreen
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '8px' }}
|
||||
></iframe>
|
||||
</div>
|
||||
+53
-34
@@ -1,13 +1,10 @@
|
||||
import Image from 'next/image';
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
import AuthSettings from './images/auth-settings.webp';
|
||||
import DBPushResult from './images/db-push-result.webp';
|
||||
import DBUrl from './images/db-url.webp';
|
||||
import MatchEnvVariables from './images/match-env.webp';
|
||||
import NewProject from './images/new-project.webp';
|
||||
import SmtpSettings from './images/smtp-settings.webp';
|
||||
---
|
||||
title: Supabase Cloud Setup
|
||||
description: Guide to setting up Supabase cloud for ClassroomIO.
|
||||
---
|
||||
|
||||
# Supabase Cloud Setup
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In this guide, you will learn how to setup a supabase cloud project and link it to your local environment.
|
||||
|
||||
@@ -16,89 +13,104 @@ In this guide, you will learn how to setup a supabase cloud project and link it
|
||||
1. You don't want to setup supabase on your local machine
|
||||
2. You can't run docker on your computer
|
||||
3. You are experiencing performance issues using supabase locally
|
||||
4. You want to selfhost our frontend app but use supabase cloud.
|
||||
4. You want to selfhost our frontend app but use supabase cloud
|
||||
|
||||
If your requirement matches any of the above, then this guide is for you.
|
||||
|
||||
## Video Walkthrough
|
||||
|
||||
<div className="nx-w-full nx-flex nx-items-center nx-justify-center">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginY: '16px' }}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/dAZAZ0oiky0?si=1h-KtppNqJ3U4BTe"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
className="nx-my-4 nx-rounded-md"
|
||||
allowfullscreen
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '8px' }}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## Step by Step guide
|
||||
## Step by Step Guide
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
### Create a Supabase project
|
||||
|
||||
For this step you need to have signed up for supabase and create an account, just visit [supabase.com](https://supabase.com).
|
||||
|
||||
Once you have an account just click on the _New Project_ on your dashboard. After that you should see a similar screen to the below, fill in the fields.
|
||||
|
||||
<Image src={NewProject} alt="New project in supabase" quality="100" />
|
||||

|
||||
|
||||
<Callout>
|
||||
<Callout title="Important">
|
||||
Make sure you save your database password somewhere, else you won't be able to get it back
|
||||
anywhere in the dashboard for security reasons.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Setup Supabase CLI
|
||||
|
||||
Follow the instructions [here](https://supabase.com/docs/guides/local-development/cli/getting-started) to setup the Supabase CLI on your computer.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Login to Supabase via the CLI
|
||||
|
||||
Open your terminal and go to the root of the project. Run the following:
|
||||
|
||||
```bash copy
|
||||
```zsh title="Terminal"
|
||||
supabase login
|
||||
```
|
||||
|
||||
Follow the instruction in your terminal to generate your access token. Paste the token in the console and hit **Enter**
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Push migrations to your Supabase project
|
||||
|
||||
- Go to the supabase dashboard, go to your project, click on the settings icon on the left side bar
|
||||
|
||||
<Image src={DBUrl} alt="DB URL in the supabase dashboard" quality="100" />
|
||||

|
||||
|
||||
- Copy the URL in the image above for your `db url`
|
||||
- Paste it somewhere
|
||||
- In the url, replace `[YOUR PASSWORD]` with your db password (which you saved in section 1).
|
||||
- Then run in your terminal:
|
||||
|
||||
```bash copy
|
||||
```zsh title="Terminal"
|
||||
supabase db push --db-url "URL HERE"
|
||||
```
|
||||
|
||||
<Callout>
|
||||
<Callout title="Important">
|
||||
Make sure you preserve the double quotes before and after the url. Only replace `URL HERE` with
|
||||
your `db url` that contains your password
|
||||
</Callout>
|
||||
|
||||
#### Result
|
||||
|
||||
<Image src={DBPushResult} alt="Result of supabase db push" quality="100" />
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure Auth in your Supabase project
|
||||
|
||||
Here we will update our email configurations, to prevent supabase from sending confirmation emails on signup. This is because we don't use Supabase for email verification, we handle that internally due to the quality of the email.
|
||||
|
||||
<Image src={AuthSettings} alt="Authentication configuration" quality="100" />
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure SMTP
|
||||
|
||||
Supabase [now requires adding custom SMTP](https://github.com/orgs/supabase/discussions/29370) to your supabase project in order to avoid abuse of their system. Without adding a custom SMTP, your signup requests to your supabase project will fail. To avoid this, you should go to the [STMP section](https://supabase.com/dashboard/project/_/settings/auth) of your supabase settings.
|
||||
Supabase [now requires adding custom SMTP](https://github.com/orgs/supabase/discussions/29370) to your supabase project in order to avoid abuse of their system. Without adding a custom SMTP, your signup requests to your supabase project will fail. To avoid this, you should go to the [SMTP section](https://supabase.com/dashboard/project/_/settings/auth) of your supabase settings.
|
||||
|
||||
Make sure to toggle **Enable Custom SMTP** and fill in the following fields:
|
||||
|
||||
@@ -109,40 +121,47 @@ Make sure to toggle **Enable Custom SMTP** and fill in the following fields:
|
||||
- SMTP username
|
||||
- SMTP password
|
||||
|
||||
<Image src={SmtpSettings} alt="SMTP Settings" quality="100" />
|
||||

|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
<Callout title="ℹ️ Learn More">
|
||||
Learn more about setting up SMTP [here](https://supabase.com/docs/guides/auth/auth-smtp)
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add configurations to your environment variables
|
||||
|
||||
Now with all these set up you need to get update the .env file in `apps/dashboard`. You need to update the following environment variables:
|
||||
|
||||
```bash copy
|
||||
```zsh title="env"
|
||||
PUBLIC_SUPABASE_URL=
|
||||
PUBLIC_SUPABASE_ANON_KEY=
|
||||
PRIVATE_SUPABASE_SERVICE_ROLE=
|
||||
```
|
||||
|
||||
To get the correct environment variables, follow the instructions in this image
|
||||
To get the correct environment variables, follow the instructions in this image:
|
||||
|
||||
<Image src={MatchEnvVariables} alt="Source of supabase environment variables" quality="100" />
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Tada - That's all
|
||||
|
||||
Now you can run:
|
||||
|
||||
```bash copy
|
||||
```zsh title="Terminal"
|
||||
pnpm dev --filter=dashboard
|
||||
```
|
||||
|
||||
- Open the url in your browser
|
||||
- Click on Sign up
|
||||
- And that's it ...
|
||||
- And that's it...
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
Visit the [Sign up guide](/quickstart/signup) to learn how to signup
|
||||
<Callout title="ℹ️ Learn More">
|
||||
Visit the [Sign up guide](/docs/quickstart/signup) to learn how to signup
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Course
|
||||
description: Learn about courses in ClassroomIO.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Welcome to ClassroomIO
|
||||
description: A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations.
|
||||
---
|
||||
|
||||
<img
|
||||
alt="A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations"
|
||||
src="https://brand.cdn.clsrio.com/og/classroomio-og.png"
|
||||
/>
|
||||
|
||||
Welcome to the ClassroomIO documentation—your comprehensive guide to streamlining training for everyone. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. Run multiple classes and cohorts from a single UI with our mobile-first application, ensuring students can access all published content from any device.
|
||||
|
||||
## What You'll Find Here
|
||||
|
||||
This documentation covers everything from setup to usage, including:
|
||||
|
||||
- **Core Features**: Well-detailed explanations of ClassroomIO's capabilities and how to take advantage of them
|
||||
- **Developer & Designer Resources**: Everything needed to successfully contribute to the project
|
||||
- **Practical Guides**: From simple functionalities to complex use cases
|
||||
|
||||
If something isn't covered in this documentation, please feel free to [open an issue](https://github.com/classroomio/classroomio/issues) and we'll address it.
|
||||
|
||||
## Get Started
|
||||
|
||||
Ready to begin? Check out our [What is ClassroomIO](/docs/what-is-classroomio) page to learn about the platform's key features and capabilities.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: How to Create Certificates
|
||||
description: Learn how to create certificates for your course.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
+8
-15
@@ -1,33 +1,26 @@
|
||||
import Image from 'next/image';
|
||||
import CourseLandingPage from './images/course-landing-page.webp';
|
||||
import CoursePage from './images/course-page.png';
|
||||
import LandingPage from './images/landing page.png';
|
||||
|
||||
# Create a Course Landing Page
|
||||
---
|
||||
title: Create a Course Landing Page
|
||||
description: Learn how to create a course landing page.
|
||||
---
|
||||
|
||||
ClassroomIO empowers you to design a course landing page, enabling you to showcase your courses to prospective students. On this page, students can explore course details, enrollment statistics, fees (if applicable), and course availability, enabling them to make informed choices. To access this, from your admin dashboard, navigate to **Settings** > **Landing Page.**
|
||||
|
||||
<br />
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Go to the Courses page
|
||||
|
||||
Once you are logged in, click on **Courses** on the left sidebar, you will be taken to your course list page where you can access the courses you have created. For this tutorial, we will focus on the **Intro to Javascript** course diaplayed in the image below. Click on the desired course to proceeed to it's respective dashboard.
|
||||
<br />
|
||||
Once you are logged in, click on **Courses** on the left sidebar, you will be taken to your course list page where you can access the courses you have created. For this tutorial, we will focus on the **Intro to Javascript** course displayed in the image below. Click on the desired course to proceed to its respective dashboard.
|
||||
|
||||
<Image src={CourseLandingPage} alt="ClassroomIO Landing Page" quality={100} />
|
||||

|
||||
|
||||
### 2. Open the Landing page editor
|
||||
|
||||
Select the "**Landing page**" option from the sidebar, which will lead you to the settings for your landing page. Here, you can customize various aspects of your course, including the course title, fees (if applicable), requirements, description, and more.
|
||||
<br />
|
||||
|
||||
<Image src={CoursePage} alt="ClassroomIO Course Page" quality={100} />
|
||||

|
||||
|
||||
### 3. Edit and Save your changes
|
||||
|
||||
After making the desired adjustments, ensure to save your changes by clicking on the "**Save**" button located at the top-left corner of the screen. Then, to preview the updated landing page, click on the arrow button situated to the right of the "**Save**" button.
|
||||
<br />
|
||||
|
||||
<Image src={LandingPage} alt="ClassroomIO Course Page Settings" quality={100} />
|
||||

|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: How to Create an Exercise
|
||||
description: Learn how to create exercises in ClassroomIO.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
In ClassroomIO, exercises are a great way to assess students' understanding through practice activities. Once you've [set up your organization](/docs/how-to-guides/customize-organization), [created a course](/docs/how-to-guides/create-first-course), and [added a lesson](/docs/how-to-guides/create-first-course#adding-lessons), you can start creating exercises. Follow the process below to create your first exercise.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Go to the Courses Page
|
||||
|
||||
Access the courses page from the dashboard by clicking on **Courses** via the sidebar menu.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Select a Course
|
||||
|
||||
On the courses page, click on a course to go to the course page.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Select a Lesson
|
||||
|
||||
On the course page, click on **Lessons** via the left sidebar menu and select the lesson you want to create an exercise for.
|
||||
|
||||
> Alternatively, you can click on the **⌄** icon next to the **Lessons** option to select a lesson.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add an Exercise
|
||||
|
||||
Locate the tabs at the top of the page and select the **Exercises** tab. Then click on the **Add** button. On the modal, select a method to create an exercise. You can create an exercise:
|
||||
|
||||

|
||||
|
||||
1. **From scratch**: Create an exercise by adding the questions yourself. This is handy if you've prepared your questions.
|
||||
- Enter the exercise name and click on the **Finish** button.
|
||||
- Click on the newly created exercise and provide the exercise description, due date, and question details.
|
||||
|
||||

|
||||
|
||||
2. **Using a template**: You can select from an array of templates with predefined exercises complete with questions and options.
|
||||
- Select a template and click on the **Finish** button.
|
||||
|
||||

|
||||
|
||||
3. **Using AI**: AI helps you generate exercises directly from lesson notes.
|
||||
- Enter the number of questions and options. Next, click on the **Generate** button.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Preview Exercise
|
||||
|
||||
To preview the exercise, click on the **preview icon**. This is to ensure the exercise looks and functions as expected.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add Questions
|
||||
|
||||
To add a question, click on the **add icon**.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Reorder Questions
|
||||
|
||||
To reorder questions, click on the **three dots** > **Reorder Questions**, then drag and drop the questions.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Save Changes
|
||||
|
||||
After previewing, adding, and rearranging questions, ensure to save your changes by clicking on the **Save** button. This will apply your modifications to the exercise.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Your Exercise Is Ready
|
||||
|
||||
You've successfully created your first exercise and it is now accessible to students enrolled in the course. Move on to the next section to start grading your exercise.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
+19
-9
@@ -1,19 +1,24 @@
|
||||
import { Steps, Callout } from 'nextra/components';
|
||||
---
|
||||
title: Creating Your First Course
|
||||
description: Learn how to create your first course on ClassroomIO.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
# Creating Your First Course
|
||||
|
||||
Below is a video guiding you through the process of creating your inaugural course. Should you have any inquiries, feel free to drop a comment in the YouTube comment section, reach out to us on **[Discord](https://discord.gg/BSaEzcfu)**, or send a DM on **[Twitter](https://x.com/rotimi_best?s=20)**.
|
||||
|
||||
<div className="nx-w-full nx-flex nx-items-center nx-justify-center">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginY: '16px' }}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/ztiZ6vo2gg0?si=v_UUL1YUPyDQ4_pp"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
className="nx-my-4 nx-rounded-md"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '8px' }}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
@@ -44,22 +49,27 @@ Following course creation, consider the following enhancements:
|
||||
Adding lessons to a course is achieved through the following steps:
|
||||
|
||||
<Steps>
|
||||
### Click the Lesson Link
|
||||
<Step>
|
||||
### Click the Lesson Link
|
||||
|
||||
On the course page, click "Lessons" in the sidebar to access the lessons page. Click the "Add" button in the top right corner to open the Add Lesson modal.
|
||||
|
||||
> Alternatively, add a lesson on-the-go by clicking the '+' icon next to the lesson link in the sidebar.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Fill in the Lesson Details
|
||||
|
||||
Provide the lesson title, select a tutor, date, and optionally lock the lesson or notes.
|
||||
|
||||
<Callout type="info" emoji="💡">
|
||||
<Callout title="💡 Tip">
|
||||
Locking the lesson renders it inaccessible to students upon course publication.
|
||||
</Callout>
|
||||
|
||||
Save your changes to be redirected to the lesson page for material addition.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
For each lesson, you may furnish materials such as notes, slides, and videos by clicking the "Edit" button on the top right corner of the **Materials** tab.
|
||||
@@ -85,7 +95,7 @@ To embed slides, navigate to the slides tab and input the link to the slides. Cl
|
||||
|
||||
Embedding videos is simple: select the "Videos" tab to open the modal for adding videos. From the "Add By" options in the modal, select the "YouTube Link" tab. Input the link to the YouTube video and click the "Add Video" button. Once added, the video is automatically included in your materials. You can delete the video using the trash icon.
|
||||
|
||||
<Callout emoji="🚨">
|
||||
<Callout type="warn">
|
||||
Please note that for now, we do not support adding videos from local sources.
|
||||
</Callout>
|
||||
|
||||
+36
-16
@@ -1,15 +1,15 @@
|
||||
import Image from 'next/image';
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
import DommainSetupOne from './images/domain-setup-one.webp';
|
||||
import DommainSetupTwo from './images/domain-setup-two.webp';
|
||||
import DommainSetupThree from './images/domain-setup-three.webp';
|
||||
import CustomizeOrg from './images/customize-organization.webp';
|
||||
---
|
||||
title: Customize Your Organization
|
||||
description: Learn how to customize your organization settings.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
# Customize Your Organization
|
||||
|
||||
ClassroomIO empowers every organization to tailor their experience to their unique needs. In this guide, you will learn how to customize your organization settings. To access this, from your admin dashboard, navigate to **Settings** > **Organization**.
|
||||
|
||||
<Image src={CustomizeOrg} alt="ClassroomIO Organization settings page" quality={100} />
|
||||

|
||||
|
||||
## Update Organization Profile
|
||||
|
||||
@@ -18,27 +18,42 @@ Easily update your organization's profile by modifying the designated fields in
|
||||
To update your organization profile details, follow these steps:
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Go to Organization Settings
|
||||
|
||||
From your dashboard, click on Settings, then select Organization in the tabs of the settings page.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Change Your Organization Name
|
||||
|
||||
Fill the Organization name field with your preferred name.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Click on Upload Image
|
||||
|
||||
Click the Upload Image button to select an image for upload.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Select an Image
|
||||
|
||||
Choose a logo or image of your choice from your device.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Click Update Organization Button
|
||||
|
||||
Save the changes by clicking the **Update Organization** button.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
By clicking the **Update Organization** button, the updated preferences in the Organization section will be applied across the entire dashboard.
|
||||
|
||||
## Customize Domain
|
||||
@@ -46,33 +61,38 @@ By clicking the **Update Organization** button, the updated preferences in the O
|
||||
Upon onboarding, each organization in ClassroomIO is assigned a custom Domain URL. This URL directs users to the organization's customizable website, making it easy for audiences to access published courses, FAQs, and other relevant information tailored to your organization's preferences. To customize your organization's domain URL, follow these steps:
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Click on "Edit Domain"
|
||||
|
||||
Click the Edit Domain button to navigate to the domain setup page.
|
||||
|
||||
<Image src={DommainSetupOne} alt="Custom Domain setup" quality={100} />
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Update the URL Field with Your Preferred Choice
|
||||
|
||||
Fill the field with your desired URL.
|
||||
|
||||
<Image src={DommainSetupTwo} alt="Edit Custom domain URL" quality={100} />
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Save Changes
|
||||
|
||||
Click on Update Domain to save the changes.
|
||||
|
||||
> You can click on **View Site** to be redirected to your Organization website with the updated URL.
|
||||
|
||||
<Image src={DommainSetupThree} alt="Edit Custom domain URL" quality={100} />
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info" emoji="⏳">
|
||||
Coming soon: Organizations can also integrate existing domain names.
|
||||
</Callout>
|
||||
<Callout title="⏳ Coming Soon">Organizations can also integrate existing domain names.</Callout>
|
||||
|
||||
## Set Brand Color
|
||||
|
||||
We understand that every organization has a preferred color that suits their brand hence we offer organizations the flexibility to choose a theme for their brand from a variety of colors like **blue**, **green**, **orange**, and more.
|
||||
To select a brand color for your organization simply click on any of the provided color theme and this would be automatically applied accross your entire application.
|
||||
We understand that every organization has a preferred color that suits their brand, hence we offer organizations the flexibility to choose a theme for their brand from a variety of colors like **blue**, **green**, **orange**, and more. To select a brand color for your organization simply click on any of the provided color theme and this would be automatically applied across your entire application.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: How to Grade an Exercise
|
||||
description: Learn how to grade exercises submitted by students.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: How to Invite Students
|
||||
description: Learn how to invite students to your courses.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Inviting a Team Member
|
||||
description: Learn how to invite team members to your organization.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"create-first-course": "Creating Your First Course",
|
||||
"create-exercise": "How to Create an Exercise",
|
||||
"customize-organization": "Customize Your Organization",
|
||||
"course-landingpage": "Create a Course Landing Page",
|
||||
"setup-profile": "Setup Profile",
|
||||
"certificates": "How to Create Certificates",
|
||||
"grade-exercise": "How to Grade an Exercise",
|
||||
"invite-students": "How to Invite Students",
|
||||
"invite-team-member": "Inviting a Team Member",
|
||||
"take-attendance": "How to Take Attendance"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Setup Profile
|
||||
description: Learn how to setup your profile on ClassroomIO.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Go to ClassroomIO
|
||||
|
||||
Go to [app.classroomio.com](https://app.classroomio.com). Click on the "Login" button on the top right to take you to the Login page.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Sign In
|
||||
|
||||
Sign in with your email & password.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Go to the Settings page
|
||||
|
||||
Click on the "Settings" button on the lower left corner on your dashboard page and you'll be routed to the profile page.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Make Changes
|
||||
|
||||
Upload a profile picture and edit any input field you want to then click "Update Profile".
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: How to Take Attendance
|
||||
description: Learn how to take attendance for your classes.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"pages": [
|
||||
"---Introduction---",
|
||||
"home",
|
||||
"what-is-classroomio",
|
||||
"quickstart",
|
||||
|
||||
"---Core Features---",
|
||||
"course",
|
||||
"audience",
|
||||
"community",
|
||||
"organization",
|
||||
"student-dashboard",
|
||||
|
||||
"---Guides---",
|
||||
"how-to-guides",
|
||||
"contributor-guides"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Organization
|
||||
description: Learn about organizations in ClassroomIO.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
|
||||
We will write about organizations here.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"signup": "Signup",
|
||||
"signup": "Sign Up",
|
||||
"onboarding": "Onboarding",
|
||||
"self-hosting": "Self-hosting"
|
||||
}
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
# Onboarding
|
||||
---
|
||||
title: Onboarding
|
||||
description: Your onboarding checklist after creating an account.
|
||||
---
|
||||
|
||||
|
||||
Welcome again to ClassroomIO and we are super excited you are giving it a try. On this page you will find your onboarding checklist, the first thing you need to do after you've created an account.
|
||||
|
||||
+24
-24
@@ -1,18 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
import { Callout } from 'nextra/components';
|
||||
import SelfhostClassroomIO from './images/selfhost-classroomio.webp';
|
||||
---
|
||||
title: Self-hosting ClassroomIO
|
||||
description: Guide to self-hosting ClassroomIO on your own servers.
|
||||
---
|
||||
|
||||
# Self-hosting ClassroomIO.com
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
This guide will help you quickly selfhost ClassroomIO
|
||||
This guide will help you quickly self-host ClassroomIO.
|
||||
|
||||
<Image src={SelfhostClassroomIO} alt="Selfhost ClassroomIO" quality="100" />
|
||||

|
||||
|
||||
ClassroomIO is made up of the following applications
|
||||
ClassroomIO is made up of the following applications:
|
||||
|
||||
- [apps/api](https://github.com/rotimi-best/classroomio/tree/main/apps/api) - an Express server
|
||||
- [apps/dashboard](https://github.com/rotimi-best/classroomio/tree/main/apps/dashboard) - the admin dashboard + LMS
|
||||
- [supabase](https://github.com/rotimi-best/classroomio/tree/main/supabase) - the database + REST API.
|
||||
- [supabase](https://github.com/rotimi-best/classroomio/tree/main/supabase) - the database + REST API
|
||||
|
||||
The quickest and easiest way to deploy both the api and dashboard is by using our [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5). However there are a couple of things that you'd need first before heading over to the template, which are:
|
||||
|
||||
@@ -24,7 +26,7 @@ The quickest and easiest way to deploy both the api and dashboard is by using ou
|
||||
|
||||
Both for the supabase and api setup, you'd need a custom SMTP for email sending. Most importantly you need these values:
|
||||
|
||||
```bash
|
||||
```zsh title="env"
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
@@ -32,22 +34,22 @@ SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
```
|
||||
|
||||
Make sure you have them handy because you'd need them while we go through this selfhosting guide.
|
||||
Make sure you have them handy because you'd need them while we go through this self-hosting guide.
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
<Callout title="Learn More">
|
||||
Learn more about setting up a custom SMTP server
|
||||
[here](https://supabase.com/docs/guides/auth/auth-smtp)
|
||||
</Callout>
|
||||
|
||||
## Supabase
|
||||
|
||||
There are 2 options to go about setting up Supabase, you could [selfhost supabase](https://supabase.com/docs/guides/self-hosting) on your own servers or you could use their cloud version. We recommend you use the cloud version because it has a very generous free tier and you get to deploy it very quickly.
|
||||
There are 2 options to go about setting up Supabase, you could [self-host supabase](https://supabase.com/docs/guides/self-hosting) on your own servers or you could use their cloud version. We recommend you use the cloud version because it has a very generous free tier and you get to deploy it very quickly.
|
||||
|
||||
We have a standalone guide to help you setup your Supabase Cloud and you can find it [here](https://classroomio.com/docs/contributor-guides/supabase-cloud)
|
||||
We have a standalone guide to help you setup your Supabase Cloud and you can find it [here](/docs/contributor-guides/supabase-cloud).
|
||||
|
||||
After you go through that guide you will need the following environment variables, please also store them somewhere:
|
||||
|
||||
```bash
|
||||
```zsh title="env"
|
||||
PUBLIC_SUPABASE_URL=
|
||||
PUBLIC_SUPABASE_ANON_KEY=
|
||||
PRIVATE_SUPABASE_SERVICE_ROLE=
|
||||
@@ -57,9 +59,7 @@ PRIVATE_SUPABASE_SERVICE_ROLE=
|
||||
|
||||
We use Cloudflare R2 for video uploading within the course builder. It is not required though and you can do without it in the app if you don't need video upload.
|
||||
|
||||
<Callout type="info" emoji="⏳">
|
||||
This section is a work in progress.
|
||||
</Callout>
|
||||
<Callout title="Work in Progress">This section is a work in progress.</Callout>
|
||||
|
||||
## Deploy on Railway
|
||||
|
||||
@@ -69,11 +69,11 @@ Go ahead and click this button to get started.
|
||||
|
||||
[](https://railway.app/template/dl9RG7?referralCode=8OyIj5)
|
||||
|
||||
### Environment Variables.
|
||||
### Environment Variables
|
||||
|
||||
#### cio-api
|
||||
|
||||
```bash
|
||||
```zsh title="env"
|
||||
# Required
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
@@ -92,14 +92,14 @@ CLOUDFLARE_SECRET_ACCESS_KEY=""
|
||||
SENTRY_DNS=""
|
||||
```
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
<Callout title="Learn More">
|
||||
Learn more about the environment variables
|
||||
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/api/.env.example)
|
||||
</Callout>
|
||||
|
||||
#### cio-dashboard
|
||||
|
||||
```bash
|
||||
```zsh title="env"
|
||||
# Required
|
||||
PUBLIC_SUPABASE_URL=""
|
||||
PUBLIC_SUPABASE_ANON_KEY=""
|
||||
@@ -114,7 +114,7 @@ PRIVATE_APP_HOST=""
|
||||
PRIVATE_APP_SUBDOMAINS=""
|
||||
```
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
<Callout title="Learn More">
|
||||
Learn more about the environment variables
|
||||
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/dashboard/.env.example)
|
||||
</Callout>
|
||||
@@ -125,7 +125,7 @@ We provide a docker-compose configuration to deploy both the api and dashboard s
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
```zsh title="Terminal"
|
||||
git clone https://github.com/rotimi-best/classroomio.git
|
||||
cd classroomio
|
||||
```
|
||||
@@ -134,7 +134,7 @@ cd classroomio
|
||||
|
||||
3. Start the services:
|
||||
|
||||
```bash
|
||||
```zsh title="Terminal"
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Sign Up for ClassroomIO
|
||||
description: Get started with ClassroomIO by signing up for an account.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
If you're new to ClassroomIO, you must create an account to set up and launch your organization. By signing up, you'll gain access to all the [exciting features we offer](/docs/what-is-classroomio#key-features) to supercharge your classroom.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Go to the Signup Page
|
||||
|
||||
Navigate to [app.classroomio.com](https://app.classroomio.com) and click the **Signup** button at the top right corner of the page.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Fill the Signup Form
|
||||
|
||||
On the signup page, enter your **valid email address**. Next, create a secure password with a **minimum of 6 characters** and confirm it. Click **Create Account**.
|
||||
|
||||
You can also sign up using your Google account.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Complete the Onboarding
|
||||
|
||||
After completing the Signup form, you'll be redirected to the **Onboarding** page. There, you'll provide your **Full Name** and **Name of Organization.** As you enter your organization name, the **Organization Site Name** field will auto-fill. To proceed, click **Continue**.
|
||||
|
||||

|
||||
|
||||
Next, you'll complete a brief survey regarding what brought you to ClassroomIO and how you discovered our platform. Your responses will help us tailor ClassroomIO's features to best suit your needs. Finally, click **Continue** to submit.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Welcome to the Dashboard
|
||||
|
||||
You are successfully signed up and ready to use ClassroomIO when you see the dashboard.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### What Next?
|
||||
|
||||
After signing up successfully, you can now set up and [customize your Organization](/docs/how-to-guides/customize-organization).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Student Dashboard
|
||||
description: Learn about the student dashboard.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Callout type="warn" title="Work in Progress">
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
+6
-4
@@ -1,15 +1,17 @@
|
||||
# What is ClassroomIO
|
||||
---
|
||||
title: What is ClassroomIO
|
||||
description: Learn about ClassroomIO and its key features.
|
||||
---
|
||||
|
||||
<br />
|
||||
<img alt="ClassroomIO Courses page" src="https://brand.cdn.clsrio.com/classroomio-courses.png" />
|
||||
|
||||
Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device.
|
||||
|
||||
### Mission: Provide Students with the Best Learning Experience
|
||||
## Mission: Provide Students with the Best Learning Experience
|
||||
|
||||
At ClassroomIO, our mission is to provide students with the best possible learning experience. We believe in empowering educators with the tools they need to deliver high-quality education that is accessible, engaging, and effective.
|
||||
|
||||
### Key Features
|
||||
## Key Features
|
||||
|
||||
1. **📚 Advanced Course Management:** You can create unlimited courses, create lessons, invite students, add assignments, grade their assignments, and even generate certificates.
|
||||
2. **👨👩👦 Multi-Teacher Management:** You can invite other teachers into your organization and assign them individual courses.
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,25 +0,0 @@
|
||||
const million = require('million/compiler');
|
||||
|
||||
const env = process.env.NODE_ENV;
|
||||
|
||||
const withNextra = require('nextra')({
|
||||
theme: 'nextra-theme-docs',
|
||||
themeConfig: './theme.config.tsx'
|
||||
});
|
||||
|
||||
const nextConfig = {
|
||||
basePath: env === 'production' ? '/docs' : undefined,
|
||||
env: {
|
||||
imagePath: env === 'production' ? '/docs' : ''
|
||||
},
|
||||
images: {
|
||||
unoptimized: true
|
||||
},
|
||||
...withNextra()
|
||||
};
|
||||
|
||||
const millionConfig = {
|
||||
auto: true
|
||||
};
|
||||
|
||||
module.exports = million.next(nextConfig, millionConfig);
|
||||
+28
-26
@@ -1,35 +1,37 @@
|
||||
{
|
||||
"name": "@cio/docs",
|
||||
"version": "0.0.1",
|
||||
"description": "ClassroomIO Documentation",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/classroomio/classroomio.git"
|
||||
},
|
||||
"author": "Shu Ding <[email protected]>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/classroomio/classroomio/issues"
|
||||
},
|
||||
"homepage": "https://github.com/classroomio/classroomio#readme",
|
||||
"dependencies": {
|
||||
"million": "^3.0.3",
|
||||
"next": "^13.0.6",
|
||||
"nextra": "2.13.2",
|
||||
"nextra-theme-docs": "2.13.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"@tanstack/react-router": "^1.134.12",
|
||||
"@tanstack/react-router-devtools": "^1.134.12",
|
||||
"@tanstack/react-start": "^1.134.12",
|
||||
"fumadocs-core": "16.2.1",
|
||||
"fumadocs-mdx": "14.0.4",
|
||||
"fumadocs-ui": "16.2.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-static": "^0.552.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.11.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^4.9.3"
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"-": {
|
||||
"type": "separator",
|
||||
"title": "Introduction"
|
||||
},
|
||||
"index": "Welcome",
|
||||
"what-is-classroomio": "What is ClassroomIO",
|
||||
"quickstart": "Quick Start",
|
||||
"--": {
|
||||
"type": "separator",
|
||||
"title": "Core Features"
|
||||
},
|
||||
"organization": "Organization",
|
||||
"course": "Course",
|
||||
"student-dashboard": "Student Dashboard",
|
||||
"community": "Community",
|
||||
"audience": "Audience",
|
||||
"---": {
|
||||
"type": "separator",
|
||||
"title": "Guides"
|
||||
},
|
||||
"contributor-guides": "Contributor Guides",
|
||||
"howtoguides": "How To Guides",
|
||||
"contact": {
|
||||
"title": "Contact ↗",
|
||||
"type": "page",
|
||||
"href": "https://twitter.com/classroomio",
|
||||
"newWindow": true
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo ↗",
|
||||
"type": "page",
|
||||
"href": "https://dub.sh/ciodemo",
|
||||
"newWindow": true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Callout } from "nextra/components";
|
||||
|
||||
# Audience
|
||||
|
||||
<Callout emoji='🚨'>
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Callout } from "nextra/components";
|
||||
|
||||
# Community
|
||||
|
||||
<Callout emoji='🚨'>
|
||||
This page is a work in progress. Help us expand it by contributing!
|
||||
</Callout>
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"demo-accounts": "Demo Accounts",
|
||||
"devs": "Developers Guide",
|
||||
"supabase-cloud": "Supabase Cloud Setup",
|
||||
"design": "Designers Guide",
|
||||
"student-dashboard": "Student Dashboard",
|
||||
"cloudflare-setup": "Cloudflare Setup"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user