feat: widgets and ai chat
This commit is contained in:
@@ -2,16 +2,10 @@
|
||||
import { page } from '$app/state';
|
||||
|
||||
import { UpgradeModal, PageLoadProgress, PageRestricted } from '$features/ui';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { CommandPalette, KeyboardShortcutListener } from '$features/search';
|
||||
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -28,79 +22,28 @@
|
||||
|
||||
let path = $derived(page.url.pathname);
|
||||
|
||||
function intialAppSetup() {
|
||||
setupCloudAnalytics();
|
||||
|
||||
if (data?.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = data.isOrgSite;
|
||||
|
||||
currentOrg.set(data.org);
|
||||
|
||||
const theme = data.org?.theme;
|
||||
setTheme(theme || 'blue');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
intialAppSetup();
|
||||
|
||||
if (data.skipAuth) return;
|
||||
});
|
||||
|
||||
const session = authClient.useSession();
|
||||
const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data);
|
||||
|
||||
$effect(() => {
|
||||
if ($session.isPending || $session.isRefetching || !!$session.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.skipAuth) return;
|
||||
|
||||
if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$session.data && !path.startsWith('/login')) {
|
||||
console.log('session data is not available, go to login');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!data?.locals?.fromSessions && isSessionReady) {
|
||||
authClient.getSession().then(() => {
|
||||
console.log('triggered new session');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
console.log('env public server url', env.PUBLIC_SERVER_URL);
|
||||
console.log('env public is selfhosted', env.PUBLIC_IS_SELFHOSTED);
|
||||
if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) {
|
||||
appInitApi.setupApp($session.data as App.Locals, {
|
||||
isOrgSite: data.isOrgSite,
|
||||
orgSiteName: data.orgSiteName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<UpgradeModal />
|
||||
<CommandPalette />
|
||||
<KeyboardShortcutListener />
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
<PageRestricted />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
@@ -12,10 +13,17 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { AiCourseChat } from '$features/ai-assistant';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { CourseMember } from '$features/course/utils/types';
|
||||
import {
|
||||
COURSE_SIDEBAR_DEFAULT_WIDTH,
|
||||
COURSE_SIDEBAR_MAX_WIDTH,
|
||||
COURSE_SIDEBAR_MIN_WIDTH,
|
||||
COURSE_SIDEBAR_STORAGE_KEY
|
||||
} from '$features/course/components/sidebar/constants';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -28,6 +36,9 @@
|
||||
}
|
||||
|
||||
let { children, path, data }: Props = $props();
|
||||
let sidebarWidth = $state(COURSE_SIDEBAR_DEFAULT_WIDTH);
|
||||
let hasLoadedSidebarWidth = $state(false);
|
||||
let sidebarProviderElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Initialize course store on the client (fetch once, then reuse store data).
|
||||
$effect(() => {
|
||||
@@ -60,6 +71,46 @@
|
||||
});
|
||||
|
||||
const isExercisePage = $derived(!!data.exerciseId);
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function handleSidebarWidthChange(width: number) {
|
||||
sidebarWidth = clampSidebarWidth(width);
|
||||
}
|
||||
|
||||
function handleSidebarWidthPreview(width: number) {
|
||||
const nextWidth = clampSidebarWidth(width);
|
||||
|
||||
sidebarProviderElement?.style.setProperty('--sidebar-width', `${nextWidth}px`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedWidth = Number(localStorage.getItem(COURSE_SIDEBAR_STORAGE_KEY));
|
||||
|
||||
if (Number.isFinite(storedWidth)) {
|
||||
sidebarWidth = clampSidebarWidth(storedWidth);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
hasLoadedSidebarWidth = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedSidebarWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(COURSE_SIDEBAR_STORAGE_KEY, String(Math.round(sidebarWidth)));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -91,12 +142,21 @@
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<CourseSidebar {path} id={data.courseId} {isCourseReady} />
|
||||
<Sidebar.Provider
|
||||
bind:ref={sidebarProviderElement}
|
||||
data-sveltekit-preload-data="off"
|
||||
style={`--sidebar-width: ${sidebarWidth}px;`}
|
||||
>
|
||||
<CourseSidebar
|
||||
{path}
|
||||
id={data.courseId}
|
||||
{isCourseReady}
|
||||
{sidebarWidth}
|
||||
onSidebarWidthPreview={handleSidebarWidthPreview}
|
||||
onSidebarWidthChange={handleSidebarWidthChange}
|
||||
/>
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<Sidebar.Inset class="ui:min-w-0 ui:flex-1">
|
||||
<CourseHeader />
|
||||
<ContentCreateModal />
|
||||
<CourseCompletionModal />
|
||||
@@ -119,4 +179,6 @@
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
|
||||
<AiCourseChat />
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { CompliancePage } from '$features/course/pages';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
async function refreshCompliancePage() {
|
||||
complianceApi.reset();
|
||||
await courseApi.refreshCourse(data.courseId, $profile.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-5xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.compliance.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData onRefresh={refreshCompliancePage} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CompliancePage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -6,20 +6,10 @@
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { ExercisePage } from '$features/course/pages';
|
||||
import {
|
||||
questionnaire,
|
||||
questionnaireMetaData,
|
||||
clearQuestionnaireValidation,
|
||||
reset
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { questionnaireMetaData, reset } from '$features/course/components/exercise/store';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Question } from '$features/course/types';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeOptionById
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -37,36 +27,7 @@
|
||||
reset();
|
||||
}
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
|
||||
const exercise = data.exercise;
|
||||
|
||||
// Transform questions and set questionnaire store
|
||||
let questions: Question[] = [];
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mapped = exercise.questions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
questions = normalizeQuestionOrder(mapped);
|
||||
}
|
||||
|
||||
questionnaire.set({
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: exercise.dueBy,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions: questions,
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
});
|
||||
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
|
||||
hydrateExercisePageData(data.exercise, data.exerciseId);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header class="sticky top-13 z-10 bg-white">
|
||||
<Page.Header isSticky class="ui:bg-background z-10">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.settings.heading')}
|
||||
|
||||
@@ -8,11 +8,40 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import type { PendingOrgInvite } from '$features/lms/utils/types';
|
||||
|
||||
let pendingInvite = $state<PendingOrgInvite | null>(null);
|
||||
let showInviteModal = $state(false);
|
||||
|
||||
const todayLabel = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date());
|
||||
|
||||
const firstName = $derived($profile.fullname?.trim().split(/\s+/)[0] || $t('dashboard.learner'));
|
||||
|
||||
let totalCompleted = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, course) => {
|
||||
const exercisesCompleted =
|
||||
'exercisesCompleted' in course && typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
|
||||
|
||||
return acc + (course.progressRate || 0) + exercisesCompleted;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
let totalLessons = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, course) => {
|
||||
const exercises =
|
||||
'exerciseCount' in course && typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
|
||||
|
||||
return acc + (course.lessonCount || 0) + exercises;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
@@ -38,15 +67,24 @@
|
||||
<title>Student Dashboard - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Root class="mx-auto w-full max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Subtitle>{todayLabel}</Page.Subtitle>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
{$t(getGreeting())},
|
||||
<span class="ui:text-primary italic">{firstName}.</span>
|
||||
</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{#if totalLessons > 0}
|
||||
{$t('dashboard.lms_today_progress', { progress: progressPercentage })}
|
||||
{:else}
|
||||
{$t('dashboard.lms_today_empty')}
|
||||
{/if}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<DashboardPage />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ProfilePage } from '$features/settings/pages';
|
||||
import { VisitOrgSiteButton } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
@@ -30,8 +29,6 @@
|
||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
||||
{$t('settings.profile.update_profile')}
|
||||
</Button>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { DashStatsSuccess } from '$features/org/utils/types';
|
||||
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
@@ -12,29 +26,37 @@ export const load = async ({ params, parent, cookies }) => {
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null
|
||||
stats: null,
|
||||
loginActivity: [] as LoginActivityData
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId]
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await safeServerApi<DashStatsSuccess>(() =>
|
||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||
safeServerApi<DashStatsSuccess>(() =>
|
||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
cache[orgId] = result.ok ? result.body.data : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error);
|
||||
cache[orgId] = null;
|
||||
}
|
||||
),
|
||||
safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId]
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
|
||||
@@ -17,12 +18,14 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$features/onboarding/components';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const loginActivity = $derived(data.loginActivity);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
@@ -85,57 +88,94 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
<div class="flex h-full flex-col">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
{:else}
|
||||
{#each topCourses as course (course.id)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4/6 space-y-1">
|
||||
<a class="hover:underline" href={`/courses/${course.id}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{course.title}
|
||||
</p>
|
||||
<ul class="ui:divide-border -mx-2 divide-y">
|
||||
{#each topCourses as course, i (course.id)}
|
||||
{@const rank = i + 1}
|
||||
<li>
|
||||
<a
|
||||
href={`/courses/${course.id}`}
|
||||
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
|
||||
rank === 1 &&
|
||||
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
|
||||
rank === 2 &&
|
||||
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
|
||||
rank === 3 &&
|
||||
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
|
||||
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
|
||||
]}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
|
||||
{course.title}
|
||||
</p>
|
||||
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<UsersIcon class="h-3 w-3" />
|
||||
<span class="tabular-nums">{course.enrollments}</span>
|
||||
<span>
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.completion')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
|
||||
</div>
|
||||
<Progress value={course.completion} class="ui:h-1.5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.certification_rate')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
|
||||
</div>
|
||||
<Progress value={course.certification} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
<p class="text-sm">
|
||||
{course.enrollments}
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto w-2/6 space-y-2">
|
||||
<div>
|
||||
<Progress value={course.completion} />
|
||||
<div class="text-sm">
|
||||
{course.completion}%
|
||||
{$t('dashboard.completion')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Progress value={course.certification} />
|
||||
<div class="text-sm">
|
||||
{course.certification}%
|
||||
{$t('dashboard.certification_rate')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,6 +230,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<LoginActivityChart data={loginActivity} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -15,8 +15,6 @@ export const load = async ({ parent, locals, cookies }) => {
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
console.log('result', result.ok ? result.body.data : []);
|
||||
|
||||
return {
|
||||
programs: result.ok ? result.body.data : []
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
$effect(() => {
|
||||
console.log('data', data);
|
||||
if (data.setupProgress && data.orgSiteName) {
|
||||
setupProgressApi.initializeFromServerData(data.setupProgress, data.orgSiteName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { GetWidgetsSuccess } from '$features/widget/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ parent, cookies, params }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
initialWidgets: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetWidgetsSuccess>(() =>
|
||||
classroomio.organization.widgets.$get({}, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
if (!result.ok && (result.status === 401 || result.status === 403)) {
|
||||
throw redirect(302, `/org/${params.slug}`);
|
||||
}
|
||||
|
||||
return {
|
||||
initialWidgets: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { WidgetsPage } from '$features/widget/pages';
|
||||
import { widgetApi } from '$features/widget';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let createModalOpen = $state(false);
|
||||
let newWidgetName = $state('');
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newWidgetName.trim()) return;
|
||||
|
||||
const widget = await widgetApi.createWidget({
|
||||
name: newWidgetName.trim(),
|
||||
layoutType: 'card_grid',
|
||||
selectionMode: 'manual',
|
||||
config: undefined,
|
||||
selectedCourseIds: []
|
||||
});
|
||||
|
||||
if (!widget) return;
|
||||
|
||||
createModalOpen = false;
|
||||
newWidgetName = '';
|
||||
goto(resolve(`/widgets/${widget.id}`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('widgets.heading')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('widgets.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('widgets.subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (createModalOpen = true)}>{$t('widgets.actions.create')}</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<WidgetsPage initialWidgets={data.initialWidgets} onCreate={() => (createModalOpen = true)} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<Dialog.Root bind:open={createModalOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('widgets.actions.create')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('widgets.create.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<InputField label={$t('widgets.form.name')} bind:value={newWidgetName} />
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (createModalOpen = false)}>{$t('app.cancel')}</Button>
|
||||
<Button disabled={!newWidgetName.trim()} onclick={handleCreate}>{$t('widgets.actions.create')}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import WidgetPreview from '$features/widget/components/widget-preview.svelte';
|
||||
import { isValidParentMessage, isAllowedOrigin } from '$features/widget/utils/preview-protocol';
|
||||
import type { WidgetPreviewIframeMessage } from '$features/widget/utils/preview-protocol';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
|
||||
let payload = $state<TWidgetPayload | null>(null);
|
||||
let errorMessage = $state('');
|
||||
let containerElement = $state<HTMLElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const iframeId = crypto.randomUUID();
|
||||
|
||||
function postToParent(message: WidgetPreviewIframeMessage) {
|
||||
if (!window.parent || window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.parent.postMessage(message, '*');
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (!isAllowedOrigin(event.origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidParentMessage(event.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_PING') {
|
||||
postToParent({ type: 'WIDGET_PREVIEW_READY', iframeId, timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_RENDER') {
|
||||
try {
|
||||
payload = message.payload;
|
||||
errorMessage = '';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
postToParent({
|
||||
type: 'WIDGET_PREVIEW_RENDERED',
|
||||
requestId: message.requestId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
} catch (renderError) {
|
||||
errorMessage = renderError instanceof Error ? renderError.message : 'Preview render failed';
|
||||
postToParent({
|
||||
type: 'WIDGET_PREVIEW_ERROR',
|
||||
requestId: message.requestId,
|
||||
error: errorMessage,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observeResize() {
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
postToParent({
|
||||
type: 'WIDGET_PREVIEW_RESIZE',
|
||||
height: Math.ceil(entry.contentRect.height)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerElement);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('message', handleMessage);
|
||||
observeResize();
|
||||
|
||||
postToParent({ type: 'WIDGET_PREVIEW_READY', iframeId, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleMessage);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<div bind:this={containerElement}>
|
||||
{#if errorMessage}
|
||||
<div class="p-4 text-sm text-red-600">{errorMessage}</div>
|
||||
{:else}
|
||||
<WidgetPreview {payload} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { WidgetEditorPage } from '$features/widget/pages';
|
||||
import { widgetApi } from '$features/widget/api/widget.svelte';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { WidgetDetail } from '$features/widget/utils/types';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let detail = $state<WidgetDetail | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const widgetId = page.params.widgetId;
|
||||
const orgId = $currentOrg.id;
|
||||
if (!widgetId || !orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
detail = null;
|
||||
loadError = null;
|
||||
|
||||
void (async () => {
|
||||
await widgetApi.getWidget(widgetId);
|
||||
if (cancelled) return;
|
||||
if (widgetApi.widgetDetail?.widget.id === widgetId) {
|
||||
detail = widgetApi.widgetDetail;
|
||||
return;
|
||||
}
|
||||
loadError = widgetApi.error ?? $t('widgets.editor.load_failed');
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-background min-h-screen w-full">
|
||||
{#if !$currentOrg.id}
|
||||
<div class="flex min-h-[50vh] flex-col items-center justify-center px-4">
|
||||
<Spinner class="size-8!" />
|
||||
<p class="text-muted-foreground mt-4 text-sm">{$t('widgets.editor.waiting_org')}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="flex min-h-[50vh] flex-col items-center justify-center px-4">
|
||||
<p class="text-destructive max-w-md text-center text-sm">{loadError}</p>
|
||||
<Button class="mt-4" href={resolve(`${$currentOrgPath}/widgets`, {})}>
|
||||
{$t('widgets.actions.back')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !detail}
|
||||
<div class="flex min-h-[50vh] items-center justify-center">
|
||||
<Spinner class="size-8!" />
|
||||
</div>
|
||||
{:else}
|
||||
<WidgetEditorPage {detail} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,47 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { PageRestricted } from '$features/ui';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
org: AccountOrg | null;
|
||||
locals: App.Locals;
|
||||
org: import('$features/app/types').AccountOrg | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data?.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = data.isOrgSite;
|
||||
|
||||
currentOrg.set(data.org);
|
||||
setTheme(data.org.theme || 'blue');
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
data.course?.status === 'ACTIVE' &&
|
||||
Boolean(data.course?.isPublished)
|
||||
);
|
||||
$inspect(data.requiresPaymentOrInvite);
|
||||
$inspect(canJoinCourse);
|
||||
$inspect(data);
|
||||
$inspect('loading', loading);
|
||||
|
||||
function getBlockedMessage(): string {
|
||||
if (data.requiresPaymentOrInvite) {
|
||||
@@ -56,6 +60,7 @@
|
||||
snackbar.error(getBlockedMessage());
|
||||
return;
|
||||
}
|
||||
console.log('profile', $profile);
|
||||
|
||||
loading = true;
|
||||
|
||||
@@ -133,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full items-center justify-center">
|
||||
<Button type="submit" disabled={!canJoinCourse || loading} loading={loading || !$profile.id}>
|
||||
<Button type="submit" disabled={!canJoinCourse || loading} {loading}>
|
||||
{$t('course.navItem.landing_page.enroll_page.join_course')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { Snackbar } from '$features/ui';
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import merge from 'lodash/merge';
|
||||
import { MetaTags } from 'svelte-meta-tags';
|
||||
import { ModeWatcher } from '@cio/ui/base/dark-mode';
|
||||
@@ -11,6 +19,42 @@
|
||||
let { data, children } = $props();
|
||||
|
||||
const metaTags = $derived(merge(data.baseMetaTags, page.data.pageMetaTags));
|
||||
|
||||
onMount(() => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
setupCloudAnalytics();
|
||||
|
||||
if (data?.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isOrgSite && data.org) {
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = true;
|
||||
currentOrg.set(data.org);
|
||||
setTheme(data.org.theme || 'blue');
|
||||
}
|
||||
});
|
||||
|
||||
const session = authClient.useSession();
|
||||
const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data);
|
||||
|
||||
$effect(() => {
|
||||
if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) {
|
||||
appInitApi.setupApp($session.data as App.Locals, {
|
||||
isOrgSite: data.isOrgSite,
|
||||
orgSiteName: data.orgSiteName
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -3,26 +3,27 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import FrownIcon from '@lucide/svelte/icons/frown';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { SimpleLogoNav } from '@cio/ui/custom/simple-logo-nav';
|
||||
import {
|
||||
buildOrgLandingPageProps,
|
||||
importThemeComponent,
|
||||
normalizeLandingPageSettings
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import PageLoader from '$features/org/components/landing-page/page-loader.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ThemeComponent = $state<Component<any> | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let DashboardEntry = $state<Component<any> | null>(null);
|
||||
|
||||
const hasSetupError = $derived(!appInitApi.loading && !!appInitApi.error);
|
||||
|
||||
const pageTitle = $derived(
|
||||
data.isOrgSite && data.org ? data.org.name : "ClassroomIO - The Course Platform That's Actually Easy To Use"
|
||||
@@ -53,35 +54,12 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
if (!data.isOrgSite || !data.org) {
|
||||
const mod = await import('./_dashboard-entry.svelte');
|
||||
DashboardEntry = mod.default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = true;
|
||||
currentOrg.set(data.org);
|
||||
setTheme(data.org.theme || 'blue');
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
const settings = normalizeLandingPageSettings(data.org.landingpage);
|
||||
const mod = await importThemeComponent(settings.theme);
|
||||
ThemeComponent = mod.default;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -92,10 +70,23 @@
|
||||
{#if ThemeComponent && landingPageProps}
|
||||
<ThemeComponent {...landingPageProps} />
|
||||
{:else}
|
||||
<PageLoader />
|
||||
<!-- Theme loading -->
|
||||
{/if}
|
||||
{:else if DashboardEntry}
|
||||
<DashboardEntry />
|
||||
{:else if hasSetupError}
|
||||
<Empty
|
||||
title="Something Went Wrong"
|
||||
description="We encountered an unexpected error. Please reload the page or contact us for support."
|
||||
icon={FrownIcon}
|
||||
variant="page"
|
||||
layout="full-page"
|
||||
showLogo={true}
|
||||
>
|
||||
<p class="my-2 text-red-500">{appInitApi.error}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" onclick={() => window.location.reload()}>Reload Page</Button>
|
||||
<Button variant="default" href="https://classroomio.com/contact">Contact Us</Button>
|
||||
</div>
|
||||
</Empty>
|
||||
{:else}
|
||||
<div class="m-2 flex h-screen w-screen flex-col items-center justify-center font-sans sm:m-0">
|
||||
<SimpleLogoNav />
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import FrownIcon from '@lucide/svelte/icons/frown';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { SimpleLogoNav } from '@cio/ui/custom/simple-logo-nav';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
|
||||
setupCloudAnalytics();
|
||||
|
||||
const session = authClient.useSession();
|
||||
const isSessionLoading = $derived($session.isPending || $session.isRefetching);
|
||||
const isLoadingApp = $derived(isSessionLoading || appInitApi.loading);
|
||||
const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data);
|
||||
|
||||
$effect(() => {
|
||||
if ($session.isPending || $session.isRefetching || !!$session.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$session.data && !page.url.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) {
|
||||
appInitApi.setupApp($session.data as App.Locals, {
|
||||
isOrgSite: false,
|
||||
orgSiteName: ''
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isLoadingApp && appInitApi.error}
|
||||
<Empty
|
||||
title="Something Went Wrong"
|
||||
description="We encountered an unexpected error. Please reload the page or contact us for support."
|
||||
icon={FrownIcon}
|
||||
variant="page"
|
||||
layout="full-page"
|
||||
showLogo={true}
|
||||
>
|
||||
{#if appInitApi.error}
|
||||
<p class="my-2 text-red-500">{appInitApi.error}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" onclick={() => window.location.reload()}>Reload Page</Button>
|
||||
<Button variant="default" href="https://classroomio.com/contact">Contact Us</Button>
|
||||
</div>
|
||||
</Empty>
|
||||
{:else}
|
||||
<div class="m-2 flex h-screen w-screen flex-col items-center justify-center font-sans sm:m-0">
|
||||
<SimpleLogoNav />
|
||||
<Spinner class="size-14! text-blue-700!" />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,26 +1,19 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Accepts browser CSP violation reports (report-only policy `report-uri`).
|
||||
* Must stay public — no session — so POST is not redirected to /login.
|
||||
*/
|
||||
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 });
|
||||
const raw = await request.text();
|
||||
if (raw) {
|
||||
JSON.parse(raw);
|
||||
// Optional: log parsed report for monitoring (shape varies by directive)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing CSP report:', error);
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
console.error('csp-report: invalid JSON body', error);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user