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,12 @@
|
||||
export const load = async ({ params }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
courseId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { CourseSidebar } from '$features/course/components/sidebar';
|
||||
import { CourseHeader } from '$features/course/components';
|
||||
import type { Course } from '$features/course/types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Confetti } from '$features/ui';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { CourseMember } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
path: string;
|
||||
data: {
|
||||
course?: Course;
|
||||
courseId: string;
|
||||
exerciseId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, path, data }: Props = $props();
|
||||
|
||||
// Initialize course store on the client (fetch once, then reuse store data).
|
||||
$effect(() => {
|
||||
if (!data.courseId || !$profile.id) return;
|
||||
|
||||
if (data.course) {
|
||||
courseApi.setCourse(data.course, $profile.id);
|
||||
return;
|
||||
}
|
||||
|
||||
courseApi.ensureCourse(data.courseId, $profile.id);
|
||||
});
|
||||
|
||||
const isCourseReady = $derived.by(() => {
|
||||
return courseApi.course?.id === data.courseId && !!courseApi.group.id;
|
||||
});
|
||||
|
||||
const user: CourseMember | undefined = $derived(
|
||||
courseApi.group.people.find((person) => person.profileId === $profile.id)
|
||||
);
|
||||
const canCheck = $derived(!!$profile.id && isCourseReady);
|
||||
|
||||
const isPermitted = $derived.by(() => {
|
||||
if (!isCourseReady) return false;
|
||||
if (!canCheck) return true;
|
||||
|
||||
if ($isOrgAdmin === null) return true;
|
||||
|
||||
return $isOrgAdmin || user;
|
||||
});
|
||||
|
||||
const isExercisePage = $derived(!!data.exerciseId);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{courseApi.course?.title || 'ClassroomIO Course'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isCourseReady}
|
||||
<Dialog.Root open={!isPermitted}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-md text-center dark:text-white">
|
||||
{$t('course.not_permitted.body')}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex justify-center">
|
||||
<Button
|
||||
onclick={() => {
|
||||
goto('/org/*');
|
||||
}}
|
||||
>
|
||||
{$t('course.not_permitted.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<CourseSidebar {path} id={data.courseId} {isCourseReady} />
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<CourseHeader />
|
||||
<ContentCreateModal />
|
||||
<CourseCompletionModal />
|
||||
|
||||
{#if !isCourseReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
<Empty
|
||||
title="Loading course…"
|
||||
description="Please wait while we load your course data."
|
||||
icon={Spinner}
|
||||
iconClass="h-8 w-8"
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isExercisePage}
|
||||
<Confetti />
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { NewsFeedPage } from '$features/course/pages';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles: number[] = [1, 2];
|
||||
|
||||
if (org.customization.course.newsfeed) {
|
||||
roles.push(3);
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
onDenied={() => {
|
||||
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
|
||||
}}
|
||||
>
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.news_feed.heading')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={() => newsfeedApi.openNewFeedModal()}>
|
||||
{$t('course.navItem.news_feed.heading_button.title')}
|
||||
</Button>
|
||||
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewsFeedPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
courseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch analytics using single API call
|
||||
const result = await safeServerApi<{ success: true; data: CourseAnalytics }>(() =>
|
||||
classroomio.course[':courseId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const courseAnalytics: CourseAnalytics | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
courseAnalytics
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { AnalyticsPage } from '$features/course/pages';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('analytics.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AnalyticsPage courseId={data.courseId} courseAnalytics={data.courseAnalytics} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { AttendancePage } from '$features/course/pages';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.attendance.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AttendancePage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { CertificatesPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { validateWithTranslation } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let savedCertificateState = $state<string | null>(null);
|
||||
let savedCertificateStateCourseId = $state<string | null>(null);
|
||||
|
||||
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
|
||||
|
||||
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
|
||||
|
||||
const userRole = $derived.by(() => {
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
});
|
||||
|
||||
const canEditCertificates = $derived(!!$isOrgAdmin || (userRole !== null && [1, 2].includes(userRole)));
|
||||
|
||||
function getCertificateFormState(course: Course | null) {
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
description: course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: course.certificate?.isDownloadable ?? false,
|
||||
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
|
||||
deadline: course.certificate?.deadline ?? null,
|
||||
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? course.certificate.exerciseMinScorePercent
|
||||
: course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null,
|
||||
emailMessage: course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCertificateStateSnapshot(course: Course | null) {
|
||||
const certificateFormState = getCertificateFormState(course);
|
||||
return certificateFormState ? JSON.stringify(certificateFormState) : null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const course = courseApi.course;
|
||||
const currentSnapshot = getCertificateStateSnapshot(course);
|
||||
|
||||
if (!course?.id || !currentSnapshot) {
|
||||
savedCertificateState = null;
|
||||
savedCertificateStateCourseId = null;
|
||||
hasUnsavedChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedCertificateStateCourseId !== course.id || savedCertificateState === null) {
|
||||
savedCertificateStateCourseId = course.id;
|
||||
savedCertificateState = currentSnapshot;
|
||||
}
|
||||
|
||||
hasUnsavedChanges = currentSnapshot !== savedCertificateState;
|
||||
});
|
||||
|
||||
async function saveCertificate() {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if ($isFreePlan) {
|
||||
errors = {};
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
description: courseApi.course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
|
||||
theme: certificateTheme || 'professional',
|
||||
deadline: courseApi.course.certificate?.deadline ?? null,
|
||||
threshold:
|
||||
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
|
||||
requiredExerciseId: courseApi.course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof courseApi.course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? courseApi.course.certificate.exerciseMinScorePercent
|
||||
: courseApi.course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null,
|
||||
emailMessage: courseApi.course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
|
||||
const validated = validateWithTranslation(ZCourseUpdate, updatePayload, 'course');
|
||||
if ('errors' in validated) {
|
||||
errors = validated.errors;
|
||||
return;
|
||||
}
|
||||
|
||||
errors = {};
|
||||
|
||||
const updated = await courseApi.update(courseApi.course.id!, validated.data, { showSuccessToast: false });
|
||||
if (updated) {
|
||||
savedCertificateState = getCertificateStateSnapshot(courseApi.course);
|
||||
hasUnsavedChanges = false;
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.certificates.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
{#if canEditCertificates}
|
||||
<Button
|
||||
onclick={saveCertificate}
|
||||
loading={courseApi.isLoading}
|
||||
disabled={courseApi.isLoading || !hasUnsavedChanges}
|
||||
>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
{/if}
|
||||
<RefreshPageData />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CertificatesPage {errors} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GetExerciseSuccess, ListExerciseSubmissionsSuccess } from '$features/course/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const exerciseId = params.exerciseId || '';
|
||||
|
||||
if (!courseId || !exerciseId) {
|
||||
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies);
|
||||
|
||||
const [exerciseResult, overviewResult] = await Promise.all([
|
||||
safeServerApi<GetExerciseSuccess>(() =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].$get({ param: { courseId, exerciseId } }, headers)
|
||||
),
|
||||
safeServerApi<ListExerciseSubmissionsSuccess>(() =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId']['submissions'].$get(
|
||||
{ param: { courseId, exerciseId } },
|
||||
headers
|
||||
)
|
||||
)
|
||||
]);
|
||||
const exercise = exerciseResult.ok && exerciseResult.body.data ? exerciseResult.body.data : null;
|
||||
const overview =
|
||||
overviewResult.ok && overviewResult.body.data ? overviewResult.body.data : { mySubmission: [], allSubmissions: [] };
|
||||
|
||||
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
|
||||
const mySubmissions = mySubmissionData;
|
||||
|
||||
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
|
||||
|
||||
return {
|
||||
courseId,
|
||||
exerciseId,
|
||||
exercise,
|
||||
submissions,
|
||||
mySubmissions
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { ExercisePage } from '$features/course/pages';
|
||||
import {
|
||||
questionnaire,
|
||||
questionnaireMetaData,
|
||||
clearQuestionnaireValidation,
|
||||
reset
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Question } from '$features/course/types';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeOptionById
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
const path = `/courses/${data.courseId}/lessons`;
|
||||
const isLockedForStudent = $derived($globalStore.isStudent && data.exercise?.isUnlocked === false);
|
||||
|
||||
$effect(() => {
|
||||
if (!data.exercise || isLockedForStudent) return;
|
||||
|
||||
const meta = get(questionnaireMetaData);
|
||||
if (meta.isFinished && meta.exerciseId === data.exerciseId) {
|
||||
return;
|
||||
}
|
||||
if (meta.exerciseId != null && meta.exerciseId !== data.exerciseId) {
|
||||
reset();
|
||||
}
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
|
||||
const exercise = data.exercise;
|
||||
|
||||
// Transform questions and set questionnaire store
|
||||
let questions: Question[] = [];
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mapped = exercise.questions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
questions = normalizeQuestionOrder(mapped);
|
||||
}
|
||||
|
||||
questionnaire.set({
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: exercise.dueBy,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions: questions,
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
});
|
||||
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
{#if isLockedForStudent}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
/>
|
||||
{:else}
|
||||
<ExercisePage
|
||||
exerciseId={data.exerciseId}
|
||||
goBack={() => goto(resolve(path, {}))}
|
||||
isFetching={false}
|
||||
submissions={data.submissions ?? []}
|
||||
mySubmissions={data.mySubmissions ?? []}
|
||||
/>
|
||||
{/if}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
import { CourseLandingPage } from '$features/ui';
|
||||
import Editor from '$features/ui/course-landing-page/components/editor/editor.svelte';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const courseId = $derived(data.courseId);
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
function syncCourseStore(_courseData: Course) {
|
||||
if (courseApi.course) {
|
||||
courseApi.course = { ...courseApi.course, ..._courseData };
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if courseApi.course}
|
||||
<div
|
||||
class="fixed inset-0 z-250 h-screen w-screen bg-white"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<Editor {courseId} bind:course={courseApi.course} {syncCourseStore} onClose={handleClose} />
|
||||
</Sidebar.Root>
|
||||
<Sidebar.Inset class="relative h-screen! overflow-y-auto">
|
||||
<div class="absolute top-2 left-2 z-60">
|
||||
<Sidebar.Trigger variant="secondary" />
|
||||
</div>
|
||||
<CourseLandingPage bind:courseData={courseApi.course} editMode={true} />
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { LessonsPage } from '$features/course/pages';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let reorder = $state(false);
|
||||
|
||||
function addContent() {
|
||||
const contentGroupingEnabled = courseApi.course?.metadata?.isContentGroupingEnabled ?? true;
|
||||
if (contentGroupingEnabled) {
|
||||
contentCreateStoreUtils.openSection();
|
||||
} else {
|
||||
contentCreateStoreUtils.openDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.lessons.heading_v2')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!$contentEditingStore}>
|
||||
{$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
|
||||
</Button>
|
||||
<Button onclick={addContent} disabled={!!$contentEditingStore}
|
||||
>{$t('course.navItem.lessons.add_content')}</Button
|
||||
>
|
||||
</RoleBasedSecurity>
|
||||
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LessonsPage courseId={data.courseId} bind:reorder />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { GetLessonSuccess } from '$features/course/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const lessonId = params.lessonId || '';
|
||||
|
||||
if (!courseId || !lessonId) {
|
||||
return { courseId, lessonId, lesson: null };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetLessonSuccess>(() =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].$get({ param: { courseId, lessonId } }, getApiHeaders(cookies))
|
||||
);
|
||||
const lesson = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
lessonId,
|
||||
lesson: lesson
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { LessonPage } from '$features/course/pages';
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Lesson } from '$features/course/utils/types';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
lesson: Lesson | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data.lesson) return;
|
||||
const lesson = data.lesson;
|
||||
if (lessonApi.lesson?.id === lesson.id) return;
|
||||
|
||||
// Set lesson data
|
||||
lessonApi.lesson = lesson;
|
||||
|
||||
// Set translations if lesson has lessonLanguages
|
||||
if (lesson.lessonLanguages) {
|
||||
lessonApi.setTranslations();
|
||||
}
|
||||
|
||||
// Set current locale from profile if available
|
||||
if ($profile.locale) {
|
||||
lessonApi.currentLocale = $profile.locale;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
{#key data.lessonId}
|
||||
<LessonPage courseId={data.courseId} lessonId={data.lessonId} />
|
||||
{/key}
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import type { MarksPageData } from '$features/course/utils/marks-utils';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
marksData: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<{ success: true; data: MarksPageData }>(() =>
|
||||
classroomio.course[':courseId'].mark.gradebook.$get({ param: { courseId } }, getApiHeaders(cookies, ''))
|
||||
);
|
||||
const marksData: MarksPageData | null = result.ok ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
marksData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { MarksPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let isDownloading = $state(false);
|
||||
|
||||
const exercises = $derived(data.marksData?.exercises ?? []);
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles = [1, 2];
|
||||
if (org.customization?.course?.grading) {
|
||||
roles.push(3);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
const downloadCSV = () => {
|
||||
if (!data.marksData) return;
|
||||
isDownloading = true;
|
||||
try {
|
||||
generateMarksCSV(
|
||||
data.marksData.students,
|
||||
exercises,
|
||||
data.marksData.studentMarksByExerciseId,
|
||||
courseApi.course?.title || 'Course'
|
||||
);
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = () => {
|
||||
if (!data.marksData) return;
|
||||
isDownloading = true;
|
||||
try {
|
||||
generateMarksPDF(
|
||||
data.marksData.students,
|
||||
exercises,
|
||||
data.marksData.studentMarksByExerciseId,
|
||||
courseApi.course?.title || 'Course'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
onDenied={() => {
|
||||
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
|
||||
}}
|
||||
>
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.marks.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="rounded-full">
|
||||
{#if isDownloading}
|
||||
<Progress />
|
||||
{:else}
|
||||
<DownloadIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={downloadCSV}>
|
||||
{$t('course.navItem.marks.export.csv')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={downloadPDF}>
|
||||
{$t('course.navItem.marks.export.pdf')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
<RefreshPageData />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<MarksPage marksData={data.marksData} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
let { data = $bindable(), children } = $props();
|
||||
|
||||
// Get back URL from query parameters
|
||||
let backUrl = $derived(page.url.searchParams.get('back'));
|
||||
|
||||
const handleClick = () => {
|
||||
goto(resolve(`${page.url.pathname}?add=true`, {}));
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (backUrl) {
|
||||
goto(resolve(backUrl, {}));
|
||||
} else {
|
||||
goto(resolve(`/courses/${data.courseId}/people`, {}));
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshPeoplePage() {
|
||||
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{#if data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton onclick={handleBackNavigation}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{$t('course.navItem.people.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button onclick={handleClick}>
|
||||
{$t('course.navItem.people.add')}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
<RefreshPageData onRefresh={refreshPeoplePage} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{@render children?.()}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || '',
|
||||
personId: params.personId || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { PeoplePage } from '$features/course/pages';
|
||||
</script>
|
||||
|
||||
<PeoplePage />
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params = { id: '' } }) => {
|
||||
return {
|
||||
courseId: params.id
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
const userId = params.personId;
|
||||
|
||||
if (!courseId || !userId) {
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||
{
|
||||
param: { courseId, userId }
|
||||
},
|
||||
getApiHeaders(cookies, '')
|
||||
)
|
||||
);
|
||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user course analytics:', error);
|
||||
return {
|
||||
courseId,
|
||||
userId,
|
||||
userCourseAnalytics: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let userCourseAnalytics: UserCourseAnalytics | undefined = $derived(data.userCourseAnalytics ?? undefined);
|
||||
|
||||
function getPercentage(a: number, b: number): number {
|
||||
if (b === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((a / b) * 100);
|
||||
}
|
||||
|
||||
let exerciseFilter: 'all' | 'completed' | 'incomplete' = $state('all');
|
||||
function toggleExerciseFilter(filter: 'completed' | 'incomplete') {
|
||||
if (exerciseFilter === filter) {
|
||||
exerciseFilter = 'all';
|
||||
} else {
|
||||
exerciseFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
let learningActivities = $derived([
|
||||
{
|
||||
description: $t('analytics.overall_course_progress_user_description'),
|
||||
icon: BookOpenIcon,
|
||||
percentage: userCourseAnalytics?.progressPercentage ?? 0,
|
||||
title: $t('analytics.overall_course_progress')
|
||||
},
|
||||
{
|
||||
description: $t('analytics.assignment_completion_description'),
|
||||
icon: ChartLineIcon,
|
||||
percentage: getPercentage(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0,
|
||||
userCourseAnalytics?.userExercisesStats?.length || 0
|
||||
),
|
||||
title: $t('analytics.assignment_completion')
|
||||
},
|
||||
{
|
||||
description: $t('analytics.average_grade_description'),
|
||||
icon: UnfoldVerticalIcon,
|
||||
percentage: userCourseAnalytics?.averageGrade ?? 0,
|
||||
title: $t('analytics.average_grade')
|
||||
}
|
||||
]);
|
||||
|
||||
let filteredExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => {
|
||||
if (exerciseFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
return exercise.isCompleted === (exerciseFilter === 'completed');
|
||||
})
|
||||
);
|
||||
let completedExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0
|
||||
);
|
||||
let incompleteExercises = $derived(
|
||||
userCourseAnalytics?.userExercisesStats?.filter((exercise) => !exercise.isCompleted)?.length || 0
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
console.log('userCourseAnalytics.user', userCourseAnalytics?.user);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if userCourseAnalytics}
|
||||
<section class="px-1">
|
||||
{#if userCourseAnalytics.user}
|
||||
<HeroProfileCard user={userCourseAnalytics.user} />
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each learningActivities as activity}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5">
|
||||
<h3 class="text-2xl">
|
||||
{$t('analytics.exercises')}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.exercises')}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.progress_description', {
|
||||
value: completedExercises,
|
||||
total: userCourseAnalytics?.userExercisesStats?.length
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={getPercentage(completedExercises, userCourseAnalytics?.userExercisesStats?.length)} />
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge
|
||||
type={exerciseFilter === 'incomplete' ? 'secondary' : 'outline'}
|
||||
class="text-yellow-700 lowercase dark:text-yellow-500"
|
||||
onclick={() => toggleExerciseFilter('incomplete')}
|
||||
>
|
||||
{incompleteExercises}
|
||||
{$t('analytics.incomplete')}
|
||||
</Badge>
|
||||
<Badge
|
||||
type={exerciseFilter === 'completed' ? 'gray' : 'outline'}
|
||||
class="text-green-700 lowercase dark:text-green-500"
|
||||
onclick={() => toggleExerciseFilter('completed')}
|
||||
>
|
||||
{completedExercises}
|
||||
{$t('analytics.complete')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredExercises as exercise, index}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 flex items-center justify-between gap-4 rounded-md border p-5 ${
|
||||
exercise.isCompleted
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex w-2/3 items-center gap-4">
|
||||
<BookOpenIcon size={16} />
|
||||
<div>
|
||||
<div class="mb-2">
|
||||
<a href={`/courses/${data.courseId}/exercises/${exercise.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{exercise.title}
|
||||
</p>
|
||||
</a>
|
||||
{#if exercise.lessonId}
|
||||
<a href={`/courses/${data.courseId}/lessons/${exercise.lessonId}`}>
|
||||
<p class="text-sm text-gray-500">
|
||||
#{exercise.lessonTitle}
|
||||
</p>
|
||||
</a>
|
||||
{:else if exercise.lessonTitle}
|
||||
<p class="text-sm text-gray-500">
|
||||
#{exercise.lessonTitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
Score: {exercise.score}/{exercise.totalPoints}
|
||||
|
||||
{#if exercise.isCompleted}
|
||||
<Badge type={exercise.status === 3 ? 'secondary' : 'outline'}>
|
||||
{exercise.status === 3 ? $t('analytics.graded') : $t('analytics.not_graded')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge class={`${exercise.isCompleted ? 'bg-green-200 text-green-700' : 'bg-yellow-200 text-yellow-700'}`}>
|
||||
{exercise.isCompleted ? $t('analytics.completed') : $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<LoadingPage />
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { CourseSettingsPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
|
||||
let settingsComponent: CourseSettingsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let { data } = $props();
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await settingsComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header class="sticky top-13 z-10 bg-white">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.settings.heading')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('course.navItem.settings.save')}
|
||||
</Button>
|
||||
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
courseId: params.id || ''
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ListSubmissionsForGradingSuccess } from '$features/course/utils/types';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiHeaders } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
export const load = async ({ params, cookies }) => {
|
||||
const courseId = params.id || '';
|
||||
|
||||
if (!courseId) {
|
||||
return {
|
||||
courseId: '',
|
||||
sections: [],
|
||||
submissionIdData: {}
|
||||
};
|
||||
}
|
||||
|
||||
const result = await safeServerApi<ListSubmissionsForGradingSuccess>(() =>
|
||||
classroomio.course[':courseId'].submission['for-grading'].$get(
|
||||
{
|
||||
param: { courseId }
|
||||
},
|
||||
getApiHeaders(cookies)
|
||||
)
|
||||
);
|
||||
|
||||
if (result.ok && result.body.data) {
|
||||
return {
|
||||
courseId,
|
||||
sections: result.body.data.sections || [],
|
||||
submissionIdData: result.body.data.submissionIdData || {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
courseId,
|
||||
sections: [],
|
||||
submissionIdData: {}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { SubmissionsPage } from '$features/course/pages';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.submissions.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<SubmissionsPage
|
||||
courseId={data.courseId}
|
||||
sections={data.sections || []}
|
||||
submissionIdData={data.submissionIdData || {}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</RoleBasedSecurity>
|
||||
Reference in New Issue
Block a user