Merge branch 'main' of https://github.com/classroomio/classroomio into feat/release-v2

This commit is contained in:
Rotimi Best
2025-11-08 20:22:53 +01:00
9 changed files with 84 additions and 74 deletions
+12
View File
@@ -0,0 +1,12 @@
# Prompt
This prompt is used to give any AI chat bot context about ClassroomIO
```txt
I am building a LMS for teachers and students to collaborate on a course. Students can enroll in a course or be giving access to a course. Teachers can be invited into an org by an admin to create courses and manage students. For auth we support Email/Password and Google.
The tech stack is
- Sveltekit - dashboard (teacher view) and lms (student view)
- Supabase - Auth, DB requests, Storage
- Hono - API for long running processes like video uploads
```
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { validateUser } from '$lib/utils/services/middlewares'; import { validateUser } from '$lib/utils/services/middlewares';
const PUBLIC_ROUTES = [ const PUBLIC_API_ROUTES = [
'/api/completion', '/api/completion',
'student_prove_payment', 'student_prove_payment',
'teacher_student_buycourse', 'teacher_student_buycourse',
@@ -11,7 +11,7 @@ const PUBLIC_ROUTES = [
]; ];
function isPublicRoute(pathname: string) { function isPublicRoute(pathname: string) {
return PUBLIC_ROUTES.some((route) => pathname.includes(route)); return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
} }
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
@@ -2,8 +2,11 @@
import get from 'lodash/get'; import get from 'lodash/get';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter'; import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { isCourseFree } from '$lib/utils/functions/course'; import {
import { getStudentInviteLink } from '$lib/utils/functions/course'; isCourseFree,
getStudentInviteLink,
calcCourseDiscount
} from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
@@ -12,7 +15,6 @@
import { ROLE } from '$lib/utils/constants/roles'; import { ROLE } from '$lib/utils/constants/roles';
import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
interface Props { interface Props {
className?: string; className?: string;
@@ -48,8 +50,10 @@
course_cost: courseData.cost, course_cost: courseData.cost,
course_free: isFree course_free: isFree
}); });
if (isFree) { if (isFree) {
const link = getStudentInviteLink(courseData, $currentOrg.siteName, $currentOrgDomain); const link = getStudentInviteLink(courseData, $currentOrg.siteName, $currentOrgDomain);
goto(link); goto(link);
} else { } else {
openModal = true; openModal = true;
@@ -1,7 +1,7 @@
<script> <script>
import { globalStore } from '$lib/utils/store/app'; import { globalStore } from '$lib/utils/store/app';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { currentOrg, orgs, defaultCurrentOrgState, currentOrgDomain, currentOrgPath } from '$lib/utils/store/org'; import { currentOrg, currentOrgDomain, currentOrgPath } from '$lib/utils/store/org';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import SettingsIcon from '@lucide/svelte/icons/settings'; import SettingsIcon from '@lucide/svelte/icons/settings';
import Avatar from '$lib/components/Avatar/index.svelte'; import Avatar from '$lib/components/Avatar/index.svelte';
@@ -9,31 +9,13 @@
import LogOutIcon from '@lucide/svelte/icons/log-out'; import LogOutIcon from '@lucide/svelte/icons/log-out';
import RocketIcon from '@lucide/svelte/icons/rocket'; import RocketIcon from '@lucide/svelte/icons/rocket';
import BellPlusIcon from '@lucide/svelte/icons/bell-plus'; import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
import { goto } from '$app/navigation';
import { profileMenu } from '../store'; import { profileMenu } from '../store';
import { supabase } from '$lib/utils/functions/supabase';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import posthog from 'posthog-js'; import { logout } from '$lib/utils/functions/logout';
async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error logging out: ', error);
}
currentOrg.set(defaultCurrentOrgState);
orgs.set([]);
user.set(defaultUserState);
profile.set(defaultProfileState);
capturePosthogEvent('user_logged_out');
posthog.reset();
async function handleLogout() {
await logout();
closeMenu(); closeMenu();
goto('/login');
} }
function closeMenu() { function closeMenu() {
@@ -153,7 +135,7 @@
</div> </div>
{/if} {/if}
<button onclick={logout} class="w-full space-y-4 pt-3"> <button onclick={handleLogout} class="w-full space-y-4 pt-3">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<LogOutIcon size={16} /> <LogOutIcon size={16} />
<p class="text-sm font-semibold">{$t('settings.profile.logout')}</p> <p class="text-sm font-semibold">{$t('settings.profile.logout')}</p>
@@ -1,7 +1,6 @@
import posthog from 'posthog-js'; import posthog from 'posthog-js';
import { supabase } from '$lib/utils/functions/supabase'; import { supabase } from '$lib/utils/functions/supabase';
import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg, orgs, defaultCurrentOrgState } from '$lib/utils/store/org';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user'; import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -12,8 +11,6 @@ export async function logout(redirect = true) {
console.error('Error logging out: ', error); console.error('Error logging out: ', error);
} }
currentOrg.set(defaultCurrentOrgState);
orgs.set([]);
user.set(defaultUserState); user.set(defaultUserState);
profile.set(defaultProfileState); profile.set(defaultProfileState);
@@ -11,6 +11,13 @@ export const getSupabase = () => {
return supabase; return supabase;
}; };
export const hasSession = async () => {
const { data } = await getSupabase().auth.getSession();
console.log('has session', data);
return data.session !== null;
};
export const isSupabaseTokenInLocalStorage = () => { export const isSupabaseTokenInLocalStorage = () => {
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
+10 -7
View File
@@ -100,11 +100,9 @@ export const load = async ({ url, cookies, request }): Promise<LoadOutput> => {
return response; return response;
} }
const answer = !!subdomain;
console.log('subdomain', subdomain); console.log('subdomain', subdomain);
response.isOrgSite = debugMode || answer; response.isOrgSite = debugMode || !!subdomain;
response.orgSiteName = debugMode ? _orgSiteName : subdomain; response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null; response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
@@ -172,11 +170,16 @@ function getBaseMetaTags(url: URL) {
} }
function getSubdomain(url: URL) { function getSubdomain(url: URL) {
const host = url.host.replace('www.', ''); const host = url.hostname.replace('www.', '');
const parts = host?.split('.'); const parts = host.split('.');
const appHost = env.PRIVATE_APP_HOST;
if (host?.endsWith(env.PRIVATE_APP_HOST)) { const appHostParts = appHost.split('.');
return parts?.length >= 3 ? parts[0] : null; const isAppHost = parts.slice(-appHostParts.length).join('.') === appHost;
if (isAppHost) {
// Subdomain exists only if extra part(s) before main domain
return parts.length > appHostParts.length ? parts[0] : null;
} }
return null; return null;
+32 -35
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; // import { browser } from '$app/environment';
import { page } from '$app/stores'; import { page } from '$app/stores';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@@ -28,11 +28,14 @@
import { globalStore } from '$lib/utils/store/app'; import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile'; import { isMobile } from '$lib/utils/store/useMobile';
import { Theme } from 'carbon-components-svelte'; // import { Theme } from 'carbon-components-svelte';
import type { CarbonTheme } from 'carbon-components-svelte/types/Theme/Theme.svelte'; // import type { CarbonTheme } from 'carbon-components-svelte/types/Theme/Theme.svelte';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { MetaTags } from 'svelte-meta-tags'; import { MetaTags } from 'svelte-meta-tags';
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
import { hasSession } from '$lib/utils/functions/supabase';
import { goto } from '$app/navigation';
import { ModeWatcher } from '@cio/ui/base/dark-mode'; import { ModeWatcher } from '@cio/ui/base/dark-mode';
@@ -43,7 +46,7 @@
let supabase = getSupabase(); let supabase = getSupabase();
let path = $derived($page.url?.pathname?.replace('/', '')); let path = $derived($page.url?.pathname?.replace('/', ''));
let queryParam = $page.url?.search; let queryParam = $page.url?.search;
let carbonTheme: CarbonTheme = $derived($globalStore.isDark ? 'g100' : 'white'); // let carbonTheme: CarbonTheme = $derived($globalStore.isDark ? 'g100' : 'white');
function handleResize() { function handleResize() {
isMobile.update(() => window.innerWidth <= 760); isMobile.update(() => window.innerWidth <= 760);
@@ -51,33 +54,43 @@
const getProfileDebounced = debounce(getProfile, 1000); const getProfileDebounced = debounce(getProfile, 1000);
onMount(() => { function pageSetup() {
console.log( console.log(
'Welcome to ClassroomIO, we are grateful you chose us.', 'Welcome to ClassroomIO, we are grateful you chose us.',
$page.url.host, $page.url.host,
`\nIs student domain: ${data.isOrgSite}` `\nIs student domain: ${data.isOrgSite}`,
data
); );
if (browser) { // $globalStore.isDark = localStorage.getItem('mode') === 'dark';
// Update theme - dark or light mode // toggleBodyByMode($globalStore.isDark);
// $globalStore.isDark = localStorage.getItem('mode') === 'dark';
// toggleBodyByMode($globalStore.isDark);
if (data.isOrgSite && data.org?.theme) {
setTheme(data.org?.theme);
}
}
setupAnalytics(); setupAnalytics();
handleResize(); handleResize();
// if (!isSupabaseTokenInLocalStorage() && !isPublicRoute($page.url?.pathname)) { if (!data.isOrgSite || !data.org) return;
// console.log('No auth token and is not a public route, redirect to login', path);
// return goto('/login?redirect=/' + path); $globalStore.orgSiteName = data.orgSiteName;
// } $globalStore.isOrgSite = data.isOrgSite;
currentOrg.set(data.org);
// Setup internal analytics
initOrgAnalytics(data.orgSiteName);
setTheme(data.org?.theme);
}
onMount(() => {
pageSetup();
if (!hasSession() && !isPublicRoute($page.url?.pathname)) {
console.log('No auth token and is not a public route, redirect to login', path);
return goto('/login?redirect=/' + path);
}
const { data: authListener } = supabase.auth.onAuthStateChange((event) => { const { data: authListener } = supabase.auth.onAuthStateChange((event) => {
// Log key events
console.log(`event`, event); console.log(`event`, event);
if (path.includes('reset')) { if (path.includes('reset')) {
@@ -85,10 +98,8 @@
return; return;
} }
// Skip Authentication
if (data.skipAuth) return; if (data.skipAuth) return;
// Authentication Steps
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') { if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
getProfileDebounced({ getProfileDebounced({
path, path,
@@ -97,22 +108,8 @@
orgSiteName: data.orgSiteName orgSiteName: data.orgSiteName
}); });
} }
// else if (!['TOKEN_REFRESHED'].includes(event)) {
// console.log('not logged in, go to login');
// return goto('/login');
// }
}); });
if (data.isOrgSite && data.org && !$currentOrg.siteName) {
$globalStore.orgSiteName = data.orgSiteName;
$globalStore.isOrgSite = data.isOrgSite;
currentOrg.set(data.org);
// Setup internal analytics
initOrgAnalytics(data.orgSiteName);
}
return () => { return () => {
console.log('unsubscribed'); console.log('unsubscribed');
authListener.subscription.unsubscribe(); authListener.subscription.unsubscribe();
@@ -110,6 +110,14 @@
} }
onMount(async () => { onMount(async () => {
// check if user has session, if not redirect to sign up with redirect back to this page
const {
data: { session }
} = await supabase.auth.getSession();
if (!session) {
return goto(`/login?redirect=${$page.url?.pathname || ''}`);
}
setTheme(data.currentOrg?.theme || ''); setTheme(data.currentOrg?.theme || '');
setCurOrg(data.currentOrg as CurrentOrg); setCurOrg(data.currentOrg as CurrentOrg);
}); });