feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
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, isOrgStudent } 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 { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
org: import('$features/app/types').AccountOrg | null;
|
||||
skipAuth: boolean;
|
||||
locals: App.Locals;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
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 (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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($isOrgStudent === null) return;
|
||||
|
||||
if ($isOrgStudent !== $globalStore.isStudent) {
|
||||
globalStore.update((s) => ({ ...s, isStudent: !!$isOrgStudent }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<UpgradeModal />
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
<PageRestricted />
|
||||
{:else}
|
||||
<PageLoadProgress zIndex={10000} />
|
||||
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export const load = async ({ params }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
courseId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { CourseSidebar } from '$features/course/components/sidebar';
|
||||
import { CourseHeader } from '$features/course/components';
|
||||
import type { Course } from '$features/course/types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Confetti } from '$features/ui';
|
||||
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 { 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';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
path: string;
|
||||
data: {
|
||||
course?: Course;
|
||||
courseId: string;
|
||||
exerciseId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, path, data }: Props = $props();
|
||||
|
||||
// Initialize course store on the client (fetch once, then reuse store data).
|
||||
$effect(() => {
|
||||
if (!data.courseId || !$profile.id) return;
|
||||
|
||||
if (data.course) {
|
||||
courseApi.setCourse(data.course, $profile.id);
|
||||
return;
|
||||
}
|
||||
|
||||
courseApi.ensureCourse(data.courseId, $profile.id);
|
||||
});
|
||||
|
||||
const isCourseReady = $derived.by(() => {
|
||||
return courseApi.course?.id === data.courseId && !!courseApi.group.id;
|
||||
});
|
||||
|
||||
const user: CourseMember | undefined = $derived(
|
||||
courseApi.group.people.find((person) => person.profileId === $profile.id)
|
||||
);
|
||||
const canCheck = $derived(!!$profile.id && isCourseReady);
|
||||
|
||||
const isPermitted = $derived.by(() => {
|
||||
if (!isCourseReady) return false;
|
||||
if (!canCheck) return true;
|
||||
|
||||
if ($isOrgAdmin === null) return true;
|
||||
|
||||
return $isOrgAdmin || user;
|
||||
});
|
||||
|
||||
const isExercisePage = $derived(!!data.exerciseId);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{courseApi.course?.title || 'ClassroomIO Course'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isCourseReady}
|
||||
<Dialog.Root open={!isPermitted}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-md text-center dark:text-white">
|
||||
{$t('course.not_permitted.body')}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex justify-center">
|
||||
<Button
|
||||
onclick={() => {
|
||||
goto('/org/*');
|
||||
}}
|
||||
>
|
||||
{$t('course.not_permitted.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<CourseSidebar {path} id={data.courseId} {isCourseReady} />
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<CourseHeader />
|
||||
<ContentCreateModal />
|
||||
<CourseCompletionModal />
|
||||
|
||||
{#if !isCourseReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
<Empty
|
||||
title="Loading course…"
|
||||
description="Please wait while we load your course data."
|
||||
icon={Spinner}
|
||||
iconClass="h-8 w-8"
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isExercisePage}
|
||||
<Confetti />
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { NewsFeedPage } from '$features/course/pages';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles: number[] = [1, 2];
|
||||
|
||||
if (org.customization.course.newsfeed) {
|
||||
roles.push(3);
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
onDenied={() => {
|
||||
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
|
||||
}}
|
||||
>
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.news_feed.heading')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={() => newsfeedApi.openNewFeedModal()}>
|
||||
{$t('course.navItem.news_feed.heading_button.title')}
|
||||
</Button>
|
||||
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewsFeedPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
courseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch analytics using single API call
|
||||
const result = await safeServerApi<{ success: true; data: CourseAnalytics }>(() =>
|
||||
classroomio.course[':courseId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const courseAnalytics: CourseAnalytics | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
courseAnalytics
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { AnalyticsPage } from '$features/course/pages';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('analytics.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AnalyticsPage courseId={data.courseId} courseAnalytics={data.courseAnalytics} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { AttendancePage } from '$features/course/pages';
|
||||
import { 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();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.attendance.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AttendancePage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { CertificatesPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { validateWithTranslation } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let savedCertificateState = $state<string | null>(null);
|
||||
let savedCertificateStateCourseId = $state<string | null>(null);
|
||||
|
||||
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
|
||||
|
||||
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
|
||||
|
||||
const userRole = $derived.by(() => {
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
});
|
||||
|
||||
const canEditCertificates = $derived(!!$isOrgAdmin || (userRole !== null && [1, 2].includes(userRole)));
|
||||
|
||||
function getCertificateFormState(course: Course | null) {
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
description: course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: course.certificate?.isDownloadable ?? false,
|
||||
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
|
||||
deadline: course.certificate?.deadline ?? null,
|
||||
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? course.certificate.exerciseMinScorePercent
|
||||
: course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null,
|
||||
emailMessage: course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCertificateStateSnapshot(course: Course | null) {
|
||||
const certificateFormState = getCertificateFormState(course);
|
||||
return certificateFormState ? JSON.stringify(certificateFormState) : null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const course = courseApi.course;
|
||||
const currentSnapshot = getCertificateStateSnapshot(course);
|
||||
|
||||
if (!course?.id || !currentSnapshot) {
|
||||
savedCertificateState = null;
|
||||
savedCertificateStateCourseId = null;
|
||||
hasUnsavedChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedCertificateStateCourseId !== course.id || savedCertificateState === null) {
|
||||
savedCertificateStateCourseId = course.id;
|
||||
savedCertificateState = currentSnapshot;
|
||||
}
|
||||
|
||||
hasUnsavedChanges = currentSnapshot !== savedCertificateState;
|
||||
});
|
||||
|
||||
async function saveCertificate() {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if ($isFreePlan) {
|
||||
errors = {};
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
description: courseApi.course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
|
||||
theme: certificateTheme || 'professional',
|
||||
deadline: courseApi.course.certificate?.deadline ?? null,
|
||||
threshold:
|
||||
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
|
||||
requiredExerciseId: courseApi.course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof courseApi.course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? courseApi.course.certificate.exerciseMinScorePercent
|
||||
: courseApi.course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null,
|
||||
emailMessage: courseApi.course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
|
||||
const validated = validateWithTranslation(ZCourseUpdate, updatePayload, 'course');
|
||||
if ('errors' in validated) {
|
||||
errors = validated.errors;
|
||||
return;
|
||||
}
|
||||
|
||||
errors = {};
|
||||
|
||||
const updated = await courseApi.update(courseApi.course.id!, validated.data, { showSuccessToast: false });
|
||||
if (updated) {
|
||||
savedCertificateState = getCertificateStateSnapshot(courseApi.course);
|
||||
hasUnsavedChanges = false;
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.certificates.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
{#if canEditCertificates}
|
||||
<Button
|
||||
onclick={saveCertificate}
|
||||
loading={courseApi.isLoading}
|
||||
disabled={courseApi.isLoading || !hasUnsavedChanges}
|
||||
>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
{/if}
|
||||
<RefreshPageData />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CertificatesPage {errors} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GetExerciseSuccess, ListExerciseSubmissionsSuccess } from '$features/course/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const exerciseId = params.exerciseId || '';
|
||||
|
||||
if (!courseId || !exerciseId) {
|
||||
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies);
|
||||
|
||||
const [exerciseResult, overviewResult] = await Promise.all([
|
||||
safeServerApi<GetExerciseSuccess>(() =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].$get({ param: { courseId, exerciseId } }, headers)
|
||||
),
|
||||
safeServerApi<ListExerciseSubmissionsSuccess>(() =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId']['submissions'].$get(
|
||||
{ param: { courseId, exerciseId } },
|
||||
headers
|
||||
)
|
||||
)
|
||||
]);
|
||||
const exercise = exerciseResult.ok && exerciseResult.body.data ? exerciseResult.body.data : null;
|
||||
const overview =
|
||||
overviewResult.ok && overviewResult.body.data ? overviewResult.body.data : { mySubmission: [], allSubmissions: [] };
|
||||
|
||||
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
|
||||
const mySubmissions = mySubmissionData;
|
||||
|
||||
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
|
||||
|
||||
return {
|
||||
courseId,
|
||||
exerciseId,
|
||||
exercise,
|
||||
submissions,
|
||||
mySubmissions
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
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 { globalStore } 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';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
const path = `/courses/${data.courseId}/lessons`;
|
||||
const isLockedForStudent = $derived($globalStore.isStudent && data.exercise?.isUnlocked === false);
|
||||
|
||||
$effect(() => {
|
||||
if (!data.exercise || isLockedForStudent) return;
|
||||
|
||||
const meta = get(questionnaireMetaData);
|
||||
if (meta.isFinished && meta.exerciseId === data.exerciseId) {
|
||||
return;
|
||||
}
|
||||
if (meta.exerciseId != null && meta.exerciseId !== data.exerciseId) {
|
||||
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 }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
{#if isLockedForStudent}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
/>
|
||||
{:else}
|
||||
<ExercisePage
|
||||
exerciseId={data.exerciseId}
|
||||
goBack={() => goto(resolve(path, {}))}
|
||||
isFetching={false}
|
||||
submissions={data.submissions ?? []}
|
||||
mySubmissions={data.mySubmissions ?? []}
|
||||
/>
|
||||
{/if}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
import { CourseLandingPage } from '$features/ui';
|
||||
import Editor from '$features/ui/course-landing-page/components/editor/editor.svelte';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const courseId = $derived(data.courseId);
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
function syncCourseStore(_courseData: Course) {
|
||||
if (courseApi.course) {
|
||||
courseApi.course = { ...courseApi.course, ..._courseData };
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if courseApi.course}
|
||||
<div
|
||||
class="fixed inset-0 z-250 h-screen w-screen bg-white"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<Editor {courseId} bind:course={courseApi.course} {syncCourseStore} onClose={handleClose} />
|
||||
</Sidebar.Root>
|
||||
<Sidebar.Inset class="relative h-screen! overflow-y-auto">
|
||||
<div class="absolute top-2 left-2 z-60">
|
||||
<Sidebar.Trigger variant="secondary" />
|
||||
</div>
|
||||
<CourseLandingPage bind:courseData={courseApi.course} editMode={true} />
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { LessonsPage } from '$features/course/pages';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let reorder = $state(false);
|
||||
|
||||
function addContent() {
|
||||
const contentGroupingEnabled = courseApi.course?.metadata?.isContentGroupingEnabled ?? true;
|
||||
if (contentGroupingEnabled) {
|
||||
contentCreateStoreUtils.openSection();
|
||||
} else {
|
||||
contentCreateStoreUtils.openDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.lessons.heading_v2')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!$contentEditingStore}>
|
||||
{$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
|
||||
</Button>
|
||||
<Button onclick={addContent} disabled={!!$contentEditingStore}
|
||||
>{$t('course.navItem.lessons.add_content')}</Button
|
||||
>
|
||||
</RoleBasedSecurity>
|
||||
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LessonsPage courseId={data.courseId} bind:reorder />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { GetLessonSuccess } from '$features/course/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const lessonId = params.lessonId || '';
|
||||
|
||||
if (!courseId || !lessonId) {
|
||||
return { courseId, lessonId, lesson: null };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetLessonSuccess>(() =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].$get({ param: { courseId, lessonId } }, getApiHeaders(cookies))
|
||||
);
|
||||
const lesson = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
lessonId,
|
||||
lesson: lesson
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { LessonPage } from '$features/course/pages';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Lesson } from '$features/course/utils/types';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
lesson: Lesson | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data.lesson) return;
|
||||
const lesson = data.lesson;
|
||||
if (lessonApi.lesson?.id === lesson.id) return;
|
||||
|
||||
// Set lesson data
|
||||
lessonApi.lesson = lesson;
|
||||
|
||||
// Set translations if lesson has lessonLanguages
|
||||
if (lesson.lessonLanguages) {
|
||||
lessonApi.setTranslations();
|
||||
}
|
||||
|
||||
// Set current locale from profile if available
|
||||
if ($profile.locale) {
|
||||
lessonApi.currentLocale = $profile.locale;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
{#key data.lessonId}
|
||||
<LessonPage courseId={data.courseId} lessonId={data.lessonId} />
|
||||
{/key}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import type { MarksPageData } from '$features/course/utils/marks-utils';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
marksData: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<{ success: true; data: MarksPageData }>(() =>
|
||||
classroomio.course[':courseId'].mark.gradebook.$get({ param: { courseId } }, getApiHeaders(cookies, ''))
|
||||
);
|
||||
const marksData: MarksPageData | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
marksData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { MarksPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let isDownloading = $state(false);
|
||||
|
||||
const exercises = $derived(data.marksData?.exercises ?? []);
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles = [1, 2];
|
||||
if (org.customization?.course?.grading) {
|
||||
roles.push(3);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
const downloadCSV = () => {
|
||||
if (!data.marksData) return;
|
||||
isDownloading = true;
|
||||
try {
|
||||
generateMarksCSV(
|
||||
data.marksData.students,
|
||||
exercises,
|
||||
data.marksData.studentMarksByExerciseId,
|
||||
courseApi.course?.title || 'Course'
|
||||
);
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = () => {
|
||||
if (!data.marksData) return;
|
||||
isDownloading = true;
|
||||
try {
|
||||
generateMarksPDF(
|
||||
data.marksData.students,
|
||||
exercises,
|
||||
data.marksData.studentMarksByExerciseId,
|
||||
courseApi.course?.title || 'Course'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
onDenied={() => {
|
||||
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
|
||||
}}
|
||||
>
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.marks.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="rounded-full">
|
||||
{#if isDownloading}
|
||||
<Progress />
|
||||
{:else}
|
||||
<DownloadIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={downloadCSV}>
|
||||
{$t('course.navItem.marks.export.csv')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={downloadPDF}>
|
||||
{$t('course.navItem.marks.export.pdf')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
<RefreshPageData />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<MarksPage marksData={data.marksData} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data = $bindable(), children } = $props();
|
||||
|
||||
// Get back URL from query parameters
|
||||
let backUrl = $derived(page.url.searchParams.get('back'));
|
||||
|
||||
const handleClick = () => {
|
||||
goto(resolve(`${page.url.pathname}?add=true`, {}));
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (backUrl) {
|
||||
goto(resolve(backUrl, {}));
|
||||
} else {
|
||||
goto(resolve(`/courses/${data.courseId}/people`, {}));
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshPeoplePage() {
|
||||
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{#if data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton onclick={handleBackNavigation}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{$t('course.navItem.people.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button onclick={handleClick}>
|
||||
{$t('course.navItem.people.add')}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
<RefreshPageData onRefresh={refreshPeoplePage} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{@render children?.()}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || '',
|
||||
personId: params.personId || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { PeoplePage } from '$features/course/pages';
|
||||
</script>
|
||||
|
||||
<PeoplePage />
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params = { id: '' } }) => {
|
||||
return {
|
||||
courseId: params.id
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const userId = params.personId;
|
||||
|
||||
if (!courseId || !userId) {
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user course analytics:', error);
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let userCourseAnalytics: UserCourseAnalytics | undefined = $derived(data.userCourseAnalytics ?? undefined);
|
||||
|
||||
function getPercentage(a: number, b: number): number {
|
||||
if (b === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((a / b) * 100);
|
||||
}
|
||||
|
||||
let exerciseFilter: 'all' | 'completed' | 'incomplete' = $state('all');
|
||||
function toggleExerciseFilter(filter: 'completed' | 'incomplete') {
|
||||
if (exerciseFilter === filter) {
|
||||
exerciseFilter = 'all';
|
||||
} else {
|
||||
exerciseFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
let learningActivities = $derived([
|
||||
{
|
||||
description: $t('analytics.overall_course_progress_user_description'),
|
||||
icon: BookOpenIcon,
|
||||
percentage: userCourseAnalytics?.progressPercentage ?? 0,
|
||||
title: $t('analytics.overall_course_progress')
|
||||
},
|
||||
{
|
||||
description: $t('analytics.assignment_completion_description'),
|
||||
icon: ChartLineIcon,
|
||||
percentage: getPercentage(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0,
|
||||
userCourseAnalytics?.userExercisesStats?.length || 0
|
||||
),
|
||||
title: $t('analytics.assignment_completion')
|
||||
},
|
||||
{
|
||||
description: $t('analytics.average_grade_description'),
|
||||
icon: UnfoldVerticalIcon,
|
||||
percentage: userCourseAnalytics?.averageGrade ?? 0,
|
||||
title: $t('analytics.average_grade')
|
||||
}
|
||||
]);
|
||||
|
||||
let filteredExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => {
|
||||
if (exerciseFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
return exercise.isCompleted === (exerciseFilter === 'completed');
|
||||
})
|
||||
);
|
||||
let completedExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0
|
||||
);
|
||||
let incompleteExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => !exercise.isCompleted)?.length || 0
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
console.log('userCourseAnalytics.user', userCourseAnalytics?.user);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if userCourseAnalytics}
|
||||
<section class="px-1">
|
||||
{#if userCourseAnalytics.user}
|
||||
<HeroProfileCard user={userCourseAnalytics.user} />
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each learningActivities as activity}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5">
|
||||
<h3 class="text-2xl">
|
||||
{$t('analytics.exercises')}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.exercises')}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.progress_description', {
|
||||
value: completedExercises,
|
||||
total: userCourseAnalytics?.userExercisesStats?.length
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={getPercentage(completedExercises, userCourseAnalytics?.userExercisesStats?.length)} />
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge
|
||||
type={exerciseFilter === 'incomplete' ? 'secondary' : 'outline'}
|
||||
class="text-yellow-700 lowercase dark:text-yellow-500"
|
||||
onclick={() => toggleExerciseFilter('incomplete')}
|
||||
>
|
||||
{incompleteExercises}
|
||||
{$t('analytics.incomplete')}
|
||||
</Badge>
|
||||
<Badge
|
||||
type={exerciseFilter === 'completed' ? 'gray' : 'outline'}
|
||||
class="text-green-700 lowercase dark:text-green-500"
|
||||
onclick={() => toggleExerciseFilter('completed')}
|
||||
>
|
||||
{completedExercises}
|
||||
{$t('analytics.complete')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredExercises as exercise, index}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 flex items-center justify-between gap-4 rounded-md border p-5 ${
|
||||
exercise.isCompleted
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex w-2/3 items-center gap-4">
|
||||
<BookOpenIcon size={16} />
|
||||
<div>
|
||||
<div class="mb-2">
|
||||
<a href={`/courses/${data.courseId}/exercises/${exercise.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{exercise.title}
|
||||
</p>
|
||||
</a>
|
||||
{#if exercise.lessonId}
|
||||
<a href={`/courses/${data.courseId}/lessons/${exercise.lessonId}`}>
|
||||
<p class="text-sm text-gray-500">
|
||||
#{exercise.lessonTitle}
|
||||
</p>
|
||||
</a>
|
||||
{:else if exercise.lessonTitle}
|
||||
<p class="text-sm text-gray-500">
|
||||
#{exercise.lessonTitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
Score: {exercise.score}/{exercise.totalPoints}
|
||||
|
||||
{#if exercise.isCompleted}
|
||||
<Badge type={exercise.status === 3 ? 'secondary' : 'outline'}>
|
||||
{exercise.status === 3 ? $t('analytics.graded') : $t('analytics.not_graded')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge class={`${exercise.isCompleted ? 'bg-green-200 text-green-700' : 'bg-yellow-200 text-yellow-700'}`}>
|
||||
{exercise.isCompleted ? $t('analytics.completed') : $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<LoadingPage />
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { CourseSettingsPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
|
||||
let settingsComponent: CourseSettingsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let { data } = $props();
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await settingsComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</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.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.settings.heading')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('course.navItem.settings.save')}
|
||||
</Button>
|
||||
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ListSubmissionsForGradingSuccess } from '$features/course/utils/types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
sections: [],
|
||||
submissionIdData: {}
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<ListSubmissionsForGradingSuccess>(() =>
|
||||
classroomio.course[':courseId'].submission['for-grading'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies)
|
||||
)
|
||||
);
|
||||
|
||||
if (result.ok && result.body.data) {
|
||||
return {
|
||||
courseId,
|
||||
sections: result.body.data.sections || [],
|
||||
submissionIdData: result.body.data.submissionIdData || {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
courseId,
|
||||
sections: [],
|
||||
submissionIdData: {}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { SubmissionsPage } from '$features/course/pages';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.submissions.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<SubmissionsPage
|
||||
courseId={data.courseId}
|
||||
sections={data.sections || []}
|
||||
submissionIdData={data.submissionIdData || {}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import { OrgLandingPage } from '$features/org';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
</script>
|
||||
|
||||
<OrgLandingPage orgSiteName={$globalStore.orgSiteName} org={$currentOrg} />
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import LmsHeader from '$features/ui/navigation/lms-header.svelte';
|
||||
import { LMSSidebar } from '$features/ui/sidebar/lms-sidebar';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<LMSSidebar />
|
||||
|
||||
<Sidebar.Inset>
|
||||
<LmsHeader />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 px-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { DashboardPage } from '$features/lms/pages';
|
||||
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import type { PendingOrgInvite } from '$features/lms/utils/types';
|
||||
|
||||
let pendingInvite = $state<PendingOrgInvite | null>(null);
|
||||
let showInviteModal = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
try {
|
||||
const response = await classroomio.invite.organization.pending.$get();
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
pendingInvite = result.data;
|
||||
showInviteModal = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check pending org invite', error);
|
||||
}
|
||||
});
|
||||
|
||||
function handleInviteAccepted() {
|
||||
window.location.href = '/lms';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Student Dashboard - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<DashboardPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
{#if pendingInvite}
|
||||
<PendingInviteModal bind:open={showInviteModal} invite={pendingInvite} onAccepted={handleInviteAccepted} />
|
||||
{/if}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { CommunityPage } from '$features/community/pages';
|
||||
import { AskCommunityButton } from '$features/community/components';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('community.title')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<AskCommunityButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CommunityPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { CommunityQuestionPage } from '$features/community/pages';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Question - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityQuestionPage slug={data.slug} backHref={resolve(communityPath, {})} />
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params = { slug: '' } }) => {
|
||||
return {
|
||||
slug: params.slug
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { AskCommunityPage } from '$features/community/pages';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Ask the Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AskCommunityPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { ExercisesPage } from '$features/lms/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Exercises - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('exercises.heading')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ExercisesPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { ExplorePage } from '$features/lms/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Explore - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('explore.heading')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ExplorePage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { MyLearningPage } from '$features/lms/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>My Learning - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('my_learning.heading')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<MyLearningPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id) {
|
||||
programApi.listEnrolledPrograms();
|
||||
}
|
||||
});
|
||||
|
||||
const programs = $derived(programApi.programs);
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
|
||||
return programs.filter((program) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('lms_navigation.programs') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('lms_navigation.programs') || 'Programs'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLMS={true}
|
||||
isLoading={programApi.isLoading && programs.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.lms.empty_title') || 'No programs yet'}
|
||||
emptyDescription={$t('programs.lms.empty_description')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,41 @@
|
||||
<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';
|
||||
|
||||
let profileComponent: ProfilePage | null = $state(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleUpdate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
await profileComponent?.handleUpdate();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header isSticky>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
||||
{$t('settings.profile.update_profile')}
|
||||
</Button>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ProfilePage bind:this={profileComponent} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { IntegrationsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Integrations - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.integrations.heading')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<IntegrationsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
import { getOrgBySiteName } from '$features/org/api/org.server';
|
||||
import { getSubdomain } from '$features/app/layout-setup';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
|
||||
const ORG_ID_COOKIE_PREFIX = 'cio_org_id_';
|
||||
|
||||
export const load = async ({ params, url, cookies }) => {
|
||||
const subdomain = getSubdomain(url);
|
||||
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
||||
|
||||
// If this is LMS but user is on org site, redirect to LMS
|
||||
if (isOrgSite && PUBLIC_IS_SELFHOSTED !== 'true') {
|
||||
console.log('isOrgSite redirecting to lms');
|
||||
redirect(307, `/lms`);
|
||||
}
|
||||
|
||||
const siteName = params.slug;
|
||||
const cookieKey = `${ORG_ID_COOKIE_PREFIX}${siteName}`;
|
||||
const cachedOrgId = cookies.get(cookieKey);
|
||||
|
||||
console.log('cachedOrgId', cachedOrgId);
|
||||
console.log('cookieKey', cookieKey);
|
||||
|
||||
let orgId: string | undefined = cachedOrgId;
|
||||
|
||||
if (!orgId) {
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
|
||||
console.log('org', org);
|
||||
if (org?.id) {
|
||||
orgId = org.id;
|
||||
|
||||
cookies.set(cookieKey, org.id, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'lax',
|
||||
httpOnly: false // Allow client-side access if needed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('returning orgId', orgId);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
orgId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { AppHeader } from '$features/ui';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
|
||||
import { VerifyEmailModal } from '$features/onboarding/components';
|
||||
|
||||
import { OrgSidebar } from '$features/ui/sidebar/org-sidebar';
|
||||
import { AddOrgModal } from '$features/org';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
function redirect(siteName: string | null) {
|
||||
if (!siteName) return;
|
||||
|
||||
const newUrl = page.url.pathname.replace('*', siteName);
|
||||
goto(newUrl + page.url.search);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
data.orgName === '*' && redirect($currentOrg.siteName);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if PUBLIC_IS_SELFHOSTED !== 'true'}
|
||||
<AddOrgModal />
|
||||
{/if}
|
||||
|
||||
<VerifyEmailModal />
|
||||
|
||||
<Sidebar.Provider>
|
||||
<OrgSidebar />
|
||||
|
||||
<Sidebar.Inset>
|
||||
<AppHeader />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
|
||||
{#if data.orgName === '*'}
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
</div>
|
||||
<Skeleton class="h-[50vh] w-full rounded-xl" />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DashStatsSuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await 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;
|
||||
}
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$features/onboarding/components';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<WelcomeModal />
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<CreateCourseButton variant="outline" isResponsive />
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="mb-10 flex flex-wrap items-start">
|
||||
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: AwardIcon,
|
||||
title: $t('dashboard.certificates_earned'),
|
||||
percentage: totalCertificates ?? 0,
|
||||
description: $t('dashboard.certificates_earned_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</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="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{: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>
|
||||
</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}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.no_certifications_yet')}
|
||||
description={$t('dashboard.no_certifications_yet_description')}
|
||||
icon={AwardIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ListAutomationKeysSuccess } from '$features/automation/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return { keys: [] };
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies, orgId);
|
||||
|
||||
const keysResult = await safeServerApi<ListAutomationKeysSuccess>(() =>
|
||||
classroomio.organization.automation.keys.$get({ query: { type: 'api' } }, headers)
|
||||
);
|
||||
|
||||
return {
|
||||
keys: keysResult.ok ? keysResult.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { ApiAutomationPage } from '$features/automation/pages';
|
||||
import { automationApi } from '$features/automation/api/automation.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data) return;
|
||||
automationApi.keys = data.keys;
|
||||
automationApi.isFetched = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('automation.tabs.api')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('automation.tabs.api')}</Page.Title>
|
||||
<Page.Subtitle>{$t('automation.api.subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="outline" onclick={() => window.open('https://classroomio.com/docs/api/reference', '_blank')}>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
{$t('automation.api.view_docs')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ApiAutomationPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { InferResponseType } from '$lib/utils/services/api';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import {
|
||||
DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
getAudienceQueryFromSearchParams,
|
||||
toAudienceRequestQuery
|
||||
} from '$lib/features/org/utils/audience-query-utils';
|
||||
import type { OrganizationAudienceSuccess } from '$lib/features/org/utils/types';
|
||||
|
||||
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, cookies, url }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
audience: [],
|
||||
pagination: null,
|
||||
query: DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
courses: []
|
||||
};
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies, orgId);
|
||||
const query = getAudienceQueryFromSearchParams(url.searchParams);
|
||||
|
||||
const [audienceResult, coursesResult] = await Promise.all([
|
||||
safeServerApi<OrganizationAudienceSuccess>(() =>
|
||||
classroomio.organization.audience.$get({ query: toAudienceRequestQuery(query)! }, headers)
|
||||
),
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
|
||||
)
|
||||
]);
|
||||
const audience = audienceResult.ok ? audienceResult.body.data : [];
|
||||
const pagination = audienceResult.ok ? audienceResult.body.pagination : null;
|
||||
const courses = coursesResult.ok ? coursesResult.body.data : [];
|
||||
|
||||
return {
|
||||
audience,
|
||||
pagination,
|
||||
query,
|
||||
courses
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { AudiencePage } from '$features/audience/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data } = $props();
|
||||
let isLoading = $state(false);
|
||||
|
||||
function exportAudience() {
|
||||
isLoading = true;
|
||||
alert('This feature is coming soon');
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
const audienceLength = $derived(data.pagination?.total || 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audience - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-6xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('audience.title')}
|
||||
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
|
||||
<span class="ml-2 text-sm">
|
||||
({audienceLength} / {$currentOrgMaxAudience})
|
||||
</span>
|
||||
{/if}
|
||||
</Page.Title>
|
||||
<Page.Subtitle>{$t('audience.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
||||
{$t('audience.export')}
|
||||
</Button>
|
||||
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
|
||||
{$t('audience.import_users')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AudiencePage audience={data.audience} pagination={data.pagination} query={data.query} courses={data.courses} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetAudienceAnalyticsRequest = (typeof classroomio.organization.audience)[':userId']['analytics']['$get'];
|
||||
type GetAudienceAnalyticsSuccess = Extract<InferResponseType<GetAudienceAnalyticsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const paramParts = params.params?.split('/') ?? [];
|
||||
|
||||
const userId = paramParts[0];
|
||||
|
||||
if (!userId || !orgId) {
|
||||
return {
|
||||
userId,
|
||||
orgId,
|
||||
analytics: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetAudienceAnalyticsSuccess>(() =>
|
||||
classroomio.organization.audience[':userId'].analytics.$get({ param: { userId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
const analytics = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
orgId,
|
||||
analytics
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { UserAnalyticsPage } from '$features/audience/pages';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('audience.user_analytics.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full md:max-w-5xl lg:mx-auto">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
<Page.Title>{$t('audience.user_analytics.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('audience.user_analytics.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<UserAnalyticsPage {data} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
courses: [],
|
||||
programs: []
|
||||
};
|
||||
}
|
||||
|
||||
const [coursesResult, programsResult] = await Promise.all([
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get({ query: {} }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses: coursesResult.ok ? coursesResult.body.data : [],
|
||||
programs: programsResult.ok ? programsResult.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ImportAudiencePage } from '$features/audience/pages';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('audience.import.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(audiencePath, {})} label={$t('audience.import.back')} class="p-0!" />
|
||||
<Page.Title>{$t('audience.import.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('audience.import.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button type="submit" form="import-audience-form">
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ImportAudiencePage courses={data.courses} programs={data.programs} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ params, url }) => {
|
||||
throw redirect(307, `/org/${params.slug}/mcp${url.search}`);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CommunityQuestionsSuccess } from '$features/community/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
questions: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<CommunityQuestionsSuccess>(() =>
|
||||
classroomio.community.$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
const questions = result.ok ? result.body.data : [];
|
||||
|
||||
return {
|
||||
questions,
|
||||
orgId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { CommunityPage } from '$features/community/pages';
|
||||
import { AskCommunityButton } from '$features/community/components';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('community.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('community.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<AskCommunityButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CommunityPage questions={data.questions} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CommunityQuestionSuccess } from '$features/community/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const slug = params.cslug || '';
|
||||
|
||||
const questionResult = await safeServerApi<CommunityQuestionSuccess>(() =>
|
||||
classroomio.community[':slug'].$get({ param: { slug } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
const question = questionResult.ok ? questionResult.body.data : null;
|
||||
|
||||
return {
|
||||
slug,
|
||||
question
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { CommunityQuestionPage } from '$features/community/pages';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Question - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityQuestionPage slug={data.slug} question={data.question} backHref={resolve(communityPath, {})} />
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { AskCommunityPage } from '$features/community/pages';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Ask the Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
|
||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||
<Page.Subtitle>{$t('community.ask.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AskCommunityPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { GetTagGroupsSuccess } from '$features/tag/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import type { InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
|
||||
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies, url }) => {
|
||||
const loadStart = performance.now();
|
||||
|
||||
const tagsParam = url.searchParams.get('tags');
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return {
|
||||
courses: []
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedTags = tagsParam
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedTagsQuery = normalizedTags && normalizedTags.length > 0 ? normalizedTags.join(',') : undefined;
|
||||
|
||||
const apiStart = performance.now();
|
||||
const [coursesResult, tagsResult] = await Promise.all([
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagsQuery
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagsQuery
|
||||
}
|
||||
}
|
||||
: { query: {} },
|
||||
getApiHeaders(cookies, orgId)
|
||||
)
|
||||
),
|
||||
safeServerApi<GetTagGroupsSuccess>(() => classroomio.organization.tags.$get({}, getApiHeaders(cookies, orgId)))
|
||||
]);
|
||||
|
||||
const apiMs = Math.round((performance.now() - apiStart) * 100) / 100;
|
||||
|
||||
const courses = coursesResult.ok ? coursesResult.body.data : [];
|
||||
const tagGroups = tagsResult.ok ? tagsResult.body.data : [];
|
||||
|
||||
const loadMs = Math.round((performance.now() - loadStart) * 100) / 100;
|
||||
console.log(
|
||||
`[org/[slug]/courses +page.server] load: ${loadMs}ms (organization courses + tags API: ${apiMs}ms) | PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}`
|
||||
);
|
||||
|
||||
return {
|
||||
courses: courses || [],
|
||||
activeTags: normalizedTags ?? [],
|
||||
tagGroups
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { CoursesPage } from '$features/course/pages';
|
||||
import { CreateCourseButton, CourseFilterPopover } from '$features/course/components';
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import {
|
||||
CourseSortBy,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
parseCourseSortOrder,
|
||||
parseCourseSortValue
|
||||
} from '$features/course/utils/constants';
|
||||
import { browser } from '$app/environment';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onMount } from 'svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
let { data } = $props();
|
||||
const getInitialSelectedTags = () => data.activeTags ?? [];
|
||||
|
||||
let searchValue = $state('');
|
||||
let sortKey: CourseSortBy = $state(DEFAULT_COURSE_SORT);
|
||||
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
|
||||
|
||||
let selectedTags = $state<string[]>(getInitialSelectedTags());
|
||||
|
||||
let hasInitializedFilters = $state(false);
|
||||
let hasCompletedInitialUrlSync = false;
|
||||
let isFiltering = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (data.courses) {
|
||||
coursesApi.orgCourses = data.courses;
|
||||
}
|
||||
});
|
||||
|
||||
function updateFiltersUrl() {
|
||||
if (!browser || !hasInitializedFilters) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(window.location.href);
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
nextUrl.searchParams.set('tags', selectedTags.join(','));
|
||||
} else {
|
||||
nextUrl.searchParams.delete('tags');
|
||||
}
|
||||
|
||||
if (sortKey !== DEFAULT_COURSE_SORT) {
|
||||
nextUrl.searchParams.set('sort', sortKey);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('sort');
|
||||
}
|
||||
|
||||
if (selectedOrder !== DEFAULT_SORT_ORDER) {
|
||||
nextUrl.searchParams.set('order', selectedOrder);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('order');
|
||||
}
|
||||
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !hasInitializedFilters) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('classroomio_filter_course_sort_key', sortKey);
|
||||
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
|
||||
|
||||
if (!hasCompletedInitialUrlSync) {
|
||||
hasCompletedInitialUrlSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
updateFiltersUrl();
|
||||
});
|
||||
|
||||
async function applyTagFilters(nextTags: string[]) {
|
||||
selectedTags = nextTags;
|
||||
updateFiltersUrl();
|
||||
isFiltering = true;
|
||||
try {
|
||||
await coursesApi.getOrgCourses(nextTags);
|
||||
} finally {
|
||||
isFiltering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTag(tagSlug: string, checked: boolean) {
|
||||
const next = new SvelteSet(selectedTags);
|
||||
|
||||
if (checked) {
|
||||
next.add(tagSlug);
|
||||
} else {
|
||||
next.delete(tagSlug);
|
||||
}
|
||||
|
||||
void applyTagFilters(Array.from(next));
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
sortKey = DEFAULT_COURSE_SORT;
|
||||
selectedOrder = DEFAULT_SORT_ORDER;
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyTagFilters([]);
|
||||
}
|
||||
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const filteredCourses = (coursesApi.orgCourses ?? []).filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const sortedCourses = [...filteredCourses];
|
||||
|
||||
if (sortKey === CourseSortBy.DateCreated) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === CourseSortOrder.Asc
|
||||
? new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
|
||||
: new Date(b.createdAt ?? '').getTime() - new Date(a.createdAt ?? '').getTime()
|
||||
);
|
||||
} else if (sortKey === CourseSortBy.Published) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === CourseSortOrder.Asc
|
||||
? Number(a.isPublished) - Number(b.isPublished)
|
||||
: Number(b.isPublished) - Number(a.isPublished)
|
||||
);
|
||||
} else if (sortKey === CourseSortBy.Lessons) {
|
||||
return sortedCourses.sort((a, b) =>
|
||||
selectedOrder === CourseSortOrder.Asc ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
|
||||
);
|
||||
}
|
||||
|
||||
return sortedCourses;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
|
||||
|
||||
if (courseView) {
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
|
||||
const sortFromStorage = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
|
||||
const orderFromStorage = parseCourseSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const sortFromUrl = parseCourseSortValue(searchParams.get('sort'));
|
||||
const orderFromUrl = parseCourseSortOrder(searchParams.get('order'));
|
||||
|
||||
if (sortFromUrl !== DEFAULT_COURSE_SORT || orderFromUrl !== DEFAULT_SORT_ORDER) {
|
||||
sortKey = sortFromUrl;
|
||||
selectedOrder = orderFromUrl;
|
||||
} else if (sortFromStorage !== DEFAULT_COURSE_SORT || orderFromStorage !== DEFAULT_SORT_ORDER) {
|
||||
sortKey = sortFromStorage;
|
||||
selectedOrder = orderFromStorage;
|
||||
}
|
||||
|
||||
hasInitializedFilters = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Courses - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('courses.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('courses.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<CreateCourseButton isResponsive />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CoursesPage courses={filteredCourses} bind:searchValue bind:sortKey showSortSelect={false}>
|
||||
{#snippet filterControls()}
|
||||
<CourseFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedTags}
|
||||
tagGroups={data.tagGroups}
|
||||
{isFiltering}
|
||||
onToggleTag={toggleTag}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</CoursesPage>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GetAutomationUsageSuccess, ListAutomationKeysSuccess } from '$features/automation/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return { keys: [], usage: null };
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies, orgId);
|
||||
|
||||
const [keysResult, usageResult] = await Promise.all([
|
||||
safeServerApi<ListAutomationKeysSuccess>(() =>
|
||||
classroomio.organization.automation.keys.$get({ query: { type: 'mcp' } }, headers)
|
||||
),
|
||||
safeServerApi<GetAutomationUsageSuccess>(() =>
|
||||
classroomio.organization.automation.usage.$get({ query: { type: 'mcp' } }, headers)
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
keys: keysResult.ok ? keysResult.body.data : [],
|
||||
usage: usageResult.ok ? usageResult.body.data : null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { McpAutomationPage } from '$features/automation/pages';
|
||||
import { automationApi } from '$features/automation/api/automation.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data) return;
|
||||
automationApi.keys = data.keys;
|
||||
automationApi.usage = data.usage;
|
||||
automationApi.isFetched = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('automation.tabs.mcp')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
|
||||
<Page.Subtitle>{$t('automation.mcp.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<McpAutomationPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { GetAssetStorageSuccess, ListAssetsSuccess } from '$features/media/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
function parsePositiveInt(value: string | null, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
export const load = async ({ parent, cookies, url }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
assets: [],
|
||||
pagination: null,
|
||||
storageSummary: null
|
||||
};
|
||||
}
|
||||
|
||||
const page = parsePositiveInt(url.searchParams.get('page'), 1);
|
||||
const limit = Math.min(parsePositiveInt(url.searchParams.get('limit'), 20), 100);
|
||||
const kind = url.searchParams.get('kind') ?? undefined;
|
||||
const status = url.searchParams.get('status') ?? undefined;
|
||||
const search = url.searchParams.get('search') ?? undefined;
|
||||
const includeExternal = url.searchParams.get('includeExternal');
|
||||
|
||||
const query = {
|
||||
page,
|
||||
limit,
|
||||
kind,
|
||||
status,
|
||||
search,
|
||||
includeExternal: includeExternal ? includeExternal === 'true' : true
|
||||
};
|
||||
|
||||
const [assetsResult, storageResult] = await Promise.all([
|
||||
safeServerApi<ListAssetsSuccess>(() =>
|
||||
classroomio.organization.assets.$get({ query }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<GetAssetStorageSuccess>(() =>
|
||||
classroomio.organization.assets.storage.$get({}, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
assets: assetsResult.ok ? assetsResult.body.data : [],
|
||||
pagination: assetsResult.ok ? assetsResult.body.pagination : null,
|
||||
storageSummary: storageResult.ok ? storageResult.body.data : null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { MediaPage } from '$features/media';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import type { AssetKindFilter, AssetStatusFilter } from '$features/media/utils';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let search = $state('');
|
||||
let kind = $state<AssetKindFilter>('all');
|
||||
let status = $state<AssetStatusFilter>('all');
|
||||
|
||||
onMount(() => {
|
||||
mediaApi.assets = data.assets ?? [];
|
||||
mediaApi.storageSummary = data.storageSummary ?? null;
|
||||
mediaApi.pagination = data.pagination ?? null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('media_manager.page_title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('media_manager.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('media_manager.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body class="space-y-4">
|
||||
{#snippet child()}
|
||||
<MediaPage bind:search bind:kind bind:status />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return { programs: [] };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
console.log('result', result.ok ? result.body.data : []);
|
||||
|
||||
return {
|
||||
programs: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
import type { Program } from '$features/program/utils/types';
|
||||
import CreateProgramModal from '$features/program/components/create-program-modal.svelte';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
} from '$features/program/utils/constants';
|
||||
import ProgramFilterPopover from '$features/program/components/program-filter-popover.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<ProgramSortBy>(DEFAULT_PROGRAM_SORT);
|
||||
let selectedOrder = $state<ProgramSortOrder>(DEFAULT_PROGRAM_SORT_ORDER);
|
||||
let selectedStatuses = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (data.programs) {
|
||||
programApi.programs = data.programs;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: 'ACTIVE' as const, label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE' as const, label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED' as const, label: $t('programs.status.archived') || 'Archived' }
|
||||
]);
|
||||
|
||||
const sortOptions = $derived([
|
||||
{ value: 'name' as const, label: $t('programs.filters.sort_name') || 'Name' },
|
||||
{ value: 'courses' as const, label: $t('programs.filters.sort_courses') || 'Courses' },
|
||||
{ value: 'students' as const, label: $t('programs.filters.sort_students') || 'Students' },
|
||||
{ value: 'status' as const, label: $t('programs.filters.sort_status') || 'Status' }
|
||||
]);
|
||||
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
const sourcePrograms = programApi.programs.filter((program: Program) => {
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
[program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(program.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const sortedPrograms = [...sourcePrograms];
|
||||
|
||||
return sortedPrograms.sort((firstProgram, secondProgram) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
comparison = firstProgram.name.localeCompare(secondProgram.name);
|
||||
} else if (sortKey === 'courses') {
|
||||
comparison = firstProgram.courseCount - secondProgram.courseCount;
|
||||
} else if (sortKey === 'students') {
|
||||
comparison = firstProgram.studentCount - secondProgram.studentCount;
|
||||
} else if (sortKey === 'status') {
|
||||
comparison = firstProgram.status.localeCompare(secondProgram.status);
|
||||
}
|
||||
|
||||
return selectedOrder === 'asc' ? comparison : comparison * -1;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const nextStatuses = new SvelteSet(selectedStatuses);
|
||||
if (checked) {
|
||||
nextStatuses.add(status);
|
||||
} else {
|
||||
nextStatuses.delete(status);
|
||||
}
|
||||
|
||||
selectedStatuses = Array.from(nextStatuses);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
sortKey = DEFAULT_PROGRAM_SORT;
|
||||
selectedOrder = DEFAULT_PROGRAM_SORT_ORDER;
|
||||
selectedStatuses = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.page_title') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.heading') || 'Programs'}</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{$t('programs.description') || 'Give a group of people access to multiple courses, like a class'}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programApi.isLoading && filteredPrograms.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.empty_title') || 'No programs yet'}
|
||||
description={$t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLoading={programApi.isLoading && filteredPrograms.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.empty_title') || 'No programs yet'}
|
||||
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
|
||||
? $t('programs.no_matching_programs') || 'No programs match your filters.'
|
||||
: $t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
>
|
||||
{#snippet filterControls()}
|
||||
<ProgramFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedStatuses}
|
||||
{statusOptions}
|
||||
{sortOptions}
|
||||
onToggleStatus={toggleStatus}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</ProgramsPage>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<CreateProgramModal bind:open={showCreateModal} />
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { Quizzes, NewQuizModal, DeleteModal } from '$features/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { createQuizModal } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Interactive Quizzes - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="mx-auto w-full max-w-6xl">
|
||||
<div class="px-5 py-10">
|
||||
<div class="mb-10 flex items-center justify-between">
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">Interactive Quizzes</h1>
|
||||
{#if $isMobile}
|
||||
<Button onclick={() => ($createQuizModal.open = true)}>
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={() => ($createQuizModal.open = true)}>Create Quiz</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<NewQuizModal />
|
||||
<DeleteModal />
|
||||
|
||||
<Quizzes />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,434 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import isBoolean from 'lodash/isBoolean';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
|
||||
import { Preview, DeleteModal, QuizQuestion } from '$features/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { quizApi } from '$features/org/api/quiz.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrg, currentOrgPath, deleteModal, quizStore } from '$lib/utils/store/org';
|
||||
import { allOptions, allThemes, booleanOptions, themeImages } from '$lib/utils/constants/quiz';
|
||||
|
||||
interface QuizOption {
|
||||
id: number;
|
||||
label: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface QuizQuestion {
|
||||
id: number;
|
||||
label: string;
|
||||
type: string;
|
||||
options: QuizOption[];
|
||||
}
|
||||
|
||||
let { data } = $props();
|
||||
const quizId = $derived(data.quizId);
|
||||
|
||||
// Questionnaire State
|
||||
let currentQuestion: QuizQuestion = $state(
|
||||
$quizStore.questions[0] || {
|
||||
id: 0,
|
||||
label: '',
|
||||
type: 'multichoice',
|
||||
options: []
|
||||
}
|
||||
);
|
||||
|
||||
// Behavioural State
|
||||
let openPreview = $state(false);
|
||||
let type = $state('multichoice');
|
||||
let errors: Array<{
|
||||
isLabelEmpty: boolean;
|
||||
hasOneAnswer: boolean;
|
||||
id: number;
|
||||
options: Array<{ id: number; error: boolean }>;
|
||||
}> = $state([]);
|
||||
let currentError: {
|
||||
isLabelEmpty?: boolean;
|
||||
hasOneAnswer?: boolean;
|
||||
id?: number;
|
||||
options: Array<{ id: number; error: boolean }>;
|
||||
} = $state({});
|
||||
let isFocused = $state(false);
|
||||
|
||||
function activeClass(q, cq) {
|
||||
if (q.id === cq.id) {
|
||||
return 'bg-gray-200 dark:bg-black';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function addQuestion() {
|
||||
const newQuestion: QuizQuestion = {
|
||||
id: new Date().getTime(),
|
||||
label: '',
|
||||
type: 'multichoice',
|
||||
options: []
|
||||
};
|
||||
$quizStore.questions = [...$quizStore.questions, newQuestion];
|
||||
type = 'multichoice';
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
const cOptIds = currentQuestion.options.map((o) => o.id);
|
||||
const nextOption = cloneDeep(allOptions).find((o) => !cOptIds.includes(Number(o.id)));
|
||||
|
||||
if (!nextOption) return;
|
||||
|
||||
currentQuestion.options = [
|
||||
...currentQuestion.options,
|
||||
{
|
||||
id: Number(nextOption.id),
|
||||
label: nextOption.label,
|
||||
isCorrect: nextOption.isCorrect
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function deleteOption() {
|
||||
const opt = cloneDeep(currentQuestion.options);
|
||||
opt.pop();
|
||||
currentQuestion.options = opt;
|
||||
}
|
||||
|
||||
function handleQuestionTypeChange(type: string) {
|
||||
const opt =
|
||||
type === 'multichoice'
|
||||
? cloneDeep(allOptions)
|
||||
.filter((o, i) => i < 2)
|
||||
.map((o) => ({
|
||||
id: Number(o.id),
|
||||
label: o.label,
|
||||
isCorrect: o.isCorrect
|
||||
}))
|
||||
: cloneDeep(booleanOptions).map((o) => ({
|
||||
id: Number(o.id),
|
||||
label: o.label,
|
||||
isCorrect: o.isCorrect
|
||||
}));
|
||||
|
||||
currentQuestion.type = type;
|
||||
currentQuestion.options = opt;
|
||||
}
|
||||
|
||||
function deleteQuestion() {
|
||||
// Only leave one question
|
||||
if ($quizStore.questions.length === 1) return;
|
||||
$quizStore.questions = $quizStore.questions.filter((q) => q.id !== currentQuestion.id);
|
||||
currentQuestion = $quizStore.questions[0];
|
||||
}
|
||||
|
||||
function previewQuiz() {
|
||||
openPreview = !openPreview;
|
||||
}
|
||||
|
||||
function validateQuiz() {
|
||||
const _errors: Array<{
|
||||
isLabelEmpty: boolean;
|
||||
hasOneAnswer: boolean;
|
||||
id: number;
|
||||
options: Array<{ id: number; error: boolean }>;
|
||||
}> = [];
|
||||
|
||||
$quizStore.questions.forEach((q) => {
|
||||
const labelError = !!((q.label?.length || 0) < 3);
|
||||
const options: Array<{ id: number; error: boolean }> = [];
|
||||
let hasOneAnswer = false;
|
||||
|
||||
q.options.forEach((o) => {
|
||||
const valLabelError = !!(o.label.length < 3);
|
||||
|
||||
if (valLabelError) {
|
||||
options.push({
|
||||
id: o.id,
|
||||
error: true
|
||||
});
|
||||
}
|
||||
|
||||
if (o.isCorrect) {
|
||||
hasOneAnswer = true;
|
||||
}
|
||||
});
|
||||
|
||||
_errors.push({
|
||||
isLabelEmpty: labelError,
|
||||
hasOneAnswer,
|
||||
id: q.id,
|
||||
options
|
||||
});
|
||||
});
|
||||
|
||||
errors = _errors;
|
||||
currentError = errors.find((e) => e.id === currentQuestion.id) || {};
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function saveQuiz() {
|
||||
const _errors = validateQuiz();
|
||||
if (Array.isArray(_errors) && _errors.length && qHasError(null, _errors)) {
|
||||
console.error('Please fix all errors', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$currentOrg.id || !quizId) return;
|
||||
|
||||
await quizApi.update($currentOrg.id, quizId, {
|
||||
...$quizStore
|
||||
});
|
||||
|
||||
if (quizApi.success) {
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
// quizApi.quizzes is already updated by the API
|
||||
} else {
|
||||
snackbar.error('snackbar.course_settings.error.not_right');
|
||||
}
|
||||
}
|
||||
|
||||
function qHasError(qId, _errs) {
|
||||
return _errs.some((qe) => {
|
||||
return (qId ? qe.id === qId : true) && (qe.isLabelEmpty || !qe.hasOneAnswer || !!qe.options.length);
|
||||
});
|
||||
}
|
||||
|
||||
function optionHasError(eId, _errs) {
|
||||
if (Array.isArray(_errs) && _errs.length) {
|
||||
return _errs.some((e) => e.id === eId && e.error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// First try to find quiz in the list
|
||||
let quiz = quizApi.quizzes.find((q) => q.id === quizId);
|
||||
|
||||
// If not in list, fetch it
|
||||
if (!quiz && $currentOrg.id) {
|
||||
await quizApi.get($currentOrg.id, quizId);
|
||||
quiz = quizApi.quiz || undefined;
|
||||
}
|
||||
|
||||
if (!quiz) {
|
||||
goto(`${$currentOrgPath}/quiz`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map API Quiz to QuizStore format for quizStore
|
||||
quizStore.set({
|
||||
uuid: quiz.id,
|
||||
title: quiz.title,
|
||||
questions: quiz.questions || [],
|
||||
timelimit: quiz.timelimit || '10s',
|
||||
theme: quiz.theme || 'standard',
|
||||
pin: ''
|
||||
});
|
||||
currentQuestion = (quiz.questions || [])[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quiz</title>
|
||||
</svelte:head>
|
||||
|
||||
<DeleteModal onDelete={deleteQuestion} />
|
||||
|
||||
{#if openPreview}
|
||||
<Preview exitPreview={previewQuiz} />
|
||||
{/if}
|
||||
|
||||
<section class="flex h-full w-screen justify-between">
|
||||
<!-- Questions list -->
|
||||
<aside class="root h-full w-1/5 bg-gray-100 p-4 dark:bg-neutral-800">
|
||||
<div class="flex h-full flex-col">
|
||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/quiz`}>
|
||||
<ArrowLeftIcon size={16} /> Back to Quizzes
|
||||
</a>
|
||||
|
||||
<h3 class="my-3">Quiz</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
{#each $quizStore.questions as question, i}
|
||||
<button
|
||||
class="mb-3 flex w-full justify-between rounded p-3 text-left text-gray-500 dark:text-white {activeClass(
|
||||
question,
|
||||
currentQuestion
|
||||
)}"
|
||||
onclick={() => {
|
||||
currentQuestion = question;
|
||||
type = question.type;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
currentQuestion = question;
|
||||
type = question.type;
|
||||
}
|
||||
}}
|
||||
>
|
||||
Question {i + 1}
|
||||
|
||||
{#if qHasError(question.id, errors)}
|
||||
<CircleAlertIcon size={16} class="filled" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-end">
|
||||
<Button onclick={addQuestion}>Add Question</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="container h-full w-3/6">
|
||||
<div
|
||||
class="h-full p-5"
|
||||
style="background: url({themeImages[$quizStore.theme]
|
||||
?.editor}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;"
|
||||
>
|
||||
<div class="content m-auto">
|
||||
<h1 class="my-5 text-white">{$quizStore.title}</h1>
|
||||
|
||||
<QuizQuestion {currentQuestion} {optionHasError} {currentError} />
|
||||
|
||||
{#if isBoolean(currentError.hasOneAnswer) && !currentError.hasOneAnswer}
|
||||
<div class="mb-5">
|
||||
<p class="text-red-500">Please select at least one correct answer</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if currentQuestion.type !== 'boolean'}
|
||||
<div class="mb-4 flex w-full justify-center gap-4">
|
||||
{#if currentQuestion.options.length < allOptions.length}
|
||||
<Button onclick={addOption}>
|
||||
{$t('components.quiz.add_more')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if currentQuestion.options.length > 0}
|
||||
<Button onclick={deleteOption}>
|
||||
{$t('components.quiz.remove_last')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz Settings -->
|
||||
<aside class="settings h-full w-1/5 bg-gray-100 p-4 dark:bg-neutral-800">
|
||||
<div class="py-5">
|
||||
<h5>Quiz settings</h5>
|
||||
<Button onclick={saveQuiz} class="my-3">Save Changes</Button>
|
||||
<Button variant="outline" onclick={previewQuiz} class="my-3">Preview Quiz</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
if ($quizStore.questions.length === 1) return;
|
||||
$deleteModal.open = true;
|
||||
$deleteModal.isQuestion = true;
|
||||
}}
|
||||
class="my-3"
|
||||
>
|
||||
Delete question
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="my-3">
|
||||
<!-- Question type -->
|
||||
<div class="mb-3">
|
||||
<Label class="mb-2">Question type</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={type}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleQuestionTypeChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{type === 'multichoice' ? 'Multi-choice answers' : 'True or False'}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="multichoice">Multi-choice answers</Select.Item>
|
||||
<Select.Item value="boolean">True or False</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- -->
|
||||
<div class="mb-3">
|
||||
<Label class="mb-2">Time limit</Label>
|
||||
<Select.Root type="single" bind:value={$quizStore.timelimit}>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{$quizStore.timelimit || 'Select time limit'}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="10 seconds">10s</Select.Item>
|
||||
<Select.Item value="20 seconds">20s</Select.Item>
|
||||
<Select.Item value="30 seconds">30s</Select.Item>
|
||||
<Select.Item value="1 minute">1m</Select.Item>
|
||||
<Select.Item value="2 minute">2m</Select.Item>
|
||||
<Select.Item value="3 minute">3m</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme settings -->
|
||||
<div class="my-3">
|
||||
<p class="mb-2">Choose a theme</p>
|
||||
|
||||
{#each allThemes as _theme}
|
||||
<div
|
||||
class="theme relative mb-5 w-full cursor-pointer rounded-md border {$quizStore.theme === _theme.id &&
|
||||
'border-primary-700'}"
|
||||
>
|
||||
{#if $quizStore.theme === _theme.id}
|
||||
<CircleCheckIcon size={16} filled />
|
||||
{/if}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex h-full w-full flex-col-reverse rounded-md border"
|
||||
style="background: url({themeImages[_theme.id]?.card});"
|
||||
onclick={() => ($quizStore.theme = _theme.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
$quizStore.theme = _theme.id;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p class="mb-3 ml-3 text-white">{_theme.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
min-width: 696px;
|
||||
}
|
||||
.content {
|
||||
max-width: 550px;
|
||||
}
|
||||
.theme {
|
||||
height: 120px;
|
||||
max-width: 260px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params = { slug: '' } }) => {
|
||||
return {
|
||||
quizId: params.slug
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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';
|
||||
|
||||
let profileComponent: ProfilePage | null = $state(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleUpdate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
await profileComponent?.handleUpdate();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header isSticky>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.profile.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
||||
{$t('settings.profile.update_profile')}
|
||||
</Button>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ProfilePage bind:this={profileComponent} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { AuthPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('settings.auth.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{t.get('settings.auth.title')}</Page.Title>
|
||||
<Page.Subtitle>{t.get('settings.auth.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AuthPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { AuthPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('settings.auth.tabs.sso')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{t.get('settings.auth.title')}</Page.Title>
|
||||
<Page.Subtitle>{t.get('settings.auth.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AuthPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { AuthPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('settings.auth.tabs.token_auth')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{t.get('settings.auth.title')}</Page.Title>
|
||||
<Page.Subtitle>{t.get('settings.auth.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AuthPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { BillingPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Billing - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.billing.sub_title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.billing.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<BillingPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { CustomizeLmsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await customizeLmsComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Customize LMS - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('components.settings.customize_lms.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
|
||||
{$t('components.settings.customize_lms.save')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CustomizeLmsPage bind:this={customizeLmsComponent} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { DomainsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Custom domain - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('components.settings.domains.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('components.settings.domains.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<DomainsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { IntegrationsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Integrations - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.integrations.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.integrations.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<IntegrationsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { LandingpagePage } 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';
|
||||
|
||||
let landingPageComponent: LandingpagePage | null = $state(null);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await landingPageComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Landing Page Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header isSticky>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.landing_page.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.landing_page.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<VisitOrgSiteButton labelKey="markdown_editor.preview" variant="secondary" />
|
||||
|
||||
<Button variant="default" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('settings.landing_page.save_changes')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LandingpagePage bind:this={landingPageComponent} bind:hasUnsavedChanges />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { landingPageSettings } from '$features/settings/utils/store';
|
||||
import { buildOrgLandingPageProps, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
|
||||
import LandingpageEditor from '$features/settings/pages/landingpage-editor.svelte';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
let hasInitialized = $state(false);
|
||||
|
||||
function isLandingPageTheme(value: string | null): value is 'minimal' | 'bold' | 'classic' {
|
||||
return value === 'minimal' || value === 'bold' || value === 'classic';
|
||||
}
|
||||
|
||||
const savedLandingPageSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||
const previewTheme = $derived.by(() => {
|
||||
const queryTheme = page.url.searchParams.get('theme');
|
||||
if (isLandingPageTheme(queryTheme)) {
|
||||
return queryTheme;
|
||||
}
|
||||
|
||||
return savedLandingPageSettings.theme;
|
||||
});
|
||||
const previewLandingPageSettings = $derived({
|
||||
...$landingPageSettings,
|
||||
theme: previewTheme
|
||||
});
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
? {
|
||||
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
|
||||
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
|
||||
}
|
||||
: {
|
||||
label: t.get('navigation.login'),
|
||||
href: '/login'
|
||||
}
|
||||
);
|
||||
|
||||
const previewProps = $derived(
|
||||
buildOrgLandingPageProps(
|
||||
$currentOrg,
|
||||
previewLandingPageSettings,
|
||||
orgApi.publicCourses,
|
||||
orgApi.hasMorePublicCourses,
|
||||
authAction
|
||||
)
|
||||
);
|
||||
|
||||
const ThemeComponent = $derived(landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal);
|
||||
|
||||
onMount(() => {
|
||||
if ($currentOrg.siteName) {
|
||||
orgApi.publicCourses = [];
|
||||
orgApi.hasMorePublicCourses = false;
|
||||
void orgApi.getPublicCoursesBySiteName($currentOrg.siteName);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (hasInitialized || !$currentOrg.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$landingPageSettings = normalizeLandingPageSettings($currentOrg.landingpage);
|
||||
hasInitialized = true;
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
const queryTheme = page.url.searchParams.get('theme');
|
||||
const querySuffix = isLandingPageTheme(queryTheme) ? `?theme=${encodeURIComponent(queryTheme)}` : '';
|
||||
goto(resolve(`${$currentOrgPath}/settings/landingpage${querySuffix}`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Landing Page Editor - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="bg-background fixed inset-0 z-250 h-screen w-screen"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<LandingpageEditor
|
||||
bind:settings={$landingPageSettings}
|
||||
onSave={async () => {
|
||||
const updatedLandingPage = normalizeLandingPageSettings({
|
||||
...$landingPageSettings,
|
||||
theme: page.url.searchParams.get('theme') ? savedLandingPageSettings.theme : $landingPageSettings.theme
|
||||
});
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
landingpage: updatedLandingPage as AccountOrg['landingpage']
|
||||
});
|
||||
}}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Sidebar.Root>
|
||||
|
||||
<Sidebar.Inset
|
||||
class="relative h-screen! w-[calc(100vw-var(--sidebar-width))] overflow-y-auto group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<div class="absolute top-2 left-2 z-60">
|
||||
<Sidebar.Trigger variant="secondary" />
|
||||
</div>
|
||||
|
||||
<ThemeComponent {...previewProps} disableCourseLinks={true} />
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { OrgPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let orgComponent: OrgPage | null = $state(null);
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleUpdate() {
|
||||
loading = true;
|
||||
try {
|
||||
await orgComponent?.handleUpdate();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Organization Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header isSticky>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="default" {loading} disabled={loading} onclick={handleUpdate}>
|
||||
{$t('settings.organization.organization_profile.update_organization')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<OrgPage bind:this={orgComponent} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { TeamsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Teams - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('upgrade.team_members')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.teams.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<TeamsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetOrganizationSetupRequest = typeof classroomio.organization.setup.$get;
|
||||
type GetOrganizationSetupSuccess = Extract<InferResponseType<GetOrganizationSetupRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug || '';
|
||||
|
||||
const result = await safeServerApi<GetOrganizationSetupSuccess>(() =>
|
||||
classroomio.organization.setup.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
const setupProgress = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
orgSiteName: siteName,
|
||||
setupProgress
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import SetupProgress from '$features/setup/components/setup-progress.svelte';
|
||||
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { goto } from '$app/navigation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (data.setupProgress && data.orgSiteName) {
|
||||
setupProgressApi.initializeFromServerData(data.setupProgress, data.orgSiteName);
|
||||
}
|
||||
});
|
||||
|
||||
const setupList = $derived(
|
||||
setupProgressApi.setupList.map((item) => {
|
||||
if (item.id === 'profile') {
|
||||
return {
|
||||
...item,
|
||||
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png') || !!$profile.avatarUrl
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
console.log('setup list', setupList);
|
||||
});
|
||||
|
||||
const completed = $derived(setupList.filter((list) => list.is_completed).length);
|
||||
const total = $derived(setupList.length);
|
||||
|
||||
const StepsEnum = {
|
||||
UPDATE_PROFILE: 'profile',
|
||||
UPDATE_ORG_PROFILE: 'organization',
|
||||
CREATE_COURSE: 'course',
|
||||
CREATE_LESSON: 'lesson',
|
||||
CREATE_EXERCISE: 'exercise',
|
||||
PUBLISH_COURSE: 'publish'
|
||||
};
|
||||
|
||||
const isCompleted = (id: string) => {
|
||||
return setupList?.find((c) => c.id === id)?.is_completed == true;
|
||||
};
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
switch (id) {
|
||||
case StepsEnum.CREATE_COURSE:
|
||||
goto(`/org/${$currentOrg.siteName}/courses?create=true`);
|
||||
break;
|
||||
|
||||
case StepsEnum.CREATE_LESSON:
|
||||
if (isCompleted('course')) {
|
||||
const courses = setupProgressApi.progress.courses || [];
|
||||
const courseId = courses[0]?.id;
|
||||
if (courseId) {
|
||||
goto(`/courses/${courseId}/lessons`);
|
||||
}
|
||||
} else {
|
||||
snackbar.info('setup.info_course');
|
||||
}
|
||||
break;
|
||||
|
||||
case StepsEnum.CREATE_EXERCISE:
|
||||
if (isCompleted('lesson')) {
|
||||
const courses = setupProgressApi.progress.courses || [];
|
||||
const lessons = setupProgressApi.progress.lessons || [];
|
||||
const courseId = courses[0]?.id;
|
||||
const lessonId = lessons[0]?.id;
|
||||
if (courseId && lessonId) {
|
||||
goto(`/courses/${courseId}/lessons/${lessonId}`);
|
||||
}
|
||||
} else {
|
||||
snackbar.info('setup.info_lesson');
|
||||
}
|
||||
break;
|
||||
|
||||
case StepsEnum.PUBLISH_COURSE:
|
||||
if (isCompleted('course')) {
|
||||
const courses = setupProgressApi.progress.courses || [];
|
||||
const courseId = courses[0]?.id;
|
||||
if (courseId) {
|
||||
goto(`/courses/${courseId}/settings`);
|
||||
}
|
||||
} else {
|
||||
snackbar.info('setup.info_course');
|
||||
}
|
||||
break;
|
||||
|
||||
case StepsEnum.UPDATE_PROFILE:
|
||||
goto(`/org/${$currentOrg.siteName}/settings`);
|
||||
break;
|
||||
|
||||
case StepsEnum.UPDATE_ORG_PROFILE:
|
||||
goto(`/org/${$currentOrg.siteName}/settings/org`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('setup.get_started')}</Page.Title>
|
||||
<Page.Subtitle>{$t('setup.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
|
||||
<Page.Action>
|
||||
<SetupProgress setupItems={setupList} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<section class="space-y-6 px-4">
|
||||
<Item.Root variant="outline" class="ui:cursor-default">
|
||||
<Item.Content>
|
||||
<Item.Description class="mb-4">
|
||||
{$t('setup.description')}
|
||||
</Item.Description>
|
||||
<div class="flex items-center gap-2">
|
||||
<Item.Description class="text-sm">
|
||||
{completed} of {total}
|
||||
{$t('setup.completed')}
|
||||
</Item.Description>
|
||||
<div class="flex gap-1">
|
||||
{#each Array(total) as _, i}
|
||||
<div
|
||||
class="size-2 rounded-full {i < completed ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
|
||||
<!-- Setup Items -->
|
||||
<Item.Group class="space-y-2">
|
||||
{#each setupList as list}
|
||||
<Item.Root variant="muted" class={list.is_completed ? 'opacity-60' : ''}>
|
||||
<Item.Media variant="icon">
|
||||
{#if list.is_completed}
|
||||
<div class="flex size-5 items-center justify-center rounded">
|
||||
<BadgeCheckIcon class="size-5 text-white" />
|
||||
</div>
|
||||
{:else if list.id === StepsEnum.CREATE_COURSE}
|
||||
<BookOpenIcon class="size-5 text-gray-600" />
|
||||
{:else if list.id === StepsEnum.CREATE_LESSON || list.id === StepsEnum.CREATE_EXERCISE}
|
||||
<FileTextIcon class="size-5 text-gray-600" />
|
||||
{:else if list.id === StepsEnum.PUBLISH_COURSE}
|
||||
<GlobeIcon class="size-5 text-gray-600" />
|
||||
{:else if list.id === StepsEnum.UPDATE_PROFILE}
|
||||
<UserPlusIcon class="size-5 text-gray-600" />
|
||||
{:else if list.id === StepsEnum.UPDATE_ORG_PROFILE}
|
||||
<UsersIcon class="size-5 text-gray-600" />
|
||||
{:else}
|
||||
<BookOpenIcon class="size-5 text-gray-600" />
|
||||
{/if}
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<Item.Title class={list.is_completed ? 'text-gray-500 line-through' : 'font-semibold'}>
|
||||
{$t(list.title)}
|
||||
</Item.Title>
|
||||
<Item.Description class={list.is_completed ? 'text-gray-500 line-through' : ''}>
|
||||
{$t(list.desc)}
|
||||
</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
{#if list.is_completed}
|
||||
<Button variant="secondary" size="sm" disabled class="bg-gray-400 text-white">
|
||||
{$t('setup.done')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" size="sm" onclick={() => handleClick(list.id)} class="w-full sm:w-auto">
|
||||
{$t('setup.todo')}
|
||||
<ChevronRightIcon class="ml-2 size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
</section>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { GetTagGroupsSuccess } from '$features/tag/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 {
|
||||
initialTagGroups: [],
|
||||
orgSlug: params.slug
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetTagGroupsSuccess>(() =>
|
||||
classroomio.organization.tags.$get({}, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
if (!result.ok && (result.status === 401 || result.status === 403)) {
|
||||
throw redirect(302, `/org/${params.slug}`);
|
||||
}
|
||||
|
||||
return {
|
||||
initialTagGroups: result.ok ? result.body.data : [],
|
||||
orgSlug: params.slug
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { TagsAdminPage } from '$features/tag/pages';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<TagsAdminPage initialTagGroups={data.initialTagGroups} orgSlug={data.orgSlug} />
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { ZapierAutomationPage } from '$features/automation/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('automation.tabs.zapier')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="mx-auto w-full max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('automation.tabs.zapier')}</Page.Title>
|
||||
<Page.Subtitle>{$t('automation.zapier.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ZapierAutomationPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
programId: params.id
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ProgramSidebar } from '$features/program/components/sidebar';
|
||||
import ProgramHeader from '$features/program/components/program-header.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
programId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data.programId || !$profile.id) return;
|
||||
programApi.ensureProgramShell(data.programId);
|
||||
});
|
||||
|
||||
const isProgramReady = $derived.by(() => {
|
||||
return (
|
||||
programApi.program?.id === data.programId &&
|
||||
programApi.loadedMembersProgramId === data.programId &&
|
||||
programApi.loadedCoursesProgramId === data.programId
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{programApi.program?.name || 'Program'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<ProgramSidebar path={page.url.pathname} id={data.programId} {isProgramReady} />
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<ProgramHeader />
|
||||
|
||||
{#if !isProgramReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
<Empty
|
||||
title="Loading program..."
|
||||
description="Please wait while we load your program data."
|
||||
icon={Spinner}
|
||||
iconClass="h-8 w-8"
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { CourseCard } from '$features/course/components';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import CoursePublishBadge from '$features/course/components/course-publish-badge.svelte';
|
||||
import { courseMetaDeta } from '$features/course/utils/store';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import GridIcon from '@lucide/svelte/icons/grid-2x2';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showAddCourseModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let courseToDeleteId = $state<string | null>(null);
|
||||
let searchValue = $state('');
|
||||
|
||||
const courses = $derived(programApi.courses);
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return courses;
|
||||
}
|
||||
|
||||
return courses.filter((item) =>
|
||||
[item.course.title, item.course.description].some((value) => value?.toLowerCase().includes(normalizedSearch))
|
||||
);
|
||||
});
|
||||
|
||||
const courseCards = $derived(
|
||||
filteredCourses.map((item) => ({
|
||||
id: item.course.id,
|
||||
slug: item.course.slug ?? '',
|
||||
logo: item.course.coverImage,
|
||||
title: item.course.title ?? '',
|
||||
description: item.course.description ?? '',
|
||||
lessonCount: 0,
|
||||
totalStudents: 0,
|
||||
isPublished: item.course.isPublished ?? false,
|
||||
tags: []
|
||||
}))
|
||||
);
|
||||
|
||||
function setViewPreference(preference: 'grid' | 'list') {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
localStorage.setItem('courseView', preference);
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(courseId: string) {
|
||||
courseToDeleteId = courseId;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
async function handleRemoveCourse() {
|
||||
if (!courseToDeleteId) return;
|
||||
|
||||
await programApi.removeCourse(data.programId, courseToDeleteId);
|
||||
showDeleteModal = false;
|
||||
courseToDeleteId = null;
|
||||
}
|
||||
|
||||
function openCourse(courseId: string) {
|
||||
goto(resolve(`/courses/[id]`, { id: courseId }));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
|
||||
|
||||
if (courseView) {
|
||||
$courseMetaDeta.view = courseView;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-5xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent class="min-w-0 flex-1">
|
||||
<Page.Title>{$t('programs.sidebar.courses') || 'Courses'}</Page.Title>
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('programs.courses.published_access_callout_title')}
|
||||
description={$t('programs.courses.published_access_callout_description')}
|
||||
class="mt-3 w-full"
|
||||
/>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<Page.BodyHeader align="right" class="p-0!">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
{#if filteredCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.courses.empty_title') || 'No courses yet'}
|
||||
description={searchValue.trim()
|
||||
? $t('programs.courses.no_matching_program_courses') || 'No program courses match your search.'
|
||||
: $t('programs.courses.empty_description') || 'Add courses to this program.'}
|
||||
icon={BookOpenIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if !searchValue.trim()}
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{:else if $courseMetaDeta.view === 'list'}
|
||||
<div class="mt-4 w-full overflow-hidden rounded-md border">
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[28%]">{$t('courses.course_card.list_view.title')}</Table.Head>
|
||||
<Table.Head class="w-[44%]">{$t('courses.course_card.list_view.description')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[8%]"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filteredCourses as item (item.id)}
|
||||
<Table.Row class="cursor-pointer" onclick={() => openCourse(item.course.id)}>
|
||||
<Table.Cell class="truncate">
|
||||
<p class="font-semibold">{item.course.title}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="truncate">
|
||||
<p>{item.course.description}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge isPublished={item.course.isPublished ?? false} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDeleteModal(item.courseId);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each courseCards as course (course.id)}
|
||||
<CourseCard {course} href={`/courses/${course.id}`}>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="text-muted-foreground hover:text-destructive absolute! top-6 right-6 z-40"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDeleteModal(course.id);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</CourseCard>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<AddCourseToProgramModal bind:open={showAddCourseModal} programId={data.programId} />
|
||||
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={programApi.isLoading} />
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { NewsFeedLoader } from '$features/course/components/newsfeed';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
|
||||
import { programApi, programNewsfeedApi } from '$features/program/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
programId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const programId = data.programId;
|
||||
|
||||
let edit = $state(false);
|
||||
let editFeed: ProgramNewsfeedItem | null = $state(null);
|
||||
let isListing = $state(true);
|
||||
let isReactingByFeedId = $state<Record<string, boolean>>({});
|
||||
|
||||
const query = new URLSearchParams(page.url.search);
|
||||
const activeFeedId = query.get('feedId') || '';
|
||||
|
||||
const currentProgramMember = $derived.by(
|
||||
() => programApi.members.find((member) => member.profile?.id === $profile.id) ?? null
|
||||
);
|
||||
|
||||
const reactionAuthorId = $derived(currentProgramMember?.id || '');
|
||||
|
||||
const currentProgramRoleId = $derived(currentProgramMember?.roleId || null);
|
||||
|
||||
const author = $derived({
|
||||
id: $profile.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatarUrl: $profile.avatarUrl || ''
|
||||
});
|
||||
|
||||
const canCreateNewsfeed = $derived(
|
||||
Boolean($isOrgAdmin) || currentProgramRoleId === ROLE.ADMIN || currentProgramRoleId === ROLE.TUTOR
|
||||
);
|
||||
const canManageNewsfeed = $derived(Boolean($isOrgAdmin) || canCreateNewsfeed);
|
||||
const canComment = $derived(Boolean(reactionAuthorId));
|
||||
|
||||
const sortedFeeds = $derived.by(() => {
|
||||
return [...programNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
|
||||
return Number(Boolean(secondFeed.isPinned)) - Number(Boolean(firstFeed.isPinned));
|
||||
});
|
||||
});
|
||||
|
||||
const loadNewsfeed = async () => {
|
||||
isListing = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.list(programId);
|
||||
} finally {
|
||||
isListing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteComment = async (feedId: string, commentId: string) => {
|
||||
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.react(programId, feedId, updatedReaction);
|
||||
} finally {
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
|
||||
}
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string) => {
|
||||
await programNewsfeedApi.createComment(programId, feedId, comment, author);
|
||||
};
|
||||
|
||||
const onPin = async (feedId: string, isPinned: boolean | null) => {
|
||||
await programNewsfeedApi.update(programId, feedId, { isPinned: !isPinned });
|
||||
};
|
||||
|
||||
const deleteFeed = async (feedId: string) => {
|
||||
await programNewsfeedApi.delete(programId, feedId);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await loadNewsfeed();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.newsfeed')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewFeedModal {programId} bind:edit bind:editFeed />
|
||||
|
||||
{#if isListing}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<NewsFeedLoader />
|
||||
<NewsFeedLoader />
|
||||
<NewsFeedLoader />
|
||||
</div>
|
||||
{:else if !sortedFeeds.length}
|
||||
<Empty
|
||||
title={$t('programs.newsfeed.empty_title')}
|
||||
description={$t('programs.newsfeed.empty_description')}
|
||||
icon={BookIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{:else}
|
||||
{#each sortedFeeds as feed (feed.id)}
|
||||
{#if feed.isPinned}
|
||||
<div class="flex items-center gap-2">
|
||||
<PinIcon size={16} class="filled" />
|
||||
<p class="text-sm">{$t('programs.newsfeed.pinned')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
comments={programNewsfeedApi.commentsByFeedId[feed.id]}
|
||||
{programId}
|
||||
{author}
|
||||
{reactionAuthorId}
|
||||
{canComment}
|
||||
canManageFeed={canManageNewsfeed}
|
||||
{deleteFeed}
|
||||
{deleteComment}
|
||||
{addNewComment}
|
||||
{addNewReaction}
|
||||
{onPin}
|
||||
bind:edit
|
||||
bind:editFeed
|
||||
isActive={activeFeedId === feed.id}
|
||||
isReacting={Boolean(isReactingByFeedId[feed.id])}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import InviteMembersModal from '$features/program/components/invite-members-modal.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let memberToDelete = $state<(typeof programApi.members)[number] | null>(null);
|
||||
let isDeleteModalOpen = $state(false);
|
||||
let filterBy = $state('all');
|
||||
let searchValue = $state('');
|
||||
let copiedEmail = $state<string | null>(null);
|
||||
|
||||
const members = $derived(sortAndFilterPeople(programApi.members, filterBy));
|
||||
const currentUserRole = $derived.by(() => {
|
||||
const currentMember = programApi.members.find((member) => member.profileId === $profile.id);
|
||||
return currentMember ? Number(currentMember.roleId) : null;
|
||||
});
|
||||
const canManageMembers = $derived.by(
|
||||
() => Boolean($isOrgAdmin) || currentUserRole === ROLE.ADMIN || currentUserRole === ROLE.TUTOR
|
||||
);
|
||||
const selectOptions = $derived([
|
||||
{ label: $t('course.navItem.people.roles.filter'), value: 'all' },
|
||||
{ label: $t(ROLE_LABEL[ROLE.TUTOR]), value: `${ROLE.TUTOR}` },
|
||||
{ label: $t(ROLE_LABEL[ROLE.STUDENT]), value: `${ROLE.STUDENT}` }
|
||||
]);
|
||||
|
||||
function sortAndFilterPeople(people: typeof programApi.members, activeFilter: string) {
|
||||
return [...(people || [])]
|
||||
.filter((person) => {
|
||||
if (activeFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Number(person.roleId) === Number(activeFilter);
|
||||
})
|
||||
.sort((a, b) => new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime())
|
||||
.sort((a, b) => Number(a.roleId) - Number(b.roleId));
|
||||
}
|
||||
|
||||
function filterPeople(query: string, people: typeof programApi.members) {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return people.filter((person) => {
|
||||
const fullname = person.profile?.fullname?.toLowerCase() ?? '';
|
||||
const email = getEmail(person).toLowerCase();
|
||||
return fullname.includes(normalizedQuery) || email.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
function getEmail(person: (typeof programApi.members)[number]) {
|
||||
return person.profile?.email ?? person.email ?? '';
|
||||
}
|
||||
|
||||
function obscureEmail(email: string) {
|
||||
const [username = '', domain = ''] = email.split('@');
|
||||
if (username.length <= 2) {
|
||||
return email;
|
||||
}
|
||||
|
||||
const obscuredUsername =
|
||||
username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
|
||||
|
||||
return `${obscuredUsername}@${domain}`;
|
||||
}
|
||||
|
||||
function getRoleLabel(roleId: number) {
|
||||
if (roleId === ROLE.ADMIN) {
|
||||
return $t(ROLE_LABEL[ROLE.ADMIN]);
|
||||
}
|
||||
|
||||
if (roleId === ROLE.TUTOR) {
|
||||
return $t(ROLE_LABEL[ROLE.TUTOR]);
|
||||
}
|
||||
|
||||
return $t(ROLE_LABEL[ROLE.STUDENT]);
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedEmail = text;
|
||||
setTimeout(() => {
|
||||
copiedEmail = null;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePerson() {
|
||||
if (!memberToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
|
||||
await programApi.removeMember(data.programId, memberToDelete.id);
|
||||
if (programApi.success) {
|
||||
isDeleteModalOpen = false;
|
||||
memberToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openInviteModal() {
|
||||
goto(resolve(`${page.url.pathname}?add=true`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.people')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canManageMembers}
|
||||
<Button onclick={openInviteModal}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('programs.people.invite')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<section class="space-y-2">
|
||||
<div class="flex flex-col items-center justify-end gap-2 md:flex-row">
|
||||
<Search placeholder={$t('course.navItem.people.search')} bind:value={searchValue} />
|
||||
<Select.Root type="single" name="roles" bind:value={filterBy}>
|
||||
<Select.Trigger class="max-w-[100px]">
|
||||
{selectOptions.find((option) => option.value === filterBy)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each selectOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} disabled={option.value === filterBy}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('course.navItem.people.name')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.people.role')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.people.action')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filterPeople(searchValue, members) as person}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-4/6 md:w-3/6">
|
||||
{#if person.profile}
|
||||
<div class="flex items-start lg:items-center">
|
||||
<Avatar.Root class="mr-3">
|
||||
{#if person.profile.avatarUrl}
|
||||
<Avatar.Image
|
||||
src={person.profile.avatarUrl}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||
<div class="mr-2">
|
||||
<p class="text-base font-normal dark:text-white">
|
||||
{person.profile.fullname}
|
||||
</p>
|
||||
<p class="ui:text-primary line-clamp-1 text-xs">
|
||||
{obscureEmail(getEmail(person))}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
{#if canManageMembers}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => copyToClipboard(getEmail(person))}
|
||||
>
|
||||
{#if copiedEmail === getEmail(person)}
|
||||
<CheckIcon size={16} class="text-green-600" />
|
||||
{:else}
|
||||
<CopyIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if person.profileId === $profile.id}
|
||||
<Badge variant="secondary">{$t('course.navItem.people.you')}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-2/4 items-start lg:items-center">
|
||||
<Chip value={shortenName(person.email ?? '')} className="mr-3" />
|
||||
<a href={`mailto:${person.email ?? ''}`} class="text-md ui:text-primary mr-2 dark:text-white">
|
||||
{person.email}
|
||||
</a>
|
||||
<div class="flex items-center justify-between">
|
||||
{#if canManageMembers}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => copyToClipboard(getEmail(person))}
|
||||
>
|
||||
{#if copiedEmail === getEmail(person)}
|
||||
<CheckIcon size={16} class="text-green-600" />
|
||||
{:else}
|
||||
<CopyIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Chip value={$t('course.navItem.people.pending')} className="bg-yellow-200 text-yellow-700" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="w-1/4">
|
||||
<p class="w-1/4 text-center text-base font-normal dark:text-white">
|
||||
{getRoleLabel(Number(person.roleId))}
|
||||
</p>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell class="w-1/4">
|
||||
{#if canManageMembers && person.profileId !== $profile.id}
|
||||
<div class="hidden space-x-2 sm:flex sm:items-center">
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
memberToDelete = person;
|
||||
isDeleteModalOpen = true;
|
||||
}}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<InviteMembersModal programId={data.programId} />
|
||||
|
||||
<Dialog.Root bind:open={isDeleteModalOpen}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
<Dialog.Header class="px-5 py-2">
|
||||
<Dialog.Title>{$t('course.navItem.people.delete_confirmation.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.people.delete_confirmation.sure')}
|
||||
<strong>{memberToDelete ? getEmail(memberToDelete) : ''}</strong>?
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (isDeleteModalOpen = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={deletePerson} loading={programApi.isLoading}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
let { data } = $props();
|
||||
const programId = data.programId as string;
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let status = $state<'ACTIVE' | 'INACTIVE' | 'ARCHIVED'>('ACTIVE');
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const p = programApi.program;
|
||||
if (p) {
|
||||
name = p.name;
|
||||
description = p.description ?? '';
|
||||
status = (p.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED', label: $t('programs.status.archived') || 'Archived' }
|
||||
];
|
||||
|
||||
const hasUnsavedChanges = $derived.by(() => {
|
||||
const currentProgram = programApi.program;
|
||||
if (!currentProgram) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
name.trim() !== currentProgram.name ||
|
||||
description.trim() !== (currentProgram.description ?? '') ||
|
||||
status !== ((currentProgram.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE')
|
||||
);
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
await programApi.updateProgram(programId, {
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || null,
|
||||
status
|
||||
});
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
$t('programs.settings.confirm_delete') || 'Are you sure you want to delete this program? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await programApi.deleteProgram(programId);
|
||||
goto(resolve(`${$currentOrgPath}/programs`, {}));
|
||||
}
|
||||
</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.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.settings') || 'Settings'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('programs.settings.save') || 'Save Changes'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="w-full px-4 py-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<section>
|
||||
<h2 class="mb-4 text-sm font-semibold">{$t('programs.settings.general') || 'General'}</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-name">{$t('programs.settings.name_label') || 'Name'} *</Label>
|
||||
<Input id="prog-name" bind:value={name} maxlength={255} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-desc">{$t('programs.settings.description_label') || 'Description'}</Label>
|
||||
<Textarea id="prog-desc" bind:value={description} rows={4} maxlength={2000} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.settings.status_label') || 'Status'}</Label>
|
||||
<Select.Root type="single" bind:value={status}>
|
||||
<Select.Trigger class="w-48">
|
||||
{statusOptions.find((o) => o.value === status)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each statusOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h2 class="mb-1 text-sm font-semibold text-red-600">
|
||||
{$t('programs.settings.danger_zone') || 'Danger Zone'}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-4 text-sm">
|
||||
{$t('programs.settings.delete_description') ||
|
||||
'Once deleted, this program and all its data cannot be recovered.'}
|
||||
</p>
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={programApi.isLoading}>
|
||||
{$t('programs.settings.delete') || 'Delete Program'}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
Reference in New Issue
Block a user