feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -13,8 +13,10 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { AiCourseChat } from '$features/ai-assistant';
|
||||
import { aiAssistantPanelDefinition } from '$features/ai-assistant';
|
||||
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { sidePanel, SidePanelRail } from '$features/side-panel';
|
||||
import { transcriptPanelDefinition } from '$features/course/components/lesson/video/transcript-panel-definition';
|
||||
import { get } from 'svelte/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
@@ -27,6 +29,9 @@
|
||||
COURSE_SIDEBAR_STORAGE_KEY
|
||||
} from '$features/course/components/sidebar/constants';
|
||||
|
||||
sidePanel.register(aiAssistantPanelDefinition);
|
||||
sidePanel.register(transcriptPanelDefinition);
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
path: string;
|
||||
@@ -186,5 +191,5 @@
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
|
||||
<AiCourseChat courseId={data.courseId} />
|
||||
<SidePanelRail />
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CourseAiTutorSettingsPage } from '$features/ai-tutor-settings';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<CourseAiTutorSettingsPage courseId={data.courseId} />
|
||||
@@ -7,12 +7,13 @@
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('analytics.title')}
|
||||
</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('analytics.course_metrics.page_subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
@@ -20,7 +21,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AnalyticsPage courseId={data.courseId} courseAnalytics={data.courseAnalytics} />
|
||||
<AnalyticsPage courseAnalytics={data.courseAnalytics} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
|
||||
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;
|
||||
@@ -40,7 +38,8 @@
|
||||
description: course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: course.certificate?.isDownloadable ?? false,
|
||||
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
|
||||
theme: parseCertificateThemeId(course.certificate?.theme),
|
||||
design: course.certificate?.design ?? null,
|
||||
deadline: course.certificate?.deadline ?? null,
|
||||
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||
@@ -92,7 +91,8 @@
|
||||
description: courseApi.course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
|
||||
theme: certificateTheme || 'professional',
|
||||
theme: certificateTheme || 'classique',
|
||||
design: courseApi.course.certificate?.design,
|
||||
deadline: courseApi.course.certificate?.deadline ?? null,
|
||||
threshold:
|
||||
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { children }: { children?: import('svelte').Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import CertificateEditor from '$features/course/components/ceritficate/editor/certificate-editor.svelte';
|
||||
|
||||
const courseId = $derived(page.params.id ?? '');
|
||||
|
||||
$effect(() => {
|
||||
if (!courseId || !$profile.id) return;
|
||||
courseApi.ensureCourse(courseId, $profile.id);
|
||||
});
|
||||
|
||||
const isReady = $derived(courseApi.course?.id === courseId);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{courseApi.course?.title ?? $t('course.navItem.certificates.editor.title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="ui:bg-background min-h-screen w-full">
|
||||
{#if isReady}
|
||||
<CertificateEditor {courseId} />
|
||||
{:else}
|
||||
<div class="flex min-h-[60vh] items-center justify-center">
|
||||
<Spinner class="size-8!" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 360px; --sidebar-width-icon: 4rem">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<Editor {courseId} bind:course={courseApi.course} {syncCourseStore} onClose={handleClose} />
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-4xl lg:max-w-5xl">
|
||||
{#key data.lessonId}
|
||||
<LessonPage courseId={data.courseId} lessonId={data.lessonId} />
|
||||
{/key}
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
import { CourseCreator } from '@cio/ui/custom/course-creator';
|
||||
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { setInitialChatModel, setInitialChatPrompt } from '$features/ai-assistant/utils/store';
|
||||
import {
|
||||
setInitialChatModel,
|
||||
setInitialChatPrompt,
|
||||
setInitialChatTemplateId
|
||||
} from '$features/ai-assistant/utils/store';
|
||||
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
|
||||
import { COURSE_TEMPLATES, type CourseTemplate, type CourseTemplateId } from '@cio/ai-assistant';
|
||||
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
|
||||
import CompassIcon from '@lucide/svelte/icons/compass';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
|
||||
@@ -33,6 +42,9 @@
|
||||
let creatingState: 'idle' | 'creating' = $state('idle');
|
||||
let creatingStep: CreatingStep = $state('reading');
|
||||
let draftingPrompt = $state('');
|
||||
let composerPrompt = $state('');
|
||||
let pinnedTemplatePrompt: string | null = $state(null);
|
||||
let selectedTemplateId: CourseTemplateId | null = $state(null);
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
@@ -86,6 +98,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
function selectTemplate(template: CourseTemplate) {
|
||||
composerPrompt = template.promptTemplate;
|
||||
pinnedTemplatePrompt = template.promptTemplate;
|
||||
selectedTemplateId = template.id;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedTemplateId || pinnedTemplatePrompt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (composerPrompt !== pinnedTemplatePrompt) {
|
||||
selectedTemplateId = null;
|
||||
pinnedTemplatePrompt = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
|
||||
if (isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
@@ -103,16 +132,33 @@
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
|
||||
creatingStep = 'naming';
|
||||
const meta = await aiAssistantApi.generateCourseMeta(prompt);
|
||||
const title = meta?.title ?? prompt.slice(0, 80);
|
||||
const description = meta?.description ?? prompt.slice(0, 150);
|
||||
|
||||
const template = selectedTemplateId ? COURSE_TEMPLATES.find((tpl) => tpl.id === selectedTemplateId) : undefined;
|
||||
|
||||
const promptIsStillTemplateDefault = template !== undefined && prompt.trim() === template.promptTemplate.trim();
|
||||
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
if (promptIsStillTemplateDefault) {
|
||||
title = t.get('course.creator.untitled_course');
|
||||
description = t.get('course.creator.untitled_course_description');
|
||||
} else {
|
||||
const meta = await aiAssistantApi.generateCourseMeta(prompt);
|
||||
title = meta?.title ?? prompt.slice(0, 80);
|
||||
description = meta?.description ?? prompt.slice(0, 150);
|
||||
}
|
||||
|
||||
creatingStep = 'building';
|
||||
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
|
||||
setInitialChatModel(selectedModel);
|
||||
|
||||
if (selectedTemplateId) {
|
||||
setInitialChatTemplateId(selectedTemplateId);
|
||||
}
|
||||
|
||||
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,11 +223,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
|
||||
<div class="flex min-h-[90vh] items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-3xl">
|
||||
<CourseCreator
|
||||
heading={$t('course.creator.heading')}
|
||||
placeholder={$t('course.creator.placeholder')}
|
||||
bind:prompt={composerPrompt}
|
||||
model={selectedModel}
|
||||
levelOptions={[
|
||||
{ value: 'beginner', label: $t('course.creator.level.beginner') },
|
||||
@@ -193,6 +240,41 @@
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
onsubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<p class="ui:text-muted-foreground my-3 text-center text-sm font-medium">
|
||||
{$t('course.creator.template.heading')}
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
{#each COURSE_TEMPLATES as template (template.id)}
|
||||
{@const display = DISPLAY_BY_ID[template.id]}
|
||||
{@const isSelected = selectedTemplateId === template.id}
|
||||
<button
|
||||
type="button"
|
||||
class="group ui:border ui:transition-colors focus-visible:ui:ring-[3px] focus-visible:ui:ring-ring/50 focus-visible:ui:outline-none flex h-full min-h-[168px] w-full cursor-pointer flex-col items-start gap-3 rounded-2xl p-5 text-left sm:p-6 {isSelected
|
||||
? 'ui:bg-primary/5 ui:border-primary ui:ring-primary/20 ui:ring-2'
|
||||
: 'ui:bg-card ui:border-border ui:hover:border-primary'}"
|
||||
onclick={() => selectTemplate(template)}
|
||||
aria-label={$t(display.titleKey)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div class="ui:text-primary ui:bg-primary/10 flex size-10 shrink-0 items-center justify-center rounded-lg">
|
||||
{#if display.iconName === 'GraduationCap'}
|
||||
<GraduationCapIcon class="size-6" />
|
||||
{:else if display.iconName === 'Compass'}
|
||||
<CompassIcon class="size-6" />
|
||||
{:else}
|
||||
<AwardIcon class="size-6" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="ui:text-foreground text-sm leading-tight font-semibold">
|
||||
{$t(display.titleKey)}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground text-xs leading-snug">
|
||||
{$t(display.descriptionKey)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Tabs from '@cio/ui/custom/underline-tabs';
|
||||
import {
|
||||
CountriesTable,
|
||||
Funnel,
|
||||
LandingKpis,
|
||||
PopularTypes,
|
||||
RangePicker,
|
||||
RefreshButton,
|
||||
TrendChart,
|
||||
analyticsApi
|
||||
} from '$features/analytics';
|
||||
|
||||
onMount(() => {
|
||||
const orgId = $currentOrg.id;
|
||||
if (orgId) analyticsApi.ensureFetched(orgId);
|
||||
});
|
||||
|
||||
function handleRangeChange(days: 7 | 30 | 90) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
analyticsApi.setRange(days, orgId);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
analyticsApi.refresh(orgId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('analytics.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('analytics.title')}</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('analytics.subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RangePicker value={analyticsApi.range} onChange={handleRangeChange} />
|
||||
<RefreshButton loading={analyticsApi.loading} onClick={handleRefresh} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="space-y-6">
|
||||
<LandingKpis data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
|
||||
|
||||
<Tabs.Root value="overview">
|
||||
<Tabs.List class="mb-6">
|
||||
<Tabs.Trigger value="overview">{$t('analytics.tabs.overview')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="funnel">{$t('analytics.tabs.funnel')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="geography">{$t('analytics.tabs.geography')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="course_types">{$t('analytics.tabs.course_types')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="overview" class="space-y-4">
|
||||
<TrendChart data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
|
||||
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
|
||||
</div>
|
||||
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="funnel">
|
||||
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="geography">
|
||||
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="course_types">
|
||||
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Tabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { StatusTiles, CourseBreakdown, LearnersTable, complianceApi } from '$features/compliance';
|
||||
import type { ComplianceLearnerRow } from '$features/compliance/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
const orgId = $currentOrg.id;
|
||||
if (orgId) complianceApi.ensureFetched(orgId);
|
||||
});
|
||||
|
||||
function handleRefresh() {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
complianceApi.fetchOverview(orgId);
|
||||
}
|
||||
|
||||
type LearnerStatus = ComplianceLearnerRow['status'];
|
||||
|
||||
const statusFilters: Array<LearnerStatus | 'all'> = [
|
||||
'all',
|
||||
'compliant',
|
||||
'expiring_soon',
|
||||
'in_grace_period',
|
||||
'non_compliant',
|
||||
'in_progress',
|
||||
'not_started',
|
||||
'waived',
|
||||
'no_record'
|
||||
];
|
||||
|
||||
let activeFilter = $state<LearnerStatus | 'all'>('all');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('compliance.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('compliance.title')}</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('compliance.subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="outline" size="sm" disabled={complianceApi.loading} onclick={handleRefresh}>
|
||||
<RefreshIcon class={complianceApi.loading ? 'animate-spin' : ''} />
|
||||
{$t('analytics.refresh')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="space-y-6">
|
||||
{#if complianceApi.loading && !complianceApi.overview}
|
||||
<div class="flex h-32 items-center justify-center">
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else}
|
||||
<StatusTiles data={complianceApi.overview} />
|
||||
|
||||
<Tabs.Root value="courses">
|
||||
<Tabs.List class="mb-6">
|
||||
<Tabs.Trigger value="courses">{$t('compliance.tabs.courses')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="learners">{$t('compliance.tabs.learners')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="courses" class="space-y-4">
|
||||
<CourseBreakdown rows={complianceApi.overview?.courses ?? []} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="learners" class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each statusFilters as filter (filter)}
|
||||
<Button
|
||||
variant={activeFilter === filter ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (activeFilter = filter)}
|
||||
>
|
||||
{$t(`compliance.filter.${filter}`)}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<LearnersTable rows={complianceApi.overview?.learners ?? []} statusFilter={activeFilter} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -25,6 +25,14 @@
|
||||
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
|
||||
|
||||
let selectedTags = $state<string[]>(getInitialSelectedTags());
|
||||
let courseType = $state<string>('all');
|
||||
|
||||
const courseTypeOptions = $derived([
|
||||
{ value: 'SELF_PACED', label: $t('new_course_modal.self_paced_label') },
|
||||
{ value: 'LIVE_CLASS', label: $t('new_course_modal.live_class_label') },
|
||||
{ value: 'COMPLIANCE', label: $t('new_course_modal.compliance_label') },
|
||||
{ value: 'PUBLIC', label: $t('new_course_modal.public_label') }
|
||||
]);
|
||||
|
||||
let hasInitializedFilters = $state(false);
|
||||
let hasCompletedInitialUrlSync = false;
|
||||
@@ -61,6 +69,12 @@
|
||||
nextUrl.searchParams.delete('order');
|
||||
}
|
||||
|
||||
if (courseType !== 'all') {
|
||||
nextUrl.searchParams.set('type', courseType);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('type');
|
||||
}
|
||||
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
@@ -107,21 +121,30 @@
|
||||
async function clearFilters() {
|
||||
sortKey = DEFAULT_COURSE_SORT;
|
||||
selectedOrder = DEFAULT_SORT_ORDER;
|
||||
courseType = 'all';
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
updateFiltersUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
await applyTagFilters([]);
|
||||
}
|
||||
|
||||
function setCourseType(nextType: string) {
|
||||
courseType = nextType;
|
||||
updateFiltersUrl();
|
||||
}
|
||||
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const filteredCourses = (coursesApi.orgCourses ?? []).filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
const matchesSearch = !searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
|
||||
return false;
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (courseType !== 'all' && course.type !== courseType) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedCourses = [...filteredCourses];
|
||||
@@ -169,6 +192,11 @@
|
||||
selectedOrder = orderFromStorage;
|
||||
}
|
||||
|
||||
const typeFromUrl = searchParams.get('type');
|
||||
if (typeFromUrl && courseTypeOptions.some((opt) => opt.value === typeFromUrl)) {
|
||||
courseType = typeFromUrl;
|
||||
}
|
||||
|
||||
hasInitializedFilters = true;
|
||||
});
|
||||
</script>
|
||||
@@ -194,10 +222,13 @@
|
||||
<CourseFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
bind:courseType
|
||||
{courseTypeOptions}
|
||||
{selectedTags}
|
||||
tagGroups={data.tagGroups}
|
||||
{isFiltering}
|
||||
onToggleTag={toggleTag}
|
||||
onCourseTypeChange={setCourseType}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -8,13 +8,8 @@ const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
if (result.status === 'rejected') return [];
|
||||
if (!result.value.ok) return [];
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { OrgAiTutorSettingsPage } from '$features/ai-tutor-settings';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AI Tutor Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<OrgAiTutorSettingsPage />
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="w-full pb-10">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
@@ -2,21 +2,7 @@
|
||||
import { LandingpagePage } from '$features/settings/pages';
|
||||
import { VisitOrgSiteButton } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let landingPageComponent: LandingpagePage | null = $state(null);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await landingPageComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -30,14 +16,10 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<VisitOrgSiteButton labelKey="markdown_editor.preview" variant="secondary" />
|
||||
|
||||
<Button variant="default" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('settings.landing_page.save_changes')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LandingpagePage bind:this={landingPageComponent} bind:hasUnsavedChanges />
|
||||
<LandingpagePage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, type Component } 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 { landingPageSettings, landingPageEditorSelection } from '$features/settings/utils/store';
|
||||
import { buildOrgLandingPageProps, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
|
||||
import { landingPageThemes } from '$features/org/utils/landing-page';
|
||||
import LandingpageEditor from '$features/settings/pages/landingpage-editor.svelte';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { setLandingPageEditContext, type LandingSectionKey } from '@cio/ui/custom/org-landing-page';
|
||||
import {
|
||||
ContentIcon,
|
||||
ExploreIcon,
|
||||
ExternalLinkIcon,
|
||||
GoalIcon,
|
||||
HeaderIcon,
|
||||
SettingsIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
const sectionIcons: Record<LandingSectionKey, Component> = {
|
||||
navigation: ContentIcon,
|
||||
hero: HeaderIcon,
|
||||
links: ExploreIcon,
|
||||
embed: ExternalLinkIcon,
|
||||
callout: GoalIcon,
|
||||
footer: SettingsIcon
|
||||
};
|
||||
|
||||
// Reset selection on every mount so the editor opens to the section list.
|
||||
landingPageEditorSelection.set(null);
|
||||
|
||||
// Mirror the store into a $state so the preview's wrappers ($derived) react
|
||||
// to selection changes. Reading store via get() inside an arrow is non-reactive.
|
||||
let selectedSectionKey = $state<LandingSectionKey | null>(null);
|
||||
$effect(() => landingPageEditorSelection.subscribe((value) => (selectedSectionKey = value)));
|
||||
|
||||
setLandingPageEditContext({
|
||||
selectedKey: () => selectedSectionKey,
|
||||
selectKey: (key) => landingPageEditorSelection.set(key),
|
||||
labelFor: (key) => t.get(`settings.landing_page.editor.sections.${key}`),
|
||||
iconFor: (key) => sectionIcons[key]
|
||||
});
|
||||
|
||||
function handlePreviewClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest('[data-landing-section]')) return;
|
||||
landingPageEditorSelection.set(null);
|
||||
}
|
||||
|
||||
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';
|
||||
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
|
||||
if (value === null) return false;
|
||||
return (landingPageThemes as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const savedLandingPageSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||
@@ -94,14 +135,14 @@
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 360px; --sidebar-width-icon: 4rem">
|
||||
<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
|
||||
theme: previewTheme
|
||||
});
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
@@ -114,6 +155,7 @@
|
||||
|
||||
<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))]"
|
||||
onclick={handlePreviewClick}
|
||||
>
|
||||
<div class="absolute top-2 left-2 z-60">
|
||||
<Sidebar.Trigger variant="secondary" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
import { WorkspacesPage } from '$features/account';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('account.workspaces.page_title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('account.workspaces.page_title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('account.workspaces.page_description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<WorkspacesPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { GoalsOverviewRow } from '$features/program/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listOverview();
|
||||
});
|
||||
|
||||
const overviewRows = $derived(programGoalApi.overviewRows);
|
||||
|
||||
const programs = $derived.by(() => {
|
||||
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
|
||||
|
||||
for (const row of overviewRows) {
|
||||
const bucket = map.get(row.programId) ?? {
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
goals: []
|
||||
};
|
||||
bucket.goals.push(row);
|
||||
map.set(row.programId, bucket);
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
|
||||
});
|
||||
|
||||
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
|
||||
if (rows.length === 0) return 0;
|
||||
const total = rows.reduce((sum, row) => sum + row.totalLearners, 0);
|
||||
if (total === 0) return 0;
|
||||
const onTrack = rows.reduce((sum, row) => sum + (row.totalLearners - row.atRiskCount - row.overdueCount), 0);
|
||||
return Math.round((onTrack / total) * 100);
|
||||
}
|
||||
|
||||
function aggregateCount(rows: GoalsOverviewRow[], field: keyof GoalsOverviewRow): number {
|
||||
return rows.reduce((sum, row) => sum + Number(row[field] ?? 0), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programGoalApi.isLoading && overviewRows.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.goals.empty_title')}
|
||||
description={$t('programs.goals.empty_description')}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-6 p-4">
|
||||
{#each programs as program (program.programId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
|
||||
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
|
||||
<section class="flex flex-col gap-3 rounded-md border p-4">
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<a
|
||||
href={resolve(`/programs/${program.programId}/settings`, {})}
|
||||
class="text-base font-semibold hover:underline"
|
||||
>
|
||||
{program.programName}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
|
||||
{$t('programs.goals.tile.on_track')}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="font-medium">{totalLearners}</span> learners
|
||||
</span>
|
||||
{#if atRiskLearners > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRiskLearners}</span>
|
||||
{$t('programs.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdueLearners > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdueLearners}</span>
|
||||
{$t('programs.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Goal</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each program.goals as goal (goal.goalId)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
|
||||
<Table.Cell>{goal.inProgressCount}</Table.Cell>
|
||||
<Table.Cell class={goal.atRiskCount > 0 ? 'text-amber-700' : ''}>
|
||||
{goal.atRiskCount}
|
||||
</Table.Cell>
|
||||
<Table.Cell class={goal.overdueCount > 0 ? 'text-red-600' : ''}>
|
||||
{goal.overdueCount}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.onTrackPct}%</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { NewsFeedLoader } from '$features/course/components/newsfeed';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/program/components/goals/my-goals-widget.svelte';
|
||||
import { programApi, programNewsfeedApi } from '$features/program/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
@@ -126,6 +127,8 @@
|
||||
{#snippet child()}
|
||||
<NewFeedModal {programId} bind:edit bind:editFeed />
|
||||
|
||||
<MyGoalsWidget {programId} />
|
||||
|
||||
{#if isListing}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<NewsFeedLoader />
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
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 GoalsOverviewTiles from '$features/program/components/goals/goals-overview-tiles.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -147,6 +148,8 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<GoalsOverviewTiles programId={data.programId} />
|
||||
|
||||
<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} />
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
import { programApi } from '$features/program/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import GoalsSection from '$features/program/components/goals/goals-section.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const programId = data.programId as string;
|
||||
@@ -29,6 +32,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const canManageGoals = $derived(
|
||||
Boolean($isOrgAdmin) || currentMemberRole === ROLE.ADMIN || currentMemberRole === ROLE.TUTOR
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
|
||||
@@ -121,6 +129,10 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<GoalsSection {programId} canManage={canManageGoals} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h2 class="mb-1 text-sm font-semibold text-red-600">
|
||||
{$t('programs.settings.danger_zone') || 'Danger Zone'}
|
||||
|
||||
Reference in New Issue
Block a user