feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
+295
@@ -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="[email protected]"
|
||||
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>
|
||||
+56
-52
@@ -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>
|
||||
|
||||
+46
-53
@@ -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>
|
||||
|
||||
+52
@@ -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>
|
||||
-22
@@ -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>
|
||||
-22
@@ -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>
|
||||
-18
@@ -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,
|
||||
|
||||
+98
-114
@@ -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>
|
||||
|
||||
+76
@@ -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>
|
||||
+181
@@ -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}
|
||||
+39
@@ -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>
|
||||
+94
@@ -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>
|
||||
+132
@@ -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>
|
||||
+45
@@ -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}
|
||||
+46
@@ -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>
|
||||
+145
@@ -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();
|
||||
+58
-24
@@ -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>
|
||||
|
||||
-56
@@ -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>
|
||||
-55
@@ -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>
|
||||
-55
@@ -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>
|
||||
-56
@@ -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>
|
||||
-55
@@ -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>
|
||||
|
||||
+13
-1
@@ -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>
|
||||
+9
-3
@@ -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>
|
||||
|
||||
+41
@@ -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();
|
||||
+216
@@ -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>
|
||||
+150
@@ -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
Reference in New Issue
Block a user