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();
|
||||
};
|
||||
+9
-8
@@ -1,6 +1,7 @@
|
||||
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 || '';
|
||||
@@ -12,15 +13,15 @@ export const load = async ({ params, cookies }) => {
|
||||
}
|
||||
|
||||
// Fetch analytics using single API call
|
||||
const response = await classroomio.course[':courseId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
const result = await safeServerApi<{ success: true; data: CourseAnalytics }>(() =>
|
||||
classroomio.course[':courseId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const courseAnalytics: CourseAnalytics | null = data.success && data.data ? data.data : null;
|
||||
const courseAnalytics: CourseAnalytics | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[calc(100vw-var(--sidebar-width))]!">
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
@@ -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,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
|
||||
};
|
||||
};
|
||||
+4
-6
@@ -1,4 +1,5 @@
|
||||
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 }) => {
|
||||
@@ -10,13 +11,10 @@ export const load = async ({ params, cookies }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const response = await classroomio.course[':courseId'].mark.gradebook.$get(
|
||||
{ param: { courseId } },
|
||||
getApiHeaders(cookies, '')
|
||||
const result = await safeServerApi<{ success: true; data: MarksPageData }>(() =>
|
||||
classroomio.course[':courseId'].mark.gradebook.$get({ param: { courseId } }, getApiHeaders(cookies, ''))
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
const marksData: MarksPageData | null = json.success && json.data ? (json.data as MarksPageData) : null;
|
||||
const marksData: MarksPageData | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
+10
-9
@@ -1,6 +1,7 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
@@ -15,15 +16,15 @@ export const load = async ({ params, cookies }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.success && result.data ? result.data : null;
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
@@ -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,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,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>
|
||||
+8
-9
@@ -1,7 +1,9 @@
|
||||
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 = {};
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
@@ -13,11 +15,8 @@ export const load = async ({ params, parent, cookies }) => {
|
||||
stats: null
|
||||
};
|
||||
}
|
||||
console.log('cache', cache);
|
||||
console.log('orgId', orgId);
|
||||
console.log('siteName', siteName);
|
||||
|
||||
if (cache[orgId]) {
|
||||
if (orgId in cache) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId]
|
||||
@@ -25,10 +24,10 @@ export const load = async ({ params, parent, cookies }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('fetching stats');
|
||||
const statsResponse = await classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId));
|
||||
const statsData = await statsResponse.json();
|
||||
cache[orgId] = statsData.success ? statsData.data : null;
|
||||
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;
|
||||
+58
-50
@@ -1,10 +1,9 @@
|
||||
<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 UserIcon from '@lucide/svelte/icons/user';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import DollarSignIcon from '@lucide/svelte/icons/dollar-sign';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -24,35 +23,11 @@
|
||||
const { data } = $props();
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const enrollments = $derived(stats?.enrollments || []);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const revenue = $derived(stats?.revenue || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
|
||||
let cards = $derived([
|
||||
{
|
||||
icon: DollarSignIcon,
|
||||
title: `${$t('dashboard.revenue')} ($)`,
|
||||
percentage: revenue ?? 0,
|
||||
description: $t('dashboard.revenue_description'),
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -79,9 +54,33 @@
|
||||
{#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">
|
||||
{#each cards as card, i (i)}
|
||||
<ActivityCard activity={card} />
|
||||
{/each}
|
||||
<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>
|
||||
|
||||
@@ -92,7 +91,7 @@
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !topCourses}
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
@@ -110,11 +109,20 @@
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto w-2/6">
|
||||
<Progress value={course.completion} />
|
||||
<div class="text-sm">
|
||||
{course.completion}%
|
||||
{$t('dashboard.completion')}
|
||||
<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>
|
||||
@@ -134,47 +142,47 @@
|
||||
|
||||
<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_enrollments')}
|
||||
{$t('dashboard.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !enrollments}
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each enrollments as enrollment, i (i)}
|
||||
{#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={enrollment.avatarUrl} alt={enrollment.name ?? 'User'} class="h-6 w-6" />
|
||||
<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">{enrollment.name}</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(enrollment.date)}
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${enrollment.courseId}`}>
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{enrollment.course}
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.publish_first_course')}
|
||||
description={$t('dashboard.publish_first_course_description')}
|
||||
icon={UserIcon}
|
||||
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.publish_course')}
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
+1
@@ -35,6 +35,7 @@
|
||||
</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}>
|
||||
@@ -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 : []
|
||||
};
|
||||
};
|
||||
+8
-1
@@ -1,4 +1,5 @@
|
||||
<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';
|
||||
@@ -20,11 +21,17 @@
|
||||
<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} />
|
||||
<ImportAudiencePage courses={data.courses} programs={data.programs} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
+1
-1
@@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ params, url }) => {
|
||||
throw redirect(307, `/org/${params.slug}/automation/mcp${url.search}`);
|
||||
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
|
||||
};
|
||||
};
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('community.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('community.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<AskCommunityButton />
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<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>
|
||||
+20
-13
@@ -1,7 +1,13 @@
|
||||
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();
|
||||
|
||||
@@ -21,25 +27,26 @@ export const load = async ({ parent, locals, cookies, url }) => {
|
||||
const normalizedTagsQuery = normalizedTags && normalizedTags.length > 0 ? normalizedTags.join(',') : undefined;
|
||||
|
||||
const apiStart = performance.now();
|
||||
const [coursesResponse, tagsResponse] = await Promise.all([
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagsQuery
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagsQuery
|
||||
const [coursesResult, tagsResult] = await Promise.all([
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagsQuery
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagsQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
: { query: {} },
|
||||
getApiHeaders(cookies, orgId)
|
||||
: { query: {} },
|
||||
getApiHeaders(cookies, orgId)
|
||||
)
|
||||
),
|
||||
classroomio.organization.tags.$get({}, getApiHeaders(cookies, orgId))
|
||||
safeServerApi<GetTagGroupsSuccess>(() => classroomio.organization.tags.$get({}, getApiHeaders(cookies, orgId)))
|
||||
]);
|
||||
|
||||
const [coursesData, tagsData] = await Promise.all([coursesResponse.json(), tagsResponse.json()]);
|
||||
const apiMs = Math.round((performance.now() - apiStart) * 100) / 100;
|
||||
|
||||
const courses = coursesData.success ? coursesData.data : [];
|
||||
const tagGroups = tagsData.success ? tagsData.data : [];
|
||||
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(
|
||||
+1
@@ -181,6 +181,7 @@
|
||||
<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 />
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data || automationApi.isFetched) return;
|
||||
if (!data) return;
|
||||
automationApi.keys = data.keys;
|
||||
automationApi.usage = data.usage;
|
||||
automationApi.isFetched = true;
|
||||
@@ -22,7 +22,7 @@
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
|
||||
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
|
||||
<Page.Subtitle>{$t('automation.mcp.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
+12
-9
@@ -1,4 +1,6 @@
|
||||
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);
|
||||
@@ -36,17 +38,18 @@ export const load = async ({ parent, cookies, url }) => {
|
||||
includeExternal: includeExternal ? includeExternal === 'true' : true
|
||||
};
|
||||
|
||||
const [assetsResponse, storageResponse] = await Promise.all([
|
||||
classroomio.organization.assets.$get({ query }, getApiHeaders(cookies, orgId)),
|
||||
classroomio.organization.assets.storage.$get({}, getApiHeaders(cookies, orgId))
|
||||
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))
|
||||
)
|
||||
]);
|
||||
|
||||
const assetsData = await assetsResponse.json();
|
||||
const storageData = await storageResponse.json();
|
||||
|
||||
return {
|
||||
assets: assetsData.success ? assetsData.data : [],
|
||||
pagination: assetsData.success ? assetsData.pagination : null,
|
||||
storageSummary: storageData.success ? storageData.data : null
|
||||
assets: assetsResult.ok ? assetsResult.body.data : [],
|
||||
pagination: assetsResult.ok ? assetsResult.body.pagination : null,
|
||||
storageSummary: storageResult.ok ? storageResult.body.data : null
|
||||
};
|
||||
};
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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} />
|
||||
+1
@@ -25,6 +25,7 @@
|
||||
<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}>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+1
@@ -25,6 +25,7 @@
|
||||
<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}>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
+7
-2
@@ -1,10 +1,12 @@
|
||||
<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() {
|
||||
@@ -24,15 +26,18 @@
|
||||
<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>
|
||||
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
|
||||
<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} />
|
||||
<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>
|
||||
+1
@@ -24,6 +24,7 @@
|
||||
<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}>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
@@ -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,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,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>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { PageRestricted } from '$features/ui';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
org: AccountOrg | null;
|
||||
locals: App.Locals;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data?.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = data.isOrgSite;
|
||||
|
||||
currentOrg.set(data.org);
|
||||
setTheme(data.org.theme || 'blue');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
<PageRestricted />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user