chore: ai, public course and other ui improvements
This commit is contained in:
@@ -30,7 +30,7 @@ export const load = async ({ params, url, cookies }) => {
|
||||
if (!orgId) {
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
|
||||
console.log('org', org);
|
||||
|
||||
if (org?.id) {
|
||||
orgId = org.id;
|
||||
|
||||
|
||||
@@ -1,62 +1,5 @@
|
||||
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null,
|
||||
loginActivity: [] as LoginActivityData
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
||||
};
|
||||
}
|
||||
|
||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||
safeServerApi<DashStatsSuccess>(() =>
|
||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||
orgName: params.slug
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,239 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { resolve } from '$app/paths';
|
||||
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { 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 { 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';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import {
|
||||
AGENT_MODELS,
|
||||
DEFAULT_PICKER_MODEL_ID,
|
||||
UI_PICKER_MODEL_IDS,
|
||||
type AgentModelId
|
||||
} from '@cio/utils/agent-models';
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$features/onboarding/components';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
type CreatingStep = 'reading' | 'naming' | 'building';
|
||||
|
||||
const { data } = $props();
|
||||
const steps: { key: CreatingStep; label: string }[] = [
|
||||
{ key: 'reading', label: t.get('course.creator.drafting.step_reading') },
|
||||
{ key: 'naming', label: t.get('course.creator.drafting.step_naming') },
|
||||
{ key: 'building', label: t.get('course.creator.drafting.step_building') }
|
||||
];
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const loginActivity = $derived(data.loginActivity);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
let creatingState: 'idle' | 'creating' = $state('idle');
|
||||
let creatingStep: CreatingStep = $state('reading');
|
||||
let draftingPrompt = $state('');
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (UI_PICKER_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function stepStatus(key: CreatingStep): 'done' | 'active' | 'pending' {
|
||||
const order: CreatingStep[] = ['reading', 'naming', 'building'];
|
||||
const current = order.indexOf(creatingStep);
|
||||
const target = order.indexOf(key);
|
||||
|
||||
if (target < current) return 'done';
|
||||
if (target === current) return 'active';
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function handleModelChange(model: string) {
|
||||
if (!isAgentModelId(model)) return;
|
||||
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = model;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, model);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockedModelSelect() {
|
||||
openUpgradeModal();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = DEFAULT_PICKER_MODEL_ID;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
|
||||
if (isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
handleModelChange(model);
|
||||
}
|
||||
|
||||
draftingPrompt = prompt;
|
||||
creatingState = 'creating';
|
||||
creatingStep = 'reading';
|
||||
|
||||
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);
|
||||
|
||||
creatingStep = 'building';
|
||||
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
|
||||
setInitialChatModel(selectedModel);
|
||||
|
||||
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||
|
||||
if (isAgentModelId(storedModel)) {
|
||||
if ($isFreePlan && paidModelIds.includes(storedModel)) {
|
||||
selectedModel = DEFAULT_PICKER_MODEL_ID;
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = storedModel;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - ClassroomIO</title>
|
||||
<title>Home - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<WelcomeModal />
|
||||
{#if creatingState === 'creating'}
|
||||
<div class="flex min-h-[80vh] flex-col items-center justify-center gap-6 px-4">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 class="text-2xl font-semibold">{$t('course.creator.drafting.heading')}</h1>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.creator.drafting.subtext')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<CreateCourseButton variant="outline" isResponsive />
|
||||
<div class="ui:border w-full max-w-lg rounded-2xl p-5">
|
||||
<p class="mb-5 text-sm text-neutral-300 italic">"{draftingPrompt}"</p>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="mb-10 flex flex-wrap items-start">
|
||||
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: AwardIcon,
|
||||
title: $t('dashboard.certificates_earned'),
|
||||
percentage: totalCertificates ?? 0,
|
||||
description: $t('dashboard.certificates_earned_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if !stats}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each steps as step (step.key)}
|
||||
{@const status = stepStatus(step.key)}
|
||||
<div class="flex items-center gap-3">
|
||||
{#if status === 'done'}
|
||||
<CheckCircleIcon class="h-4 w-4 shrink-0 text-green-500" />
|
||||
{:else if status === 'active'}
|
||||
<LoaderCircleIcon class="h-4 w-4 shrink-0 animate-spin text-white" />
|
||||
{:else}
|
||||
<ul class="ui:divide-border -mx-2 divide-y">
|
||||
{#each topCourses as course, i (course.id)}
|
||||
{@const rank = i + 1}
|
||||
<li>
|
||||
<a
|
||||
href={`/courses/${course.id}`}
|
||||
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
|
||||
rank === 1 &&
|
||||
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
|
||||
rank === 2 &&
|
||||
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
|
||||
rank === 3 &&
|
||||
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
|
||||
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
|
||||
]}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
|
||||
{course.title}
|
||||
</p>
|
||||
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<UsersIcon class="h-3 w-3" />
|
||||
<span class="tabular-nums">{course.enrollments}</span>
|
||||
<span>
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.completion')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
|
||||
</div>
|
||||
<Progress value={course.completion} class="ui:h-1.5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.certification_rate')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
|
||||
</div>
|
||||
<Progress value={course.certification} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<CircleIcon class="h-4 w-4 shrink-0 text-neutral-600" />
|
||||
{/if}
|
||||
<span
|
||||
class="text-sm {status === 'done'
|
||||
? 'text-neutral-400 line-through'
|
||||
: status === 'active'
|
||||
? 'ui:text-foreground'
|
||||
: 'ui:text-muted-foreground'}"
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.no_certifications_yet')}
|
||||
description={$t('dashboard.no_certifications_yet_description')}
|
||||
icon={AwardIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<LoginActivityChart data={loginActivity} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-[80vh] 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')}
|
||||
model={selectedModel}
|
||||
levelOptions={[
|
||||
{ value: 'beginner', label: $t('course.creator.level.beginner') },
|
||||
{ value: 'intermediate', label: $t('course.creator.level.intermediate') },
|
||||
{ value: 'advanced', label: $t('course.creator.level.advanced') }
|
||||
]}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
onModelChange={handleModelChange}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
onsubmit={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null,
|
||||
loginActivity: [] as LoginActivityData
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
||||
};
|
||||
}
|
||||
|
||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||
safeServerApi<DashStatsSuccess>(() =>
|
||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const loginActivity = $derived(data.loginActivity);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>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.Action>
|
||||
<CreateCourseButton variant="outline" isResponsive />
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="mb-10 flex flex-wrap items-start">
|
||||
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: AwardIcon,
|
||||
title: $t('dashboard.certificates_earned'),
|
||||
percentage: totalCertificates ?? 0,
|
||||
description: $t('dashboard.certificates_earned_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if !stats}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
{:else}
|
||||
<ul class="ui:divide-border -mx-2 divide-y">
|
||||
{#each topCourses as course, i (course.id)}
|
||||
{@const rank = i + 1}
|
||||
<li>
|
||||
<a
|
||||
href={`/courses/${course.id}`}
|
||||
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
|
||||
rank === 1 &&
|
||||
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
|
||||
rank === 2 &&
|
||||
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
|
||||
rank === 3 &&
|
||||
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
|
||||
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
|
||||
]}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
|
||||
{course.title}
|
||||
</p>
|
||||
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<UsersIcon class="h-3 w-3" />
|
||||
<span class="tabular-nums">{course.enrollments}</span>
|
||||
<span>
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.completion')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
|
||||
</div>
|
||||
<Progress value={course.completion} class="ui:h-1.5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.certification_rate')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
|
||||
</div>
|
||||
<Progress value={course.certification} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.no_certifications_yet')}
|
||||
description={$t('dashboard.no_certifications_yet_description')}
|
||||
icon={AwardIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<LoginActivityChart data={loginActivity} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { AiCreditsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AI Credits - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.ai_credits.sub_title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.ai_credits.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AiCreditsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
Reference in New Issue
Block a user