feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+3 -3
View File
@@ -114,11 +114,11 @@ html .dark {
.prose p,
.prose li,
.prose blockquote {
@apply leading-7;
@apply leading-relaxed;
}
.prose p {
@apply my-0;
@apply mb-4;
}
.prose a {
@@ -236,7 +236,7 @@ body {
iframe.iframe {
width: 100%;
max-width: 800px;
max-width: 48rem;
margin: 0 auto;
height: 450px;
border: 1px solid gray;
+19
View File
@@ -5,6 +5,21 @@ import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes';
const ANALYTICS_SESSION_COOKIE = 'cio_aid';
const ANALYTICS_SESSION_MAX_AGE = 60 * 60 * 24 * 90;
function ensureAnalyticsSessionCookie(cookies: Parameters<Handle>[0]['event']['cookies']) {
if (cookies.get(ANALYTICS_SESSION_COOKIE)) return;
cookies.set(ANALYTICS_SESSION_COOKIE, crypto.randomUUID(), {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: true,
maxAge: ANALYTICS_SESSION_MAX_AGE
});
}
export const handle: Handle = async (args) => {
const { event } = args;
const sessionData = await getSessionData(event.cookies);
@@ -13,6 +28,10 @@ export const handle: Handle = async (args) => {
event.locals = sessionData;
}
if (!event.url.pathname.includes('/api')) {
ensureAnalyticsSessionCookie(event.cookies);
}
let response: Response;
if (event.url.pathname.includes('/api')) {
@@ -0,0 +1,82 @@
import type {
AccountUsage,
AccountWorkspaceLimits,
AccountWorkspaceList,
CreateAccountWorkspaceRequest,
DeleteAccountWorkspaceRequest,
GetAccountUsageRequest,
ListAccountWorkspacesRequest
} from '../utils/types';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
class AccountApi extends BaseApiWithErrors {
workspaces = $state<AccountWorkspaceList>([]);
limits = $state<AccountWorkspaceLimits | null>(null);
usage = $state<AccountUsage | null>(null);
async listWorkspaces() {
return this.execute<ListAccountWorkspacesRequest>({
requestFn: () => classroomio.account.workspaces.$get(),
logContext: 'fetching account workspaces',
onSuccess: (response) => {
this.workspaces = response.data.workspaces;
this.limits = response.data.limits;
}
});
}
async loadUsage() {
return this.execute<GetAccountUsageRequest>({
requestFn: () => classroomio.account.usage.$get(),
logContext: 'fetching account usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createWorkspace(fields: TCreateWorkspace) {
const parsed = ZCreateWorkspace.safeParse(fields);
if (!parsed.success) {
this.errors = mapZodErrorsToTranslations(parsed.error);
return;
}
return this.execute<CreateAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces.$post({ json: parsed.data }),
logContext: 'creating workspace',
onSuccess: async (response) => {
this.workspaces = [...this.workspaces, response.data];
snackbar.success('snackbar.account.workspaceCreated');
await this.listWorkspaces();
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
async deleteWorkspace(workspaceId: string) {
return this.execute<DeleteAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces[':workspaceId'].$delete({ param: { workspaceId } }),
logContext: 'deleting workspace',
onSuccess: () => {
this.workspaces = this.workspaces.filter((row) => row.id !== workspaceId);
snackbar.success('snackbar.account.workspaceDeleted');
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
}
export const accountApi = new AccountApi();
@@ -0,0 +1,2 @@
export { default as WorkspacesPage } from './pages/workspaces.svelte';
export { accountApi } from './api/account.svelte';
@@ -0,0 +1,190 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Spinner } from '@cio/ui/base/spinner';
import * as Dialog from '@cio/ui/base/dialog';
import * as Field from '@cio/ui/base/field';
import { accountApi } from '$features/account/api/account.svelte';
import { currentOrg, isPrimaryWorkspace } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let isCreateOpen = $state(false);
let formName = $state('');
let formSiteName = $state('');
let confirmDeleteId = $state<string | null>(null);
$effect(() => {
if (!$currentOrg.id) return;
accountApi.listWorkspaces();
accountApi.loadUsage();
});
const limits = $derived(accountApi.limits);
const usage = $derived(accountApi.usage);
const workspaces = $derived(accountApi.workspaces);
const ctaState = $derived.by(() => {
if (!limits) return { kind: 'loading' as const };
if (limits.requiresUpgrade) {
return { kind: 'upgrade' as const };
}
if (!limits.canCreate) {
return { kind: 'limit' as const, allowance: limits.allowance };
}
return { kind: 'create' as const, used: limits.used, allowance: limits.allowance };
});
async function onCreate() {
await accountApi.createWorkspace({ name: formName, siteName: formSiteName });
if (accountApi.success) {
formName = '';
formSiteName = '';
isCreateOpen = false;
}
}
async function onDelete(id: string) {
await accountApi.deleteWorkspace(id);
confirmDeleteId = null;
}
</script>
{#if !$isPrimaryWorkspace}
<div class="ui:text-muted-foreground rounded border p-4">
{$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })}
</div>
{:else}
<Field.Group class="w-full max-w-2xl px-2">
{#if usage}
<Field.Set>
<div class="rounded border p-4">
<p class="text-sm">
{$t('account.workspaces.usage_banner', {
learners: usage.learnerCount,
tokens: usage.tokensUsedThisMonth,
workspaces: usage.workspaceCount
})}
</p>
</div>
</Field.Set>
<Field.Separator />
{/if}
<Field.Set>
<div class="flex items-center justify-between">
<Field.Legend>{$t('account.workspaces.page_title')}</Field.Legend>
{#if ctaState.kind === 'create'}
<Button onclick={() => (isCreateOpen = true)}>
{$t('account.workspaces.create_cta')}
<span class="ui:text-muted-foreground ml-2">
{$t('account.workspaces.create_cta_used', { used: ctaState.used, allowance: ctaState.allowance })}
</span>
</Button>
{:else if ctaState.kind === 'limit'}
<Button disabled variant="outline">
{$t('account.workspaces.create_cta_disabled_limit', { allowance: ctaState.allowance })}
</Button>
{:else if ctaState.kind === 'upgrade'}
<Button variant="outline" href="/pricing">
{$t('account.workspaces.create_cta_disabled_upgrade')}
<span class="ml-2 underline">{$t('account.workspaces.upgrade_link')}</span>
</Button>
{/if}
</div>
<Field.Group>
{#if accountApi.isLoading && workspaces.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else}
{#each workspaces as workspace}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<p class="text-sm font-medium">{workspace.name}</p>
<span class="ui:text-muted-foreground text-xs">{workspace.siteName}</span>
{#if workspace.isPrimary}
<Badge variant="secondary">{$t('account.workspaces.primary_badge')}</Badge>
{/if}
{#if workspace.readOnlyUntil}
<Badge variant="outline" class="text-yellow-700">{$t('account.workspaces.read_only_badge')}</Badge>
{/if}
</div>
{#if !workspace.isPrimary}
<Button
variant="ghost"
class="text-red-500 hover:text-red-700"
onclick={() => (confirmDeleteId = workspace.id)}
>
{$t('account.workspaces.delete_action')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
{/if}
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.create_modal_title')}</Dialog.Title>
</Dialog.Header>
<Field.Group>
<Field.Field>
<Field.Label>{$t('account.workspaces.name_label')}</Field.Label>
<Input bind:value={formName} />
{#if accountApi.errors.name}
<Field.Error>{accountApi.errors.name}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('account.workspaces.site_name_label')}</Field.Label>
<Input bind:value={formSiteName} />
{#if accountApi.errors.siteName}
<Field.Error>{accountApi.errors.siteName}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCreateOpen = false)}>{$t('account.workspaces.cancel')}</Button>
<Button onclick={onCreate} loading={accountApi.isLoading} disabled={accountApi.isLoading}>
{$t('account.workspaces.create_modal_submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root open={confirmDeleteId !== null} onOpenChange={(open) => !open && (confirmDeleteId = null)}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.delete_confirm_title')}</Dialog.Title>
</Dialog.Header>
<p class="ui:text-muted-foreground">{$t('account.workspaces.delete_confirm_body')}</p>
<Dialog.Footer>
<Button variant="outline" onclick={() => (confirmDeleteId = null)}>{$t('account.workspaces.cancel')}</Button>
<Button
variant="destructive"
onclick={() => confirmDeleteId && onDelete(confirmDeleteId)}
loading={accountApi.isLoading}
disabled={accountApi.isLoading}
>
{$t('account.workspaces.delete_action')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,14 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces.$get;
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
export type AccountWorkspaceList = ListSuccess['data']['workspaces'];
export type AccountWorkspaceLimits = ListSuccess['data']['limits'];
export type AccountWorkspace = AccountWorkspaceList[number];
export type AccountUsage = UsageSuccess['data'];
@@ -0,0 +1,33 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { snackbar } from '$features/ui/snackbar/store';
import type { GenerateTextRequest } from '../utils/types';
export class TextGenerationApi extends BaseApiWithErrors {
async generate(
prompt: string,
tone: string,
format: 'plain' | 'html' = 'plain',
context?: string,
courseId?: string
): Promise<string | null> {
let generatedText: string | null = null;
await this.execute<GenerateTextRequest>({
requestFn: () =>
classroomio.agent['generate-text'].$post({
json: { prompt, tone: tone as 'professional' | 'casual' | 'expert' | 'friendly', format, context, courseId }
}),
logContext: 'generating text',
onSuccess: (response) => {
generatedText = response.data.text;
},
onError: () => {
snackbar.error('Failed to generate text. Please try again.');
}
});
return generatedText;
}
}
export const textGenerationApi = new TextGenerationApi();
@@ -0,0 +1,22 @@
<script lang="ts">
import { AIGeneratePopover } from '@cio/ui/custom/ai-generate-popover';
import { textGenerationApi } from '../api/text-generation.svelte';
interface Props {
onInsert: (text: string) => void;
context?: string;
format?: 'plain' | 'html';
courseId?: string;
align?: 'start' | 'center' | 'end';
}
let { onInsert, context, format = 'plain', courseId, align = 'right' }: Props = $props();
</script>
<AIGeneratePopover
{align}
onGenerate={async (prompt, tone) => {
const text = await textGenerationApi.generate(prompt, tone, format, context, courseId);
if (text) onInsert(text);
}}
/>
@@ -0,0 +1,4 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GenerateTextRequest = (typeof classroomio.agent)['generate-text']['$post'];
export type GenerateTextSuccess = Extract<InferResponseType<GenerateTextRequest>, { success: true }>;
@@ -1,12 +1,14 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
import ChatInput from '$features/ai-assistant/chat-input.svelte';
import { resolve } from '$app/paths';
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import {
getAgentToolInput,
getAgentToolName,
getAgentToolResult,
getAgentToolStatus,
@@ -14,12 +16,16 @@
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import {
isAiAssistantOpen,
chatDraft,
clearChatDraft,
initialChatModel,
initialChatPrompt,
initialChatTemplateId,
clearInitialChatModel,
clearInitialChatPrompt
clearInitialChatPrompt,
clearInitialChatTemplateId
} from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
@@ -28,23 +34,24 @@
import { getMentionableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user';
import type {
AiAssistantMessage,
AiAssistantMessageMetadata,
AiAssistantTemplateMetadata,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
import {
AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH,
AI_COURSE_CHAT_MAX_WIDTH,
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
STUDENT_QUICK_ACTION_ENTRIES
} from '$features/ai-assistant/utils/constants';
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
import {
AGENT_MODELS,
AGENT_MODEL_IDS,
@@ -53,14 +60,18 @@
type AgentModelId
} from '@cio/utils/agent-models';
/** Every ~30% / 60% / 90% of tool steps completed, refetch course so UI reflects partial mutations */
const AGENT_STEP_PROGRESS_REFRESH_RATIOS = [0.3, 0.6, 0.9] as const;
/** Completed-step thresholds already refreshed for this streaming session */
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
let lastSeenStreamingFlag = false;
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
interface Props {
/** Course id from route / layout `data` (authoritative for this page). */
courseId: string;
}
let { courseId }: Props = $props();
// Read course id from the route. The chat panel is only mounted inside the
// course content layout, so `page.params.id` is always the active course.
const courseId = $derived(page.params?.id as string);
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -69,15 +80,13 @@
let inputValue = $state('');
let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false);
let contextFullBusy = $state<null | 'compact' | 'new_chat'>(null);
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
let statusFetchedForCourseId: string | null = $state(null);
let conversationsLoadedForCourseId: string | null = $state(null);
let activeConversationId: string | null = $state(null);
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
let hasLoadedChatWidth = $state(false);
let isChatResizing = $state(false);
let stopChatResize: (() => void) | null = null;
let chatShellElement: HTMLDivElement | null = null;
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
@@ -120,15 +129,6 @@
}
});
function clampChatWidth(width: number) {
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
}
function clearChatResizeListeners() {
stopChatResize?.();
stopChatResize = null;
}
function getStorageKey(courseId: string) {
return `ai-chat-active-${courseId}`;
}
@@ -197,15 +197,25 @@
}
}
// Fetch status and load conversations when the panel opens or route `courseId` changes
$effect(() => {
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
async function handleRenameConversation(conversationId: string, title: string) {
const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
return;
if (!updatedTitle) {
const rawError = aiAssistantApi.error;
let message = t.get('ai_assistant.rename_chat_failed');
if (rawError && !rawError.startsWith('{')) {
message = rawError;
}
throw new Error(message);
}
}
// Fetch status and load conversations when the chat panel mounts (the
// SidePanelRail only mounts this component while the panel is active) or
// when the route's courseId changes.
$effect(() => {
if (!courseId) return;
if (statusFetchedForCourseId !== courseId) {
@@ -216,14 +226,20 @@
if (conversationsLoadedForCourseId !== courseId) {
conversationsLoadedForCourseId = courseId;
// If the panel was just opened to start a fresh chat (e.g. via quoteInChat),
// skip the auto-load so the draft effect can call startNewChat itself.
const pendingDraft = get(chatDraft);
if (pendingDraft?.mode === 'new') {
return;
}
const listForCourseId = courseId;
const savedId = getActiveConversationId(listForCourseId);
aiAssistantApi.listConversations(listForCourseId).then(() => {
if (courseId !== listForCourseId) return;
// If the initial-prompt flow already created and claimed a conversation while
// listConversations was in-flight, don't clobber it by loading another one.
if (activeConversationId) return;
if (savedId) {
@@ -323,18 +339,53 @@
}
});
const quickActions = [
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
'Summarize this lesson',
'Help me publish this course'
];
function buildTemplateAnswersSummary(
templateId: CourseTemplateId,
answers: Record<string, string>,
fields: TemplateFormField[]
): string {
const template = getCourseTemplate(templateId);
const registryById = new Map((template?.fields ?? []).map((field) => [field.id, field]));
const lines: string[] = ['Here are my answers for the course template:'];
const seen = new Set<string>();
for (const field of fields) {
if (!field?.id || seen.has(field.id)) {
continue;
}
seen.add(field.id);
const trimmed = answers[field.id]?.trim() ?? '';
if (!trimmed) {
continue;
}
const label = registryById.get(field.id)?.label ?? field.label ?? field.id;
lines.push(`- ${label}: ${trimmed}`);
}
// Capture any answers whose field isn't in the rendered list (defensive — shouldn't happen)
for (const [fieldId, value] of Object.entries(answers)) {
if (seen.has(fieldId)) continue;
const trimmed = value.trim();
if (!trimmed) continue;
const label = registryById.get(fieldId)?.label ?? fieldId;
lines.push(`- ${label}: ${trimmed}`);
}
return lines.join('\n');
}
function handleSend() {
if (!inputValue.trim() || chat.status === 'streaming') return;
const text = inputValue;
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
const messageAttachment = uploadedDocument
? {
documentId: uploadedDocument.id,
@@ -342,6 +393,18 @@
}
: undefined;
const metadata: AiAssistantMessageMetadata = {};
if (messageAttachment) {
metadata.attachment = messageAttachment;
}
if (templateForFirstMessage) {
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
metadata.template = templateMeta;
pendingInitialTemplateId = null;
}
inputValue = '';
// Create conversation eagerly before sending the first message
@@ -357,7 +420,48 @@
chat.sendMessage({
text,
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
...(Object.keys(metadata).length > 0 ? { metadata } : {})
});
}
async function handleSubmitTemplateAnswers(payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
metadata: {
template: {
action: 'submit_template_answers',
templateId: payload.templateId,
answers: payload.answers
}
}
});
}
async function handleSkipTemplateForm(payload: { templateId: CourseTemplateId }) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: "I'll answer your questions in chat instead of the form.",
metadata: {
template: {
action: 'skip_template_form',
templateId: payload.templateId
}
}
});
}
@@ -428,8 +532,20 @@
});
}
function handleAskPlanChanges() {
inputValue = '';
function handleAskPlanChanges(message: string) {
const trimmed = message.trim();
if (!trimmed || chat.status === 'streaming') return;
if (!activeConversationId && courseId) {
aiAssistantApi.createConversation(courseId).then((created) => {
if (created) {
setActiveConversationId(courseId, created.id);
}
});
}
chat.sendMessage({ text: trimmed });
}
function handleResume() {
@@ -441,9 +557,94 @@
goto(resolve(route, {}));
}
async function handleStartNewChatWithSummary() {
if (!courseId || contextFullBusy) return;
contextFullBusy = 'new_chat';
try {
const summary = await aiAssistantApi.summarizeConversation(chat.messages as AiAssistantMessage[], courseId);
chat.messages = [];
setActiveConversationId(courseId, null);
const created = await aiAssistantApi.createConversation(courseId);
if (created) {
setActiveConversationId(courseId, created.id);
}
if (summary) {
inputValue = summary;
handleSend();
}
} finally {
contextFullBusy = null;
}
}
async function handleCompactConversation() {
if (!courseId || !activeConversationId || contextFullBusy) return;
contextFullBusy = 'compact';
try {
const compacted = await aiAssistantApi.compactConversation(activeConversationId);
if (compacted && compacted.length > 0) {
chat.messages = compacted as AiAssistantMessage[];
await chat.regenerate();
}
} finally {
contextFullBusy = null;
}
}
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
const status = $derived(aiAssistantApi.status);
const isStudent = $derived(status?.role === 'student');
const tutorStatus = $derived(status?.tutor);
const tutorErrorCode = $derived.by(() => {
if (!chat.error) return null;
const message = chat.error.message ?? '';
if (message.includes('AI_TUTOR_DISABLED')) return 'AI_TUTOR_DISABLED' as const;
if (message.includes('LEARNER_CAP_REACHED')) return 'LEARNER_CAP_REACHED' as const;
if (message.includes('POOL_EXHAUSTED')) return 'POOL_EXHAUSTED' as const;
return null;
});
const tutorBlocked = $derived.by(() => {
if (!isStudent) return null;
if (tutorErrorCode) return tutorErrorCode;
if (tutorStatus && tutorStatus.enabled === false) return 'AI_TUTOR_DISABLED' as const;
if (tutorStatus && tutorStatus.enforced && tutorStatus.capRemaining !== null && tutorStatus.capRemaining <= 0) {
return 'LEARNER_CAP_REACHED' as const;
}
return null;
});
const quickActions = $derived(isStudent ? [...STUDENT_QUICK_ACTION_ENTRIES] : [...AI_ASSISTANT_QUICK_ACTION_ENTRIES]);
// Student-facing per-learner monthly cap (100 messages). Hidden when the agent reports
// no tutor data (e.g. provider unconfigured) so we don't render a 0/0 bar.
const studentMessageUsage = $derived.by(() => {
if (!isStudent || !tutorStatus || tutorStatus.cap == null || tutorStatus.capRemaining == null) {
return null;
}
return {
used: Math.max(0, tutorStatus.cap - tutorStatus.capRemaining),
cap: tutorStatus.cap
};
});
const contextUsage = $derived(
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
);
const mentionItems = $derived(
getMentionableContent(courseApi.course).map((item) => ({
id: item.id,
@@ -455,9 +656,31 @@
// Show an activity card whenever the agent calls any tool.
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
const planExecutionState = $derived.by(() => {
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
const lastMsg = chat.messages[chat.messages.length - 1];
if (!lastAssistantMsg) return null;
if (!lastMsg) return null;
// If the most recent message is from the user, the agent has not produced a
// reply yet — don't show the previous assistant's tool card. While streaming
// is starting up, fall through to the thinking placeholder below.
if (lastMsg.role !== 'assistant') {
if (!isStreaming) return null;
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const lastAssistantMsg = lastMsg;
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit';
@@ -466,9 +689,29 @@
) as AgentToolPart[];
// generate_course_plan renders its own PlanView — exclude it from the card
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
const toolParts = allToolParts.filter((part) => {
const toolName = getAgentToolName(part);
return toolName !== 'generate_course_plan' && toolName !== 'ask_template_questions';
});
if (toolParts.length === 0) return null;
if (toolParts.length === 0) {
if (!isStreaming) return null;
// The agent is mid-turn (reasoning / preparing a tool call) but hasn't emitted
// a real tool part yet. Show a placeholder step so the user always sees activity.
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const steps: ProgressStep[] = toolParts.flatMap((part) => {
const toolName = getAgentToolName(part);
@@ -479,9 +722,12 @@
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
const status = getAgentToolStatus(part);
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
const line =
status === 'completed'
? getCompletedToolLine(toolName, result)
: getPendingToolLine(toolName, getAgentToolInput(part));
return [{ label, status }];
return [{ line, status }];
});
const allDone = steps.every((s) => s.status === 'completed');
@@ -494,55 +740,60 @@
const toolName = getAgentToolName(part);
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
});
const title = hasMutations ? 'Applying changes' : 'Working';
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
const currentActionLine = steps.find((s) => s.status === 'in_progress')?.line;
return { steps, currentAction, isStopped, title };
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
});
$effect(() => {
const streamingNow = isStreaming;
if (streamingNow && !lastSeenStreamingFlag) {
agentMutationProgressThresholdsTriggered.clear();
}
lastSeenStreamingFlag = streamingNow;
});
$effect(() => {
if (!isStreaming) {
return;
}
const state = planExecutionState;
if (!state?.hasMutations || state.isStopped) {
return;
}
const total = state.steps.length;
if (total === 0) {
return;
}
const completedStepCount = state.steps.filter((s) => s.status === 'completed').length;
const stepThresholds = [
...new Set(
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
)
].sort((a, b) => a - b);
for (const threshold of stepThresholds) {
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
agentMutationProgressThresholdsTriggered.add(threshold);
refreshCourseStateAfterChat();
}
}
});
const conversationTitle = $derived(
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
);
function handleChatResizePointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
clearChatResizeListeners();
const startWidth = chatWidth;
stopChatResize = startResizablePanelDrag({
event,
startWidth,
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
onPreview: (width) => {
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
},
onCommit: ({ width }) => {
chatWidth = width;
},
onDragStart: () => {
isChatResizing = true;
},
onDragEnd: () => {
isChatResizing = false;
stopChatResize = null;
}
});
}
onMount(() => {
try {
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
if (Number.isFinite(storedWidth)) {
chatWidth = clampChatWidth(storedWidth);
}
} catch {
// localStorage unavailable
}
try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
@@ -552,28 +803,22 @@
} catch {
// localStorage unavailable
}
hasLoadedChatWidth = true;
return () => {
clearChatResizeListeners();
};
});
onDestroy(() => {
clearChatResizeListeners();
});
$effect(() => {
const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return;
if (!prompt || !courseId) return;
const model = $initialChatModel;
const templateFromHome = $initialChatTemplateId;
clearInitialChatPrompt();
clearInitialChatModel();
clearInitialChatTemplateId();
tick().then(() => {
pendingInitialTemplateId = templateFromHome ?? null;
if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
@@ -589,21 +834,37 @@
});
$effect(() => {
if (!hasLoadedChatWidth) {
const draft = $chatDraft;
if (!draft || !draft.text) {
return;
}
try {
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
} catch {
// localStorage unavailable
clearChatDraft();
if (draft.mode === 'new') {
void startNewChat().then(() =>
tick().then(() => {
inputValue = `${draft.text}\n\n`;
})
);
return;
}
void tick().then(() => {
const existing = inputValue.trimEnd();
inputValue = existing ? `${draft.text}\n\n${existing}` : `${draft.text}\n\n`;
});
});
</script>
{#snippet chatPanelContent()}
<div class="flex min-h-0 flex-1 flex-col">
<ChatHeader
{tokenUsage}
{isStudent}
{studentMessageUsage}
{conversationTitle}
conversations={aiAssistantApi.conversations}
{activeConversationId}
@@ -611,18 +872,21 @@
onNewChat={startNewChat}
onLoadConversation={loadConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
/>
<ChatMessageList
messages={chat.messages}
status={chat.status}
{isStreaming}
{isStudent}
{courseId}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan}
onAskPlanChanges={handleAskPlanChanges}
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
onSkipTemplateForm={handleSkipTemplateForm}
onStop={handleStop}
onResume={handleResume}
onMentionClick={handleMentionClick}
@@ -633,9 +897,15 @@
{isStreaming}
{isExhausted}
{isUploading}
{contextFullBusy}
compactConversationDisabled={!activeConversationId}
onCompactConversation={handleCompactConversation}
{uploadedDocument}
{mentionItems}
{selectedModel}
{contextUsage}
{isStudent}
{tutorBlocked}
lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error}
onSend={handleSend}
@@ -644,37 +914,6 @@
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
onStartNewChatWithSummary={handleStartNewChatWithSummary}
/>
{/snippet}
{#if $isAiAssistantOpen}
<div
bind:this={chatShellElement}
data-ai-chat-resizing={isChatResizing}
class="contents"
style={`--ai-chat-width: ${chatWidth}px;`}
>
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
<div
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
>
<button
type="button"
aria-label="Resize AI assistant panel"
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
onpointerdown={handleChatResizePointerDown}
>
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
</button>
{@render chatPanelContent()}
</div>
</div>
{/if}
<style>
:global([data-ai-chat-resizing='true'] *) {
transition-property: none !important;
}
</style>
</div>
@@ -4,7 +4,9 @@ import type {
AgentConversationCreateData,
AgentConversationSummary,
AgentStatusData,
AiAssistantMessage
AiAssistantMessage,
CompactConversationRequest,
CompactConversationSuccess
} from '../utils/types';
class AiAssistantApi extends BaseApiWithErrors {
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
return generatedTitle;
}
async renameConversation(conversationId: string, title: string): Promise<string | null> {
let newTitle: string | null = null;
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$patch']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$patch({
param: { conversationId },
json: { title }
}),
logContext: 'renaming conversation',
onSuccess: (result) => {
newTitle = (result.data as { id: string; title: string }).title;
this.conversations = this.conversations.map((c) => (c.id === conversationId ? { ...c, title: newTitle } : c));
if (this.currentConversation?.id === conversationId) {
this.currentConversation = { ...this.currentConversation, title: newTitle };
}
}
});
return newTitle;
}
async summarizeConversation(messages: AiAssistantMessage[], courseId: string): Promise<string | null> {
let summary: string | null = null;
await this.execute<(typeof classroomio.agent)['summarize']['$post']>({
requestFn: () =>
classroomio.agent.summarize.$post({
json: { messages, courseId }
}),
logContext: 'summarizing conversation',
onSuccess: (result) => {
summary = (result.data as { summary: string }).summary;
}
});
return summary;
}
async compactConversation(conversationId: string): Promise<CompactConversationSuccess['data']['messages'] | null> {
let compacted: CompactConversationSuccess['data']['messages'] | null = null;
await this.execute<CompactConversationRequest>({
requestFn: () =>
classroomio.agent.history[':conversationId'].compact.$post({
param: { conversationId }
}),
logContext: 'compacting conversation',
onSuccess: (result) => {
compacted = result.data.messages;
if (this.currentConversation?.id === conversationId) {
this.currentConversation = {
...this.currentConversation,
messages: compacted as AiAssistantMessage[]
};
}
}
});
return compacted;
}
}
export const aiAssistantApi = new AiAssistantApi();
@@ -1,5 +1,7 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { Input } from '@cio/ui/base/input';
import { tick } from 'svelte';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -22,8 +24,15 @@
remaining: number;
}
interface StudentMessageUsage {
used: number;
cap: number;
}
interface Props {
tokenUsage: TokenUsage | null;
isStudent: boolean;
studentMessageUsage: StudentMessageUsage | null;
conversations: Conversation[];
activeConversationId: string | null;
conversationTitle: string | null;
@@ -31,25 +40,132 @@
onNewChat: () => void;
onLoadConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
/** Resolves on success; rejects with Error when rename fails (message is user-presentable). */
onRenameConversation: (id: string, title: string) => Promise<void>;
}
let {
tokenUsage,
isStudent,
studentMessageUsage,
conversations,
activeConversationId,
conversationTitle,
isNewChatDisabled,
onNewChat,
onLoadConversation,
onDeleteConversation
onDeleteConversation,
onRenameConversation
}: Props = $props();
let historyPopoverOpen = $state(false);
let editingConversationTitle = $state(false);
let draftConversationTitle = $state('');
let renameConversationError = $state<string | null>(null);
let titleInputRef: HTMLInputElement | null = $state(null);
let renameCommitInFlight = $state(false);
let snapshotConversationIdForRename = $state<string | null>(null);
const canRenameConversation = $derived(
Boolean(activeConversationId && conversationTitle && conversationTitle !== 'New conversation')
);
function handleLoadConversation(id: string) {
onLoadConversation(id);
historyPopoverOpen = false;
}
function cancelRenameConversation() {
if (renameCommitInFlight) {
return;
}
if (!editingConversationTitle) {
return;
}
editingConversationTitle = false;
renameConversationError = null;
draftConversationTitle = conversationTitle ?? '';
snapshotConversationIdForRename = null;
}
$effect(() => {
if (
editingConversationTitle &&
snapshotConversationIdForRename != null &&
activeConversationId !== snapshotConversationIdForRename
) {
cancelRenameConversation();
}
});
async function startRenameConversation() {
if (!canRenameConversation || !activeConversationId || !conversationTitle) {
return;
}
renameConversationError = null;
draftConversationTitle = conversationTitle;
snapshotConversationIdForRename = activeConversationId;
editingConversationTitle = true;
await tick();
titleInputRef?.focus();
titleInputRef?.select();
}
async function commitRenameConversation() {
if (!activeConversationId) {
return;
}
const trimmedTitle = draftConversationTitle.trim();
if (trimmedTitle.length === 0) {
cancelRenameConversation();
return;
}
if (trimmedTitle === conversationTitle) {
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
return;
}
renameCommitInFlight = true;
try {
await onRenameConversation(activeConversationId, trimmedTitle);
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
} catch (renameError) {
const message = renameError instanceof Error ? renameError.message : t.get('ai_assistant.rename_chat_failed');
renameConversationError = message;
} finally {
renameCommitInFlight = false;
}
}
function handleTitleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
void commitRenameConversation();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelRenameConversation();
}
}
</script>
<div class="border-b px-4 py-3">
@@ -59,7 +175,32 @@
<div class="min-w-0">
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
{#if conversationTitle && conversationTitle !== 'New conversation'}
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
{#if editingConversationTitle}
<div class="min-w-0">
<Input
bind:ref={titleInputRef}
bind:value={draftConversationTitle}
class="ui:h-7 ui:min-h-0 ui:px-1.5 ui:py-0 text-xs"
placeholder={$t('ai_assistant.rename_chat_placeholder')}
aria-label={$t('ai_assistant.rename_chat_input_aria')}
onkeydown={handleTitleKeydown}
onblur={cancelRenameConversation}
/>
{#if renameConversationError}
<p class="ui:text-destructive mt-0.5 text-[10px]">{renameConversationError}</p>
{/if}
</div>
{:else}
<button
type="button"
class="ui:text-muted-foreground ui:max-w-full ui:cursor-pointer ui:rounded-md ui:border ui:border-transparent ui:px-1.5 ui:py-0.5 hover:ui:bg-muted/40 hover:ui:border-border truncate text-left text-xs"
aria-label={$t('ai_assistant.rename_chat_aria')}
disabled={!canRenameConversation}
onclick={startRenameConversation}
>
{conversationTitle}
</button>
{/if}
{/if}
</div>
</div>
@@ -103,7 +244,28 @@
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{#if isStudent && studentMessageUsage}
{@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))}
<div class="mt-2">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()}
{$t('ai_assistant.messages_used_label')}</span
>
<span>{usagePercent}%</span>
</div>
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
<div
class="h-1 rounded-full transition-all {usagePercent > 90
? 'bg-red-500'
: usagePercent > 70
? 'bg-amber-500'
: 'ui:bg-primary'}"
style="width: {usagePercent}%"
></div>
</div>
</div>
{:else if !isStudent && tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
<div class="mt-2">
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
@@ -14,6 +17,10 @@
import { openUpgradeModal } from '$lib/utils/functions/org';
import { ModelPicker } from '@cio/ui/custom/model-picker';
import type { AgentModelId } from '@cio/utils/agent-models';
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
import ContextIndicator from './context-indicator.svelte';
import ContextFullState from './context-full-state.svelte';
import type { ContextUsage } from './utils/context-utils';
interface UploadedDocument {
id: string;
@@ -25,17 +32,25 @@
isStreaming: boolean;
isExhausted: boolean;
isUploading: boolean;
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
error: Error | null | undefined;
mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
contextUsage: ContextUsage | null;
isStudent?: boolean;
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void;
onStartNewChatWithSummary: () => void;
onCompactConversation: () => void;
}
let {
@@ -43,23 +58,64 @@
isStreaming,
isExhausted,
isUploading,
contextFullBusy,
compactConversationDisabled = false,
error,
mentionItems,
uploadedDocument,
selectedModel,
lockedModelIds = [],
contextUsage,
isStudent = false,
tutorBlocked = null,
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel,
onLockedModelSelect
onLockedModelSelect,
onStartNewChatWithSummary,
onCompactConversation
}: Props = $props();
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
if (reason === 'LEARNER_CAP_REACHED') return t.get('aiTutor.takeABreak.learnerCap');
if (reason === 'POOL_EXHAUSTED') return t.get('aiTutor.takeABreak.poolExhausted');
return t.get('aiTutor.takeABreak.disabled');
}
let fileInputEl: HTMLInputElement | undefined = $state();
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
let wasBusy = $state(false);
function readAgentRunningWarningDismissed() {
if (!browser) {
return false;
}
try {
return localStorage.getItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY) === '1';
} catch {
return false;
}
}
let agentRunningWarningDismissed = $state(readAgentRunningWarningDismissed());
onMount(() => {
agentRunningWarningDismissed = readAgentRunningWarningDismissed();
});
function dismissAgentRunningWarning() {
agentRunningWarningDismissed = true;
try {
localStorage.setItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY, '1');
} catch {
// localStorage unavailable
}
}
$effect(() => {
const isBusy = isStreaming || isUploading;
@@ -102,6 +158,31 @@
return t.get('ai_assistant.mention_lesson');
}
function getUserFriendlyErrorMessage(errorMessage: string): string {
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('quota exceeded') || lowerMessage.includes('rate limit')) {
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)/i);
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) : null;
return waitSeconds
? t.get('ai_assistant.error_rate_limit_with_wait', { seconds: waitSeconds })
: t.get('ai_assistant.error_rate_limit');
}
if (lowerMessage.includes('context length') || lowerMessage.includes('too long')) {
return t.get('ai_assistant.error_context_too_long');
}
if (lowerMessage.includes('network') || lowerMessage.includes('connection')) {
return t.get('ai_assistant.error_network');
}
return errorMessage;
}
const displayErrorMessage = $derived(error ? getUserFriendlyErrorMessage(error.message) : null);
</script>
<input
@@ -112,108 +193,144 @@
onchange={handleFileChange}
/>
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
<span>{$t('ai_assistant.tokens_exhausted')}</span>
{#if $currentOrgPath !== '#'}
<Button
variant="outline"
size="sm"
href={`${resolve(`${$currentOrgPath}/settings/ai-credits`)}#buy-tokens`}
class="w-full shrink-0 sm:w-auto"
>
{$t('ai_assistant.tokens_exhausted_buy_more')}
</Button>
{/if}
{#if tutorBlocked}
<div class="border-t px-3 py-4">
<div class="ui:bg-muted/40 ui:text-muted-foreground rounded-md border px-3 py-3 text-sm">
<p class="ui:text-foreground mb-1 text-sm font-medium">{$t('aiTutor.takeABreak.title')}</p>
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
</div>
{:else}
{#if isStreaming}
</div>
{:else if contextUsage?.isFull && !isStreaming}
<ContextFullState
{contextFullBusy}
{compactConversationDisabled}
{onCompactConversation}
onStartNewChat={onStartNewChatWithSummary}
/>
{:else}
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
{$t('ai_assistant.agent_running_warning')}
</div>
{/if}
{#if error}
<div
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{error.message}
</div>
{/if}
{#if isUploading}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
</div>
{:else if uploadedDocument}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
<button
onclick={onRemoveDocument}
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
>
<XIcon size={12} />
</button>
</div>
{/if}
<div class="flex items-end gap-2">
<ChatTextarea
bind:ref={chatTextareaRef}
bind:value={inputValue}
{mentionItems}
onSubmit={onSend}
placeholder={$t('ai_assistant.input_placeholder')}
disabled={isStreaming || isUploading}
typeLabel={getTypeLabel}
emptyMessage={t.get('ai_assistant.mention_no_results')}
rows={2}
class="flex-1"
>
{#snippet icon({ item })}
{#if item.type === 'EXERCISE'}
<ExerciseIcon size={14} />
{:else if item.type === 'SECTION'}
<TableOfContentsIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button
onclick={handlePaperclipClick}
disabled={isUploading}
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
<span>{$t('ai_assistant.tokens_exhausted')}</span>
{#if $currentOrgPath !== '#'}
<Button
variant="outline"
size="sm"
href={`${resolve(`${$currentOrgPath}/settings/ai-credits`)}#buy-tokens`}
class="w-full shrink-0 sm:w-auto"
>
<PaperclipIcon size={16} />
{$t('ai_assistant.tokens_exhausted_buy_more')}
</Button>
{/if}
</div>
{:else}
{#if isStreaming && !agentRunningWarningDismissed && !isStudent}
<div
class="mb-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
<span class="min-w-0 flex-1">{$t('ai_assistant.agent_running_warning')}</span>
<IconButton
variant="outline"
size="icon-xs"
type="button"
tooltip={t.get('ai_assistant.agent_running_warning_dismiss')}
class="border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
onclick={dismissAgentRunningWarning}
>
<XIcon size={12} />
</IconButton>
</div>
{/if}
{#if displayErrorMessage}
<div
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{displayErrorMessage}
</div>
{/if}
{#if isUploading}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
</div>
{:else if uploadedDocument}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
<button
onclick={onRemoveDocument}
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
>
<XIcon size={12} />
</button>
</div>
{/if}
<div class="flex-1"></div>
<div class="flex items-end gap-2">
<ChatTextarea
bind:ref={chatTextareaRef}
bind:value={inputValue}
{mentionItems}
onSubmit={onSend}
placeholder={$t('ai_assistant.input_placeholder')}
disabled={isStreaming || isUploading}
typeLabel={getTypeLabel}
emptyMessage={t.get('ai_assistant.mention_no_results')}
rows={2}
class="flex-1"
>
{#snippet icon({ item })}
{#if item.type === 'EXERCISE'}
<ExerciseIcon size={14} />
{:else if item.type === 'SECTION'}
<TableOfContentsIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
</Button>
{:else}
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
{#snippet actions()}
{#if !isStudent}
<button
onclick={handlePaperclipClick}
disabled={isUploading}
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
>
<PaperclipIcon size={16} />
</button>
{/if}
<div class="flex-1"></div>
{#if contextUsage && contextUsage.usedTokens > 0}
<ContextIndicator {contextUsage} />
{/if}
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
</Button>
{:else if !isStudent}
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{:else}
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
{$t('ai_assistant.send')}
</Button>
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
{/if}
@@ -5,26 +5,40 @@
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
interface QuickActionOption {
key: string;
prompt: string;
}
interface PlanExecutionState {
title: string;
titleKey: string;
steps: ProgressStep[];
currentAction?: string;
currentActionLine?: ToolLineUi;
isStopped: boolean;
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
hasMutations?: boolean;
}
interface Props {
messages: AiAssistantMessage[];
status: string;
isStreaming: boolean;
isStudent: boolean;
courseId: string;
planExecutionState: PlanExecutionState | null;
quickActions: string[];
quickActions: QuickActionOption[];
onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onAskPlanChanges: (message: string) => void;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onStop: () => void;
onResume: () => void;
onMentionClick: (route: string) => void;
@@ -32,14 +46,16 @@
let {
messages,
status,
isStreaming,
isStudent,
courseId,
planExecutionState,
quickActions,
onQuickAction,
onImplementPlan,
onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onStop,
onResume,
onMentionClick
@@ -48,13 +64,34 @@
let messagesContainer: HTMLDivElement | undefined = $state();
let lastMessageCount = $state(0);
let lastStepsCount = $state(0);
let lastCurrentAction = $state('');
let lastCurrentActionSig = $state('');
let lastStreamingSig = $state(0);
const isEmpty = $derived(messages.length === 0);
function scrollToBottom() {
// Total text length of the most recent assistant message — grows as tokens stream in.
// Used to keep the view pinned to the latest text while the AI is typing.
const streamingContentSig = $derived.by(() => {
if (messages.length === 0) return 0;
const last = messages[messages.length - 1];
if (last.role !== 'assistant') return 0;
let total = 0;
for (const part of last.parts ?? []) {
if ((part as { type?: string }).type === 'text') {
total += ((part as { text?: string }).text ?? '').length;
}
}
return total;
});
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
requestAnimationFrame(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
});
}
@@ -67,6 +104,16 @@
scrollToBottom();
});
$effect(() => {
if (!messagesContainer) return;
if (streamingContentSig === lastStreamingSig) return;
lastStreamingSig = streamingContentSig;
// Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor.
scrollToBottom('auto');
});
$effect(() => {
if (!messagesContainer || !planExecutionState) return;
@@ -77,9 +124,10 @@
return;
}
const action = planExecutionState.currentAction ?? '';
if (action !== lastCurrentAction) {
lastCurrentAction = action;
const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
if (actionSig !== lastCurrentActionSig) {
lastCurrentActionSig = actionSig;
scrollToBottom();
}
});
@@ -92,17 +140,17 @@
<div class="flex flex-col items-center gap-2 text-center">
<SparklesIcon size={32} class="ui:text-muted-foreground" />
<p class="ui:text-muted-foreground text-sm">
{$t('ai_assistant.empty_state')}
{$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
{#each quickActions as action (action)}
{#each quickActions as option (option.key)}
<button
onclick={() => onQuickAction(action)}
onclick={() => onQuickAction(option.prompt)}
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
>
{action}
{$t(option.key)}
</button>
{/each}
</div>
@@ -110,11 +158,31 @@
{:else}
<!-- Message list -->
<div class="flex flex-col gap-3">
{#each messages as message (message.id)}
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
{#each messages as message, messageIndex (message.id)}
{@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction}
{@const isLast = messageIndex === messages.length - 1}
{#if compaction}
<p class="ui:text-muted-foreground mx-1 px-2 text-[11px] leading-snug">
{$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })}
</p>
{/if}
<MessageBubble
{message}
{messages}
{courseId}
{isStreaming}
{isLast}
{onImplementPlan}
{onAskPlanChanges}
{onSubmitTemplateAnswers}
{onSkipTemplateForm}
{onMentionClick}
/>
{/each}
{#if status === 'submitted' && isStreaming}
{#if isStreaming && (isStudent || !planExecutionState)}
<div class="flex items-start">
<div class="ui:bg-muted rounded-lg px-3 py-2">
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
@@ -122,12 +190,14 @@
</div>
{/if}
{#if planExecutionState}
{#if planExecutionState && !isStudent}
<div class="mt-2">
<ProgressCard
title={planExecutionState.title}
titleKey={planExecutionState.titleKey}
steps={planExecutionState.steps}
currentAction={planExecutionState.currentAction}
currentActionLine={planExecutionState.currentActionLine}
{courseId}
onNavigate={onMentionClick}
onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped}
/>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
import Minimize2Icon from '@lucide/svelte/icons/minimize-2';
import LoaderIcon from '@lucide/svelte/icons/loader';
import { t } from '$lib/utils/functions/translations';
interface Props {
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
onCompactConversation: () => void;
onStartNewChat: () => void;
}
let { contextFullBusy, compactConversationDisabled = false, onCompactConversation, onStartNewChat }: Props = $props();
const compactBusy = $derived(contextFullBusy === 'compact');
const busy = $derived(contextFullBusy !== null);
</script>
<div class="border-t px-3 py-4">
<div
class="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-5 text-center dark:border-amber-800 dark:bg-amber-950"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{$t('ai_assistant.context_full_title')}
</p>
<p class="ui:text-muted-foreground text-xs">
{$t('ai_assistant.context_full_description')}
</p>
<div class="flex w-full max-w-xs flex-col gap-2 pt-1 sm:max-w-sm">
<Button
variant="default"
size="sm"
onclick={onCompactConversation}
disabled={busy || compactConversationDisabled}
class="w-full gap-2"
>
{#if compactBusy}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_compacting')}
{:else}
<Minimize2Icon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_compact')}
{/if}
</Button>
<Button
variant="outline"
size="sm"
onclick={onStartNewChat}
disabled={busy}
class="w-full gap-2 border-amber-300 bg-amber-50/70 text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100 dark:hover:bg-amber-900"
>
{#if contextFullBusy === 'new_chat'}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_summarizing')}
{:else}
<MessageSquarePlusIcon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_new_chat')}
{/if}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { CircularProgress } from '@cio/ui/custom/circular-progress';
import { Tooltip, Provider, Trigger, Content } from '@cio/ui/base/tooltip';
import { t } from '$lib/utils/functions/translations';
import { formatTokenCount, type ContextUsage } from './utils/context-utils';
interface Props {
contextUsage: ContextUsage;
}
let { contextUsage }: Props = $props();
const progressClass = $derived(
contextUsage.isFull ? 'ui:stroke-destructive' : contextUsage.isNearlyFull ? 'stroke-amber-500' : 'ui:stroke-primary'
);
const tooltipText = $derived(
t.get('ai_assistant.context_usage_tooltip', {
used: formatTokenCount(contextUsage.usedTokens),
max: formatTokenCount(contextUsage.maxTokens)
})
);
</script>
<Provider>
<Tooltip>
<Trigger>
<button
type="button"
class="ui:text-muted-foreground hover:ui:bg-muted flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors"
>
<CircularProgress
value={contextUsage.percentage}
size={16}
strokeWidth={2}
{progressClass}
trackClass="ui:stroke-muted"
/>
<span>{contextUsage.percentage}%</span>
</button>
</Trigger>
<Content side="top">
<p class="text-xs">{tooltipText}</p>
</Content>
</Tooltip>
</Provider>
@@ -1,4 +1,14 @@
export { default as AiCourseChat } from './ai-course-chat.svelte';
export { default as PlanView } from './plan-view.svelte';
export { default as ProgressCard } from './progress-card.svelte';
export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store';
export {
AI_ASSISTANT_PANEL_ID,
chatDraft,
clearChatDraft,
openAiAssistant,
closeAiAssistant,
quoteInChat,
setChatDraft,
toggleAiAssistant
} from './utils/store';
export { aiAssistantPanelDefinition } from './panel';
@@ -8,26 +8,64 @@
import { renderMarkdown } from '$features/ai-assistant/utils/markdown';
import { renderMentions } from '$features/ai-assistant/utils/mentions';
import PlanView from '$features/ai-assistant/plan-view.svelte';
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
import TemplateFormCard from '$features/ai-assistant/template-form-card.svelte';
import { isTemplateFormResolved } from '$features/ai-assistant/utils/template-form-resolved';
import { mergeTemplateFieldsWithRegistry } from '$features/ai-assistant/utils/template-fields';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import {
getCompletedToolLine,
getPendingToolI18nKey,
getPendingToolI18nVars,
getPendingToolLine
} from '$features/ai-assistant/utils/tool-labels';
import type { CoursePlan } from '$features/ai-assistant/utils/course-plan';
import { t } from '$lib/utils/functions/translations';
import {
getAgentToolName,
getAgentToolInput,
getAgentToolResult,
getAgentToolStatus,
isAgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
interface Props {
message: AiAssistantMessage;
messages: AiAssistantMessage[];
courseId: string;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onAskPlanChanges: (message: string) => void;
isStreaming: boolean;
isLast?: boolean;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onMentionClick: (route: string) => void;
}
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
let {
message,
messages,
courseId,
onImplementPlan,
onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onMentionClick,
isStreaming,
isLast = false
}: Props = $props();
function localizePendingTool(toolName: string): string {
const vars = getPendingToolI18nVars(toolName);
const key = getPendingToolI18nKey(toolName);
return t.get(key, vars ?? {});
}
function handleBubbleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
@@ -46,12 +84,15 @@
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
/** Tool parts rendered in a second pass so narrative text always appears above them (stream order often emits tools first). */
function isDeferredPlanPart(part: Record<string, unknown>) {
if (!isAgentToolPart(part)) {
return false;
}
return getAgentToolName(part) === 'generate_course_plan';
const name = getAgentToolName(part);
return name === 'generate_course_plan' || name === 'ask_template_questions';
}
function truncateErrorText(errorText: string): string {
@@ -72,61 +113,104 @@
return (part as { errorText?: string }).errorText;
}
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
const inlineParts = $derived(message.parts.filter((part) => (part as { type?: string }).type === 'text'));
const deferredPlanParts = $derived(
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
);
const hasBubbleContent = $derived(inlineParts.length > 0 || deferredPlanParts.length > 0 || !!messageAttachment);
const showStreamingSpinner = $derived(message.role === 'assistant' && !hasBubbleContent && isStreaming && isLast);
</script>
<div data-role={message.role} class="flex flex-col gap-1 {message.role === 'user' ? 'items-end' : 'items-start'}">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:bg-muted'}"
onclick={handleBubbleClick}
>
{#if messageAttachment}
<div
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
? 'bg-white/15 text-white/90'
: 'ui:bg-background/70 ui:text-muted-foreground'}"
>
<FileTextIcon size={12} class="shrink-0" />
<span class="min-w-0 truncate">{messageAttachment.name}</span>
</div>
{/if}
{#each inlineParts as part, partIndex (partIndex)}
{#if part.type === 'text'}
{#if message.role === 'assistant' && !hasBubbleContent && !showStreamingSpinner}
<!-- Assistant message has no renderable parts yet and isn't streaming — skip the empty bubble -->
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:bg-muted'}"
onclick={handleBubbleClick}
>
{#if showStreamingSpinner}
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
{/if}
{#if messageAttachment}
<div
class="prose prose-sm dark:prose-invert max-w-none {message.role === 'user' && 'ui:text-primary-foreground!'}"
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
? 'bg-white/15 text-white/90'
: 'ui:bg-background/70 ui:text-muted-foreground'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
<FileTextIcon size={12} class="shrink-0" />
<span class="min-w-0 truncate">{messageAttachment.name}</span>
</div>
{:else if isAgentToolPart(part)}
{/if}
{#each inlineParts as part, partIndex (partIndex)}
{#if part.type === 'text'}
<div
class="ai-chat-prose prose prose-sm dark:prose-invert max-w-none {message.role === 'user' &&
'ui:text-primary-foreground!'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
</div>
{/if}
{/each}
{#each deferredPlanParts as part, partIndex (partIndex)}
{@const toolName = getAgentToolName(part)}
{@const toolResult = getAgentToolResult(part)}
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<div class="mt-3">
<PlanView
plan={toolResult as CoursePlan}
onImplement={onImplementPlan}
onAskChanges={onAskPlanChanges}
isBusy={isStreaming}
/>
</div>
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
{@const merged = (toolResult ?? getAgentToolInput(part)) as
| { templateId?: CourseTemplateId; fields?: TemplateFormField[] }
| undefined}
{#if merged?.templateId}
{@const canonicalFields = mergeTemplateFieldsWithRegistry(merged.templateId, merged.fields)}
{#if canonicalFields.length > 0}
<div class="mt-3">
<TemplateFormCard
templateId={merged.templateId}
fields={canonicalFields}
allMessages={messages}
submitted={isTemplateFormResolved(messages, merged.templateId)}
disableFormInputs={toolStatus === 'in_progress'}
onSubmit={onSubmitTemplateAnswers}
onSkip={onSkipTemplateForm}
/>
</div>
{/if}
{/if}
{:else}
<div class="ui:bg-background/70 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
{#if toolStatus === 'completed' && toolName}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
<span>{getToolResultLabel(toolName, toolResult)}</span>
<ToolLine line={getCompletedToolLine(toolName, toolResult)} {courseId} onNavigate={onMentionClick} />
{:else if toolStatus === 'failed' && toolName}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
{#if errorText}
{truncateErrorText(errorText)}
{:else}
{$t('ai_assistant.run_failed_after', { action: localizePendingTool(toolName) })}
{/if}
</span>
{#if errorText}
<button
type="button"
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
title="Copy full error"
title={$t('ai_assistant.copy_full_error')}
onclick={() => copyToClipboard(errorText)}
>
<CopyIcon size={11} />
@@ -134,68 +218,39 @@
{/if}
{:else if toolStatus === 'in_progress' && toolName}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
<span>{getToolLabel(toolName)}</span>
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
{:else}
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
{#if toolName}
<span class="ui:text-muted-foreground">
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
</span>
{:else}
<span class="ui:text-muted-foreground">{$t('ai_assistant.generic_working')}</span>
{/if}
{/if}
</div>
{/if}
{/if}
{/each}
{/each}
{#each deferredPlanParts as part, partIndex (partIndex)}
{@const toolName = getAgentToolName(part)}
{@const toolResult = getAgentToolResult(part)}
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<div class="mt-3">
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
</div>
{:else}
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
{#if toolStatus === 'completed' && toolName}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
<span>{getToolResultLabel(toolName, toolResult)}</span>
{:else if toolStatus === 'failed' && toolName}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
</span>
{#if errorText}
<button
type="button"
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
title="Copy full error"
onclick={() => copyToClipboard(errorText)}
>
<CopyIcon size={11} />
</button>
{/if}
{:else if toolStatus === 'in_progress' && toolName}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
<span>{getToolLabel(toolName)}</span>
{:else}
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
{/if}
{#if message.role === 'assistant' && tokenUsage}
<div class="mt-1 flex justify-end">
<span class="ui:text-muted-foreground text-[10px]">
{tokenUsage.totalTokens.toLocaleString()}
{$t('ai_assistant.tokens_label')}
</span>
</div>
{/if}
{/each}
{#if message.role === 'assistant' && tokenUsage}
<div class="mt-1 flex justify-end">
<span class="ui:text-muted-foreground text-[10px]">
{tokenUsage.totalTokens.toLocaleString()}
{$t('ai_assistant.tokens_label')}
</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
/* Reset global `apps/dashboard/src/app.css` `.prose p { mb-4 }` for chat bubbles */
:global(.ai-chat-prose.prose p) {
margin-bottom: 0;
}
:global(.mention-link) {
display: inline;
cursor: pointer;
@@ -207,10 +262,16 @@
transition: opacity 0.15s;
}
:global([data-role='user'] a) {
:global([data-role='user'] a),
:global([data-role='user'] .prose a) {
color: var(--primary-foreground);
}
:global([data-role='user'] .prose code:not(pre code)) {
color: rgb(9 9 11);
background-color: rgb(255 255 255 / 0.95);
}
:global(.mention-link:hover) {
opacity: 0.8;
}
@@ -0,0 +1,22 @@
import type { Component } from 'svelte';
import {
AI_COURSE_CHAT_DEFAULT_WIDTH,
AI_COURSE_CHAT_MAX_WIDTH,
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
} from './utils/constants';
import { AI_ASSISTANT_PANEL_ID } from './utils/store';
import type { SidePanelDefinition } from '$features/side-panel';
import AiCourseChat from './ai-course-chat.svelte';
export const aiAssistantPanelDefinition: SidePanelDefinition = {
id: AI_ASSISTANT_PANEL_ID,
titleKey: 'side_panel.titles.ai_assistant',
scope: 'course',
component: AiCourseChat as Component<Record<string, unknown>>,
defaultWidth: AI_COURSE_CHAT_DEFAULT_WIDTH,
minWidth: AI_COURSE_CHAT_MIN_WIDTH,
maxWidth: AI_COURSE_CHAT_MAX_WIDTH,
widthStorageKey: AI_COURSE_CHAT_STORAGE_KEY
};
@@ -4,8 +4,11 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import PencilIcon from '@lucide/svelte/icons/pencil';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { SvelteSet } from 'svelte/reactivity';
import { t } from '$lib/utils/functions/translations';
import type { CoursePlan } from './utils/course-plan';
/** Token cost estimation heuristics (tokens per item) */
@@ -18,11 +21,31 @@
interface Props {
plan: CoursePlan;
onImplement: (editedPlan: CoursePlan) => void;
onAskChanges: () => void;
onAskChanges: (message: string) => void;
/** When true, disables the inline change-request composer (agent is already working). */
isBusy?: boolean;
remainingTokens?: number;
}
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
let changeRequest = $state('');
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
function submitChangeRequest() {
if (!canSendChange) return;
const message = changeRequest.trim();
changeRequest = '';
onAskChanges(message);
}
function handleChangeKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submitChangeRequest();
}
}
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
@@ -237,9 +260,29 @@
</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => onImplement(editablePlan)} class="flex-1">Implement Plan</Button>
<Button size="sm" variant="outline" onclick={onAskChanges} class="flex-1">Ask for Changes</Button>
<div class="flex flex-col gap-2">
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
{$t('ai_assistant.plan_implement')}
</Button>
<div class="flex items-end gap-2">
<Textarea
bind:value={changeRequest}
placeholder={$t('ai_assistant.plan_ask_changes_placeholder')}
rows={1}
disabled={isBusy}
onkeydown={handleChangeKeydown}
class="ui:min-h-9! ui:resize-none ui:py-2 ui:text-sm"
/>
<Button
size="icon-sm"
variant="default"
disabled={!canSendChange}
onclick={submitChangeRequest}
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
>
<ArrowUpIcon class="ui:size-4" />
</Button>
</div>
</div>
</div>
</div>
@@ -2,27 +2,42 @@
import CheckIcon from '@lucide/svelte/icons/check';
import LoaderIcon from '@lucide/svelte/icons/loader';
import CircleIcon from '@lucide/svelte/icons/circle';
import SquareIcon from '@lucide/svelte/icons/square';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import SquareIcon from '@lucide/svelte/icons/square';
import { Button } from '@cio/ui/base/button';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import { t } from '$lib/utils/functions/translations';
interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
interface Props {
title: string;
titleKey: string;
steps: ProgressStep[];
currentAction?: string;
courseId: string;
/** Same handler as `@mention` clicks (SPA navigation into lesson / exercise). */
onNavigate: (route: string) => void;
currentActionLine?: ToolLineUi;
onStop?: () => void;
isStopped?: boolean;
error?: string;
}
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
function stepRowKey(step: ProgressStep, index: number): string {
if (step.line.shape === 'i18n') {
return `${index}-${step.line.key}-${JSON.stringify(step.line.vars ?? {})}`;
}
if (step.line.shape === 'lesson_written') {
return `${index}-lesson-${step.line.lessonId}-${step.line.charCount}-${step.line.title}`;
}
if (step.line.shape === 'landing_page_updated') {
return `${index}-landing-${step.line.title}`;
}
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
}
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
const totalCount = $derived(steps.length);
@@ -30,14 +45,14 @@
<div class="ui:bg-background rounded-lg border">
<div class="border-b px-3 py-2">
<h4 class="text-sm font-semibold">{title}</h4>
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
<p class="ui:text-muted-foreground text-xs">
{completedCount} / {totalCount} steps completed
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
</p>
</div>
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
{#each steps as step, i (`${i}-${step.label}`)}
{#each steps as step, i (stepRowKey(step, i))}
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
{#if step.status === 'completed'}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
@@ -49,7 +64,7 @@
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
{/if}
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
{step.label}
<ToolLine line={step.line} {courseId} {onNavigate} />
</span>
</div>
{/each}
@@ -60,8 +75,10 @@
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
{/if}
{#if currentAction && !isStopped}
<p class="ui:text-muted-foreground text-xs">{currentAction}</p>
{#if currentActionLine && !isStopped}
<div class="ui:text-muted-foreground text-xs leading-snug">
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
</div>
{/if}
{#if isStopped}
@@ -71,7 +88,7 @@
{#if onStop && !isStopped && completedCount < totalCount}
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
<SquareIcon size={12} class="mr-1" />
Stop
{$t('ai_assistant.stop')}
</Button>
{/if}
</div>
@@ -0,0 +1,254 @@
<script lang="ts">
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
import { getCourseTemplate } from '@cio/ai-assistant';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { t } from '$lib/utils/functions/translations';
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
interface Props {
templateId: CourseTemplateId;
fields: TemplateFormField[];
allMessages: AiAssistantMessage[];
submitted: boolean;
/** True while the `ask_template_questions` tool is still in progress (not the global chat busy flag). */
disableFormInputs: boolean;
onSubmit: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkip: (payload: { templateId: CourseTemplateId }) => void;
}
let { templateId, fields, allMessages, submitted, disableFormInputs, onSubmit, onSkip }: Props = $props();
const display = $derived(DISPLAY_BY_ID[templateId]);
let answers = $state<Record<string, string>>({});
$effect.pre(() => {
void templateId;
void fields;
const next: Record<string, string> = {};
for (const field of fields ?? []) {
if (!field?.id) {
continue;
}
if (field.type === 'select') {
next[field.id] = field.options?.[0]?.value ?? '';
} else {
next[field.id] = '';
}
}
answers = next;
});
const resolutionKind = $derived.by((): 'submitted' | 'skipped' | null => {
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
const message = allMessages[index];
if (message.role !== 'user') {
continue;
}
const tmpl = message.metadata?.template;
if (!tmpl || !('action' in tmpl)) {
continue;
}
if (tmpl.templateId !== templateId) {
continue;
}
if (tmpl.action === 'submit_template_answers') {
return 'submitted';
}
if (tmpl.action === 'skip_template_form') {
return 'skipped';
}
}
return null;
});
function resolveFieldLabel(field: TemplateFormField): string {
const key = display?.fieldLabelKeys?.[field.id];
if (key) {
const translated = t.get(key);
if (translated && translated !== key) {
return translated;
}
}
return field.label;
}
function resolveFormTitle(): string {
const key = display?.formTitleKey;
if (!key) {
return getCourseTemplate(templateId)?.formTitle ?? '';
}
const translated = t.get(key);
if (translated && translated !== key) {
return translated;
}
return getCourseTemplate(templateId)?.formTitle ?? '';
}
function isValidHttpUrl(value: string): boolean {
if (!value.trim()) {
return true;
}
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const canSubmit = $derived.by(() => {
if (submitted || disableFormInputs) {
return false;
}
for (const field of fields ?? []) {
if (!field?.id) {
continue;
}
const raw = answers[field.id] ?? '';
const trimmed = raw.trim();
if (field.type === 'select' && !field.options?.length) {
return false;
}
if (field.required && !trimmed) {
return false;
}
if (field.type === 'url' && trimmed && !isValidHttpUrl(trimmed)) {
return false;
}
}
return true;
});
function handleSubmit() {
if (!canSubmit) {
return;
}
onSubmit({ templateId, answers: { ...answers }, fields });
}
</script>
<div class="ui:bg-card max-w-md rounded-xl border p-4">
<h4 class="ui:text-foreground mb-3 text-sm font-medium">{resolveFormTitle()}</h4>
{#if submitted}
<p class="ui:text-muted-foreground text-sm">
{resolutionKind === 'skipped'
? $t('course.creator.template.skipped_summary')
: $t('course.creator.template.submitted_summary')}
</p>
{:else}
<div class="flex flex-col gap-4">
{#each fields as field (field.id)}
{#if field.type === 'text'}
<InputField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
isRequired={field.required === true}
isDisabled={disableFormInputs}
/>
{:else if field.type === 'textarea'}
<TextareaField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
isRequired={field.required === true}
disabled={disableFormInputs}
rows={4}
/>
{:else if field.type === 'select'}
<Field.Field>
<Field.Label>
{resolveFieldLabel(field)}
{#if field.required}
<span class="ui:text-red-700">*</span>
{/if}
</Field.Label>
<Select.Root type="single" bind:value={answers[field.id]} disabled={disableFormInputs}>
<Select.Trigger class="ui:w-full">
{(field.options ?? []).find((option) => option.value === answers[field.id])?.label ?? ''}
</Select.Trigger>
<Select.Content style="z-index: 251">
{#each field.options ?? [] as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
{:else if field.type === 'url'}
<div class="flex flex-col gap-1">
<div class="flex items-start gap-2">
<GlobeIcon class="ui:text-muted-foreground mt-2.5 size-4 shrink-0" />
<div class="min-w-0 flex-1">
<InputField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
type="url"
isRequired={false}
isDisabled={disableFormInputs}
/>
</div>
</div>
<p class="ui:text-muted-foreground pl-7 text-xs">
{$t('course.creator.template.field.documentation_url.hint')}
</p>
</div>
{/if}
{/each}
<div class="flex flex-col gap-2">
<Button size="sm" disabled={!canSubmit} onclick={handleSubmit}>
{$t('course.creator.template.submit')}
</Button>
<button
type="button"
class="ui:text-muted-foreground hover:ui:text-foreground text-xs underline-offset-2 hover:underline"
disabled={disableFormInputs}
onclick={() => onSkip({ templateId })}
>
{$t('course.creator.template.skip')}
</button>
</div>
</div>
{/if}
</div>
@@ -1,27 +1,29 @@
<script lang="ts">
import LoaderIcon from '@lucide/svelte/icons/loader';
import CheckIcon from '@lucide/svelte/icons/check';
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import { getCompletedToolLine, getPendingToolLine } from '$features/ai-assistant/utils/tool-labels';
interface Props {
toolName: string;
state: string;
result?: unknown;
/** Required for linked lesson/exercise summaries in ToolLine rows */
courseId: string;
onNavigate?: (route: string) => void;
}
let { toolName, state, result }: Props = $props();
let { toolName, state, result, courseId, onNavigate = () => {} }: Props = $props();
</script>
<div class="ui:bg-background/50 flex items-center gap-2 rounded border px-2 py-1 text-xs">
{#if state === 'call' || state === 'partial-call'}
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">
{getToolLabel(toolName)}...
<span class="ui:text-muted-foreground inline-flex flex-wrap gap-x-1">
<ToolLine line={getPendingToolLine(toolName)} {courseId} {onNavigate} />…
</span>
{:else if state === 'result'}
<CheckIcon size={12} class="ui:text-primary" />
<span>
{getToolResultLabel(toolName, result)}
</span>
<span><ToolLine line={getCompletedToolLine(toolName, result)} {courseId} {onNavigate} /></span>
{/if}
</div>
@@ -1,5 +1,52 @@
/** When set (e.g. to `1`), the streaming "don't reload" banner stays hidden across sessions */
export const AI_AGENT_RUNNING_WARNING_DISMISSED_KEY = 'classroomio-ai-agent-running-warning-dismissed';
export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360;
export const AI_COURSE_CHAT_MIN_WIDTH = 280;
export const AI_COURSE_CHAT_MAX_WIDTH = 520;
export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width';
export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model';
/** Display via `$t(entry.key)`; `prompt` is sent to the model (English). */
export const AI_ASSISTANT_QUICK_ACTION_ENTRIES = [
{
key: 'ai_assistant.quick_action.upload_document_course',
prompt: 'Upload a document to create a course'
},
{
key: 'ai_assistant.quick_action.draft_lesson',
prompt: 'Draft a lesson'
},
{
key: 'ai_assistant.quick_action.generate_questions_from_lesson',
prompt: 'Generate questions from this lesson'
},
{
key: 'ai_assistant.quick_action.summarize_lesson',
prompt: 'Summarize this lesson'
},
{
key: 'ai_assistant.quick_action.publish_course',
prompt: 'Help me publish this course'
}
] as const;
/** Student-facing quick-action chips. Display via `$t(entry.key)`; `prompt` is sent to the model (English). */
export const STUDENT_QUICK_ACTION_ENTRIES = [
{
key: 'ai_assistant.student_quick_action.explain_lesson',
prompt: 'Explain this lesson'
},
{
key: 'ai_assistant.student_quick_action.quiz_me',
prompt: 'Quiz me on this'
},
{
key: 'ai_assistant.student_quick_action.summarize_section',
prompt: 'Summarize this section'
},
{
key: 'ai_assistant.student_quick_action.what_to_review',
prompt: 'What should I review next?'
}
] as const;
@@ -0,0 +1,115 @@
import type { AiAssistantMessage, AiAssistantMessageMetadata } from './types';
const APPROX_CHARS_PER_TOKEN = 4;
const MAX_TOOL_OUTPUT_CHARS = 2000;
export interface ContextUsage {
/** Tokens used by the latest model request, or an estimate before the first response */
usedTokens: number;
/** Maximum context window size for the model */
maxTokens: number;
/** Percentage of context used (0-100) */
percentage: number;
/** Whether the context is nearly full (>= threshold) */
isNearlyFull: boolean;
/** Whether the context is completely full (>= 100%) */
isFull: boolean;
}
/** Threshold percentage at which we consider context "nearly full" */
export const CONTEXT_NEARLY_FULL_THRESHOLD = 90;
/** Threshold percentage at which we consider context "full" and block input */
export const CONTEXT_FULL_THRESHOLD = 95;
function getSerializedLength(value: unknown): number {
if (value == null) {
return 0;
}
if (typeof value === 'string') {
return value.length;
}
try {
return JSON.stringify(value).length;
} catch {
return 0;
}
}
function estimatePartChars(part: unknown): number {
if (!part || typeof part !== 'object') {
return getSerializedLength(part);
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'step-start') {
return 0;
}
if (type.startsWith('tool-')) {
const outputChars =
record.state === 'output-available' ? Math.min(getSerializedLength(record.output), MAX_TOOL_OUTPUT_CHARS) : 0;
return type.length + getSerializedLength(record.input) + outputChars + getSerializedLength(record.errorText);
}
if (typeof record.text === 'string') {
return record.text.length;
}
return getSerializedLength(record);
}
function estimateContextTokens(messages: AiAssistantMessage[]): number {
const chars = messages.reduce((messageTotal, message) => {
const parts = Array.isArray(message.parts) ? message.parts : [];
const partsChars = parts.reduce((partTotal, part) => partTotal + estimatePartChars(part), 0);
return messageTotal + message.role.length + partsChars;
}, 0);
return Math.ceil(chars / APPROX_CHARS_PER_TOKEN);
}
/**
* Tracks the latest provider-reported request size as the context guard.
* Do not sum historical usage: every assistant turn can include a full prompt
* replay, so summing turns overstates current context. The character estimate
* is only a fallback before the first provider usage report exists.
*/
export function calculateContextUsage(messages: AiAssistantMessage[], contextWindow: number): ContextUsage {
const latestTokenUsage = [...messages]
.reverse()
.map((message) => (message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage)
.find((tokenUsage) => tokenUsage !== undefined);
const usedTokens = latestTokenUsage?.totalTokens ?? estimateContextTokens(messages);
const percentage = contextWindow > 0 ? Math.min(100, Math.round((usedTokens / contextWindow) * 100)) : 0;
return {
usedTokens,
maxTokens: contextWindow,
percentage,
isNearlyFull: percentage >= CONTEXT_NEARLY_FULL_THRESHOLD,
isFull: percentage >= CONTEXT_FULL_THRESHOLD
};
}
/**
* Formats token count for display (e.g., "125K" for 125000)
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
if (tokens >= 1_000) {
return `${Math.round(tokens / 1_000)}K`;
}
return tokens.toString();
}
@@ -1,23 +1,36 @@
type MentionType = 'lesson' | 'exercise' | 'section';
type MentionType = 'lesson' | 'exercise' | 'section' | 'landingpage';
/**
* Mention format: @[Title](type:id)
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
* Landing page: @[Title](landingpage) or @[Title](landingpage:COURSE_ID) — resolves to this course's landing page editor.
*/
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
const RAW_LANDINGPAGE_MENTION_REGEX = /@\[([^\]]+)\]\(landingpage(?::[a-zA-Z0-9_-]+)?\)/gi;
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
const HTML_LINK_LANDINGPAGE_MENTION_REGEX = /@?<a\b[^>]*href=(["'])landingpage(?::[a-zA-Z0-9_-]+)?\1[^>]*>(.*?)<\/a>/gi;
function normalizeMentionType(type: string): MentionType | null {
const normalizedType = type.toLowerCase();
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
if (
normalizedType === 'lesson' ||
normalizedType === 'exercise' ||
normalizedType === 'section' ||
normalizedType === 'landingpage'
) {
return normalizedType;
}
return null;
}
function getLandingPageEditorRoute(courseId: string): string {
return `/courses/${courseId}/landingpage`;
}
function getMentionRoute(courseId: string, type: MentionType, id: string) {
if (type === 'landingpage') return getLandingPageEditorRoute(courseId);
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
@@ -38,11 +51,19 @@ function buildMentionLink(route: string, label: string, title: string) {
}
/**
* Replace AI lesson/exercise mentions with real in-app links.
* Replace AI lesson, exercise, section, and landing-page mentions with in-app links.
* Handles both raw mention syntax and markdown-generated anchor tags.
*/
export function renderMentions(html: string, courseId: string): string {
const linksWithRoutes = html.replace(
const landingHtmlRoutes = html.replace(
HTML_LINK_LANDINGPAGE_MENTION_REGEX,
(match, _quote: string, label: string) => {
const route = getLandingPageEditorRoute(courseId);
return buildMentionLink(route, label, 'landingpage');
}
);
const linksWithRoutes = landingHtmlRoutes.replace(
HTML_LINK_MENTION_REGEX,
(match, _quote: string, type: string, id: string, label: string) => {
const mentionType = normalizeMentionType(type);
@@ -54,7 +75,12 @@ export function renderMentions(html: string, courseId: string): string {
}
);
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
const withLandingRaw = linksWithRoutes.replace(RAW_LANDINGPAGE_MENTION_REGEX, (match, title: string) => {
const route = getLandingPageEditorRoute(courseId);
return buildMentionLink(route, escapeHtml(title), `landingpage: ${title}`);
});
return withLandingRaw.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
const mentionType = normalizeMentionType(type);
if (!mentionType) return match;
@@ -1,19 +1,34 @@
import { writable } from 'svelte/store';
import type { AgentModelId } from '@cio/utils/agent-models';
import type { CourseTemplateId } from '@cio/ai-assistant';
import { sidePanel } from '$features/side-panel';
/** Controls whether the AI assistant right sidebar Sheet is open. */
export const isAiAssistantOpen = writable(false);
/**
* AI assistant is one of several side-panel apps. These helpers proxy to the
* generic side-panel store so existing call sites keep working.
*/
export const AI_ASSISTANT_PANEL_ID = 'ai-assistant';
export function openAiAssistant() {
isAiAssistantOpen.set(true);
sidePanel.open(AI_ASSISTANT_PANEL_ID);
}
export function closeAiAssistant() {
isAiAssistantOpen.set(false);
sidePanel.close();
}
export function toggleAiAssistant() {
isAiAssistantOpen.update((open) => !open);
sidePanel.toggle(AI_ASSISTANT_PANEL_ID);
}
export const initialChatTemplateId = writable<CourseTemplateId | null>(null);
export function setInitialChatTemplateId(id: CourseTemplateId) {
initialChatTemplateId.set(id);
}
export function clearInitialChatTemplateId() {
initialChatTemplateId.set(null);
}
/** Carries a prompt from the home page course creator into the AI chat on first open. */
@@ -35,3 +50,48 @@ export function clearInitialChatPrompt() {
export function clearInitialChatModel() {
initialChatModel.set(null);
}
/**
* Pending composer action picked up by the chat component:
* - `append` adds the text to whatever is already in the input (panel was open).
* - `new` starts a fresh conversation and replaces the input (panel was closed).
*/
export type ChatDraft = { text: string; mode: 'append' | 'new' };
export const chatDraft = writable<ChatDraft | null>(null);
export function clearChatDraft() {
chatDraft.set(null);
}
export function setChatDraft(draft: ChatDraft) {
chatDraft.set(draft);
}
function mdQuote(body: string) {
return body
.trim()
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
/**
* Quote `text` into the assistant. If the panel is already open the quote is
* appended to the current draft; otherwise a new conversation is opened.
*/
export function quoteInChat(text: string) {
const quoted = mdQuote(text);
if (!quoted.trim()) {
return;
}
const isOpen = sidePanel.activePanelId === AI_ASSISTANT_PANEL_ID;
chatDraft.set({ text: quoted, mode: isOpen ? 'append' : 'new' });
if (!isOpen) {
openAiAssistant();
}
}
@@ -0,0 +1,50 @@
import type { CourseTemplateId } from '@cio/ai-assistant';
export type CourseTemplateDisplay = {
iconName: 'GraduationCap' | 'Compass' | 'Award';
titleKey: string;
descriptionKey: string;
formTitleKey: string;
fieldLabelKeys: Record<string, string>;
};
export const DISPLAY_BY_ID: Record<CourseTemplateId, CourseTemplateDisplay> = {
product_101: {
iconName: 'GraduationCap',
titleKey: 'course.creator.template.product_101.title',
descriptionKey: 'course.creator.template.product_101.description',
formTitleKey: 'course.creator.template.product_101.form_title',
fieldLabelKeys: {
product_name: 'course.creator.template.field.product_name',
product_summary: 'course.creator.template.field.product_summary',
audience: 'course.creator.template.field.audience',
outcome: 'course.creator.template.field.outcome',
depth: 'course.creator.template.field.depth',
documentation_url: 'course.creator.template.field.documentation_url'
}
},
product_onboarding: {
iconName: 'Compass',
titleKey: 'course.creator.template.product_onboarding.title',
descriptionKey: 'course.creator.template.product_onboarding.description',
formTitleKey: 'course.creator.template.product_onboarding.form_title',
fieldLabelKeys: {
product_name: 'course.creator.template.field.product_name',
audience: 'course.creator.template.field.audience',
outcome: 'course.creator.template.field.outcome',
documentation_url: 'course.creator.template.field.documentation_url'
}
},
expert_on_x: {
iconName: 'Award',
titleKey: 'course.creator.template.expert_on_x.title',
descriptionKey: 'course.creator.template.expert_on_x.description',
formTitleKey: 'course.creator.template.expert_on_x.form_title',
fieldLabelKeys: {
topic: 'course.creator.template.field.topic',
expertise_level: 'course.creator.template.field.expertise_level',
outcome: 'course.creator.template.field.outcome',
documentation_url: 'course.creator.template.field.documentation_url'
}
}
};
@@ -0,0 +1,58 @@
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
/**
* The agent occasionally produces `ask_template_questions` payloads where a select field
* has lost options, fewer options than the registry, or different option labels. The
* registry is the source of truth for what the teacher should see; merge over agent output
* so the form always matches our pedagogy + UI expectations.
*/
export function mergeTemplateFieldsWithRegistry(
templateId: CourseTemplateId,
agentFields: TemplateFormField[] | undefined
): TemplateFormField[] {
const registry = getCourseTemplate(templateId);
const registryFields = registry?.fields ?? [];
if (!agentFields || agentFields.length === 0) {
return registryFields;
}
const registryById = new Map(registryFields.map((field) => [field.id, field]));
// Agent occasionally emits fields with missing/empty id, or duplicates; clean both so {#each} keying stays unique.
const seenIds = new Set<string>();
const validAgentFields = agentFields.filter((field): field is TemplateFormField => {
if (typeof field?.id !== 'string' || field.id.length === 0) return false;
if (seenIds.has(field.id)) return false;
seenIds.add(field.id);
return true;
});
return validAgentFields.map((agentField) => {
const canonical = registryById.get(agentField.id);
if (!canonical) {
return agentField;
}
if (canonical.type === 'select') {
return {
...agentField,
type: 'select',
label: canonical.label,
required: canonical.required,
placeholder: canonical.placeholder,
options: canonical.options
} as TemplateFormField;
}
return {
...agentField,
type: canonical.type,
label: canonical.label,
required: canonical.required,
placeholder: canonical.placeholder
} as TemplateFormField;
});
}
@@ -0,0 +1,22 @@
import type { CourseTemplateId } from '@cio/ai-assistant';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
export function isTemplateFormResolved(messages: AiAssistantMessage[], templateId: CourseTemplateId): boolean {
return messages.some((message) => {
if (message.role !== 'user') {
return false;
}
const meta = message.metadata as AiAssistantMessageMetadata | undefined;
const tmpl = meta?.template;
if (!tmpl || !('action' in tmpl)) {
return false;
}
return (
(tmpl.action === 'submit_template_answers' || tmpl.action === 'skip_template_form') &&
tmpl.templateId === templateId
);
});
}
@@ -1,80 +1,269 @@
const TOOL_LABELS: Record<string, string> = {
get_course_structure: 'Reading course structure',
get_lesson_content: 'Reading lesson content',
get_exercise_details: 'Reading exercise',
create_section: 'Creating section',
update_section: 'Updating section',
create_lesson: 'Creating lesson',
update_lesson: 'Updating lesson',
update_lesson_content: 'Writing lesson content',
create_exercise: 'Creating exercise',
create_exercise_section: 'Creating exercise section',
update_exercise: 'Updating exercise',
update_exercise_section: 'Updating exercise section',
add_questions: 'Adding questions',
update_questions: 'Updating questions',
reorder_content: 'Reordering content',
update_course_landing_page: 'Updating landing page',
check_course_go_live_readiness: 'Checking go-live readiness',
go_live_course: 'Publishing course',
generate_course_plan: 'Generating course plan'
};
export type AgentToolProgressStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export function getToolLabel(toolName: string): string {
return TOOL_LABELS[toolName] || toolName;
/** Render in UI via `ToolLine` / `$t` — no user-facing literals in `.ts`. */
export type ToolLineUi =
| { shape: 'i18n'; key: string; vars?: Record<string, string | number> }
| { shape: 'lesson_written'; lessonId: string; title: string; charCount: number }
| {
shape: 'exercise_questions';
exerciseId: string;
title: string;
count: number;
action: 'added' | 'updated';
}
/** Course landing page editor (same path as sidebar "Landing page"). Title may be empty. */
| { shape: 'landing_page_updated'; title: string };
export interface ProgressStep {
status: AgentToolProgressStatus;
indent?: boolean;
line: ToolLineUi;
}
export function getToolResultLabel(toolName: string, result: unknown): string {
const r = result as Record<string, unknown>;
const TOOLS_WITH_PENDING_COPY = new Set([
'get_course_structure',
'get_lesson_content',
'get_exercise_details',
'create_section',
'update_section',
'create_lesson',
'update_lesson',
'update_lesson_content',
'create_exercise',
'create_exercise_section',
'update_exercise',
'update_exercise_section',
'add_questions',
'update_questions',
'reorder_content',
'update_course_landing_page',
'check_course_go_live_readiness',
'go_live_course',
'generate_course_plan',
'ask_template_questions',
'fetch_documentation_url'
]);
/** i18n key for the running / pending description of `toolName` */
export function getPendingToolI18nKey(toolName: string): string {
if (TOOLS_WITH_PENDING_COPY.has(toolName)) {
return `ai_assistant.tool.pending.${toolName}`;
}
return 'ai_assistant.tool.pending.unknown_tool';
}
export function getPendingToolI18nVars(toolName: string): Record<string, string | number | undefined> | undefined {
if (TOOLS_WITH_PENDING_COPY.has(toolName)) {
return undefined;
}
return { toolName };
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const v = record[key];
return typeof v === 'string' ? v : undefined;
}
function prettifyUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '');
return `${parsed.host}${path}${parsed.search}`;
} catch {
return rawUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
}
function readPositiveInt(record: Record<string, unknown>, key: string): number {
const v = record[key];
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
}
export function getCompletedToolLine(toolName: string, result: unknown): ToolLineUi {
const r = (result ?? {}) as Record<string, unknown>;
switch (toolName) {
case 'create_section':
return `Created section: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_section':
return `Updated section: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'create_lesson':
return `Created lesson: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_lesson',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_lesson':
return `Updated lesson: ${r.title}`;
case 'update_lesson_content':
return `Wrote ${r.contentLength} chars`;
case 'create_exercise':
return `Created exercise with ${r.questionCount} questions`;
case 'update_exercise':
return `Updated exercise: ${r.title}`;
case 'update_exercise_section':
return `Updated exercise section: ${r.title}`;
case 'add_questions':
return `Added ${r.addedCount} questions`;
case 'update_questions':
return `Updated ${r.updatedCount} questions`;
case 'get_course_structure':
return 'Course structure loaded';
case 'get_lesson_content':
return 'Lesson content loaded';
case 'get_exercise_details':
return 'Exercise loaded';
case 'reorder_content':
return 'Content reordered';
case 'update_course_landing_page':
return `Updated landing page: ${r.title}`;
case 'check_course_go_live_readiness': {
const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0;
const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_lesson',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_lesson_content': {
const lessonId = readString(r, 'lessonId') ?? '';
const rawTitle = readString(r, 'lessonTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const contentLength = readPositiveInt(r, 'contentLength');
return blockerCount === 0
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
: `${blockerCount} blockers before go-live`;
if (lessonId && displayTitle) {
return { shape: 'lesson_written', lessonId, title: displayTitle, charCount: contentLength };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_lesson_content_fallback',
vars: { count: contentLength }
};
}
case 'create_exercise': {
const count = readPositiveInt(r, 'questionCount');
const title = readString(r, 'title') ?? '';
const exerciseId = readString(r, 'id') ?? '';
if (exerciseId && title) {
return { shape: 'exercise_questions', exerciseId, title, count, action: 'added' };
}
return { shape: 'i18n', key: 'ai_assistant.tool.done.create_exercise', vars: { title, count } };
}
case 'update_exercise':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_exercise',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_exercise_section':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_exercise_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'add_questions': {
const exerciseId = readString(r, 'exerciseId') ?? '';
const rawTitle = readString(r, 'exerciseTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const addedCount = readPositiveInt(r, 'addedCount');
if (exerciseId && displayTitle) {
return { shape: 'exercise_questions', exerciseId, title: displayTitle, count: addedCount, action: 'added' };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.add_questions_fallback',
vars: { count: addedCount }
};
}
case 'update_questions': {
const exerciseId = readString(r, 'exerciseId') ?? '';
const rawTitle = readString(r, 'exerciseTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const updatedCount = readPositiveInt(r, 'updatedCount');
if (exerciseId && displayTitle) {
return { shape: 'exercise_questions', exerciseId, title: displayTitle, count: updatedCount, action: 'updated' };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_questions_fallback',
vars: { count: updatedCount }
};
}
case 'create_exercise_section':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_exercise_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'get_course_structure':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_course_structure' };
case 'get_lesson_content':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_lesson_content' };
case 'get_exercise_details':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_exercise_details' };
case 'reorder_content':
return { shape: 'i18n', key: 'ai_assistant.tool.done.reorder_content' };
case 'update_course_landing_page':
return { shape: 'landing_page_updated', title: readString(r, 'title')?.trim() ?? '' };
case 'check_course_go_live_readiness': {
const blockers = r.blockers;
const blockerCount = Array.isArray(blockers) ? blockers.length : 0;
const warnings = r.warnings;
const warningCount = Array.isArray(warnings) ? warnings.length : 0;
if (blockerCount === 0) {
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.check_go_live_ready',
vars: { warningCount }
};
}
return { shape: 'i18n', key: 'ai_assistant.tool.done.check_go_live_blocked', vars: { blockerCount } };
}
case 'go_live_course': {
const isPublished = r.isPublished === true;
return isPublished
? {
shape: 'i18n',
key: 'ai_assistant.tool.done.go_live_published',
vars: { title: readString(r, 'title') ?? '' }
}
: { shape: 'i18n', key: 'ai_assistant.tool.done.go_live_not_published' };
}
case 'go_live_course':
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
case 'generate_course_plan':
return 'Course plan generated';
return { shape: 'i18n', key: 'ai_assistant.tool.done.generate_course_plan' };
case 'ask_template_questions':
return { shape: 'i18n', key: 'ai_assistant.tool.done.ask_template_questions' };
case 'fetch_documentation_url': {
const rawUrl = readString(r, 'url') ?? '';
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.fetch_documentation_url',
vars: { url: rawUrl ? prettifyUrl(rawUrl) : '' }
};
}
default:
return 'Done';
return { shape: 'i18n', key: 'ai_assistant.tool.done.generic' };
}
}
export function getPendingToolLine(toolName: string, input?: unknown): ToolLineUi {
if (toolName === 'fetch_documentation_url') {
const rawUrl =
typeof input === 'object' && input !== null ? readString(input as Record<string, unknown>, 'url') : undefined;
if (rawUrl) {
return {
shape: 'i18n',
key: 'ai_assistant.tool.pending.fetch_documentation_url_with_url',
vars: { url: prettifyUrl(rawUrl) }
};
}
}
return {
shape: 'i18n',
key: getPendingToolI18nKey(toolName),
vars: getPendingToolI18nVars(toolName) as Record<string, string | number> | undefined
};
}
export const MUTATION_TOOLS = [
'create_section',
'update_section',
@@ -91,9 +280,3 @@ export const MUTATION_TOOLS = [
'update_course_landing_page',
'go_live_course'
];
export interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
@@ -0,0 +1,92 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import { t } from '$lib/utils/functions/translations';
interface Props {
line: ToolLineUi;
courseId: string;
onNavigate: (route: string) => void;
}
let { line, courseId, onNavigate }: Props = $props();
function lessonPath(lessonId: string): string {
return resolve(`/courses/${courseId}/lessons/${lessonId}`, {});
}
function exercisePath(exerciseId: string): string {
return resolve(`/courses/${courseId}/exercises/${exerciseId}`, {});
}
function landingPageEditorPath(): string {
return resolve(`/courses/${courseId}/landingpage`, {});
}
</script>
{#if line.shape === 'i18n'}
{$t(line.key, line.vars ?? {})}
{:else if line.shape === 'lesson_written'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(lessonPath(line.lessonId))}
>
{line.title}
</button>
<span class="ui:text-muted-foreground font-normal"
>{$t('ai_assistant.tool.meta.lesson_char_count', { count: line.charCount })}</span
>
</span>
{:else if line.shape === 'exercise_questions'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(exercisePath(line.exerciseId))}
>
{line.title}
</button>
<span class="ui:text-muted-foreground font-normal">
{#if line.action === 'added'}
{$t('ai_assistant.tool.meta.exercise_questions_added', { count: line.count })}
{:else}
{$t('ai_assistant.tool.meta.exercise_questions_updated', { count: line.count })}
{/if}
</span>
</span>
{:else if line.shape === 'landing_page_updated'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(landingPageEditorPath())}
>
{line.title ? line.title : $t('course.navItems.nav_landing_page')}
</button>
<span class="ui:text-muted-foreground font-normal">{$t('ai_assistant.tool.meta.landing_page_preview')}</span>
</span>
{/if}
<style>
:global(.mention-link) {
display: inline;
cursor: pointer;
font-weight: 500;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
border-radius: 0.125rem;
transition: opacity 0.15s;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
}
:global(.mention-link:hover) {
opacity: 0.85;
}
</style>
@@ -46,6 +46,10 @@ export function getAgentToolResult(part: AgentToolPart): unknown {
return part.output;
}
export function getAgentToolInput(part: AgentToolPart): unknown {
return (part as { input?: unknown }).input;
}
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
if (part.state === 'result' || part.state === 'output-available') {
return 'completed';
@@ -1,5 +1,6 @@
import type { UIMessage } from 'ai';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import type { CourseTemplateId } from '@cio/ai-assistant';
export interface UploadedDocument {
id: string;
@@ -23,10 +24,22 @@ export interface AiAssistantMessageContinuation {
finishReason?: string;
}
export type AiAssistantTemplateMetadata =
| { id: CourseTemplateId }
| { action: 'submit_template_answers'; templateId: CourseTemplateId; answers: Record<string, string> }
| { action: 'skip_template_form'; templateId: CourseTemplateId };
export interface AiAssistantCompactionMetadata {
compactedAt: string;
originalMessageCount: number;
}
export interface AiAssistantMessageMetadata {
attachment?: AiAssistantMessageAttachment;
tokenUsage?: AiAssistantMessageTokenUsage;
continuation?: AiAssistantMessageContinuation;
template?: AiAssistantTemplateMetadata;
compaction?: AiAssistantCompactionMetadata;
}
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
@@ -60,8 +73,13 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data']
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
export type AgentHistoryRenameRequest = (typeof classroomio.agent.history)[':conversationId']['$patch'];
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
export type CompactConversationRequest = (typeof classroomio.agent.history)[':conversationId']['compact']['$post'];
export type CompactConversationSuccess = Extract<InferResponseType<CompactConversationRequest>, { success: true }>;
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];
@@ -0,0 +1,116 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { ZAiTutorSettingsUpdate, ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
import { snackbar } from '$features/ui/snackbar/store';
import type {
CourseAiTutorOverride,
CourseAiTutorView,
GetCourseAiTutorRequest,
GetOrgAiTutorRequest,
OrgAiTutorSettings,
UpdateCourseAiTutorRequest,
UpdateOrgAiTutorRequest
} from '../utils/types';
class AiTutorApi extends BaseApiWithErrors {
orgSettings = $state<OrgAiTutorSettings | null>(null);
courseSettings = $state<CourseAiTutorView | null>(null);
loading = $state(false);
saving = $state(false);
async fetchOrgSettings() {
this.loading = true;
try {
await this.execute<GetOrgAiTutorRequest>({
requestFn: () => classroomio.organization['ai-tutor'].$get(),
logContext: 'fetching org AI tutor settings',
onSuccess: (response) => {
this.orgSettings = response.data;
}
});
} finally {
this.loading = false;
}
}
async updateOrgSettings(patch: Partial<OrgAiTutorSettings>) {
const result = ZAiTutorSettingsUpdate.safeParse(patch);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
this.saving = true;
try {
await this.execute<UpdateOrgAiTutorRequest>({
requestFn: () => classroomio.organization['ai-tutor'].$put({ json: result.data }),
logContext: 'updating org AI tutor settings',
onSuccess: (response) => {
this.orgSettings = response.data;
this.errors = {};
snackbar.success('aiTutor.snackbar.saved');
},
onError: (result) => {
if (typeof result !== 'string' && 'field' in result && result.field) {
this.errors[result.field] = result.error;
}
}
});
} finally {
this.saving = false;
}
}
async fetchCourseSettings(courseId: string) {
this.loading = true;
try {
await this.execute<GetCourseAiTutorRequest>({
requestFn: () => classroomio.course[':courseId']['ai-tutor'].$get({ param: { courseId } }),
logContext: 'fetching course AI tutor settings',
onSuccess: (response) => {
this.courseSettings = response.data;
}
});
} finally {
this.loading = false;
}
}
async updateCourseSettings(courseId: string, patch: CourseAiTutorOverride) {
const result = ZCourseAiTutorOverride.safeParse(patch ?? {});
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
this.saving = true;
try {
await this.execute<UpdateCourseAiTutorRequest>({
requestFn: () =>
classroomio.course[':courseId']['ai-tutor'].$put({
param: { courseId },
json: result.data
}),
logContext: 'updating course AI tutor settings',
onSuccess: (response) => {
if (this.courseSettings) {
this.courseSettings = { ...this.courseSettings, override: response.data };
}
this.errors = {};
snackbar.success('aiTutor.snackbar.saved');
}
});
} finally {
this.saving = false;
}
}
}
export const aiTutorApi = new AiTutorApi();
@@ -0,0 +1,295 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { t } from '$lib/utils/functions/translations';
import type { AiTutorSettings } from '@cio/ai-assistant/tutor-config';
interface Props {
store: Writable<AiTutorSettings>;
disabled?: boolean;
}
let { store, disabled = false }: Props = $props();
const personaOptions = [
{ value: 'encouraging', labelKey: 'aiTutor.persona.encouraging' as const },
{ value: 'friendly', labelKey: 'aiTutor.persona.friendly' as const },
{ value: 'formal', labelKey: 'aiTutor.persona.formal' as const },
{ value: 'socratic', labelKey: 'aiTutor.persona.socratic' as const },
{ value: 'custom', labelKey: 'aiTutor.persona.custom' as const }
];
const responseLengthOptions = [
{ value: 'short', labelKey: 'aiTutor.responseLength.short' as const },
{ value: 'medium', labelKey: 'aiTutor.responseLength.medium' as const },
{ value: 'long', labelKey: 'aiTutor.responseLength.long' as const }
];
const assessmentModeOptions = [
{ value: 'hint_only', labelKey: 'aiTutor.assessmentMode.hint_only' as const },
{ value: 'direct_answer', labelKey: 'aiTutor.assessmentMode.direct_answer' as const },
{ value: 'block_during_exercise', labelKey: 'aiTutor.assessmentMode.block_during_exercise' as const }
];
const codePolicyOptions = [
{ value: 'hints_only', labelKey: 'aiTutor.codePolicy.hints_only' as const },
{ value: 'allowed', labelKey: 'aiTutor.codePolicy.allowed' as const },
{ value: 'forbidden', labelKey: 'aiTutor.codePolicy.forbidden' as const }
];
const groundingScopeOptions = [
{ value: 'course', labelKey: 'aiTutor.groundingScope.course' as const },
{ value: 'lesson', labelKey: 'aiTutor.groundingScope.lesson' as const },
{ value: 'workspace', labelKey: 'aiTutor.groundingScope.workspace' as const }
];
let forbiddenTopicsInput = $state($store.forbiddenTopics.join(', '));
$effect(() => {
forbiddenTopicsInput = $store.forbiddenTopics.join(', ');
});
function syncForbiddenTopics() {
const list = forbiddenTopicsInput
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.slice(0, 20);
store.update((value) => ({ ...value, forbiddenTopics: list }));
}
</script>
<Field.Group>
<Field.Set>
<Field.Legend>{$t('aiTutor.section.availability')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.availability_description')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.enabled} {disabled} />
<Field.Label>{$t('aiTutor.field.enabled')}</Field.Label>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.personality')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.personality_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.persona')}</Field.Label>
<Select.Root type="single" bind:value={$store.persona} {disabled}>
<Select.Trigger
>{$t(
personaOptions.find((o) => o.value === $store.persona)?.labelKey ?? 'aiTutor.persona.encouraging'
)}</Select.Trigger
>
<Select.Content>
{#each personaOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
{#if $store.persona === 'custom'}
<Field.Field>
<Field.Label>{$t('aiTutor.field.customPersona')}</Field.Label>
<Textarea rows={3} bind:value={$store.customPersona} {disabled} />
<Field.Description>{$t('aiTutor.field.customPersona_description')}</Field.Description>
</Field.Field>
{/if}
<Field.Field>
<Field.Label>{$t('aiTutor.field.responseLength')}</Field.Label>
<Select.Root type="single" bind:value={$store.responseLength} {disabled}>
<Select.Trigger
>{$t(
responseLengthOptions.find((o) => o.value === $store.responseLength)?.labelKey ??
'aiTutor.responseLength.medium'
)}</Select.Trigger
>
<Select.Content>
{#each responseLengthOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.welcomeMessage')}</Field.Label>
<Textarea rows={2} maxlength={500} bind:value={$store.welcomeMessage} {disabled} />
<Field.Description>{$t('aiTutor.field.welcomeMessage_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.disclaimerFooter')}</Field.Label>
<Input maxlength={200} bind:value={$store.disclaimerFooter} {disabled} />
<Field.Description>{$t('aiTutor.field.disclaimerFooter_description')}</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.guidance')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.guidance_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.thingsToSay')}</Field.Label>
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsToSay} {disabled} />
<Field.Description>{$t('aiTutor.field.thingsToSay_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.thingsNotToSay')}</Field.Label>
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsNotToSay} {disabled} />
<Field.Description>{$t('aiTutor.field.thingsNotToSay_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.forbiddenTopics')}</Field.Label>
<Input
bind:value={forbiddenTopicsInput}
onblur={syncForbiddenTopics}
placeholder={$t('aiTutor.field.forbiddenTopics_placeholder')}
{disabled}
/>
<Field.Description>{$t('aiTutor.field.forbiddenTopics_description')}</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.blockOffTopic} {disabled} />
<Label>{$t('aiTutor.field.blockOffTopic')}</Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.profanityFilter} {disabled} />
<Label>{$t('aiTutor.field.profanityFilter')}</Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.assessment')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.assessment_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.assessmentMode')}</Field.Label>
<Select.Root type="single" bind:value={$store.assessmentMode} {disabled}>
<Select.Trigger
>{$t(
assessmentModeOptions.find((o) => o.value === $store.assessmentMode)?.labelKey ??
'aiTutor.assessmentMode.hint_only'
)}</Select.Trigger
>
<Select.Content>
{#each assessmentModeOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.revealSolutionsAfterAttempts')}</Field.Label>
<Input
type="number"
min="0"
max="20"
value={$store.revealSolutionsAfterAttempts}
oninput={(e) => {
const v = Number((e.target as HTMLInputElement).value);
if (Number.isFinite(v)) store.update((s) => ({ ...s, revealSolutionsAfterAttempts: v }));
}}
{disabled}
/>
<Field.Description>{$t('aiTutor.field.revealSolutionsAfterAttempts_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.codePolicy')}</Field.Label>
<Select.Root type="single" bind:value={$store.codePolicy} {disabled}>
<Select.Trigger
>{$t(
codePolicyOptions.find((o) => o.value === $store.codePolicy)?.labelKey ?? 'aiTutor.codePolicy.hints_only'
)}</Select.Trigger
>
<Select.Content>
{#each codePolicyOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.grounding')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.grounding_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.groundingScope')}</Field.Label>
<Select.Root type="single" bind:value={$store.groundingScope} {disabled}>
<Select.Trigger
>{$t(
groundingScopeOptions.find((o) => o.value === $store.groundingScope)?.labelKey ??
'aiTutor.groundingScope.course'
)}</Select.Trigger
>
<Select.Content>
{#each groundingScopeOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.requireCitations} {disabled} />
<Label>{$t('aiTutor.field.requireCitations')}</Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.escalation')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.escalation_description')}</Field.Description>
<Field.Group>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.escalation.enabled} {disabled} />
<Label>{$t('aiTutor.field.escalationEnabled')}</Label>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.escalationEmail')}</Field.Label>
<Input
type="email"
bind:value={$store.escalation.email}
placeholder="instructor@example.com"
disabled={disabled || !$store.escalation.enabled}
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,3 @@
export { aiTutorApi } from './api/ai-tutor.svelte';
export { default as OrgAiTutorSettingsPage } from './pages/org-settings.svelte';
export { default as CourseAiTutorSettingsPage } from './pages/course-settings.svelte';
@@ -0,0 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
let inheritFromOrg = $state(true);
let initialized = $state(false);
onMount(async () => {
await aiTutorApi.fetchCourseSettings(courseId);
const view = aiTutorApi.courseSettings;
if (view) {
formStore.set(view.effective ?? defaultAiTutorSettings);
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
}
initialized = true;
});
async function handleSave() {
if (inheritFromOrg) {
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
return;
}
const data = $formStore;
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !initialized}
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
{:else}
<Field.Group>
<Field.Set>
<Field.Legend>{$t('aiTutor.section.inheritance')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.inheritance_description')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch bind:checked={inheritFromOrg} />
<Label>{$t('aiTutor.field.inheritFromOrg')}</Label>
</Field.Field>
</Field.Set>
{#if !inheritFromOrg}
<Field.Separator />
<TutorSettingsForm store={formStore} disabled={aiTutorApi.saving} />
{/if}
</Field.Group>
{/if}
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
let initialized = $state(false);
onMount(async () => {
await aiTutorApi.fetchOrgSettings();
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
initialized = true;
});
async function handleSave() {
await aiTutorApi.updateOrgSettings($orgTutorSettingsStore);
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !initialized}
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
{:else}
<TutorSettingsForm store={orgTutorSettingsStore} disabled={aiTutorApi.saving} />
{/if}
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,30 @@
import { writable } from 'svelte/store';
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
export type AiTutorFormState = AiTutorSettings;
export const orgTutorSettingsStore = writable<AiTutorFormState>({ ...defaultAiTutorSettings });
export interface CourseTutorFormState {
override: AiTutorSettings;
inheritFromOrg: boolean;
}
export const courseTutorSettingsStore = writable<CourseTutorFormState>({
override: { ...defaultAiTutorSettings },
inheritFromOrg: true
});
export function applyOrgSettings(settings: AiTutorSettings) {
orgTutorSettingsStore.set({ ...defaultAiTutorSettings, ...settings });
}
export function applyCourseSettings(
effective: AiTutorSettings,
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null
) {
courseTutorSettingsStore.set({
override: { ...effective, ...(override ?? {}) },
inheritFromOrg: override?.inheritFromOrg !== false && override == null ? true : (override?.inheritFromOrg ?? false)
});
}
@@ -0,0 +1,27 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$get'];
export type UpdateOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$put'];
export type GetCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$get'];
export type UpdateCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$put'];
export type GetOrgAiTutorSuccess = Extract<InferResponseType<GetOrgAiTutorRequest>, { success: true }>;
export type OrgAiTutorSettings = GetOrgAiTutorSuccess['data'];
export type GetCourseAiTutorSuccess = Extract<InferResponseType<GetCourseAiTutorRequest>, { success: true }>;
export type CourseAiTutorView = GetCourseAiTutorSuccess['data'];
export type CourseAiTutorOverride = CourseAiTutorView['override'];
export type GetTutorLeaderboardRequest = (typeof classroomio.agent)['tutor-usage']['leaderboard']['$get'];
export type GetTutorSummaryRequest = (typeof classroomio.agent)['tutor-usage']['summary']['$get'];
export type GetTutorLearnerDetailRequest = (typeof classroomio.agent)['tutor-usage'][':userId']['$get'];
export type TutorLeaderboardSuccess = Extract<InferResponseType<GetTutorLeaderboardRequest>, { success: true }>;
export type TutorLeaderboardData = TutorLeaderboardSuccess['data'];
export type TutorSummarySuccess = Extract<InferResponseType<GetTutorSummaryRequest>, { success: true }>;
export type TutorSummaryData = TutorSummarySuccess['data'];
export type TutorLearnerDetailSuccess = Extract<InferResponseType<GetTutorLearnerDetailRequest>, { success: true }>;
export type TutorLearnerDetailData = TutorLearnerDetailSuccess['data'];
@@ -0,0 +1,119 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type {
CountryBreakdownData,
CountryBreakdownRequest,
CourseFunnelData,
CourseFunnelRequest,
LandingStatsData,
LandingStatsRequest,
PopularTypesData,
PopularTypesRequest
} from '../utils/types';
class AnalyticsApi extends BaseApi {
landing = $state<LandingStatsData | null>(null);
country = $state<CountryBreakdownData>([]);
funnel = $state<CourseFunnelData | null>(null);
popularTypes = $state<PopularTypesData>([]);
loadingLanding = $state(false);
loadingCountry = $state(false);
loadingFunnel = $state(false);
loadingPopularTypes = $state(false);
range = $state<7 | 30 | 90>(30);
lastFetchedOrgId = $state<string | null>(null);
lastFetchedRange = $state<7 | 30 | 90 | null>(null);
get loading() {
return this.loadingLanding || this.loadingCountry || this.loadingFunnel || this.loadingPopularTypes;
}
private query(orgId: string, bust: boolean) {
return { orgId, days: String(this.range), ...(bust && { bust: '1' }) };
}
async fetchLanding(orgId: string, bust = false) {
this.loadingLanding = true;
await this.execute<LandingStatsRequest>({
requestFn: () => classroomio.dash['landing-stats'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics landing stats',
onSuccess: (response) => {
this.landing = response.data;
}
});
this.loadingLanding = false;
}
async fetchCountry(orgId: string, bust = false) {
this.loadingCountry = true;
await this.execute<CountryBreakdownRequest>({
requestFn: () => classroomio.dash['country-breakdown'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics country breakdown',
onSuccess: (response) => {
this.country = response.data;
}
});
this.loadingCountry = false;
}
async fetchFunnel(orgId: string, bust = false) {
this.loadingFunnel = true;
await this.execute<CourseFunnelRequest>({
requestFn: () => classroomio.dash['course-funnel'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics course funnel',
onSuccess: (response) => {
this.funnel = response.data;
}
});
this.loadingFunnel = false;
}
async fetchPopularTypes(orgId: string, bust = false) {
this.loadingPopularTypes = true;
await this.execute<PopularTypesRequest>({
requestFn: () => classroomio.dash['popular-types'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics popular types',
onSuccess: (response) => {
this.popularTypes = response.data;
}
});
this.loadingPopularTypes = false;
}
/** Fire all four in parallel; each card shows its own spinner. */
fetchAll(orgId: string, bust = false) {
if (!orgId) return Promise.resolve();
this.lastFetchedOrgId = orgId;
this.lastFetchedRange = this.range;
return Promise.all([
this.fetchLanding(orgId, bust),
this.fetchCountry(orgId, bust),
this.fetchFunnel(orgId, bust),
this.fetchPopularTypes(orgId, bust)
]);
}
/**
* Fetch only if data for this (orgId, range) wasn't fetched in this session.
* Singleton state means navigating away and back reuses prior data.
*/
ensureFetched(orgId: string) {
if (this.lastFetchedOrgId === orgId && this.lastFetchedRange === this.range) return;
this.fetchAll(orgId);
}
setRange(days: 7 | 30 | 90, orgId: string) {
this.range = days;
return this.fetchAll(orgId);
}
refresh(orgId: string) {
return this.fetchAll(orgId, true);
}
}
export const analyticsApi = new AnalyticsApi();
@@ -0,0 +1,26 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { cn } from '@cio/ui/tools';
import type { Snippet } from 'svelte';
interface Props {
title: string;
description?: string;
class?: string;
children?: Snippet;
}
let { title, description, class: className, children }: Props = $props();
</script>
<Card.Root class={cn(className)}>
<Card.Header>
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</Card.Header>
<Card.Content>
{@render children?.()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,59 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import GlobeIcon from '@lucide/svelte/icons/globe';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { CountryBreakdownData } from '../utils/types';
interface Props {
data: CountryBreakdownData;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const maxViews = $derived(Math.max(1, ...data.map((row) => row.views)));
const totalViews = $derived(data.reduce((sum, row) => sum + row.views, 0));
</script>
<AnalyticsPanelCard
title={$t('analytics.countries.heading')}
description={data.length > 0 ? $t('analytics.countries.subtitle', { total: totalViews.toLocaleString() }) : undefined}
>
{#snippet children()}
{#if loading && data.length === 0}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if data.length === 0}
<Empty icon={GlobeIcon} title={$t('analytics.countries.empty')} />
{:else}
<div class="ui:text-muted-foreground mb-2 flex justify-between text-xs font-medium tracking-wide uppercase">
<span>{$t('analytics.countries.country')}</span>
<span>{$t('analytics.countries.visitors')}</span>
</div>
<ul class="ui:divide-border divide-y">
{#each data.slice(0, 10) as row (row.country)}
<li class="relative py-2.5">
<div
class="ui:bg-primary/10 absolute inset-y-1 left-0 rounded-r"
style="width: {(row.views / maxViews) * 100}%"
aria-hidden="true"
></div>
<div class="relative flex items-center justify-between text-sm">
<span class="ui:text-foreground font-medium">{row.country}</span>
<div class="flex items-center gap-3">
<span class="ui:text-muted-foreground tabular-nums">
{row.enrollments.toLocaleString()}
{$t('analytics.countries.enrollments_short')}
</span>
<span class="ui:text-foreground tabular-nums">{row.views.toLocaleString()}</span>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,60 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { CourseFunnelData } from '../utils/types';
interface Props {
data: CourseFunnelData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const stepLabel = (name: string) => $t(`analytics.funnel.step.${name}`);
const topCount = $derived(data?.steps[0]?.count ?? 0);
</script>
<AnalyticsPanelCard title={$t('analytics.funnel.heading')} description={$t('analytics.funnel.description')}>
{#snippet children()}
{#if loading && !data}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if !data || data.steps.length === 0 || topCount === 0}
<Empty icon={TrendingDownIcon} title={$t('analytics.funnel.empty')} />
{:else}
<ol class="space-y-3">
{#each data.steps as step, index (step.name)}
<li>
<div class="mb-1.5 flex items-baseline justify-between">
<div class="flex items-baseline gap-2">
<span class="ui:text-muted-foreground text-xs font-medium tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
<span class="ui:text-foreground text-sm font-medium">{stepLabel(step.name)}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="ui:text-foreground text-sm tabular-nums">{step.count.toLocaleString()}</span>
{#if step.conversionFromPrev != null}
<span class="ui:bg-primary/10 ui:text-primary rounded px-1.5 py-0.5 text-xs font-medium tabular-nums">
{Math.round(step.conversionFromPrev * 100)}%
</span>
{/if}
</div>
</div>
<div class="ui:bg-muted h-2 overflow-hidden rounded-full">
<div
class="ui:bg-primary h-full rounded-full transition-all"
style="width: {topCount > 0 ? (step.count / topCount) * 100 : 0}%"
></div>
</div>
</li>
{/each}
</ol>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,9 @@
export { default as AnalyticsPanelCard } from './analytics-panel-card.svelte';
export { default as RangePicker } from './range-picker.svelte';
export { default as RefreshButton } from './refresh-button.svelte';
export { default as CountriesTable } from './countries-table.svelte';
export { default as Funnel } from './funnel.svelte';
export { default as PopularTypes } from './popular-types.svelte';
export { default as LandingKpis } from './landing-kpis.svelte';
export { default as KpiCard } from './kpi-card.svelte';
export { default as TrendChart } from './trend-chart.svelte';
@@ -0,0 +1,83 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { Spinner } from '@cio/ui/base/spinner';
import { cn } from '@cio/ui/tools';
import type { Component } from 'svelte';
interface Props {
title: string;
value: number | string;
description?: string;
icon?: Component;
accent?: 'primary' | 'success' | 'warning' | 'danger';
sparkline?: number[];
loading?: boolean;
class?: string;
}
let {
title,
value,
description,
icon: Icon,
accent = 'primary',
sparkline,
loading = false,
class: className
}: Props = $props();
const accentClass: Record<NonNullable<Props['accent']>, string> = {
primary: 'ui:bg-primary/10 ui:text-primary',
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
danger: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
};
const sparklinePath = $derived.by(() => {
if (!sparkline || sparkline.length < 2) return '';
const max = Math.max(...sparkline, 1);
const min = Math.min(...sparkline, 0);
const span = max - min || 1;
const width = 100;
const height = 30;
return sparkline
.map((value, index) => {
const x = (index / (sparkline.length - 1)) * width;
const y = height - ((value - min) / span) * height;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
});
</script>
<Card.Root class={cn('ui:bg-card relative overflow-hidden', className)}>
<Card.Header class="pb-2">
<div class="flex items-start justify-between gap-2">
<Card.Title class="ui:text-muted-foreground text-xs font-medium tracking-wide uppercase">
{title}
</Card.Title>
{#if Icon}
<div class={cn('flex h-9 w-9 items-center justify-center rounded-full', accentClass[accent])}>
<Icon class="h-4 w-4" />
</div>
{/if}
</div>
</Card.Header>
<Card.Content class="pt-0">
{#if loading}
<div class="flex h-9 items-center">
<Spinner class="ui:text-muted-foreground size-5" />
</div>
{:else}
<p class="ui:text-foreground text-3xl font-semibold tabular-nums">{value}</p>
{/if}
{#if description}
<p class="ui:text-muted-foreground mt-1 text-xs">{description}</p>
{/if}
{#if sparklinePath && !loading}
<svg viewBox="0 0 100 30" class="mt-3 h-8 w-full" preserveAspectRatio="none" aria-hidden="true">
<path d={sparklinePath} fill="none" stroke="currentColor" stroke-width="1.5" class="ui:text-primary" />
</svg>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,66 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import EyeIcon from '@lucide/svelte/icons/eye';
import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
import KpiCard from './kpi-card.svelte';
import type { LandingStatsData } from '../utils/types';
interface Props {
data: LandingStatsData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const totals = $derived(
data?.totals ?? {
landingViews: 0,
coursePageViews: 0,
uniqueVisitors: 0,
enrollments: 0,
completions: 0
}
);
const viewSpark = $derived(data?.sparkline.map((row) => row.views) ?? []);
const enrollSpark = $derived(data?.sparkline.map((row) => row.enrollments) ?? []);
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
title={$t('analytics.kpi.landing_views')}
value={totals.landingViews.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={EyeIcon}
accent="primary"
sparkline={viewSpark}
/>
<KpiCard
title={$t('analytics.kpi.unique_visitors')}
value={totals.uniqueVisitors.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={UsersIcon}
accent="success"
/>
<KpiCard
title={$t('analytics.kpi.course_page_views')}
value={totals.coursePageViews.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={BookOpenIcon}
accent="warning"
/>
<KpiCard
title={$t('analytics.kpi.enrollments')}
value={totals.enrollments.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={UserPlusIcon}
accent="primary"
sparkline={enrollSpark}
/>
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import LayersIcon from '@lucide/svelte/icons/layers';
import { t } from '$lib/utils/functions/translations';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { PopularTypesData } from '../utils/types';
interface Props {
data: PopularTypesData;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const maxEnrollments = $derived(Math.max(1, ...data.map((row) => row.enrollments)));
const totalEnrollments = $derived(data.reduce((sum, row) => sum + row.enrollments, 0));
function typeLabel(type: string) {
return $t(`analytics.popularTypes.types.${type}`, { default: type });
}
</script>
<AnalyticsPanelCard
title={$t('analytics.popularTypes.heading')}
description={data.length > 0
? $t('analytics.popularTypes.subtitle', { total: totalEnrollments.toLocaleString() })
: $t('analytics.popularTypes.description')}
>
{#snippet children()}
{#if loading && data.length === 0}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if data.length === 0}
<Empty icon={LayersIcon} title={$t('analytics.popularTypes.empty')} />
{:else}
<ul class="ui:divide-border divide-y">
{#each data as row (row.type)}
<li class="relative py-3">
<div
class="ui:bg-primary/10 absolute inset-y-1 left-0 rounded-r"
style="width: {(row.enrollments / maxEnrollments) * 100}%"
aria-hidden="true"
></div>
<div class="relative flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="ui:text-foreground text-sm font-medium">{typeLabel(row.type)}</p>
<p class="ui:text-muted-foreground text-xs">
{$t('analytics.popularTypes.course_count', { count: row.courseCount })}
</p>
</div>
<div class="text-right">
<p class="ui:text-foreground text-sm font-semibold tabular-nums">
{row.enrollments.toLocaleString()}
</p>
<p class="ui:text-muted-foreground text-xs tabular-nums">
{row.views.toLocaleString()}
{$t('analytics.popularTypes.views_short')}
</p>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
value: 7 | 30 | 90;
onChange: (value: 7 | 30 | 90) => void;
}
let { value, onChange }: Props = $props();
const options: Array<{ days: 7 | 30 | 90; labelKey: string }> = [
{ days: 7, labelKey: 'analytics.range.7d' },
{ days: 30, labelKey: 'analytics.range.30d' },
{ days: 90, labelKey: 'analytics.range.90d' }
];
</script>
<div class="inline-flex gap-1 rounded-md border p-1">
{#each options as opt (opt.days)}
<Button variant={value === opt.days ? 'default' : 'ghost'} size="sm" onclick={() => onChange(opt.days)}>
{$t(opt.labelKey)}
</Button>
{/each}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
import { t } from '$lib/utils/functions/translations';
interface Props {
loading: boolean;
onClick: () => void;
}
let { loading, onClick }: Props = $props();
</script>
<Button variant="outline" size="sm" disabled={loading} onclick={onClick}>
<RefreshIcon class={loading ? 'animate-spin' : ''} />
{$t('analytics.refresh')}
</Button>
@@ -0,0 +1,52 @@
<script lang="ts">
import * as Chart from '@cio/ui/base/chart';
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import { t } from '$lib/utils/functions/translations';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { LandingStatsData } from '../utils/types';
interface Props {
data: LandingStatsData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const chartConfig = $derived({
views: {
label: $t('analytics.trend.views'),
color: 'var(--primary)'
},
enrollments: {
label: $t('analytics.trend.enrollments'),
color: 'var(--chart-2, var(--primary))'
}
} satisfies Chart.ChartConfig);
const series = [
{ key: 'views', value: 'views', label: 'Views', color: 'var(--color-views)' },
{ key: 'enrollments', value: 'enrollments', label: 'Enrollments', color: 'var(--color-enrollments)' }
];
const hasData = $derived(
(data?.sparkline.length ?? 0) > 0 && data?.sparkline.some((r) => r.views > 0 || r.enrollments > 0)
);
</script>
<AnalyticsPanelCard title={$t('analytics.trend.heading')} description={$t('analytics.trend.description')}>
{#snippet children()}
{#if loading && !data}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasData && data}
<Chart.ChartContainer class="h-[280px] w-full" config={chartConfig}>
<Chart.AreaChart data={data.sparkline} x="date" axis="x" {series} />
</Chart.ChartContainer>
{:else}
<Empty icon={TrendingUpIcon} title={$t('analytics.trend.empty')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,2 @@
export * from './components';
export { analyticsApi } from './api/analytics.svelte';
@@ -0,0 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type LandingStatsRequest = (typeof classroomio.dash)['landing-stats']['$get'];
export type CountryBreakdownRequest = (typeof classroomio.dash)['country-breakdown']['$get'];
export type CourseFunnelRequest = (typeof classroomio.dash)['course-funnel']['$get'];
export type PopularTypesRequest = (typeof classroomio.dash)['popular-types']['$get'];
export type LandingStatsSuccess = Extract<InferResponseType<LandingStatsRequest>, { success: true }>;
export type CountryBreakdownSuccess = Extract<InferResponseType<CountryBreakdownRequest>, { success: true }>;
export type CourseFunnelSuccess = Extract<InferResponseType<CourseFunnelRequest>, { success: true }>;
export type PopularTypesSuccess = Extract<InferResponseType<PopularTypesRequest>, { success: true }>;
export type LandingStatsData = LandingStatsSuccess['data'];
export type CountryBreakdownData = CountryBreakdownSuccess['data'];
export type CourseFunnelData = CourseFunnelSuccess['data'];
export type PopularTypesData = PopularTypesSuccess['data'];
export type AnalyticsRange = 7 | 30 | 90;
@@ -1,6 +1,6 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { isOrgStudent } from '$lib/utils/store/app';
import { currentOrg, orgs } from '$lib/utils/store/org';
import { currentOrg, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
import type { AccountResponse } from './types';
@@ -93,12 +93,12 @@ class AppInitApi extends BaseApi {
return;
}
orgs.set(this.data.organizations);
orgs.set(this.data.organizations.map((org) => mergeAccountOrgFromServer(org)));
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
const lastOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName);
currentOrg.set(lastOrg || this.data.organizations[0]);
currentOrg.set(mergeAccountOrgFromServer(lastOrg || this.data.organizations[0]));
const theme = get(currentOrg)?.theme;
@@ -0,0 +1,32 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { ComplianceOverviewData, ComplianceOverviewRequest } from '../utils/types';
class ComplianceApi extends BaseApi {
overview = $state<ComplianceOverviewData | null>(null);
loading = $state(false);
lastFetchedOrgId = $state<string | null>(null);
async fetchOverview(orgId: string) {
if (!orgId) return;
this.loading = true;
this.lastFetchedOrgId = orgId;
await this.execute<ComplianceOverviewRequest>({
requestFn: () => classroomio.dash['compliance-overview'].$get({ query: { orgId } }),
logContext: 'fetching org compliance overview',
onSuccess: (response) => {
this.overview = response.data;
}
});
this.loading = false;
}
ensureFetched(orgId: string) {
if (this.lastFetchedOrgId === orgId) return;
this.fetchOverview(orgId);
}
}
export const complianceApi = new ComplianceApi();
@@ -0,0 +1,60 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import BookIcon from '@lucide/svelte/icons/book';
import { t } from '$lib/utils/functions/translations';
import type { ComplianceCourseRow } from '../utils/types';
interface Props {
rows: ComplianceCourseRow[];
}
let { rows }: Props = $props();
</script>
<Card.Root>
<Card.Header>
<Card.Title>{$t('compliance.courses.heading')}</Card.Title>
<Card.Description>{$t('compliance.courses.description')}</Card.Description>
</Card.Header>
<Card.Content>
{#if rows.length === 0}
<Empty icon={BookIcon} title={$t('compliance.courses.empty')} />
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('compliance.courses.course')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.courses.learners')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.compliant')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.expiring_soon')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.non_compliant')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.courses.completion')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.courseId)}
{@const complete = row.counts.compliant}
{@const percent = row.learnerCount > 0 ? Math.round((complete / row.learnerCount) * 100) : 0}
<Table.Row>
<Table.Cell class="font-medium">{row.courseTitle}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.learnerCount}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.compliant}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.expiring_soon}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.non_compliant}</Table.Cell>
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-2">
<div class="ui:bg-muted h-1.5 w-20 overflow-hidden rounded-full">
<div class="ui:bg-primary h-full" style="width: {percent}%"></div>
</div>
<span class="ui:text-muted-foreground w-9 text-right text-xs tabular-nums">{percent}%</span>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,3 @@
export { default as StatusTiles } from './status-tiles.svelte';
export { default as CourseBreakdown } from './course-breakdown.svelte';
export { default as LearnersTable } from './learners-table.svelte';
@@ -0,0 +1,88 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import * as Table from '@cio/ui/base/table';
import { Badge } from '@cio/ui/base/badge';
import { Empty } from '@cio/ui/custom/empty';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import UsersIcon from '@lucide/svelte/icons/users';
import { t } from '$lib/utils/functions/translations';
import type { ComplianceLearnerRow } from '../utils/types';
interface Props {
rows: ComplianceLearnerRow[];
statusFilter?: ComplianceLearnerRow['status'] | 'all';
}
let { rows, statusFilter = 'all' }: Props = $props();
const filtered = $derived(statusFilter === 'all' ? rows : rows.filter((row) => row.status === statusFilter));
const statusTone: Record<ComplianceLearnerRow['status'], string> = {
compliant: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
expiring_soon: 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
in_grace_period: 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
non_compliant: 'bg-rose-500/10 text-rose-700 dark:text-rose-300',
waived: 'ui:bg-muted ui:text-muted-foreground',
in_progress: 'ui:bg-primary/10 ui:text-primary',
not_started: 'ui:bg-muted ui:text-muted-foreground',
no_record: 'ui:bg-muted ui:text-muted-foreground'
};
function statusLabel(status: ComplianceLearnerRow['status']) {
return $t(`compliance.status.${status}`);
}
function formatDate(iso: string | null) {
if (!iso) return '—';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString();
}
</script>
<Card.Root>
<Card.Header>
<Card.Title>{$t('compliance.learners.heading')}</Card.Title>
<Card.Description>
{$t('compliance.learners.subtitle', { count: filtered.length })}
</Card.Description>
</Card.Header>
<Card.Content>
{#if filtered.length === 0}
<Empty icon={UsersIcon} title={$t('compliance.learners.empty')} />
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('compliance.learners.learner')}</Table.Head>
<Table.Head>{$t('compliance.learners.course')}</Table.Head>
<Table.Head>{$t('compliance.learners.status')}</Table.Head>
<Table.Head>{$t('compliance.learners.due_date')}</Table.Head>
<Table.Head>{$t('compliance.learners.expires')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as row (row.groupMemberId + ':' + row.courseId)}
<Table.Row>
<Table.Cell>
<div class="flex items-center gap-2">
<UserAvatar src={row.avatarUrl} alt={row.fullname ?? row.email ?? 'Learner'} class="h-7 w-7" />
<div class="min-w-0">
<p class="ui:text-foreground truncate text-sm font-medium">{row.fullname ?? '—'}</p>
<p class="ui:text-muted-foreground truncate text-xs">{row.email ?? ''}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-sm">{row.courseTitle}</Table.Cell>
<Table.Cell>
<Badge variant="outline" class={statusTone[row.status]}>{statusLabel(row.status)}</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">{formatDate(row.dueDate)}</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">{formatDate(row.validUntil)}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,70 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { cn } from '@cio/ui/tools';
import { t } from '$lib/utils/functions/translations';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
import ClockIcon from '@lucide/svelte/icons/clock';
import ShieldOffIcon from '@lucide/svelte/icons/shield-off';
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash';
import Footprints from '@lucide/svelte/icons/footprints';
import CircleDashedIcon from '@lucide/svelte/icons/circle-dashed';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import type { Component } from 'svelte';
import type { ComplianceOverviewData } from '../utils/types';
interface Props {
data: ComplianceOverviewData | null;
}
let { data }: Props = $props();
type Tile = {
key: keyof ComplianceOverviewData['summary']['counts'];
labelKey: string;
icon: Component;
tone: 'success' | 'warning' | 'danger' | 'info' | 'muted';
};
/** Order: healthy → risk → pipeline → administrative, so the grid reads left-to-top then downward. */
const tiles: Tile[] = [
{ key: 'compliant', labelKey: 'compliance.status.compliant', icon: ShieldCheckIcon, tone: 'success' },
{ key: 'non_compliant', labelKey: 'compliance.status.non_compliant', icon: ShieldOffIcon, tone: 'danger' },
{ key: 'expiring_soon', labelKey: 'compliance.status.expiring_soon', icon: AlertTriangleIcon, tone: 'warning' },
{ key: 'in_grace_period', labelKey: 'compliance.status.in_grace_period', icon: ClockIcon, tone: 'warning' },
{ key: 'in_progress', labelKey: 'compliance.status.in_progress', icon: Footprints, tone: 'info' },
{ key: 'not_started', labelKey: 'compliance.status.not_started', icon: CircleDashedIcon, tone: 'muted' },
{ key: 'waived', labelKey: 'compliance.status.waived', icon: CircleSlashIcon, tone: 'muted' },
{ key: 'no_record', labelKey: 'compliance.status.no_record', icon: HelpCircleIcon, tone: 'muted' }
];
const toneClass: Record<Tile['tone'], string> = {
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
danger: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
info: 'ui:bg-primary/10 ui:text-primary',
muted: 'ui:bg-muted ui:text-muted-foreground'
};
const counts = $derived(data?.summary.counts ?? null);
</script>
<Card.Root class="ui:bg-card">
<Card.Header class="pb-2">
<Card.Title class="text-base font-semibold">{$t('compliance.summary.heading')}</Card.Title>
<Card.Description>{$t('compliance.summary.description')}</Card.Description>
</Card.Header>
<Card.Content class="pt-0">
<div class="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 lg:grid-cols-4">
{#each tiles as tile (tile.key)}
<div class="flex min-w-0 flex-col gap-2">
<div class={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-full', toneClass[tile.tone])}>
<tile.icon class="h-4 w-4" />
</div>
<p class="ui:text-foreground text-2xl font-semibold tabular-nums">{counts?.[tile.key] ?? 0}</p>
<p class="ui:text-muted-foreground text-xs leading-snug">{$t(tile.labelKey)}</p>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,2 @@
export * from './components';
export { complianceApi } from './api/compliance.svelte';
@@ -0,0 +1,12 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ComplianceOverviewRequest = (typeof classroomio.dash)['compliance-overview']['$get'];
export type ComplianceOverviewSuccess = Extract<InferResponseType<ComplianceOverviewRequest>, { success: true }>;
export type ComplianceOverviewData = ComplianceOverviewSuccess['data'];
export type ComplianceStatus = ComplianceOverviewData['summary']['counts'] extends Record<infer K, number> ? K : never;
export type ComplianceCourseRow = ComplianceOverviewData['courses'][number];
export type ComplianceLearnerRow = ComplianceOverviewData['learners'][number];
@@ -675,6 +675,24 @@ export class CourseApi extends BaseApiWithErrors {
lessonTabsOrder.push(tab);
}
});
/** Legacy stacked-lesson materials order [Note, Slide, Video, Documents]; migrated to Video first in view mode */
const legacyDefaultLessonTabIds: Array<1 | 2 | 3 | 4> = [1, 2, 3, 4];
const lessonMaterialsVideoFirstTabIds: Array<1 | 2 | 3 | 4> = [3, 1, 2, 4];
const isLegacyDefaultLessonMaterialsOrder =
lessonTabsOrder.length === legacyDefaultLessonTabIds.length &&
lessonTabsOrder.every((tab, index) => tab.id === legacyDefaultLessonTabIds[index]);
if (isLegacyDefaultLessonMaterialsOrder) {
const entriesById = new Map(lessonTabsOrder.map((tab) => [tab.id, tab]));
lessonTabsOrder.length = 0;
lessonMaterialsVideoFirstTabIds.forEach((id) => {
const entry = entriesById.get(id);
if (entry) lessonTabsOrder.push(entry);
});
}
}
if (data.metadata && data.metadata.isContentGroupingEnabled === undefined) {
@@ -1,44 +0,0 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
title: string;
value: string | number;
subtitle?: string;
icon: Component;
iconColor?: string;
className?: string;
}
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
let colorClasses = $derived({
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
});
const SvelteComponent = $derived(icon);
</script>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
<div class="flex items-center space-x-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
<SvelteComponent className={colorClasses[iconColor]} />
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{value}
</p>
{#if subtitle}
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
{subtitle}
</p>
{/if}
</div>
</div>
</div>
@@ -1,5 +1,9 @@
<script lang="ts">
import * as Chart from '@cio/ui/base/chart';
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import BarChartIcon from '@lucide/svelte/icons/bar-chart-3';
import { AnalyticsPanelCard } from '$features/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
@@ -29,10 +33,9 @@
}
} satisfies Chart.ChartConfig);
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
const hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
// Transform data for progress distribution chart
let progressChartData = $derived<DistributionChartData[]>(
const progressChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
@@ -55,8 +58,7 @@
: []
);
// Transform data for grade distribution chart
let gradeChartData = $derived<DistributionChartData[]>(
const gradeChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
@@ -83,7 +85,7 @@
: []
);
let progressChartProps = $derived({
const progressChartProps = $derived({
xAxis: {
label: $t('analytics.progress_range')
},
@@ -92,7 +94,7 @@
}
});
let gradeChartProps = $derived({
const gradeChartProps = $derived({
xAxis: {
label: $t('analytics.grade_range')
},
@@ -101,7 +103,7 @@
}
});
let progressSeries = $derived([
const progressSeries = $derived([
{
key: 'students',
value: 'value',
@@ -110,7 +112,7 @@
}
]);
let gradeSeries = $derived([
const gradeSeries = $derived([
{
key: 'students',
value: 'value',
@@ -118,50 +120,52 @@
color: 'var(--color-students)'
}
]);
const chartsLoading = $derived(!courseAnalytics);
</script>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart -->
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_distribution')}
</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={progressChartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
axis="x"
props={progressChartProps}
series={progressSeries}
/>
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_progress_data')}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AnalyticsPanelCard
title={$t('analytics.student_progress_distribution')}
description={$t('analytics.course_metrics.progress_distribution_card_description')}
>
{#snippet children()}
{#if chartsLoading}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
axis="x"
props={progressChartProps}
series={progressSeries}
/>
</Chart.ChartContainer>
{:else}
<Empty icon={BarChartIcon} title={$t('analytics.no_progress_data')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
<!-- Grade Distribution Chart -->
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={gradeChartConfig}>
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_grade_data')}
</div>
{/if}
</div>
<AnalyticsPanelCard
title={$t('analytics.grade_distribution')}
description={$t('analytics.course_metrics.grade_distribution_card_description')}
>
{#snippet children()}
{#if chartsLoading}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
</Chart.ChartContainer>
{:else}
<Empty icon={BarChartIcon} title={$t('analytics.no_grade_data')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
</div>
@@ -2,61 +2,54 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="min-h-screen">
<div class="px-4 py-8">
<!-- Course Overview Cards Skeleton -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _, i (i)}
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center space-x-4">
<Skeleton class="h-[48px] w-[48px]" />
<div class="flex-1">
<Skeleton class="h-[48px] w-[48px]" />
<Skeleton class="mb-2 h-[16px] w-[80px]" />
<Skeleton class="mb-1 h-[24px] w-[40px]" />
<Skeleton class="h-[12px] w-[60px]" />
</div>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each Array(4) as _, i (i)}
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
<Skeleton class="h-3 w-24" />
</div>
<div class="px-6 py-6">
<Skeleton class="h-8 w-20" />
<Skeleton class="mt-4 h-3 w-full max-w-[180px]" />
</div>
</div>
{/each}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each Array(2) as _, chartIndex (chartIndex)}
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
<Skeleton class="h-5 w-48" />
<Skeleton class="h-4 w-full max-w-md" />
</div>
<div class="px-6 py-6">
<Skeleton class="h-[280px] w-full" />
</div>
</div>
{/each}
</div>
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border border-b px-6 py-5">
<Skeleton class="h-5 w-56" />
<Skeleton class="mt-2 h-4 max-w-xl" />
</div>
<div class="ui:divide-border divide-y px-6 py-2">
{#each Array(5) as _, i (i)}
<div class="flex flex-wrap items-center gap-4 py-4">
<Skeleton class="size-8 rounded-full" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-[150px]" />
<Skeleton class="h-3 w-[200px]" />
</div>
{/each}
</div>
</div>
<!-- Charts Section Skeleton -->
<div class="mb-12">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Skeleton class="h-[300px] w-full" />
<Skeleton class="h-[300px] w-full" />
<Skeleton class="h-4 w-[80px]" />
<Skeleton class="h-4 w-[100px]" />
<Skeleton class="h-6 w-[48px]" />
<Skeleton class="h-8 w-[90px]" />
</div>
</div>
</div>
<!-- Students Table Skeleton -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
</div>
<div class="p-6">
<div class="space-y-4">
{#each Array(5) as _, i (i)}
<div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1">
<Skeleton class="mb-1 h-[16px] w-[150px]" />
<Skeleton class="h-[12px] w-[200px]" />
</div>
<Skeleton class="h-[16px] w-[80px]" />
<Skeleton class="h-[16px] w-[60px]" />
<Skeleton class="h-[20px] w-[40px]" />
<Skeleton class="h-[12px] w-[80px]" />
<Skeleton class="h-[32px] w-[100px]" />
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import PercentIcon from '@lucide/svelte/icons/percent';
import UsersIcon from '@lucide/svelte/icons/users';
import { KpiCard } from '$features/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
data: CourseAnalytics;
}
let { data }: Props = $props();
const tutorsCount = $derived(data.totalTutors ?? 0);
const exerciseTotal = $derived(data.totalExercises ?? 0);
const avgGrade = $derived(data.averageGrade ?? 0);
const lessonRate = $derived(data.lessonCompletionRate ?? 0);
const exerciseRate = $derived(data.exerciseCompletionRate ?? 0);
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
title={$t('analytics.students')}
value={(data.totalStudents ?? 0).toLocaleString()}
description={$t('analytics.course_metrics.kpi_team_tutors', { count: tutorsCount })}
icon={UsersIcon}
accent="primary"
/>
<KpiCard
title={$t('analytics.lessons')}
value={(data.totalLessons ?? 0).toLocaleString()}
description={$t('analytics.course_metrics.kpi_lessons_exercises', { count: exerciseTotal })}
icon={BookOpenIcon}
accent="warning"
/>
<KpiCard
title={$t('analytics.progress')}
value={`${lessonRate}%`}
description={$t('analytics.course_metrics.kpi_progress_grade', { grade: avgGrade })}
icon={PercentIcon}
accent="success"
/>
<KpiCard
title={$t('analytics.exercises')}
value={`${exerciseRate}%`}
description={$t('analytics.completion_rate')}
icon={ListChecksIcon}
accent="primary"
/>
</div>
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fill-rule="evenodd"
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clip-rule="evenodd"
/>
</svg>
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
@@ -1,18 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
</svg>
@@ -59,11 +59,11 @@
/>
{:else}
<!-- Responsive Table -->
<div class="relative h-[300px] overflow-auto rounded-md border">
<div class="ui:border-border relative max-h-[min(480px,70vh)] overflow-auto rounded-md border">
<Table.Root class="min-w-[800px]">
<Table.Header class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
<Table.Header class="ui:bg-muted/80 sticky top-0 z-10 backdrop-blur-sm">
<Table.Row>
<Table.Head class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 dark:bg-neutral-800">
<Table.Head class="ui:bg-muted/95 sticky top-0 left-0 z-20 min-w-[200px] px-4 py-3 backdrop-blur-sm">
{$t('analytics.student_name')}
</Table.Head>
<Table.Head class="min-w-[150px] px-4 py-3">{$t('analytics.lessons_completed')}</Table.Head>
@@ -77,7 +77,7 @@
{#each paginatedStudents as student (student.id)}
<Table.Row class="group h-[100px]">
<Table.Cell
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
class="ui:bg-card group-hover:ui:bg-muted/50 sticky left-0 z-10 min-w-[200px] px-4 py-3 transition-colors"
>
<div class="flex items-center gap-3">
<UserAvatar
@@ -86,10 +86,10 @@
class="size-8"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
<p class="ui:text-foreground truncate font-medium">
{student.profile.fullname}
</p>
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
<p class="ui:text-muted-foreground truncate text-sm">
{student.profile.email}
</p>
</div>
@@ -97,18 +97,18 @@
</Table.Cell>
<Table.Cell class="min-w-[150px] px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
<span class="ui:text-foreground text-sm font-medium">
{student.lessonsCompleted}/{student.totalLessons}
</span>
<div class="w-20 flex-shrink-0">
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
<div class="ui:bg-muted h-2 rounded-full">
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
</div>
</div>
</div>
</Table.Cell>
<Table.Cell class="min-w-[140px] px-4 py-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
<span class="ui:text-foreground text-sm font-medium">
{student.exercisesSubmitted} out of {student.totalExercises}
</span>
</Table.Cell>
@@ -124,7 +124,7 @@
</Badge>
</Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3">
<span class="text-sm text-gray-500 dark:text-gray-400">
<span class="ui:text-muted-foreground text-sm">
{student.lastSeen}
</span>
</Table.Cell>
@@ -141,8 +141,8 @@
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="ui:border-border mt-4 flex items-center justify-between border-t px-4 py-3">
<div class="ui:text-muted-foreground text-sm">
{$t('analytics.showing_students', {
start: startItem,
end: endItem,
@@ -1,134 +1,118 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Label } from '@cio/ui/base/label';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { Badge } from '@cio/ui/base/badge';
import { Certificate } from '@cio/ui';
import * as Card from '@cio/ui/base/card';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import ZapIcon from '@lucide/svelte/icons/zap';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
import {
CERTIFICATE_TEMPLATES,
DEFAULT_CERTIFICATE_DESIGN,
type CertificateDesign,
resolveTemplateId
} from '@cio/certificates';
type Props = {
errors: Record<string, string>;
errors?: Record<string, string>;
};
let { errors }: Props = $props();
let { errors: _errors }: Props = $props();
const studentNamePlaceholder = 'Name of student';
const design: CertificateDesign = $derived.by(() => {
const certificate = courseApi.course?.certificate;
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
const legacyTheme = certificate?.theme as string | undefined;
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
return {
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
descriptionOverride: stored?.descriptionOverride,
signatories: [
{
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
},
{
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
}
],
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
};
});
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
const previewData = $derived({
recipientName: 'Eleanor Vance',
courseName: courseApi.course?.title ?? 'Course Title',
courseDescription: design.descriptionOverride || courseApi.course?.description || '',
orgName: $currentOrg.name || 'Organization',
orgLogoUrl: $currentOrg.avatarUrl || undefined,
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
certificateId: (design.idFormat ?? 'N° {seq}').replace('{seq}', '0247')
});
const templateLabel = $derived(
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === design.templateId)?.label ?? design.templateId
);
function onDescriptionInput(e: Event) {
if (!courseApi.course) return;
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
}
function goToOrgSettings() {
goto(
resolve('/org/[slug]/settings', {
slug: $currentOrg.siteName || ''
})
);
}
const courseId = $derived(courseApi.course?.id ?? '');
const editorHref = $derived(courseId ? resolve('/courses/[id]/certificates/editor', { id: courseId }) : '#');
</script>
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
<div class="grid w-full gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div class="aspect-[1.4/1] w-full">
<Certificate.Preview {design} data={previewData} zoom="fit" />
</div>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
<RadioGroup.Root
value={certificateTheme}
onValueChange={(value) => {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
theme: value as CertificateThemeId
};
}}
disabled={$isFreePlan}
class="mb-6"
>
<div class="flex flex-wrap justify-between gap-y-5">
{#each CERTIFICATE_THEME_IDS as theme (theme)}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
<Field.Description>
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</Field.Description>
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</Field.Field>
<Field.Field>
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
<Textarea
id="cert-design-description"
rows={6}
class="w-full"
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description ?? ''}
oninput={onDescriptionInput}
disabled={$isFreePlan}
/>
<Field.Description>{helperText}</Field.Description>
{#if errors.description}
<Field.Error>{errors.description}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
</section>
<section class="flex w-full rounded-md lg:w-3/5">
<div class="w-[95%]">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
<div class="flex flex-col gap-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-base">{$t('course.navItem.certificates.editor.summary_title')}</Card.Title>
<Card.Description>{$t('course.navItem.certificates.editor.summary_subtitle')}</Card.Description>
</Card.Header>
<Card.Content class="space-y-3 pb-4">
<div class="flex items-center justify-between">
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
{$t('course.navItem.certificates.editor.field_template')}
</span>
<Badge variant="secondary" class="text-[10px] uppercase">{templateLabel}</Badge>
</div>
<div class="flex items-center justify-between">
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
{$t('course.navItem.certificates.editor.field_accent')}
</span>
<span class="ui:border-border inline-flex items-center gap-2 rounded-full border px-2 py-0.5 text-xs">
<span class="size-3.5 rounded-full" style:background-color={design.accentColor} aria-hidden="true"></span>
{design.accentColor}
</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
{$t('course.navItem.certificates.editor.field_signatories')}
</span>
<div class="text-right text-xs">
<div class="font-medium">{design.signatories[0].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
<div class="mt-1 font-medium">{design.signatories[1].name}</div>
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button class="w-full justify-center" disabled={$isFreePlan || !courseId} href={editorHref}>
{#if $isFreePlan}
<ZapIcon class="size-4" />
{/if}
{$t('course.navItem.certificates.editor.customize_design')}
<ArrowRightIcon class="size-4" />
</Button>
</Card.Footer>
</Card.Root>
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
interface Props {
courseId: string;
courseTitle: string;
templateLabel: string;
isDirty: boolean;
isSaving: boolean;
isFreePlan: boolean;
onSave: () => void;
onDiscard: () => void;
}
let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props();
</script>
<header
class="ui:border-border ui:bg-background ui:text-foreground flex shrink-0 items-center justify-between gap-3 border-b px-3 py-3"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
variant="ghost"
size="icon"
href={resolve('/courses/[id]/certificates', { id: courseId })}
class="ui:size-8 shrink-0"
aria-label={$t('course.navItem.certificates.editor.back')}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<h1 class="truncate text-sm tracking-tight sm:text-base">
{courseTitle}
</h1>
<div class="flex flex-wrap items-center gap-1">
<Badge variant="secondary" class="text-[10px] uppercase">
{templateLabel}
</Badge>
{#if isDirty}
<Badge variant="outline" class="text-[10px]">
{$t('course.navItem.certificates.editor.unsaved')}
</Badge>
{/if}
{#if isFreePlan}
<Badge variant="outline" class="text-[10px]">
{$t('course.navItem.certificates.editor.free_plan')}
</Badge>
{/if}
</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
{#if isDirty}
<Button variant="ghost" size="sm" type="button" onclick={onDiscard} class="hidden sm:inline-flex">
{$t('course.navItem.certificates.editor.discard')}
</Button>
{/if}
<Button
variant="outline"
size="sm"
type="button"
disabled={!isDirty || isSaving}
loading={isSaving}
onclick={onSave}
>
{$t('course.navItem.certificates.editor.save')}
</Button>
</div>
</header>
@@ -0,0 +1,181 @@
<script lang="ts">
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
import { Certificate } from '@cio/ui';
import { IconButton } from '@cio/ui/custom/icon-button';
import LayersIcon from '@lucide/svelte/icons/layers';
import TypeIcon from '@lucide/svelte/icons/type';
import PaletteIcon from '@lucide/svelte/icons/palette';
import DownloadIcon from '@lucide/svelte/icons/download';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
import { isFreePlan } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import CertificateEditorHeader from './certificate-editor-header.svelte';
import TemplatesPanel from './panels/templates-panel.svelte';
import ContentPanel from './panels/content-panel.svelte';
import ColorsPanel from './panels/colors-panel.svelte';
import ExportPanel from './panels/export-panel.svelte';
import { certificateEditorStore, type CertificateEditorPanel } from './store/certificate-editor.store.svelte';
let { courseId }: { courseId: string } = $props();
const store = certificateEditorStore;
$effect(() => {
if (!courseApi.course?.id || courseApi.course.id !== courseId) return;
store.syncFromCourse(courseId);
});
const courseTitle = $derived(courseApi.course?.title ?? '');
const activeTemplateMeta = $derived(
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === store.draft.templateId) ?? CERTIFICATE_TEMPLATES[0]
);
const previewDesign = $derived(store.toDesign());
const sampleRenderData = $derived({
recipientName: $profile.fullname || $t('course.navItem.certificates.editor.sample_recipient'),
courseName: courseTitle || $t('course.navItem.certificates.editor.sample_course'),
courseDescription:
store.draft.descriptionOverride ||
courseApi.course?.description ||
$t('course.navItem.certificates.editor.sample_description'),
orgName: $currentOrg.name || $t('course.navItem.certificates.editor.sample_org'),
orgLogoUrl: $currentOrg.avatarUrl || undefined,
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
certificateId: (store.draft.idFormat || 'N° {seq}').replace('{seq}', '0247')
});
function navVariant(panel: CertificateEditorPanel) {
return store.activePanel === panel ? ('default' as const) : ('ghost' as const);
}
function setActive(panel: CertificateEditorPanel) {
store.activePanel = panel;
}
</script>
{#if courseApi.course?.id === courseId}
<div class="ui:bg-background ui:text-foreground flex h-dvh flex-col">
<CertificateEditorHeader
{courseId}
{courseTitle}
templateLabel={activeTemplateMeta.label}
isDirty={store.isDirty}
isSaving={store.isSaving}
isFreePlan={$isFreePlan}
onSave={() => store.save()}
onDiscard={() => store.reset()}
/>
<div class="flex min-h-0 flex-1">
<nav
class="ui:border-border ui:bg-secondary flex w-14 shrink-0 flex-col items-center gap-1.5 border-r py-3"
aria-label={$t('course.navItem.certificates.editor.sections')}
>
<IconButton
type="button"
variant={navVariant('templates')}
tooltip={$t('course.navItem.certificates.editor.panel_templates')}
tooltipSide="right"
aria-label={$t('course.navItem.certificates.editor.panel_templates')}
aria-current={store.activePanel === 'templates' ? 'page' : undefined}
onclick={() => setActive('templates')}
>
<LayersIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navVariant('content')}
tooltip={$t('course.navItem.certificates.editor.panel_content')}
tooltipSide="right"
aria-label={$t('course.navItem.certificates.editor.panel_content')}
aria-current={store.activePanel === 'content' ? 'page' : undefined}
onclick={() => setActive('content')}
>
<TypeIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navVariant('colors')}
tooltip={$t('course.navItem.certificates.editor.panel_colors')}
tooltipSide="right"
aria-label={$t('course.navItem.certificates.editor.panel_colors')}
aria-current={store.activePanel === 'colors' ? 'page' : undefined}
onclick={() => setActive('colors')}
>
<PaletteIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navVariant('export')}
tooltip={$t('course.navItem.certificates.editor.panel_export')}
tooltipSide="right"
aria-label={$t('course.navItem.certificates.editor.panel_export')}
aria-current={store.activePanel === 'export' ? 'page' : undefined}
onclick={() => setActive('export')}
>
<DownloadIcon class="size-4" />
</IconButton>
</nav>
<aside class="ui:border-border ui:bg-card flex w-[min(100%,380px)] shrink-0 flex-col border-r">
<div class="ui:border-border border-b px-5 py-4">
{#if store.activePanel === 'templates'}
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_templates')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">
{$t('course.navItem.certificates.editor.panel_templates_subtitle')}
</p>
{:else if store.activePanel === 'content'}
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_content')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">
{$t('course.navItem.certificates.editor.panel_content_subtitle')}
</p>
{:else if store.activePanel === 'colors'}
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_colors')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">
{$t('course.navItem.certificates.editor.panel_colors_subtitle')}
</p>
{:else}
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_export')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">
{$t('course.navItem.certificates.editor.panel_export_subtitle')}
</p>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{#if store.activePanel === 'templates'}
<TemplatesPanel
value={store.draft.templateId}
disabled={$isFreePlan}
onSelect={(id: CertificateTemplateId) => store.setTemplate(id)}
/>
{:else if store.activePanel === 'content'}
<ContentPanel disabled={$isFreePlan} />
{:else if store.activePanel === 'colors'}
<ColorsPanel
value={store.draft.accentColor}
disabled={$isFreePlan}
onSelect={(color) => store.setAccent(color)}
/>
{:else}
<ExportPanel {courseId} {courseTitle} disabled={$isFreePlan} />
{/if}
</div>
</aside>
<section
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-auto bg-zinc-100 bg-[radial-gradient(circle,#d4d4d8_1px,transparent_1px)] [background-size:18px_18px] dark:bg-zinc-950 dark:bg-[radial-gradient(circle,rgba(113,113,122,0.4)_1px,transparent_1px)]"
aria-label={$t('course.navItem.certificates.editor.preview')}
>
<div class="flex min-h-0 flex-1 items-center justify-center p-6 sm:p-10 lg:p-14">
<Certificate.Preview design={previewDesign} data={sampleRenderData} showControls />
</div>
</section>
</div>
</div>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
import { ACCENT_COLORS } from '@cio/certificates';
import { t } from '$lib/utils/functions/translations';
import { cn } from '@cio/ui/tools';
interface Props {
value: string;
onSelect: (color: string) => void;
disabled?: boolean;
}
let { value, onSelect, disabled = false }: Props = $props();
</script>
<div class="space-y-3">
<div class="grid grid-cols-6 gap-2">
{#each ACCENT_COLORS as color (color)}
{@const isActive = color.toLowerCase() === value.toLowerCase()}
<button
type="button"
class={cn(
'ui:border-border relative aspect-square rounded-md border transition-transform',
'hover:-translate-y-0.5',
isActive && 'ui:ring-foreground ring-2 ring-offset-2',
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0'
)}
style:background-color={color}
aria-label={color}
aria-pressed={isActive}
{disabled}
onclick={() => onSelect(color)}
></button>
{/each}
</div>
<p class="ui:text-muted-foreground text-xs">
{$t('course.navItem.certificates.editor.accent_hint')}
</p>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations';
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
interface Props {
disabled?: boolean;
}
let { disabled = false }: Props = $props();
</script>
<Field.Group>
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.editor.section_header')}</Field.Legend>
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.subtitle')}
bind:value={certificateEditorStore.draft.subtitle}
placeholder={$t('course.navItem.certificates.editor.subtitle_placeholder')}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<TextareaField
label={$t('course.navItem.certificates.editor.description_override')}
rows={4}
bind:value={certificateEditorStore.draft.descriptionOverride}
placeholder={$t('course.navItem.certificates.editor.description_override_placeholder')}
{disabled}
/>
<Field.Description>
{$t('course.navItem.certificates.editor.description_override_hint')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.editor.section_signatories')}</Field.Legend>
<Field.Group>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_name')}
bind:value={certificateEditorStore.draft.signatories[0].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_one_role')}
bind:value={certificateEditorStore.draft.signatories[0].role}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_name')}
bind:value={certificateEditorStore.draft.signatories[1].name}
isDisabled={disabled}
/>
</Field.Field>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.signatory_two_role')}
bind:value={certificateEditorStore.draft.signatories[1].role}
isDisabled={disabled}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.editor.section_reference')}</Field.Legend>
<Field.Field>
<InputField
label={$t('course.navItem.certificates.editor.id_format')}
bind:value={certificateEditorStore.draft.idFormat}
placeholder={'N° {seq}'}
isDisabled={disabled}
/>
<Field.Description>
{$t('course.navItem.certificates.editor.id_format_hint')}
</Field.Description>
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,132 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import DownloadIcon from '@lucide/svelte/icons/download';
import ImageIcon from '@lucide/svelte/icons/image';
import PrinterIcon from '@lucide/svelte/icons/printer';
import { t } from '$lib/utils/functions/translations';
import { classroomio } from '$lib/utils/services/api';
import { snackbar } from '$features/ui/snackbar/store';
import { profile } from '$lib/utils/store/user';
interface Props {
courseId: string;
courseTitle: string;
disabled?: boolean;
}
let { courseId, courseTitle, disabled = false }: Props = $props();
let isPdfLoading = $state(false);
let isPngLoading = $state(false);
let isPrintLoading = $state(false);
const downloadName = $derived((courseTitle || 'certificate').replace(/\s+/g, '-').toLowerCase());
async function buildBody() {
return {
studentName: $profile.fullname || 'Preview Recipient',
studentId: $profile.id || undefined,
issuedAt: new Date().toISOString(),
previewMode: true
} as const;
}
async function downloadPdf() {
if (disabled || isPdfLoading) return;
isPdfLoading = true;
try {
const body = await buildBody();
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: body
});
const blob = await response.blob();
triggerDownload(new Blob([blob], { type: 'application/pdf' }), `${downloadName}.pdf`);
} catch (error) {
console.error('Preview PDF error', error);
snackbar.error('course.navItem.certificates.editor.preview_failed');
} finally {
isPdfLoading = false;
}
}
async function downloadPng() {
if (disabled || isPngLoading) return;
isPngLoading = true;
try {
const body = await buildBody();
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
param: { courseId },
json: body
});
const blob = await response.blob();
triggerDownload(new Blob([blob], { type: 'image/png' }), `${downloadName}.png`);
} catch (error) {
console.error('Preview PNG error', error);
snackbar.error('course.navItem.certificates.editor.preview_failed');
} finally {
isPngLoading = false;
}
}
async function print() {
if (disabled || isPrintLoading) return;
isPrintLoading = true;
try {
const body = await buildBody();
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
param: { courseId },
json: body
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(
`<!doctype html><html><head><title>${courseTitle}</title>` +
`<style>@page{size:A4 landscape;margin:0}body{margin:0;display:flex;align-items:center;justify-content:center;background:#fff}img{width:100vw;max-width:1100px;height:auto}
</style>` +
`</head><body><img alt="" src="${url}" onload="setTimeout(()=>window.print(),300)"></body></html>`
);
printWindow.document.close();
}
} catch (error) {
console.error('Preview print error', error);
snackbar.error('course.navItem.certificates.editor.preview_failed');
} finally {
isPrintLoading = false;
}
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
</script>
<div class="space-y-2">
<Button class="w-full justify-start" {disabled} loading={isPdfLoading} onclick={downloadPdf}>
<DownloadIcon class="size-4" />
{$t('course.navItem.certificates.editor.download_pdf')}
</Button>
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPngLoading} onclick={downloadPng}>
<ImageIcon class="size-4" />
{$t('course.navItem.certificates.editor.download_png')}
</Button>
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPrintLoading} onclick={print}>
<PrinterIcon class="size-4" />
{$t('course.navItem.certificates.editor.print')}
</Button>
<p class="ui:text-muted-foreground mt-3 text-xs">
{$t('course.navItem.certificates.editor.export_hint')}
</p>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { CertificateTemplateId } from '@cio/certificates';
interface Props {
id: CertificateTemplateId;
}
let { id }: Props = $props();
</script>
{#if id === 'classique'}
<div class="flex h-full w-full flex-col items-center justify-center bg-[#faf6ec] text-[#6b4513]">
<span class="text-[18px] italic" style="font-family:'Bodoni Moda',serif">Classique</span>
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">— EST 1892 —</span>
</div>
{:else if id === 'brutalist'}
<div class="flex h-full w-full items-end justify-start bg-[#f0ede4] p-2">
<div
class="w-full text-left leading-[0.85] text-black"
style="font-family:'Archivo Black',sans-serif;font-size:18px"
>
RAW<br />
<span class="text-[#ff4500] italic" style="font-family:'Playfair Display',serif;font-weight:400">form</span>
</div>
</div>
{:else if id === 'noir'}
<div class="flex h-full w-full flex-col items-center justify-center bg-[#0e0e0e] text-[#d4af37]">
<span class="text-[16px] text-[#f5f1e8] italic" style="font-family:'Playfair Display',serif">Noir</span>
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">★ ATELIER ★</span>
</div>
{:else if id === 'poster'}
<div class="relative h-full w-full overflow-hidden bg-[#fef2dc]">
<span class="absolute -top-3 -right-4 h-10 w-10 rounded-full bg-[#ff5722]"></span>
<span class="absolute -bottom-3 -left-3 h-9 w-9 rounded-full bg-[#1e3a8a]"></span>
<div class="relative z-10 flex h-full w-full items-center justify-center">
<span class="font-black italic" style="font-family:'Playfair Display',serif;font-size:18px">Poster</span>
</div>
</div>
{:else}
<div class="flex h-full w-full flex-col items-center justify-center bg-white text-black">
<span class="text-[18px] font-light italic" style="font-family:'Cormorant Garamond',serif">Minimal</span>
<span class="mt-1 block h-px w-7 bg-black"></span>
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'JetBrains Mono',monospace">N°001</span>
</div>
{/if}
@@ -0,0 +1,46 @@
<script lang="ts">
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
import { t } from '$lib/utils/functions/translations';
import { cn } from '@cio/ui/tools';
import TemplateThumb from './template-thumb.svelte';
interface Props {
value: CertificateTemplateId;
onSelect: (id: CertificateTemplateId) => void;
disabled?: boolean;
}
let { value, onSelect, disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-2 gap-3">
{#each CERTIFICATE_TEMPLATES as template (template.id)}
{@const isActive = template.id === value}
<button
type="button"
class={cn(
'group ui:border-border relative aspect-[1.4/1] overflow-hidden rounded-md border bg-white text-left transition-transform',
'hover:-translate-y-0.5 hover:shadow-md',
isActive && 'ui:border-primary ui:ring-primary -translate-y-0.5 shadow-md ring-2',
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0 hover:shadow-none'
)}
{disabled}
aria-pressed={isActive}
onclick={() => onSelect(template.id)}
>
<TemplateThumb id={template.id} />
<span
class={cn(
'absolute right-0 bottom-0 left-0 px-2 py-1 text-center text-[9px] font-medium tracking-[0.18em] uppercase',
isActive ? 'ui:bg-primary ui:text-primary-foreground' : 'ui:bg-foreground ui:text-background'
)}
>
{template.label}
</span>
</button>
{/each}
</div>
<p class="ui:text-muted-foreground mt-4 text-xs">
{$t('course.navItem.certificates.editor.templates_hint')}
</p>
@@ -0,0 +1,145 @@
import {
DEFAULT_CERTIFICATE_DESIGN,
resolveTemplateId,
type CertificateDesign,
type CertificateTemplateId
} from '@cio/certificates';
import { courseApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
export type CertificateEditorPanel = 'templates' | 'content' | 'colors' | 'export';
/**
* The store keeps optional fields as concrete strings so two-way bindings to
* inputs are simple — we collapse empty strings to `undefined` only when
* shipping a payload back to the API.
*/
export interface CertificateEditorDraft {
templateId: CertificateTemplateId;
accentColor: string;
subtitle: string;
descriptionOverride: string;
idFormat: string;
signatories: [{ name: string; role: string }, { name: string; role: string }];
}
function toDraft(design: CertificateDesign): CertificateEditorDraft {
return {
templateId: design.templateId,
accentColor: design.accentColor,
subtitle: design.subtitle ?? '',
descriptionOverride: design.descriptionOverride ?? '',
idFormat: design.idFormat ?? '',
signatories: [
{ name: design.signatories[0]?.name ?? '', role: design.signatories[0]?.role ?? '' },
{ name: design.signatories[1]?.name ?? '', role: design.signatories[1]?.role ?? '' }
]
};
}
function fromDraft(draft: CertificateEditorDraft): CertificateDesign {
return {
templateId: draft.templateId,
accentColor: draft.accentColor,
subtitle: draft.subtitle.trim() || undefined,
descriptionOverride: draft.descriptionOverride.trim() || undefined,
idFormat: draft.idFormat.trim() || undefined,
signatories: [
{ name: draft.signatories[0].name, role: draft.signatories[0].role },
{ name: draft.signatories[1].name, role: draft.signatories[1].role }
]
};
}
function readStoredDesign(): CertificateDesign {
const certificate = courseApi.course?.certificate;
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
const legacyTheme = certificate?.theme;
return {
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
descriptionOverride: stored?.descriptionOverride,
signatories: [
{
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
},
{
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
}
],
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
};
}
class CertificateEditorStore {
activePanel = $state<CertificateEditorPanel>('templates');
draft = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
initial = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
isSaving = $state(false);
#initializedCourseId: string | null = null;
readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial));
syncFromCourse(courseId: string, force = false) {
if (!force && this.#initializedCourseId === courseId) return;
const stored = readStoredDesign();
this.initial = toDraft(stored);
this.draft = toDraft(stored);
this.#initializedCourseId = courseId;
}
reset() {
this.draft = toDraft(fromDraft(this.initial));
}
setTemplate(templateId: CertificateTemplateId) {
this.draft.templateId = templateId;
}
setAccent(color: string) {
this.draft.accentColor = color;
}
/**
* Returns a render-ready design with empty optional strings collapsed to
* `undefined`, suitable for handing to `Certificate.Preview` / API payload.
*/
toDesign(): CertificateDesign {
return fromDraft(this.draft);
}
async save() {
const course = courseApi.course;
if (!course?.id) return;
this.isSaving = true;
try {
const certificate = {
...(course.certificate ?? {}),
design: fromDraft(this.draft),
theme: this.draft.templateId
};
const updated = await courseApi.update(course.id, { certificate }, { showSuccessToast: false });
if (updated) {
this.initial = {
...this.draft,
signatories: [{ ...this.draft.signatories[0] }, { ...this.draft.signatories[1] }]
};
snackbar.success(t.get('course.navItem.certificates.editor.saved'));
}
} finally {
this.isSaving = false;
}
}
}
export const certificateEditorStore = new CertificateEditorStore();
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import DownloadIcon from '@lucide/svelte/icons/download';
import ImageIcon from '@lucide/svelte/icons/image';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
@@ -14,6 +14,7 @@
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
let isPngLoading = $state(false);
let isCourseComplete = $state(false);
let evaluation = $state<CertificationEvaluationData | null>(null);
@@ -54,6 +55,25 @@
}
}
function buildBody() {
return {
studentName: $profile.fullname || 'Recipient',
studentId: $profile.id || undefined,
issuedAt: new Date().toISOString()
} as const;
}
async function triggerSave(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
document.body.append(link);
link.download = filename;
link.href = url;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
const downLoadCertificate = async () => {
if (!isCourseComplete || !courseId) return;
@@ -61,27 +81,13 @@
try {
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: {
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
studentName: `${$profile.fullname}`,
courseName: `${courseApi.course?.title}`,
courseDescription: `${courseApi.course?.description}`,
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
orgName: `${$currentOrg.name}`
}
json: buildBody()
});
const blobResponse = await response.blob();
const file = new Blob([blobResponse], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = 'Certificate of Completion - ' + $currentOrg.name;
a.href = fileURL;
a.click();
a.remove();
await triggerSave(
new Blob([blobResponse], { type: 'application/pdf' }),
`certificate-${courseApi.course?.title ?? 'course'}.pdf`
);
} catch (error) {
console.error('Error downloading', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
@@ -90,6 +96,28 @@
isLoading = false;
};
const downloadCertificateImage = async () => {
if (!isCourseComplete || !courseId) return;
isPngLoading = true;
try {
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
param: { courseId },
json: buildBody()
});
const blobResponse = await response.blob();
await triggerSave(
new Blob([blobResponse], { type: 'image/png' }),
`certificate-${courseApi.course?.title ?? 'course'}.png`
);
} catch (error) {
console.error('Error downloading image', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
}
isPngLoading = false;
};
const loadEvaluation = async () => {
if (!courseId) return;
isLoading = true;
@@ -149,9 +177,15 @@
{/each}
{/if}
</div>
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</Button>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
</Button>
<Button variant="outline" onclick={downloadCertificateImage} disabled={!isCourseComplete} loading={isPngLoading}>
<ImageIcon size={16} />
{$t('course.navItem.certificates.download_image')}
</Button>
</div>
</Empty>
</div>
@@ -1,56 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="relative w-full dark:text-black">
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
<div class="w-[70%] pl-3">
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-16">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-32 max-w-none md:w-40" />
</div>
</div>
</div>
@@ -1,55 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3">
<header class="flex items-center justify-start gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-7">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="mt-8 mb-5 flex items-center justify-between text-center">
<div class="">
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -1,53 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-4';
const bordercolor = 'border-pink-700';
const logo = '/logo-512.png';
</script>
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
<div class="my-2 flex items-center gap-1">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class=" h-10 w-10 rounded-md" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
<div class="w-full px-5 py-3">
<p class="my-2 w-full text-center text-sm font-normal tracking-widest text-gray-500 uppercase">
Certificate of completion
</p>
<div class="mb-4 border-b border-gray-500">
<p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-black">has successfully completed the course</p>
<div>
<p class="clamp bg-transparent text-center text-base font-semibold text-pink-700 uppercase">
{courseApi.course?.title}
</p>
</div>
</div>
<p class="desc text-center dark:text-black">{courseApi.course?.description}</p>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
.certificate-bg {
background-color: #f5f8fe;
}
</style>
@@ -1,55 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const borderwidth = 'border-l-8';
const bordercolor = 'border-primary-800';
const logo = '/logo-512.png';
</script>
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
<div class="{borderwidth} {bordercolor} w-full">
<div class="w-full px-5 py-3">
<p class="my-2 text-sm font-normal text-gray-500 uppercase">
Certificate<br /> of completion
</p>
<div class="mb-4 border-b border-gray-500">
<p class="text-xs font-medium text-black">This is to certify that</p>
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
<div>
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
{courseApi.course?.title}
</p>
</div>
</div>
<p class="desc mb-4 text-justify dark:text-black">{courseApi.course?.description}</p>
<div class="my-2 flex items-center gap-1">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="h-10 w-10 rounded-md" />
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
</div>
</div>
</div>
</div>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
.desc {
font-size: 10px;
}
.clamp {
font-size: clamp(14px, 3vw, 16px);
}
.certificate-bg {
background-color: #f5f8fe;
}
</style>
@@ -1,56 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="relative w-full dark:text-black">
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
<div class="w-[70%] pl-3">
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-16">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="my-8 flex items-center justify-between text-center">
<div>
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="flex items-center justify-center overflow-hidden">
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
</div>
</div>
</div>
@@ -1,55 +0,0 @@
<script lang="ts">
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
interface Props {
studentName?: string;
}
let { studentName = '' }: Props = $props();
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full bg-[#95449A] p-3 dark:text-black">
<div class="flex w-full bg-white pb-5">
<div class="w-[80%] pl-3">
<header class="flex items-center justify-start gap-1 text-sm">
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="mt-7">
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
</div>
<div class="mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="m-0 text-lg">
{courseApi.course?.title}
</h2>
</div>
<div class="mt-8 mb-5 flex items-center justify-between text-center">
<div class="">
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black text-[7px]">Signature</p>
</div>
</div>
<footer class="desc w-[90%] text-[8px]">
{courseApi.course?.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -8,12 +8,14 @@
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { page } from '$app/state';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { getActiveCourseNavKey } from '$features/course/utils/functions';
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
@@ -22,6 +24,7 @@
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
$effect(() => {
if (!siteName) return;
@@ -39,9 +42,7 @@
</script>
<header
class="border-border ui:bg-background sticky top-0 z-100 flex w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 {isPublicCourse
? 'h-auto min-h-14 py-1.5'
: 'h-12'}"
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -53,7 +54,7 @@
<div class="flex w-[60%] min-w-0 flex-1 flex-col justify-center gap-0.5">
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
{activeNavKey ? $t(activeNavKey) : ''}
</p>
{#if isPublicCourse}
@@ -64,12 +65,6 @@
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
{/if}
</div>
{#if isPublicCourse}
<p class="ui:text-muted-foreground max-w-xl truncate text-xs">
{$t('course.header.public_course_helper')}
</p>
{/if}
</div>
<span class="grow"></span>
@@ -94,7 +94,9 @@
moveUp: t.get('course.navItem.lessons.exercises.all_exercises.section.move_up'),
moveDown: t.get('course.navItem.lessons.exercises.all_exercises.section.move_down'),
color: t.get('course.navItem.lessons.exercises.all_exercises.section.color'),
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder')
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder'),
collapseSection: t.get('course.navItem.lessons.exercises.all_exercises.section.collapse_section'),
expandSection: t.get('course.navItem.lessons.exercises.all_exercises.section.expand_section')
});
const sectionAfterBehaviorLabels = $derived({
afterSectionPrefix: t.get('course.navItem.lessons.exercises.all_exercises.section.after_section'),
@@ -10,6 +10,8 @@
import XIcon from '@lucide/svelte/icons/x';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
const COLOR_THEMES: ExerciseSectionColorTheme[] = ['blue', 'green', 'amber', 'rose', 'violet', 'slate'];
@@ -82,6 +84,8 @@
let localTitle = $derived(title);
let localDescription = $derived(description ?? '');
let sectionCollapsed = $state(false);
function handleTitleInput() {
onTitleChange?.(localTitle);
}
@@ -94,12 +98,49 @@
<section
id={`exercise-section-${exerciseSectionId}`}
class={`overflow-hidden rounded-lg border bg-white shadow-xs dark:bg-zinc-950 ${BORDER_CLASSES[colorTheme]}`}
aria-expanded={!sectionCollapsed}
>
<div class="flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
<div class="group/sectionhead flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
{#if showReorderHandles}
<GripVerticalIcon class="ui:text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
{/if}
<span class={`h-2.5 w-2.5 shrink-0 rounded-full ${COLOR_CLASSES[colorTheme]}`}></span>
<div class="relative h-7 w-7 shrink-0">
{#if !sectionCollapsed}
<span
class={`pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-100 transition-opacity ${COLOR_CLASSES[colorTheme]} group-hover/sectionhead:opacity-0`}
aria-hidden="true"
></span>
<div
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/sectionhead:opacity-100"
>
<IconButton
type="button"
variant="ghost"
size="icon-sm"
onclick={() => (sectionCollapsed = true)}
tooltip={labels.collapseSection ?? 'Collapse section'}
tooltipSide="bottom"
class="shrink-0"
>
<ChevronUpIcon class="h-4 w-4" aria-hidden="true" />
</IconButton>
</div>
{:else}
<div class="flex h-full w-full items-center justify-center">
<IconButton
type="button"
variant="ghost"
size="icon-sm"
onclick={() => (sectionCollapsed = false)}
tooltip={labels.expandSection ?? 'Expand section'}
tooltipSide="bottom"
class="shrink-0"
>
<ChevronDownIcon class="h-4 w-4" aria-hidden="true" />
</IconButton>
</div>
{/if}
</div>
<Input
bind:value={localTitle}
oninput={handleTitleInput}
@@ -137,48 +178,50 @@
</div>
{/if}
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
<TextareaField
bind:value={localDescription}
rows={2}
oninput={handleDescriptionInput}
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
errorMessage={descriptionError}
className="m-0"
bgColor="resize-none border-dashed bg-transparent text-sm"
/>
</div>
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
{@render children?.()}
<div class="flex justify-center">
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
{#if !sectionCollapsed}
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
<TextareaField
bind:value={localDescription}
rows={2}
oninput={handleDescriptionInput}
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
errorMessage={descriptionError}
className="m-0"
bgColor="resize-none border-dashed bg-transparent text-sm"
/>
</div>
</div>
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
<p class="ui:text-muted-foreground text-sm">
{requiredQuestionCount}
{labels.required ?? 'required'}
</p>
<div class="flex items-center gap-1">
<IconButton
onclick={onMoveUp}
disabled={!canMoveUp}
tooltip={labels.moveUp ?? 'Move section up'}
tooltipSide="bottom"
>
<ArrowUpIcon size={16} />
</IconButton>
<IconButton
onclick={onMoveDown}
disabled={!canMoveDown}
tooltip={labels.moveDown ?? 'Move section down'}
tooltipSide="bottom"
>
<ArrowDownIcon size={16} />
</IconButton>
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
{@render children?.()}
<div class="flex justify-center">
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
<p class="ui:text-muted-foreground text-sm">
{requiredQuestionCount}
{labels.required ?? 'required'}
</p>
<div class="flex items-center gap-1">
<IconButton
onclick={onMoveUp}
disabled={!canMoveUp}
tooltip={labels.moveUp ?? 'Move section up'}
tooltipSide="bottom"
>
<ArrowUpIcon size={16} />
</IconButton>
<IconButton
onclick={onMoveDown}
disabled={!canMoveDown}
tooltip={labels.moveDown ?? 'Move section down'}
tooltipSide="bottom"
>
<ArrowDownIcon size={16} />
</IconButton>
</div>
</div>
{/if}
</section>
@@ -1,5 +1,5 @@
<script lang="ts">
import { questionnaire } from './store';
import { hasSections, questionnaire } from './store';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
@@ -19,6 +19,11 @@
return acc;
}, 0);
}
const activeExerciseSectionsCount = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).length
);
const showSectionsInPreviewSummary = $derived(hasSections($questionnaire.sections ?? []));
</script>
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
@@ -31,6 +36,13 @@
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
</p>
|
{#if showSectionsInPreviewSummary}
<p class="mx-2 dark:text-white">
<strong>{activeExerciseSectionsCount}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.sections')}
</p>
|
{/if}
<p class="mx-2 dark:text-white">
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
@@ -761,6 +761,13 @@
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
</p>
|
{#if hasSectionedExercise}
<p class="mx-2 dark:text-white">
<strong>{activeSections.length}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.sections')}
</p>
|
{/if}
<p class="mx-2 dark:text-white">
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
@@ -13,6 +13,12 @@ interface MaterialTab {
}
export const tabs: MaterialTab[] = [
{
label: 'course.navItem.lessons.materials.tabs.video.title',
icon: VideoIcon,
value: 3,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.note.title',
icon: BookOpenIcon,
@@ -25,12 +31,6 @@ export const tabs: MaterialTab[] = [
value: 2,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.video.title',
icon: VideoIcon,
value: 3,
badgeValue: 0
},
{
label: 'course.navItem.lessons.materials.tabs.document.title',
icon: FileTextIcon,
@@ -5,9 +5,10 @@
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import MODES from '$lib/utils/constants/mode';
import type { Content } from '@cio/ui/custom/editor';
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
import type { TLocale } from '@cio/db/types';
import AIButton from '$features/course/components/lesson/ai-button.svelte';
import QuoteSelection from '$features/course/components/lesson/note/quote-selection.svelte';
import type { Writable } from 'svelte/store';
import { saveDraft } from '$features/course/utils/lesson-draft';
@@ -20,6 +21,19 @@
let { mode = MODES.view, lessonId = '', isLoading, callAI = () => {} }: Props = $props();
let noteRoot: HTMLElement | undefined = $state();
let editRoot: HTMLElement | undefined = $state();
$effect(() => {
if (mode !== MODES.edit) {
editRoot = undefined;
}
});
function bindEditorRoot(editor: TiptapEditor) {
editRoot = editor.view.dom as HTMLElement;
}
let hasAtLeastOneTranslation = $derived(
Object.values(lessonApi.translations[lessonId] || {}).some((content) => {
return content && !!content.length;
@@ -52,15 +66,20 @@
<TextEditor
{content}
onChange={(content) => onEditorChange(content)}
onReady={bindEditorRoot}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
</div>
<QuoteSelection root={editRoot} enabled />
{:else}
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
<SafeHtmlContent {content} />
</HTMLRender>
<div class="relative mx-auto w-full max-w-2xl" bind:this={noteRoot}>
<HTMLRender>
<SafeHtmlContent {content} />
</HTMLRender>
<QuoteSelection root={noteRoot} enabled />
</div>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
{$t('course.navItem.lessons.materials.no_translation')}
@@ -0,0 +1,212 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import QuoteIcon from '@lucide/svelte/icons/quote';
import { t } from '$lib/utils/functions/translations';
import { quoteInChat } from '$features/ai-assistant/utils/store';
interface Props {
root: HTMLElement | undefined;
enabled?: boolean;
}
let { root = undefined, enabled = false }: Props = $props();
let top = $state(0);
let left = $state(0);
let visible = $state(false);
let picked = $state('');
let dragging = false;
let buttonEl: HTMLButtonElement | undefined = $state();
function reset() {
visible = false;
picked = '';
}
function endRect(range: Range): DOMRect | null {
const rects = range.getClientRects();
if (rects.length > 0) {
return rects[rects.length - 1];
}
const box = range.getBoundingClientRect();
return box.width > 0 || box.height > 0 ? box : null;
}
function inRoot(rootEl: HTMLElement, node: Node | null): boolean {
if (!node) return false;
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
return el ? rootEl.contains(el) : false;
}
/** Re-evaluate selection and either reposition / show the chip or hide it. */
function evaluate() {
if (!browser || !enabled || !root) {
reset();
return;
}
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
reset();
return;
}
const range = sel.getRangeAt(0);
if (!inRoot(root, range.commonAncestorContainer) && !inRoot(root, sel.anchorNode) && !inRoot(root, sel.focusNode)) {
reset();
return;
}
const raw = sel.toString();
if (!raw.trim()) {
reset();
return;
}
const rect = endRect(range);
if (!rect) {
reset();
return;
}
picked = raw;
visible = true;
const btn = 28;
const pad = 6;
const gap = 4;
const desiredLeft = rect.right + gap;
const desiredTop = rect.top + (rect.height - btn) / 2;
left = Math.min(Math.max(pad, desiredLeft), window.innerWidth - btn - pad);
top = Math.max(pad, Math.min(desiredTop, window.innerHeight - btn - pad));
}
/**
* Selection-change handler: only used to *hide* the chip if the selection
* collapses or moves out of the note. We never *show* the chip from here so
* the chip can't appear (and thrash the DOM) mid-drag.
*/
function onSelectionChange() {
if (!visible) return;
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
reset();
}
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
const target = e.target as Node | null;
if (buttonEl && target && buttonEl.contains(target)) {
return;
}
dragging = true;
if (visible) reset();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
evaluate();
}
function onKeyUp(e: KeyboardEvent) {
if (!e.shiftKey) return;
evaluate();
}
function onScrollOrResize() {
if (!visible) return;
evaluate();
}
onMount(() => {
if (!browser) return;
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('keyup', onKeyUp);
document.addEventListener('selectionchange', onSelectionChange);
window.addEventListener('resize', onScrollOrResize);
document.addEventListener('scroll', onScrollOrResize, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('keyup', onKeyUp);
document.removeEventListener('selectionchange', onSelectionChange);
window.removeEventListener('resize', onScrollOrResize);
document.removeEventListener('scroll', onScrollOrResize, true);
};
});
function submit(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const text = picked;
reset();
window.getSelection()?.removeAllRanges();
quoteInChat(text);
}
function portal(node: HTMLElement) {
if (!browser) return;
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
}
</script>
<button
type="button"
bind:this={buttonEl}
use:portal
title={t.get('ai_assistant.quote_lesson_note')}
aria-label={t.get('ai_assistant.quote_lesson_note')}
aria-hidden={!visible}
tabindex={visible ? 0 : -1}
onpointerdown={(e) => e.preventDefault()}
onmousedown={(e) => e.preventDefault()}
onclick={submit}
style:position="fixed"
style:top="{top}px"
style:left="{left}px"
style:z-index="2147483000"
style:opacity={visible ? '1' : '0'}
style:visibility={visible ? 'visible' : 'hidden'}
style:pointer-events={visible ? 'auto' : 'none'}
class="ui:bg-secondary ui:text-secondary-foreground ui:border-border ui:hover:bg-accent inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border shadow-md transition-opacity"
>
<QuoteIcon size={14} />
</button>
@@ -24,11 +24,17 @@
<Dialog.Root
bind:open={$lessonVideoUpload.isModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
onOpenChange={(details) => {
if (!details.open && $lessonVideoUpload.isUploading) return;
if (!details.open) onClose();
}}
>
<Dialog.Content class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden">
<Dialog.Content
class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden"
onCloseAutoFocus={(e) => {
if ($lessonVideoUpload.isUploading) e.preventDefault();
}}
>
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
</Dialog.Header>
@@ -0,0 +1,41 @@
import type { AssetTranscriptPayload } from '$features/media/utils/types';
type SeekFn = (seconds: number) => void;
class LessonVideoBus {
/** Asset id of the currently mounted upload-video player (if any). */
assetId = $state<string | null>(null);
/** Last reported playback time in seconds. */
currentTimeSeconds = $state(0);
/** True after the user (or autoplay) has started playback at least once for the current asset. */
hasPlayed = $state(false);
/** Latest transcript payload for the active asset; null if none / still loading. */
transcript = $state<AssetTranscriptPayload | null>(null);
/** True while the transcript fetch is in flight. */
transcriptLoading = $state(false);
private seekFn: SeekFn = () => {};
setSeekFn(seekFn: SeekFn) {
this.seekFn = seekFn;
}
seek(seconds: number) {
this.seekFn(seconds);
}
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
reset(opts?: { keepAssetId?: boolean }) {
if (!opts?.keepAssetId) {
this.assetId = null;
}
this.currentTimeSeconds = 0;
this.hasPlayed = false;
this.transcript = null;
this.transcriptLoading = false;
this.seekFn = () => {};
}
}
export const lessonVideoBus = new LessonVideoBus();
@@ -0,0 +1,216 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { MediaPlayer } from '@cio/ui/custom/media-player';
import { mediaApi } from '$features/media/api';
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
import { t } from '$lib/utils/functions/translations';
import { sidePanel } from '$features/side-panel';
import type { LessonVideo } from './video-card-utils';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
interface Props {
video: LessonVideo;
}
let { video }: Props = $props();
// The parent keys this component on the upload assetId / link, so each
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
const uploadAssetId =
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
let isMounted = true;
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
let hasAutoOpenedTranscript = false;
function clearVttRefetchTimer() {
if (vttRefetchTimer) {
clearTimeout(vttRefetchTimer);
vttRefetchTimer = null;
}
}
function scheduleVttRefetch(expiresAtIso: string | undefined) {
clearVttRefetchTimer();
if (!expiresAtIso) return;
const deadlineMs = new Date(expiresAtIso).getTime() - 60_000;
const delayMs = deadlineMs - Date.now();
if (delayMs <= 0) {
void loadTranscript();
return;
}
vttRefetchTimer = setTimeout(() => {
void loadTranscript();
}, delayMs);
}
function stopJobPoller() {
jobPoller?.stop();
jobPoller = null;
}
async function loadTranscript(): Promise<void> {
if (!uploadAssetId) {
if (isMounted) {
lessonVideoBus.transcript = null;
}
return;
}
if (!isMounted) return;
lessonVideoBus.transcriptLoading = true;
try {
const data = await mediaApi.getAssetTranscript(uploadAssetId);
if (!isMounted) return;
lessonVideoBus.transcript = data;
scheduleVttRefetch(data?.vttUrlExpiresAt);
} finally {
if (isMounted) {
lessonVideoBus.transcriptLoading = false;
}
}
}
async function watchJobsUntilTerminalThenReload(): Promise<void> {
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
stopJobPoller();
const runs = await jobsApi.getMediaJobsForAsset(uploadAssetId);
if (!isMounted) return;
const latest = runs?.[0];
if (!latest) return;
if (latest.job.status === 'completed') {
void loadTranscript();
return;
}
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
return;
}
jobPoller = new JobPoller<MediaJobEnvelope>({
fetch: () => jobsApi.getMediaJob(latest.job.id),
onUpdate: (envelope) => {
if (envelope.job.status === 'completed') {
stopJobPoller();
void loadTranscript();
}
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
stopJobPoller();
}
}
});
jobPoller.start();
}
onMount(() => {
lessonVideoBus.reset();
lessonVideoBus.assetId = uploadAssetId;
void (async () => {
await loadTranscript();
if (!isMounted) return;
await watchJobsUntilTerminalThenReload();
})();
});
onDestroy(() => {
isMounted = false;
clearVttRefetchTimer();
stopJobPoller();
lessonVideoBus.reset();
sidePanel.closeIfScope('lesson');
});
const tracks = $derived(
lessonVideoBus.transcript?.vttUrl
? [
{
kind: 'captions' as const,
src: lessonVideoBus.transcript.vttUrl,
srclang: lessonVideoBus.transcript.language,
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
default: false
}
]
: []
);
// Plyr can't add a <track> after construction, so when the captions URL
// appears asynchronously we remount the player once. Include a stable key when
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
const playerRemountKey = $derived.by(() => {
if (!lessonVideoBus.transcript) return 'no-transcript';
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
});
function handleFirstPlay() {
lessonVideoBus.hasPlayed = true;
if (hasAutoOpenedTranscript) return;
if (!lessonVideoBus.transcript?.segments?.length) return;
hasAutoOpenedTranscript = true;
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
const transcriptPanelControl = $derived(
lessonVideoBus.transcript
? {
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
onClick: () => {
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
}
: undefined
);
</script>
<div class="w-full">
{#key playerRemountKey}
<MediaPlayer
source={{
type: video.type,
url: video.link,
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
tracks
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true,
onTimeUpdate: (seconds) => {
lessonVideoBus.currentTimeSeconds = seconds;
},
onPlayerReady: (player) => {
lessonVideoBus.setSeekFn((seconds) => {
player.currentTime = seconds;
});
},
onFirstPlay: handleFirstPlay,
transcriptPanelControl
}}
/>
{/key}
</div>
@@ -0,0 +1,150 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import { Image } from '$features/ui';
import VideoIcon from '@lucide/svelte/icons/video';
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
import { courseApi } from '$features/course/api';
import VideoCardDropdown from './video-card-dropdown.svelte';
import {
getVideoThumbnailUrl,
getVideoTitle,
getVideoDurationSeconds,
formatVideoDuration,
getVideoCreatedAt,
formatVideoCreatedAt,
type LessonVideo
} from './video-card-utils';
import { t } from '$lib/utils/functions/translations';
interface Props {
video: LessonVideo;
index: number;
isEditMode: boolean;
onRemove: () => void;
}
let { video, index, isEditMode, onRemove }: Props = $props();
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
const title = $derived(getVideoTitle(video, index));
const durationSeconds = $derived(getVideoDurationSeconds(video));
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
const createdAtIso = $derived(getVideoCreatedAt(video));
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
const courseLogoSrc = $derived.by(() => {
const raw = courseApi.course?.logo;
if (typeof raw === 'string' && raw.trim().length > 0) {
return raw.trim();
}
return null;
});
const channelLine = $derived.by(() => {
const courseTitle = courseApi.course?.title?.trim();
if (courseTitle) {
return courseTitle;
}
return t.get('course.navItem.lessons.materials.tabs.video.simple_card.course_fallback');
});
const sourceKindLabel = $derived.by(() => {
const key =
video.type === 'youtube'
? 'kind_youtube'
: video.type === 'upload'
? 'kind_upload'
: video.type === 'google_drive'
? 'kind_google_drive'
: 'kind_generic';
return t.get(`course.navItem.lessons.materials.tabs.video.simple_card.${key}`);
});
const metaLine = $derived.by(() => {
if (createdAtFormatted) {
return `${sourceKindLabel} · ${createdAtFormatted}`;
}
return sourceKindLabel;
});
</script>
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
{#if thumbnailUrl}
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
{:else}
<div
class="flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 px-3 py-4"
role="img"
aria-label={title}
>
<VideoIcon class="ui:text-muted-foreground size-12 shrink-0" />
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
{title}
</span>
</div>
{/if}
{#if durationFormatted}
<div class="absolute right-2 bottom-2 rounded bg-black/80 px-1.5 py-0.5 text-xs font-medium text-white">
{durationFormatted}
</div>
{/if}
</div>
<div class="mt-3 flex min-w-0 gap-3 px-3 py-3">
<Avatar.Root class="mt-0.5 size-9 shrink-0">
{#if courseLogoSrc}
<Avatar.Image src={courseLogoSrc} alt={channelLine} />
{/if}
<Avatar.Fallback>
<VideoIcon class="ui:text-muted-foreground size-4" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-1">
<div class="ui:text-foreground min-w-0 flex-1 text-base leading-snug font-semibold" {title}>
{#if isYoutubeWithLink}
<HoverableItem class="block min-w-0">
{#snippet children(isHovered)}
<a
href={video.link}
target="_blank"
rel="noopener noreferrer"
class="hover:ui:underline focus:ui:underline focus:ui:outline-none flex min-w-0 items-start gap-1.5"
>
<span class="line-clamp-2 min-w-0 flex-1 break-words">{title}</span>
<ExternalLinkIcon
{isHovered}
size={14}
class="ui:text-muted-foreground mt-0.5 shrink-0"
ariaHidden={true}
/>
</a>
{/snippet}
</HoverableItem>
{:else}
<p class="line-clamp-2 min-w-0 break-words">{title}</p>
{/if}
</div>
{#if isEditMode}
<VideoCardDropdown {video} {onRemove} menuPlacement="inline" />
{/if}
</div>
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
{channelLine}
</p>
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">{metaLine}</p>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More