chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -0,0 +1,25 @@
import type { TAgentCreditPurchase } from '@cio/utils/validation/agent';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
type RecordPurchaseRequest = typeof classroomio.agent.credits.purchase.$post;
type RecordPurchaseSuccess = Extract<InferResponseType<RecordPurchaseRequest>, { success: true }>;
export class CreditPurchaseApiServer {
/**
* Record a token-pack purchase from the Polar webhook (server-side, API key auth).
*/
static async recordPurchase(params: TAgentCreditPurchase) {
const result = await safeServerApi<RecordPurchaseSuccess>(() =>
classroomio.agent.credits.purchase.$post({ json: params }, getApiKeyHeaders())
);
if (!result.ok) {
console.error('Error recording credit purchase (server):', result);
return null;
}
return result.body.data;
}
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
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';
@@ -13,7 +13,13 @@
isAgentToolPart,
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
import {
isAiAssistantOpen,
initialChatModel,
initialChatPrompt,
clearInitialChatModel,
clearInitialChatPrompt
} from '$features/ai-assistant/utils/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
@@ -23,9 +29,15 @@
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 { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user';
import type { AiAssistantMessage, UploadedDocument } from '$features/ai-assistant/utils/types';
import type {
AiAssistantMessage,
AiAssistantMessageMetadata,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import {
AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH,
@@ -33,7 +45,22 @@
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
} from '$features/ai-assistant/utils/constants';
import { AGENT_MODEL_IDS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
import {
AGENT_MODELS,
AGENT_MODEL_IDS,
DEFAULT_PICKER_MODEL_ID,
UI_PICKER_MODEL_IDS,
type AgentModelId
} from '@cio/utils/agent-models';
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();
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -43,8 +70,8 @@
let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false);
let usageFetched = $state(false);
let conversationsLoaded = $state(false);
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);
@@ -52,12 +79,18 @@
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);
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
}
function handleSelectModel(id: AgentModelId) {
if ($isFreePlan && paidModelIds.includes(id)) {
openUpgradeModal();
return;
}
selectedModel = id;
try {
@@ -67,8 +100,26 @@
}
}
function handleLockedModelSelect() {
openUpgradeModal();
}
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
$effect(() => {
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
return;
}
selectedModel = DEFAULT_PICKER_MODEL_ID;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
} catch {
// localStorage unavailable
}
});
function clampChatWidth(width: number) {
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
}
@@ -105,8 +156,6 @@
}
async function loadConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.loadConversation(conversationId);
@@ -114,14 +163,19 @@
const conversation = aiAssistantApi.currentConversation;
if (conversation) {
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
// Only overwrite in-memory messages if the loaded conversation actually has saved
// messages. An empty result means the conversation was just created and the first
// message hasn't been persisted yet — overwriting would wipe the optimistic message
// that chat.sendMessage already placed in the UI.
if (loadedMessages.length > 0) {
chat.messages = loadedMessages;
}
setActiveConversationId(courseId, conversationId);
}
}
async function startNewChat() {
const courseId = courseApi.course?.id;
if (!courseId) return;
const created = await aiAssistantApi.createConversation(courseId);
@@ -133,8 +187,6 @@
}
async function handleDeleteConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.deleteConversation(conversationId);
@@ -145,21 +197,35 @@
}
}
// Fetch status and load conversations when panel opens
// Fetch status and load conversations when the panel opens or route `courseId` changes
$effect(() => {
const courseId = courseApi.course?.id;
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
if ($isAiAssistantOpen && !usageFetched && courseId) {
usageFetched = true;
return;
}
if (!courseId) return;
if (statusFetchedForCourseId !== courseId) {
statusFetchedForCourseId = courseId;
aiAssistantApi.fetchStatus(courseId);
}
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
conversationsLoaded = true;
if (conversationsLoadedForCourseId !== courseId) {
conversationsLoadedForCourseId = courseId;
const savedId = getActiveConversationId(courseId);
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;
aiAssistantApi.listConversations(courseId).then(() => {
if (savedId) {
loadConversation(savedId);
} else if (aiAssistantApi.conversations.length > 0) {
@@ -167,20 +233,78 @@
}
});
}
if (!$isAiAssistantOpen) {
usageFetched = false;
conversationsLoaded = false;
}
});
function refreshCourseStateAfterChat() {
const profileId = $profile.id;
// Force refetch course data so new sections/lessons/exercises show in the UI
if (courseId && profileId) {
void courseApi.refreshCourse(courseId, profileId);
}
// Refresh current lesson content if viewing a lesson
if (courseId && currentLessonId) {
void lessonApi.get(courseId, currentLessonId);
}
// Refresh current exercise if viewing an exercise
if (courseId && currentExerciseId) {
void refreshExercisePageData(courseId, currentExerciseId);
}
// Refresh usage meter
if (courseId) {
void aiAssistantApi.fetchStatus(courseId);
}
}
async function persistFinishedChat(messages: AiAssistantMessage[], conversationId: string | null) {
if (conversationId) {
await aiAssistantApi.saveMessages(conversationId, messages);
// Generate a smart title after the first exchange (when title is still default)
const activeConv = aiAssistantApi.conversations.find((c) => c.id === conversationId);
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
const firstUserMsg = messages.find((message) => message.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
);
if (isDefaultTitle && textPart?.text) {
await aiAssistantApi.generateTitle(conversationId, textPart.text.slice(0, 500));
}
return;
}
if (!courseId) return;
// No active conversation yet — create one and save
const created = await aiAssistantApi.createConversation(courseId);
if (!created) return;
setActiveConversationId(courseId, created.id);
await aiAssistantApi.saveMessages(created.id, messages);
const firstUserMsg = messages.find((message) => message.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
);
if (textPart?.text) {
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
}
}
const chat = new Chat({
id: 'ai-assistant',
transport: new DefaultChatTransport({
api: `${getRequestBaseUrl()}/agent/chat`,
credentials: 'include',
body: () => ({
courseId: courseApi.course?.id ?? '',
courseId,
model: selectedModel,
context: {
lessonId: page.params?.lessonId,
@@ -190,64 +314,12 @@
}),
fetch: (input, init) => apiClient.request(input, init)
}),
onFinish: async () => {
const courseId = courseApi.course?.id;
const profileId = $profile.id;
// Force refetch course data so new sections/lessons/exercises show in the UI
if (courseId && profileId) {
courseApi.refreshCourse(courseId, profileId);
}
// Refresh current lesson content if viewing a lesson
if (courseId && currentLessonId) {
lessonApi.get(courseId, currentLessonId);
}
// Refresh current exercise if viewing an exercise
if (courseId && currentExerciseId) {
await refreshExercisePageData(courseId, currentExerciseId);
}
// Refresh usage meter
if (courseId) {
aiAssistantApi.fetchStatus(courseId);
}
onFinish: () => {
// Clear document attachment after message is processed
uploadedDocument = null;
// Persist messages to the active conversation
if (activeConversationId) {
await aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
// Generate a smart title after the first exchange (when title is still default)
const activeConv = aiAssistantApi.conversations.find((c) => c.id === activeConversationId);
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
const textPart = firstUserMsg?.parts?.find((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text');
if (isDefaultTitle && textPart?.text) {
await aiAssistantApi.generateTitle(activeConversationId, textPart.text.slice(0, 500));
}
} else if (courseId) {
// No active conversation yet — create one and save
const created = await aiAssistantApi.createConversation(courseId);
if (created) {
setActiveConversationId(courseId, created.id);
await aiAssistantApi.saveMessages(created.id, chat.messages as AiAssistantMessage[]);
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text'
);
if (textPart?.text) {
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
}
}
}
refreshCourseStateAfterChat();
void persistFinishedChat(chat.messages as AiAssistantMessage[], activeConversationId);
}
});
@@ -255,8 +327,8 @@
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
"Improve this lesson's text",
'Summarize this lesson'
'Summarize this lesson',
'Help me publish this course'
];
function handleSend() {
@@ -274,8 +346,6 @@
// Create conversation eagerly before sending the first message
if (!activeConversationId) {
const courseId = courseApi.course?.id;
if (!courseId) return;
aiAssistantApi.createConversation(courseId).then((created) => {
@@ -304,8 +374,6 @@
}
async function handleFileSelect(file: File) {
const courseId = courseApi.course?.id;
if (!courseId) return;
const conversationId = await ensureActiveConversation(courseId);
@@ -339,8 +407,6 @@
inputValue = '';
const courseId = courseApi.course?.id;
if (!activeConversationId) {
if (!courseId) return;
@@ -367,7 +433,7 @@
}
function handleResume() {
inputValue = 'Continue implementing the plan from where you left off.';
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
handleSend();
}
@@ -393,6 +459,8 @@
if (!lastAssistantMsg) return null;
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit';
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
isAgentToolPart(part)
) as AgentToolPart[];
@@ -417,10 +485,10 @@
});
const allDone = steps.every((s) => s.status === 'completed');
const isStopped = !isStreaming && !allDone;
const isStopped = !isStreaming && (!allDone || reachedStepLimit);
// Hide the card once the agent finishes cleanly — the text response takes over
if (allDone && !isStreaming) return null;
if (allDone && !isStreaming && !reachedStepLimit) return null;
const hasMutations = toolParts.some((part) => {
const toolName = getAgentToolName(part);
@@ -496,6 +564,30 @@
clearChatResizeListeners();
});
$effect(() => {
const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return;
const model = $initialChatModel;
clearInitialChatPrompt();
clearInitialChatModel();
tick().then(() => {
if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
handleSelectModel(model);
}
inputValue = prompt;
handleSend();
});
});
$effect(() => {
if (!hasLoadedChatWidth) {
return;
@@ -525,7 +617,7 @@
messages={chat.messages}
status={chat.status}
{isStreaming}
courseId={courseApi.course?.id ?? ''}
{courseId}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
@@ -544,12 +636,14 @@
{uploadedDocument}
{mentionItems}
{selectedModel}
lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error}
onSend={handleSend}
onStop={handleStop}
onFileSelect={handleFileSelect}
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
/>
{/snippet}
@@ -105,6 +105,23 @@ class AiAssistantApi extends BaseApiWithErrors {
});
}
async generateCourseMeta(prompt: string): Promise<{ title: string; description: string } | null> {
let meta: { title: string; description: string } | null = null;
await this.execute<(typeof classroomio.agent)['generate-course-title']['$post']>({
requestFn: () =>
classroomio.agent['generate-course-title'].$post({
json: { prompt }
}),
logContext: 'generating course meta',
onSuccess: (result) => {
meta = (result as { data: { title: string; description: string } }).data;
}
});
return meta;
}
async uploadDocument(
file: File,
courseId: string,
@@ -8,6 +8,7 @@
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
interface Conversation {
id: string;
@@ -75,11 +76,19 @@
<Popover.Root bind:open={historyPopoverOpen}>
<Popover.Trigger>
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
<HistoryIcon size={16} />
</IconButton>
{#snippet child({ props })}
<Button
variant="outline"
size="icon-xs"
{...props}
aria-label={$t('ai_assistant.chat_history')}
title={$t('ai_assistant.chat_history')}
>
<HistoryIcon size={16} />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="ui:p-0! w-72" align="end">
<Popover.Content class="ui:p-0! z-200! w-72" align="center">
<ChatHistoryPopover
{conversations}
{activeConversationId}
@@ -94,12 +103,13 @@
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.allowance > 0}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
{#if 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">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
>{tokenUsage.used.toLocaleString()} / {totalBudget.toLocaleString()}
{$t('ai_assistant.tokens_label')}</span
>
<span>{usagePercent}%</span>
@@ -9,9 +9,10 @@
import LoaderIcon from '@lucide/svelte/icons/loader';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import { resolve } from '$app/paths';
import { currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
import { ModelPicker } from '@cio/ui/custom/model-picker';
import type { AgentModelId } from '@cio/utils/agent-models';
interface UploadedDocument {
@@ -28,11 +29,13 @@
mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void;
}
let {
@@ -44,11 +47,13 @@
mentionItems,
uploadedDocument,
selectedModel,
lockedModelIds = [],
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel
onSelectModel,
onLockedModelSelect
}: Props = $props();
let fileInputEl: HTMLInputElement | undefined = $state();
@@ -110,11 +115,29 @@
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="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.tokens_exhausted')}
<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}
</div>
{:else}
{#if isStreaming}
<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"
>
{$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"
@@ -181,7 +204,13 @@
<SquareIcon size={12} />
</Button>
{:else}
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{/if}
{/snippet}
</ChatTextarea>
@@ -9,6 +9,8 @@
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 type { CoursePlan } from '$features/ai-assistant/utils/course-plan';
import { t } from '$lib/utils/functions/translations';
import {
getAgentToolName,
getAgentToolResult,
@@ -42,6 +44,7 @@
}
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
function isDeferredPlanPart(part: Record<string, unknown>) {
if (!isAgentToolPart(part)) {
@@ -108,7 +111,7 @@
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
{:else}
<div class="ui:bg-background/70 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
{#if toolStatus === 'completed' && toolName}
@@ -148,7 +151,7 @@
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<div class="mt-3">
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<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">
@@ -180,6 +183,15 @@
</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>
@@ -1,49 +0,0 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import CheckIcon from '@lucide/svelte/icons/check';
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
interface Props {
value: AgentModelId;
disabled?: boolean;
onChange: (id: AgentModelId) => void;
}
let { value, disabled = false, onChange }: Props = $props();
let open = $state(false);
function handleSelect(id: AgentModelId) {
onChange(id);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="ui:text-muted-foreground hover:ui:bg-muted ui:hover:text-foreground flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<span class="truncate">{AGENT_MODELS[value].label}</span>
<ChevronDownIcon size={12} />
</Popover.Trigger>
<Popover.Content class="ui:p-0! ui:z-[110] w-48" align="end" side="top">
<div class="py-1">
{#each UI_PICKER_MODEL_IDS as id (id)}
{@const isSelected = id === value}
<button
type="button"
onclick={() => handleSelect(id)}
class="hover:ui:bg-muted flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors"
>
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
{#if isSelected}
<CheckIcon size={14} class="ui:text-primary shrink-0" />
{/if}
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
@@ -6,6 +6,7 @@
import PencilIcon from '@lucide/svelte/icons/pencil';
import { Button } from '@cio/ui/base/button';
import { SvelteSet } from 'svelte/reactivity';
import type { CoursePlan } from './utils/course-plan';
/** Token cost estimation heuristics (tokens per item) */
const TOKEN_COST_ESTIMATES = {
@@ -14,25 +15,6 @@
EXERCISE_CREATE: 1500
} as const;
interface PlanItem {
type: 'lesson' | 'exercise';
title: string;
description: string;
order: number;
hasExercise: boolean;
}
interface PlanSection {
title: string;
order: number;
items: PlanItem[];
}
interface CoursePlan {
title: string;
sections: PlanSection[];
}
interface Props {
plan: CoursePlan;
onImplement: (editedPlan: CoursePlan) => void;
@@ -0,0 +1,18 @@
export interface CoursePlanSectionItem {
type: 'lesson' | 'exercise';
title: string;
description: string;
order: number;
hasExercise: boolean;
}
export interface CoursePlanSection {
title: string;
order: number;
items: CoursePlanSectionItem[];
}
export interface CoursePlan {
title: string;
sections: CoursePlanSection[];
}
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
import type { AgentModelId } from '@cio/utils/agent-models';
/** Controls whether the AI assistant right sidebar Sheet is open. */
export const isAiAssistantOpen = writable(false);
@@ -14,3 +15,23 @@ export function closeAiAssistant() {
export function toggleAiAssistant() {
isAiAssistantOpen.update((open) => !open);
}
/** Carries a prompt from the home page course creator into the AI chat on first open. */
export const initialChatPrompt = writable<string | null>(null);
export const initialChatModel = writable<AgentModelId | null>(null);
export function setInitialChatPrompt(prompt: string) {
initialChatPrompt.set(prompt);
}
export function setInitialChatModel(model: AgentModelId) {
initialChatModel.set(model);
}
export function clearInitialChatPrompt() {
initialChatPrompt.set(null);
}
export function clearInitialChatModel() {
initialChatModel.set(null);
}
@@ -8,7 +8,9 @@ const TOOL_LABELS: Record<string, string> = {
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',
@@ -40,6 +42,8 @@ export function getToolResultLabel(toolName: string, result: unknown): string {
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':
@@ -78,7 +82,9 @@ export const MUTATION_TOOLS = [
'update_lesson',
'update_lesson_content',
'create_exercise',
'create_exercise_section',
'update_exercise',
'update_exercise_section',
'add_questions',
'update_questions',
'reorder_content',
@@ -12,12 +12,18 @@ export interface AgentToolPart {
};
}
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
if (part.type === 'tool-invocation') {
export function isAgentToolPart(part: unknown): part is AgentToolPart {
if (!part || typeof part !== 'object') {
return false;
}
const record = part as Record<string, unknown>;
if (record.type === 'tool-invocation') {
return true;
}
return typeof part.type === 'string' && part.type.startsWith('tool-');
return typeof record.type === 'string' && record.type.startsWith('tool-');
}
export function getAgentToolName(part: AgentToolPart): string | null {
@@ -11,8 +11,22 @@ export interface AiAssistantMessageAttachment {
name: string;
}
export interface AiAssistantMessageTokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface AiAssistantMessageContinuation {
reason: 'step_limit';
maxSteps: number;
finishReason?: string;
}
export interface AiAssistantMessageMetadata {
attachment?: AiAssistantMessageAttachment;
tokenUsage?: AiAssistantMessageTokenUsage;
continuation?: AiAssistantMessageContinuation;
}
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
@@ -47,3 +61,7 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data']
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];
@@ -349,7 +349,10 @@ export class CourseApi extends BaseApiWithErrors {
* @param fields Course creation fields (title, description, type)
* @returns The created course data or null on error
*/
async create(fields: { title: string; description: string; type: TCourseType }) {
async create(
fields: { title: string; description: string; type: TCourseType },
onCreated?: (courseId: string) => void
) {
const org = get(currentOrg);
const userProfile = get(profile);
@@ -390,11 +393,12 @@ export class CourseApi extends BaseApiWithErrors {
user_email: userProfile.email
});
// Navigate to the new course
goto(resolve(`/courses/${newCourse.id}`, {}));
// Show success message
snackbar.success('Course created successfully');
if (onCreated) {
onCreated(newCourse.id);
} else {
goto(resolve(`/courses/${newCourse.id}`, {}));
snackbar.success('Course created successfully');
}
// Mark as successful
this.success = true;
@@ -6,10 +6,25 @@ import type {
Exercise,
GetExerciseRequest,
SubmitExerciseRequest,
UpdateExerciseRequest
UpdateExerciseRequest,
VideoRecordingPlaybackRequest,
VideoRecordingUploadCompleteRequest,
VideoRecordingUploadInitRequest
} from '../utils/types';
import type { TExerciseCreate, TExerciseSubmissionCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import { ZExerciseCreate, ZExerciseSubmissionCreate, ZExerciseUpdate } from '@cio/utils/validation/exercise';
import type {
TExerciseCreate,
TExerciseSubmissionCreate,
TExerciseUpdate,
TExerciseVideoRecordingUploadComplete,
TExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import {
ZExerciseCreate,
ZExerciseSubmissionCreate,
ZExerciseUpdate,
ZExerciseVideoRecordingUploadComplete,
ZExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
@@ -32,7 +47,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
@@ -64,7 +79,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -106,7 +121,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
@@ -193,6 +208,71 @@ export class ExerciseApi extends BaseApiWithErrors {
});
}
async initVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadInit
) {
const result = ZExerciseVideoRecordingUploadInit.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadInitRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.init.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'initializing video recording upload'
});
}
async completeVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadComplete
) {
const result = ZExerciseVideoRecordingUploadComplete.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadCompleteRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.complete.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'completing video recording upload'
});
}
async getVideoRecordingPlaybackUrl(
courseId: string,
exerciseId: string,
submissionId: string,
questionId: number | string
) {
return await this.execute<VideoRecordingPlaybackRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission[':submissionId'].question[':questionId'][
'video-recording'
].playback.$get({
param: { courseId, exerciseId, submissionId, questionId: String(questionId) }
}),
logContext: 'getting video recording playback URL'
});
}
/**
* Creates an exercise from a template
* @param courseId Course ID
@@ -220,7 +300,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -163,9 +163,20 @@ export class LessonApi extends BaseApiWithErrors {
json: result.data
}),
logContext: 'updating lesson',
onSuccess: () => {
this.errors = {};
},
onError: (result) => {
console.log('onError', result);
snackbar.error('snackbar.lessons.lesson_update_failed');
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.lesson_update_failed');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else {
snackbar.error('snackbar.lessons.lesson_update_failed');
}
}
});
@@ -2,7 +2,10 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import { currentOrgDomain } from '$lib/utils/store/org';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
@@ -14,9 +17,23 @@
title: string;
description: string;
isPublished?: boolean;
courseType?: string | null;
slug?: string;
/** Compact menu for LMS course cards */
lmsPublicQuickOnly?: boolean;
}
let { id, title, description, isPublished = false }: Props = $props();
let {
id,
title,
description,
isPublished = false,
courseType = null,
slug = '',
lmsPublicQuickOnly = false
}: Props = $props();
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
function redirect(url: string) {
goto(resolve(url, {}));
@@ -43,40 +60,65 @@
$copyCourseModal.description = description;
}
function handleOpenCourseSite() {
window.open(`/courses/${id}`, '_blank');
function handleOpenPublicCourse() {
openCoursePreview({
courseId: id,
courseSlug: slug,
currentOrgDomain: $currentOrgDomain
});
}
async function handleCopyCourseUrl() {
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if isPublished}
<DropdownMenu.Item onclick={handleOpenCourseSite}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_course_site')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{#if lmsPublicQuickOnly ? showPublicCourseLinks : true}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if lmsPublicQuickOnly}
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{:else}
{#if showPublicCourseLinks}
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -7,6 +7,7 @@
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { Button } from '@cio/ui/base/button';
@@ -165,6 +166,18 @@
: undefined
);
const visibilityBadge = $derived(
type === 'PUBLIC'
? {
label: $t('courses.course_card.public_badge'),
icon: GlobeIcon,
iconClass: 'custom ui:text-primary size-3.5 shrink-0'
}
: undefined
);
const showLmsPublicCourseMenu = $derived(isLMS && type === 'PUBLIC' && !!slug?.trim() && isPublished);
const complianceStatusKey = $derived(
isLMS && type === 'COMPLIANCE' && !isExplore
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
@@ -202,6 +215,7 @@
{title}
{description}
{typeBadge}
{visibilityBadge}
class="group relative"
>
{#snippet media()}
@@ -211,8 +225,12 @@
{#snippet overlay()}
{#if actions}
{@render actions()}
{:else if !isLMS && !isOnLandingPage}
<CardDropdown {id} {title} {description} {isPublished} />
{:else if !isOnLandingPage}
{#if !isLMS}
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} />
{:else if showLmsPublicCourseMenu}
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} lmsPublicQuickOnly={true} />
{/if}
{/if}
{/snippet}
@@ -16,11 +16,12 @@
import { openCoursePreview } from '$features/course/utils/course-preview';
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import CoursePublishBadge from './course-publish-badge.svelte';
import CoursePublicBadge from './course-public-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
$effect(() => {
if (!siteName) return;
@@ -38,7 +39,9 @@
</script>
<header
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"
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'}"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -47,24 +50,34 @@
<Separator orientation="vertical" />
</div>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<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 || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{#if isPublicCourse}
<CoursePublicBadge class="shrink-0" />
{/if}
{#if showCoursePublishBadge}
<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>
{#if !$isFreePlan}
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
</Button>
{/if}
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
</Button>
<Popover.Root>
<Popover.Trigger>
@@ -11,8 +11,13 @@
import { Image } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import { currentOrgDomain } from '$lib/utils/store/org';
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
import CourseContentIcon from './course-content-icon.svelte';
import CoursePublicBadge from './course-public-badge.svelte';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
interface Tag {
id: string;
@@ -59,10 +64,14 @@
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0);
const showActionsColumn = $derived(!isLMS || (isLMS && showPublicCourseLinks));
const gridTemplateColumns = $derived(
isLMS
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
showActionsColumn
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
);
const studentPlaceholderAvatarUrls = $derived.by(() => [
@@ -112,6 +121,10 @@
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
async function handleCopyCourseUrl() {
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
}
</script>
<ResourceListRow.Root
@@ -133,7 +146,12 @@
<!-- Title / type / updated -->
<div class="flex min-w-0 flex-col gap-0.5">
<p class="line-clamp-2 text-base">{title}</p>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<p class="line-clamp-2 min-w-0 flex-1 text-base">{title}</p>
{#if type === 'PUBLIC'}
<CoursePublicBadge class="shrink-0" />
{/if}
</div>
{#if typeLabel}
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
{/if}
@@ -225,7 +243,7 @@
</div>
<!-- Actions -->
{#if !isLMS}
{#if showActionsColumn}
<div class="flex justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -235,7 +253,7 @@
variant="ghost"
size="icon"
class="size-8"
aria-label="Course actions"
aria-label={$t('courses.course_card.actions_menu_aria')}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="size-4" />
@@ -243,16 +261,38 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleOpen}>
{$t('courses.course_card.context_menu.open')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleClone}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{#if showPublicCourseLinks}
<DropdownMenu.Item
onclick={() =>
openCoursePreview({
courseId: id,
courseSlug: slug,
currentOrgDomain: $currentOrgDomain
})}
>
<ExternalLinkIcon class="mr-2 size-4" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon class="mr-2 size-4" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{/if}
{#if !isLMS}
{#if showPublicCourseLinks}
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleOpen}>
{$t('courses.course_card.context_menu.open')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleClone}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { t } from '$lib/utils/functions/translations';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<Badge
variant="secondary"
class="ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1 ui:rounded-md! ui:text-xs ui:capitalize {className}"
>
<GlobeIcon class="size-3.5 shrink-0" />
{$t('courses.course_card.public_badge')}
</Badge>
@@ -4,18 +4,23 @@
import { t } from '$lib/utils/functions/translations';
import { deleteConfirmation } from './store';
let { onDelete = () => {}, onCancel = () => {} } = $props();
let {
title = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title'),
description = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure'),
cancelLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no'),
confirmLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes'),
onDelete = () => {},
onCancel = () => {}
} = $props();
</script>
<Dialog.Root bind:open={$deleteConfirmation.open}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p>
<p class="mt-0 text-base dark:text-white">{description}</p>
<div class="mt-5 flex items-center justify-between">
<Button
@@ -25,7 +30,7 @@
onCancel();
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
{cancelLabel}
</Button>
<Button
onclick={() => {
@@ -33,7 +38,7 @@
$deleteConfirmation.open = false;
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
{confirmLabel}
</Button>
</div>
</div>
@@ -1,24 +1,32 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { tick } from 'svelte';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode
handleCode,
handleAddQuestion,
handleRemoveSection,
handleReorderSections,
handleReorderQuestionsInSection,
handleUpdateSectionAfterBehavior,
handleUpdateSectionColorTheme,
handleUpdateSectionDescription,
handleUpdateSectionTitle,
hasSections,
getQuestionsForSection
} from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { QUESTION_TYPES } from '$features/ui/question/constants';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
import QuestionTypeSelect from './question-type-select.svelte';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
@@ -26,17 +34,20 @@
import { QuestionContainer } from '$features/course/components';
import { uploadImage } from '$lib/utils/services/upload';
import {
getExerciseEditorQuestionTypeLabel,
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types';
import { getExerciseQuestionLabels } from './question-labels';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import SectionEditor from './section-editor.svelte';
import type { Question } from '$features/course/types';
import type { ExerciseEditorErrors } from './store';
const flipDurationMs = 200;
const initialQuestionsLength = $questionnaire.questions.length;
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
interface Props {
shouldDeleteExercise?: boolean;
@@ -45,19 +56,52 @@
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
/** Public course restricts picker to auto-gradable question types only. */
isPublicCourse?: boolean;
reorderQuestions?: boolean;
}
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
exerciseId: _exerciseId,
goBack: _goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
selfPacedCourse = false,
isPublicCourse = false,
reorderQuestions = false
}: Props = $props();
const availableQuestionTypes = $derived(
isPublicCourse ? QUESTION_TYPES.filter((type) => type.autoGradable) : QUESTION_TYPES
);
let questionIdToDelete = $state(null);
let sectionIdToDelete = $state<string | null>(null);
let deleteConfirmationType = $state<'question' | 'section'>('question');
let reorderQuestionItemsBySectionId = $state<Record<string, Question[]>>({});
let previousReorderQuestions = $state(false);
let errors = $derived($questionnaireValidation);
const questionLabels = $derived(getExerciseQuestionLabels());
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const sectionLabels = $derived({
questions: t.get('course.navItem.lessons.exercises.all_exercises.section.questions'),
shortQuestions: t.get('course.navItem.lessons.exercises.all_exercises.section.short_questions'),
required: t.get('course.navItem.lessons.exercises.all_exercises.section.required'),
addQuestion: t.get('course.navItem.lessons.exercises.all_exercises.section.add_question'),
delete: t.get('course.navItem.lessons.exercises.all_exercises.section.delete'),
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')
});
const sectionAfterBehaviorLabels = $derived({
afterSectionPrefix: t.get('course.navItem.lessons.exercises.all_exercises.section.after_section'),
continue: t.get('course.navItem.lessons.exercises.all_exercises.section.continue'),
goToSection: t.get('course.navItem.lessons.exercises.all_exercises.section.go_to_section'),
submit: t.get('course.navItem.lessons.exercises.all_exercises.section.submit')
});
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
@@ -67,25 +111,62 @@
return isLast && initialQuestionsLength !== currentQuestionsLength;
}
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
function getQuestionElementId(questionId: string | number) {
return `exercise-question-${questionId}`;
}
async function addQuestionToSection(sectionId: string) {
const questionId = handleAddQuestion(sectionId);
await scrollToExerciseElement(getQuestionElementId(questionId));
}
function onInitDeleteClicked(questionId) {
return () => {
deleteConfirmationType = 'question';
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
if (deleteConfirmationType === 'section' && sectionIdToDelete) {
deleteSection(sectionIdToDelete);
sectionIdToDelete = null;
deleteConfirmationType = 'question';
return;
}
handleRemoveQuestion(questionIdToDelete)();
questionIdToDelete = null;
}
function getQuestionErrorMsg(errors, question, errorKey) {
function onCancelDeleteClicked() {
questionIdToDelete = null;
sectionIdToDelete = null;
deleteConfirmationType = 'question';
}
function getQuestionErrorMsg(
errors: ExerciseEditorErrors,
question: Question,
errorKey: 'option' | 'title' | 'points'
) {
return errors[question.id] ? errors[question.id][errorKey] : null;
}
function getQuestionTypeLabel(questionType) {
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
if (questionType.labelKey) return t.get(questionType.labelKey);
return questionType.label;
function getSectionErrorMsg(errors: ExerciseEditorErrors, sectionId: string, errorKey: 'title' | 'description') {
return errors[sectionId]?.[errorKey];
}
function makeDefaultOption() {
@@ -117,6 +198,7 @@
const current = q.questions[idx];
let nextOptions = current.options ?? [];
let nextSettings = current.settings ?? {};
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
@@ -130,11 +212,20 @@
nextOptions = [];
}
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.VIDEO_RECORDING) {
nextSettings = {
...nextSettings,
maxDurationSeconds: 60,
allowRetakes: true
};
}
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
settings: nextSettings,
options: nextOptions,
isDirty: true
};
@@ -179,12 +270,158 @@
return { ...q, questions: next };
});
}
function moveSection(sectionId: string, direction: -1 | 1) {
const currentIndex = activeSections.findIndex((section) => section.id === sectionId);
const targetIndex = currentIndex + direction;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= activeSections.length) return;
const orderedIds = activeSections.map((section) => section.id);
const [sectionToMove] = orderedIds.splice(currentIndex, 1);
orderedIds.splice(targetIndex, 0, sectionToMove);
handleReorderSections(orderedIds);
}
function deleteSection(sectionId: string) {
const sectionIndex = activeSections.findIndex((section) => section.id === sectionId);
const nearestSection = activeSections[sectionIndex + 1] ?? activeSections[sectionIndex - 1];
handleRemoveSection(sectionId, nearestSection?.id);
}
function onInitSectionDeleteClicked(sectionId: string) {
deleteConfirmationType = 'section';
sectionIdToDelete = sectionId;
$deleteConfirmation.open = true;
}
function syncReorderQuestionItems() {
reorderQuestionItemsBySectionId = Object.fromEntries(
activeSections.map((section) => [section.id, getQuestionsForSection($questionnaire.questions, section.id)])
);
}
function getReorderQuestionsForSection(sectionId: string, sectionQuestions: Question[]) {
return reorderQuestionItemsBySectionId[sectionId] ?? sectionQuestions;
}
function handleSectionQuestionDndConsider(sectionId: string, event: QuestionDndEvent) {
reorderQuestionItemsBySectionId = {
...reorderQuestionItemsBySectionId,
[sectionId]: event.detail.items
};
}
function handleSectionQuestionDndFinalize(sectionId: string, event: QuestionDndEvent) {
reorderQuestionItemsBySectionId = {
...reorderQuestionItemsBySectionId,
[sectionId]: event.detail.items
};
handleReorderQuestionsInSection(
sectionId,
event.detail.items.map((question) => question.id)
);
}
function getRequiredQuestionCount(questions: Question[]) {
return questions.filter((question) => toExerciseQuestionModel(question).required !== false).length;
}
$effect(() => {
if (reorderQuestions && !previousReorderQuestions) {
syncReorderQuestionItems();
}
if (!reorderQuestions && previousReorderQuestions) {
reorderQuestionItemsBySectionId = {};
}
previousReorderQuestions = reorderQuestions;
});
</script>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<DeleteConfirmationModal
title={deleteConfirmationType === 'section'
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_title')
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
description={deleteConfirmationType === 'section'
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_sure')
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
onCancel={onCancelDeleteClicked}
onDelete={onFinalDeleteClicked}
/>
<OrderModal />
{#snippet questionEditor(question, index)}
<QuestionContainer
elementId={getQuestionElementId(question.id)}
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
>
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
labels: questionLabels,
onImageUpload: uploadImage
}}
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
>
{#snippet questionTypeSelect()}
<QuestionTypeSelect
value={question.questionTypeId?.toString()}
onValueChange={(nextValue) => onQuestionTypeChange(question.id, nextValue)}
triggerQuestionType={question?.questionType}
types={availableQuestionTypes}
{selfPacedCourse}
/>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/snippet}
{#snippet compactQuestionRow(question, index)}
<div class="border-border flex items-center gap-3 rounded-md border bg-white px-4 py-3 dark:bg-black">
<span class="ui:text-muted-foreground w-6 text-sm font-medium tabular-nums">{index + 1}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">
{question.title || getExerciseEditorQuestionTypeLabel(question?.questionType)}
</p>
<p class="ui:text-muted-foreground text-xs">{getExerciseEditorQuestionTypeLabel(question?.questionType)}</p>
</div>
</div>
{/snippet}
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
@@ -192,107 +429,92 @@
</div>
{/if}
<div class="questions px-6 pt-6">
{#each $questionnaire.questions as question, index (question.id)}
{#if !question.deletedAt}
<QuestionContainer
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
>
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
labels: questionLabels,
onImageUpload: uploadImage
{#if hasSections($questionnaire.sections)}
<div class="space-y-5">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
{@const reorderSectionQuestions = getReorderQuestionsForSection(section.id, sectionQuestions)}
<SectionEditor
exerciseSectionId={section.id}
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
requiredQuestionCount={getRequiredQuestionCount(sectionQuestions)}
labels={sectionLabels}
titleError={getSectionErrorMsg(errors, section.id, 'title')}
descriptionError={getSectionErrorMsg(errors, section.id, 'description')}
showReorderHandles={reorderQuestions}
onTitleChange={(title) => handleUpdateSectionTitle(section.id, title)}
onDescriptionChange={(description) => handleUpdateSectionDescription(section.id, description)}
onColorThemeChange={(colorTheme) => handleUpdateSectionColorTheme(section.id, colorTheme)}
onAddQuestion={() => addQuestionToSection(section.id)}
onDelete={() => onInitSectionDeleteClicked(section.id)}
onMoveUp={() => moveSection(section.id, -1)}
onMoveDown={() => moveSection(section.id, 1)}
canMoveUp={sectionIndex > 0}
canMoveDown={sectionIndex < activeSections.length - 1}
>
<div
class="space-y-2"
use:dndzone={{
items: reorderQuestions ? reorderSectionQuestions : sectionQuestions,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed',
'border-radius': '0.5rem'
},
dragDisabled: !reorderQuestions,
type: `exercise-section-question-${section.id}`
}}
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
>
{#snippet questionTypeSelect()}
<Select.Root
type="single"
value={question.questionTypeId?.toString()}
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
{#each reorderQuestions ? reorderSectionQuestions : sectionQuestions as question, index (question.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="flex items-start gap-3 transition {reorderQuestions
? 'cursor-grab active:cursor-grabbing'
: ''}"
>
<Select.Trigger class="w-[180px]">
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type (type.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
openUpgradeModal();
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getQuestionTypeLabel(type)}</span>
{#if selfPacedCourse}
<Badge
variant={type.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{type.autoGradable
? $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
)
: $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
)}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
<div class="ui:text-muted-foreground flex w-5 shrink-0 flex-col items-center gap-2 pt-3">
{#if reorderQuestions}
<GripVerticalIcon class="h-4 w-4 cursor-grab active:cursor-grabbing" aria-hidden="true" />
{/if}
<span class="text-xs font-medium tabular-nums">{index + 1}</span>
</div>
<div class="min-w-0 flex-1">
{#if reorderQuestions}
{@render compactQuestionRow(question, index)}
{:else}
{@render questionEditor(question, index)}
{/if}
</div>
</div>
{/each}
</div>
</SectionEditor>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/if}
{/each}
<ExerciseQuestion.SectionAfterBehavior
{sectionIndex}
currentSectionId={section.id}
allSections={activeSections}
afterBehavior={section.afterBehavior}
labels={sectionAfterBehaviorLabels}
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
/>
{/each}
</div>
{:else}
{#each $questionnaire.questions as question, index (question.id)}
{#if !question.deletedAt}
{@render questionEditor(question, index)}
{/if}
{/each}
{/if}
</div>
</div>
@@ -2,11 +2,13 @@
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { questionnaire } from './store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { slugifyTitle } from '@cio/utils/validation';
type Props = {
exerciseId: string;
@@ -15,13 +17,21 @@
let { exerciseId }: Props = $props();
let saving = $state(false);
let slug = $state($questionnaire.slug ?? '');
$effect(() => {
slug = $questionnaire.slug ?? '';
});
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
async function saveSettings() {
if (!courseApi.course?.id) return;
saving = true;
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
slug: isPublicCourse && slug ? slug : undefined
});
saving = false;
@@ -50,6 +60,21 @@
/>
</div>
{#if isPublicCourse}
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<InputField
label={$t('course.navItem.settings.slug.label')}
helperMessage={$t('course.navItem.settings.slug.description')}
value={slug}
placeholder={slugifyTitle($questionnaire.title ?? '')}
onInputChange={(e) => {
slug = e.currentTarget.value;
}}
errorMessage={exerciseApi.errors.slug}
/>
</div>
{/if}
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
</Button>
@@ -158,6 +158,7 @@ export function transformQuestionsToApiFormat(
question: q.title,
points: q.points || 0,
questionTypeId: q.questionTypeId,
exerciseSectionId: q.exerciseSectionId ?? null,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings: getQuestionSettings(q),
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
@@ -6,10 +6,14 @@
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { ExerciseQuestion } from '@cio/ui';
import type { QuestionnaireMetaData } from './store';
import { NumberBadge } from '@cio/ui/base/number-badge';
import type { ExerciseSectionState, QuestionnaireMetaData } from './store';
import { t } from '$lib/utils/functions/translations';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
questions?: Question[];
sections?: ExerciseSectionState[];
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
grades?: Record<string, number>;
@@ -22,6 +26,7 @@
let {
questions = [],
sections = [],
questionnaireMetaData: _questionnaireMetaData = {},
grades = $bindable({}),
disableGrading = true,
@@ -46,6 +51,9 @@
const questionModels = $derived(questions.map((question) => toExerciseQuestionModel(question)));
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived(sections.filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order));
const hasSectionGroups = $derived(activeSections.length > 0);
const answersByKey = $derived(
questionModels.reduce(
(acc, questionModel, index) => {
@@ -56,9 +64,55 @@
{} as Record<string, AnswerData>
)
);
function getQuestionsForSection(exerciseSectionId: string) {
return questions.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId);
}
function getSectionPoints(sectionQuestions: Question[]) {
return sectionQuestions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if disableGrading && !Object.keys(grades || {}).length}
{#if hasSectionGroups}
<div class="space-y-6">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection(section.id)}
{@const sectionQuestionModels = sectionQuestions.map((question) => toExerciseQuestionModel(question))}
<section class="space-y-4 pt-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: disableGrading ? 'review' : 'review',
questions: sectionQuestionModels,
answersByKey,
labels: questionLabels,
disabled: true
}}
itemClass="mb-4"
/>
</section>
{/each}
</div>
{:else if disableGrading && !Object.keys(grades || {}).length}
<ExerciseQuestion.QuestionList
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
itemClass="mb-4"
@@ -67,8 +121,8 @@
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<div class="flex items-center justify-between gap-2">
<NumberBadge number={index + 1} active />
<Grade grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} disableGrading={true} />
</div>
@@ -87,8 +141,8 @@
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<div class="flex items-center justify-between gap-2">
<NumberBadge number={index + 1} active />
<Grade bind:grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} {disableGrading} />
</div>
@@ -289,6 +289,111 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'file_upload.preview.max_size_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label'
),
'video_recording.edit.max_duration_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_label'
),
'video_recording.edit.max_duration_helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_helper'
),
'video_recording.edit.max_duration_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_placeholder'
),
'video_recording.edit.manual_grading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.manual_grading'
),
'video_recording.take.unsupported': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.unsupported'
),
'video_recording.take.permission_prompt': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_prompt'
),
'video_recording.take.permission_denied': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_denied'
),
'video_recording.take.start_camera': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_camera'
),
'video_recording.take.camera_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_label'
),
'video_recording.take.camera_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_placeholder'
),
'video_recording.take.play_preview': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.play_preview'
),
'video_recording.take.pause_preview': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.pause_preview'
),
'video_recording.take.start_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_recording'
),
'video_recording.take.stop_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.stop_recording'
),
'video_recording.take.elapsed': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.elapsed'
),
'video_recording.take.remaining': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.remaining'
),
'video_recording.take.max_duration': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.max_duration'
),
'video_recording.take.too_short': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.too_short'
),
'video_recording.take.use_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.use_recording'
),
'video_recording.take.retake': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retake'
),
'video_recording.take.delete': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.delete'
),
'video_recording.take.retry_upload': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retry_upload'
),
'video_recording.take.uploading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploading'
),
'video_recording.take.uploaded': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploaded'
),
'video_recording.take.upload_failed': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.upload_failed'
),
'video_recording.take.replaces_previous': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.replaces_previous'
),
'video_recording.preview.max_duration_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.max_duration_label'
),
'video_recording.preview.manual_grading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.manual_grading'
),
'video_recording.submission.no_video': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.no_video'
),
'video_recording.submission.unavailable': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.unavailable'
),
'video_recording.submission.duration': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.duration'
),
'video_recording.submission.file_size': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.file_size'
),
'video_recording.submission.recorded_at': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.recorded_at'
),
'video_recording.submission.uploaded_at': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.uploaded_at'
),
'video_recording.submission.retake_count': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.retake_count'
),
'link.edit.instructions_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder'
),
@@ -0,0 +1,66 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Badge } from '@cio/ui/base/badge';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import { getExerciseEditorQuestionTypeLabel } from './question-type-utils';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
type QuestionTypeEntry = (typeof QUESTION_TYPES)[number];
interface Props {
value: string | undefined;
onValueChange: (value: string) => void;
triggerQuestionType: Question['questionType'] | undefined;
types: QuestionTypeEntry[];
selfPacedCourse?: boolean;
}
let { value, onValueChange, triggerQuestionType, types, selfPacedCourse = false }: Props = $props();
</script>
<Select.Root type="single" {value} {onValueChange}>
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
</Select.Trigger>
<Select.Content>
{#each types as typeEntry (typeEntry.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
onclick={(clickEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
openUpgradeModal();
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
</button>
{:else}
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
{#if selfPacedCourse}
<Badge
variant={typeEntry.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{typeEntry.autoGradable
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
@@ -8,9 +8,19 @@ import {
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants';
import { t } from '$lib/utils/functions/translations';
type QuestionTypeOption = (typeof QUESTION_TYPES)[number];
/** Label for exercise editor picker and lists (translations via labelKey when present). */
export function getExerciseEditorQuestionTypeLabel(
questionType: Question['questionType'] | QuestionTypeOption | null | undefined
): string {
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
if ('labelKey' in questionType && questionType.labelKey) return t.get(questionType.labelKey);
return questionType.label ?? t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
}
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
QUESTION_TYPE_KEY.RADIO,
QUESTION_TYPE_KEY.CHECKBOX,
@@ -54,6 +64,12 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {};
const required =
typeof (question as Question & { required?: boolean }).required === 'boolean'
? (question as Question & { required?: boolean }).required
: typeof settings.required === 'boolean'
? settings.required
: true;
return {
id: question.id,
@@ -61,6 +77,7 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
title: question.title || '',
questionType: getQuestionTypeKey(question),
points: Number(question.points || 0),
required,
options,
settings
};
@@ -0,0 +1,184 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
import * as Field from '@cio/ui/base/field';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import XIcon from '@lucide/svelte/icons/x';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
const COLOR_THEMES: ExerciseSectionColorTheme[] = ['blue', 'green', 'amber', 'rose', 'violet', 'slate'];
const COLOR_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
violet: 'bg-violet-500',
slate: 'bg-slate-500'
};
const BORDER_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'border-blue-500',
green: 'border-emerald-500',
amber: 'border-amber-500',
rose: 'border-rose-500',
violet: 'border-violet-500',
slate: 'border-slate-500'
};
interface Props {
exerciseSectionId: string;
title: string;
description?: string | null;
sectionNumber: number;
totalSections: number;
colorTheme: ExerciseSectionColorTheme;
questionCount: number;
requiredQuestionCount: number;
titleError?: string;
descriptionError?: string;
showReorderHandles?: boolean;
onTitleChange?: (title: string) => void;
onDescriptionChange?: (description: string | null) => void;
onColorThemeChange?: (colorTheme: ExerciseSectionColorTheme) => void;
onAddQuestion?: () => void;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
labels?: Record<string, string>;
children?: Snippet;
}
let {
exerciseSectionId,
title,
description = null,
colorTheme,
questionCount,
requiredQuestionCount,
titleError = '',
descriptionError = '',
showReorderHandles = false,
onTitleChange,
onDescriptionChange,
onColorThemeChange,
onAddQuestion,
onDelete,
onMoveUp,
onMoveDown,
canMoveUp = false,
canMoveDown = false,
labels = {},
children
}: Props = $props();
let localTitle = $derived(title);
let localDescription = $derived(description ?? '');
function handleTitleInput() {
onTitleChange?.(localTitle);
}
function handleDescriptionInput() {
onDescriptionChange?.(localDescription || null);
}
</script>
<section
id={`exercise-section-${exerciseSectionId}`}
class={`overflow-hidden rounded-lg border bg-white shadow-xs dark:bg-zinc-950 ${BORDER_CLASSES[colorTheme]}`}
>
<div class="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>
<Input
bind:value={localTitle}
oninput={handleTitleInput}
aria-invalid={titleError ? 'true' : undefined}
class={`min-w-0 flex-1 border-transparent bg-transparent px-1 font-semibold shadow-none focus-visible:ring-1 ${titleError ? 'ui:border-destructive' : ''}`}
/>
<div class="flex shrink-0 items-center gap-2">
{#each COLOR_THEMES as theme (theme)}
<button
type="button"
class={`ui:border h-3 w-3 cursor-pointer rounded-full ${COLOR_CLASSES[theme]} ${theme === colorTheme ? 'ring-2 ring-offset-2 dark:ring-offset-zinc-900' : ''}`}
title={`${labels.color ?? 'Section color'}: ${theme}`}
onclick={() => onColorThemeChange?.(theme)}
></button>
{/each}
</div>
<span class="ui:text-muted-foreground shrink-0 text-sm font-semibold">
{questionCount}
{labels.shortQuestions ?? labels.questions ?? 'q'}
</span>
<IconButton
onclick={onDelete}
tooltip={labels.delete ?? 'Delete section'}
tooltipSide="bottom"
variant="outline"
size="icon-sm"
>
<XIcon size={16} />
</IconButton>
</div>
{#if titleError}
<div class="bg-white px-4 pt-2 dark:bg-zinc-950">
<Field.Error>{titleError}</Field.Error>
</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>
</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>
</section>
@@ -1,18 +1,33 @@
import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants';
import type { AnswerData } from '@cio/question-types';
import type {
AnswerData,
ExerciseSectionAfterBehavior,
ExerciseSectionColorTheme,
ExerciseSectionDisplayMode
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import { STATUS } from './constants';
import type { Writable } from 'svelte/store';
import { isUUID } from '$lib/utils/functions/isUUID';
import { writable } from 'svelte/store';
import { UNTITLED_EXERCISE_SECTION_TITLE } from '$features/course/utils/exercise-section-utils';
export const isQuestionnaireFetching = writable(false);
export const deleteConfirmation = writable({ open: false });
export const questionnaireOrder = writable({ open: false });
// {'question-id': { option: '', title: '', ... }}
export const questionnaireValidation = writable({});
export interface ExerciseEditorFieldErrors {
option?: string;
title?: string;
points?: string;
description?: string;
}
export type ExerciseEditorErrors = Record<string, ExerciseEditorFieldErrors>;
// {'question-or-section-id': { option: '', title: '', description: '', ... }}
export const questionnaireValidation = writable<ExerciseEditorErrors>({});
export interface QuestionnaireMetaData {
answers: Record<string, AnswerData | undefined>;
@@ -27,6 +42,34 @@ export interface QuestionnaireMetaData {
comment: string;
/** Set when exercise is finished so route knows not to overwrite questionnaire */
exerciseId: string | null;
currentSectionIndex: number;
sectionPhase: 'overview' | 'questions';
}
export interface ExerciseSectionState {
id: string;
title: string;
description: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
isDirty?: boolean;
deletedAt?: string;
}
export interface QuestionnaireState {
title?: string | null;
dueBy?: string | null;
isDueByDirty?: boolean;
isTitleDirty?: boolean;
description?: string | null;
isDescriptionDirty?: boolean;
questions: Question[];
sections: ExerciseSectionState[];
sectionDisplayMode: ExerciseSectionDisplayMode;
totalSubmissions: number;
allowMultipleAttempts?: boolean;
slug?: string | null;
}
const initAnswerState: QuestionnaireMetaData = {
@@ -40,22 +83,14 @@ const initAnswerState: QuestionnaireMetaData = {
progressValue: 0,
status: STATUS.PENDING,
comment: '',
exerciseId: null
exerciseId: null,
currentSectionIndex: 0,
sectionPhase: 'overview'
};
export const questionnaireMetaData: Writable<QuestionnaireMetaData> = writable(initAnswerState);
export const questionnaire: Writable<{
title?: string | null;
dueBy?: string | null;
isDueByDirty?: boolean;
isTitleDirty?: boolean;
description?: string | null;
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
allowMultipleAttempts?: boolean;
}> = writable({
export const questionnaire: Writable<QuestionnaireState> = writable({
title: '',
dueBy: '',
isDueByDirty: false,
@@ -63,8 +98,11 @@ export const questionnaire: Writable<{
description: '',
isDescriptionDirty: false,
questions: [],
sections: [],
sectionDisplayMode: 'one_question',
totalSubmissions: 0,
allowMultipleAttempts: false
allowMultipleAttempts: false,
slug: ''
});
export function clearQuestionnaireValidation() {
@@ -84,7 +122,9 @@ export function reset() {
finalTotalGrade: 0,
status: STATUS.PENDING,
comment: '',
progressValue: 100
progressValue: 100,
currentSectionIndex: 0,
sectionPhase: 'overview'
}));
clearQuestionnaireValidation();
@@ -106,8 +146,8 @@ export function resetStudentExerciseTake() {
export function mapZodErrorsToQuestionErrors(
zodErrors: Record<string, string>,
questions: Question[]
): Record<string, { option?: string; title?: string; points?: string }> {
const errors: Record<string, { option?: string; title?: string; points?: string }> = {};
): ExerciseEditorErrors {
const errors: ExerciseEditorErrors = {};
for (const [path, message] of Object.entries(zodErrors)) {
const pathParts = path.split('.');
const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1;
@@ -134,20 +174,45 @@ export function mapZodErrorsToQuestionErrors(
return errors;
}
export function handleAddQuestion() {
export function hasSections(sections: ExerciseSectionState[]) {
return sections.some((section) => !section.deletedAt);
}
function compareQuestionsByOrder(leftQuestion: Question, rightQuestion: Question) {
const leftOrder = leftQuestion.order ?? 0;
const rightOrder = rightQuestion.order ?? 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(leftQuestion.id).localeCompare(String(rightQuestion.id), undefined, { numeric: true });
}
export function getQuestionsForSection(questions: Question[], exerciseSectionId: string) {
return questions
.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId)
.sort(compareQuestionsByOrder);
}
export function handleAddQuestion(exerciseSectionId?: string | null) {
let nextQuestionId: string | number = '';
questionnaire.update((q) => {
const { questions } = q;
const activeSections = q.sections.filter((section) => !section.deletedAt);
const nextExerciseSectionId = exerciseSectionId ?? (activeSections.length > 0 ? activeSections[0].id : null);
nextQuestionId = `${questions.length + 1}-form`;
return {
...q,
questions: [
...questions,
{
...QUESTION_TEMPLATE,
id: `${questions.length + 1}-form`,
name: `${questions.length + 1}-form`,
id: nextQuestionId,
name: `${nextQuestionId}`,
value: '',
points: 0,
order: questions.length,
exerciseSectionId: nextExerciseSectionId,
questionType: DEFAULT_QUESTION_TYPE,
questionTypeId: DEFAULT_QUESTION_TYPE.id,
options: [
@@ -162,6 +227,217 @@ export function handleAddQuestion() {
]
};
});
return nextQuestionId;
}
export function handleAddSection() {
let nextSectionId = '';
questionnaire.update((q) => {
const activeSections = q.sections.filter((section) => !section.deletedAt);
const firstAvailableSectionOrder =
activeSections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
const activeUnsectionedQuestions = q.questions.filter(
(question) => !question.deletedAt && !question.exerciseSectionId
);
const nextSectionDisplayNumber = activeSections.length + (activeUnsectionedQuestions.length > 0 ? 2 : 1);
const nextSections = [...q.sections];
let questions = q.questions;
let nextSectionOrder = firstAvailableSectionOrder;
if (activeUnsectionedQuestions.length > 0) {
const untitledSectionId = crypto.randomUUID();
const untitledSection: ExerciseSectionState = {
id: untitledSectionId,
title: UNTITLED_EXERCISE_SECTION_TITLE,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
};
nextSectionOrder += 1;
nextSections.push(untitledSection);
questions = q.questions.map((question) => {
if (question.deletedAt || question.exerciseSectionId) return question;
return {
...question,
exerciseSectionId: untitledSectionId,
isDirty: true
};
});
}
const sectionId = crypto.randomUUID();
nextSectionId = sectionId;
const nextSection: ExerciseSectionState = {
id: sectionId,
title: `Section ${nextSectionDisplayNumber}`,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
};
return {
...q,
questions,
sections: [...nextSections, nextSection]
};
});
return nextSectionId;
}
export function handleRemoveSection(exerciseSectionId: string, moveQuestionsToExerciseSectionId?: string | null) {
questionnaire.update((q) => {
const activeSections = q.sections.filter((section) => !section.deletedAt);
const remainingSections = activeSections.filter((section) => section.id !== exerciseSectionId);
const shouldClearSections = remainingSections.length === 0;
const deletedAt = new Date().toString();
return {
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, deletedAt, isDirty: true } : section
),
questions: q.questions.map((question) => {
if (question.exerciseSectionId !== exerciseSectionId) return question;
if (moveQuestionsToExerciseSectionId || shouldClearSections) {
return {
...question,
exerciseSectionId: shouldClearSections ? null : moveQuestionsToExerciseSectionId,
isDirty: true
};
}
return {
...question,
deletedAt,
isDirty: true
};
})
};
});
}
export function handleMoveQuestionToSection(questionId: string | number, exerciseSectionId: string | null) {
questionnaire.update((q) => ({
...q,
questions: q.questions.map((question) =>
question.id === questionId ? { ...question, exerciseSectionId, isDirty: true } : question
)
}));
}
export function handleUpdateSectionAfterBehavior(
exerciseSectionId: string,
afterBehavior: ExerciseSectionAfterBehavior
) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, afterBehavior, isDirty: true } : section
)
}));
}
export function handleUpdateSectionColorTheme(exerciseSectionId: string, colorTheme: ExerciseSectionColorTheme) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, colorTheme, isDirty: true } : section
)
}));
}
export function handleUpdateSectionTitle(exerciseSectionId: string, title: string) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, title, isDirty: true } : section
)
}));
}
export function handleUpdateSectionDescription(exerciseSectionId: string, description: string | null) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, description, isDirty: true } : section
)
}));
}
export function handleReorderSections(orderedSectionIds: string[]) {
const orderBySectionId = new Map(orderedSectionIds.map((sectionId, index) => [sectionId, index]));
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) => {
const nextOrder = orderBySectionId.get(section.id);
if (nextOrder === undefined || nextOrder === section.order) return section;
return { ...section, order: nextOrder, isDirty: true };
})
}));
}
export function handleReorderQuestionsInSection(exerciseSectionId: string, orderedQuestionIds: Array<string | number>) {
questionnaire.update((q) => {
const activeSections = [...q.sections].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order);
const sectionExists = activeSections.some((section) => section.id === exerciseSectionId);
if (!sectionExists) return q;
const questionById = new Map(q.questions.map((question) => [String(question.id), question]));
const orderedQuestionIdSet = new Set(orderedQuestionIds.map((questionId) => String(questionId)));
const reorderedActiveQuestions: Question[] = [];
for (const section of activeSections) {
const sectionQuestions = getQuestionsForSection(q.questions, section.id);
if (section.id !== exerciseSectionId) {
reorderedActiveQuestions.push(...sectionQuestions);
continue;
}
const orderedSectionQuestions = orderedQuestionIds
.map((questionId) => questionById.get(String(questionId)))
.filter(
(question): question is Question =>
Boolean(question) && !question.deletedAt && question.exerciseSectionId === exerciseSectionId
);
const missingSectionQuestions = sectionQuestions.filter(
(question) => !orderedQuestionIdSet.has(String(question.id))
);
reorderedActiveQuestions.push(...orderedSectionQuestions, ...missingSectionQuestions);
}
const activeUnsectionedQuestions = q.questions
.filter((question) => !question.deletedAt && !question.exerciseSectionId)
.sort(compareQuestionsByOrder);
const nextOrderByQuestionId = new Map(
[...reorderedActiveQuestions, ...activeUnsectionedQuestions].map((question, index) => [
String(question.id),
index + 1
])
);
return {
...q,
questions: q.questions.map((question) => {
const nextOrder = nextOrderByQuestionId.get(String(question.id));
if (nextOrder === undefined || question.order === nextOrder) return question;
return { ...question, order: nextOrder, isDirty: true };
})
};
});
}
export function handleAddOption(questionId) {
@@ -4,13 +4,14 @@
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { submissions } from './store';
import { questionnaire } from '../store';
import { getQuestionsForSection, questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { Question } from '$features/course/types';
import type { AnswerData } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseQuestionLabels } from '../question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
isLoading?: boolean;
@@ -20,6 +21,11 @@
let studentSelected = $state(0);
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionGroups = $derived(activeSections.length > 0);
function toNumericQuestionId(question: Question): number | null {
const numericId = Number(question.id);
@@ -42,6 +48,20 @@
return submittedAnswer.answerData;
}
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) {
return questions.reduce((total, question) => {
const questionId = toNumericQuestionId(question);
if (questionId === null) return total;
const answer = student.answers.find((item) => item.questionId === questionId);
return total + (answer?.point ?? 0);
}, 0);
}
function getSectionMaxPoints(questions: Question[]) {
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if isLoading}
@@ -74,17 +94,66 @@
)}
</p>
{#if $submissions[studentSelected]}
{#if $questionnaire.questions}
{#if hasSectionGroups}
<div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionMaxPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{#each sectionQuestions as q, i (`${q.id}-${i}`)}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'review',
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
questionNumber={i + 1}
questionNumberActive={false}
/>
</div>
{/each}
<p class="border-t pt-2 text-sm font-medium">
{getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
</p>
</section>
{/each}
</div>
{:else if $questionnaire.questions}
{#each $questionnaire.questions as q, i (`${q.id}-${i}`)}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'review',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
questionNumber={i + 1}
questionNumberActive={false}
/>
</div>
{/each}
@@ -4,18 +4,20 @@
import { ExerciseQuestion } from '@cio/ui';
import { submissions } from './store';
import { questionnaire } from '../store';
import { getQuestionsForSection, questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
import type { ExerciseSubmissions } from './types';
import type { AnswerData, ExerciseQuestionLabels, ExerciseSubmissionModel } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
isLoading?: boolean;
}
let { isLoading = $bindable(true) }: Props = $props();
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
function toAnswerData(answerData: unknown): AnswerData | null {
if (!answerData || typeof answerData !== 'object' || !('type' in answerData)) return null;
@@ -34,13 +36,6 @@
};
}
function toSubmissionQuestionModel(question: Question, index: number) {
return {
...toExerciseQuestionModel(question),
title: `${index + 1}. ${question.title || ''}`
};
}
const submissionLabels = $derived.by(
(): ExerciseQuestionLabels => ({
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
@@ -59,8 +54,16 @@
const submissionQuestions = $derived(
$questionnaire.questions
.filter((question) => !question.deletedAt)
.map((question, index) => toSubmissionQuestionModel(question, index))
.map((question) => toExerciseQuestionModel(question))
);
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionGroups = $derived(activeSections.length > 0);
function getSectionPoints(questions: Question[]) {
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if isLoading}
@@ -71,14 +74,52 @@
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
</p>
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: submissionQuestions,
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
{#if hasSectionGroups}
<div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: sectionQuestions.map((question) => toExerciseQuestionModel(question)),
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
</section>
{/each}
</div>
{:else}
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: submissionQuestions,
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
{/if}
</div>
{/if}
@@ -4,7 +4,13 @@
import { fly } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { courseApi } from '$features/course/api';
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
import {
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireMetaData,
resetStudentExerciseTake
} from './store';
import Preview from './preview.svelte';
import { ExerciseQuestion } from '@cio/ui';
import * as Alert from '@cio/ui/base/alert';
@@ -22,6 +28,7 @@
getTextareaAnswerCharacterCount,
toApiPayload,
type AnswerData,
type ExerciseQuestionVideoRecordingUploader,
validateTextareaAnswer
} from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
@@ -37,10 +44,12 @@
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
import axios from 'axios';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import type { Question } from '$features/course/types';
interface Props {
preview?: boolean;
@@ -61,11 +70,17 @@
let slideDirection = $state<'next' | 'prev'>('next');
let selectedTryIndex = $state(-1);
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
function handleStart() {
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 1,
currentSectionIndex: 0,
sectionPhase: hasSectionedExercise ? 'overview' : 'questions'
}));
}
function normalizeAnswerValue(
@@ -84,11 +99,107 @@
return toApiPayload(answerData, questionId);
}
async function submitExercise() {
if (!courseApi.course?.id || isSubmitting) return;
isSubmitting = true;
const updated = get(questionnaireMetaData);
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
const answersForApi = Object.entries(updated.answers)
.map(([questionKey, val]) => {
const question = $questionnaire.questions.find(
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
);
if (!question) return null;
return mapAnswerToApiPayload(question, val);
})
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
if (answersForApi.length === 0) {
isSubmitting = false;
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
return;
}
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
toggleConfetti();
setTimeout(toggleConfetti, 3000);
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
isSubmitting = false;
}
function evaluateSectionAfterBehavior() {
if (!currentSection) return;
const behavior = currentSection.afterBehavior;
if (behavior.action === 'submit') {
submitExercise();
return;
}
if (behavior.action === 'go_to_section' && behavior.exerciseSectionId) {
const targetIndex = activeSections.findIndex((section) => section.id === behavior.exerciseSectionId);
if (targetIndex >= 0) {
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: targetIndex,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
return;
}
}
const nextSectionIndex = $questionnaireMetaData.currentSectionIndex + 1;
if (nextSectionIndex < activeSections.length) {
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: nextSectionIndex,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
return;
}
submitExercise();
}
async function onSubmit(id, value) {
if (!courseApi.course?.id) return;
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const questions = hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions;
const prevAnswer = answers[id];
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
@@ -110,63 +221,11 @@
const isLastQuestion = currentIndex === questions.length;
if (isLastQuestion) {
// Stay on last question, show spinner, submit to server
if (isSubmitting) return;
isSubmitting = true;
const updated = get(questionnaireMetaData);
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
const answersForApi = Object.entries(updated.answers)
.map(([questionKey, val]) => {
const question = questions.find(
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
);
if (!question) return null;
return mapAnswerToApiPayload(question, val);
})
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
if (answersForApi.length === 0) {
isSubmitting = false;
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
return;
if (hasSectionedExercise) {
evaluateSectionAfterBehavior();
} else {
await submitExercise();
}
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
toggleConfetti();
setTimeout(toggleConfetti, 3000);
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
isSubmitting = false;
} else {
// Advance to next question
slideDirection = 'next';
@@ -177,6 +236,11 @@
}
function onPrevious() {
if (hasSectionedExercise && $questionnaireMetaData.currentQuestionIndex <= 1) {
questionnaireMetaData.update((m) => ({ ...m, sectionPhase: 'overview' }));
return;
}
slideDirection = 'prev';
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
}
@@ -186,6 +250,14 @@
return 100;
}
if (hasSectionedExercise) {
const activeQuestionCount = $questionnaire.questions.filter((question) => !question.deletedAt).length;
const answeredCount = Object.values($questionnaireMetaData.answers).filter((answer) =>
hasAnswerValue(answer)
).length;
return Math.round((answeredCount / activeQuestionCount) * 100) || 0;
}
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
}
@@ -233,6 +305,48 @@
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
}
const handleVideoRecordingUpload: ExerciseQuestionVideoRecordingUploader = async (input) => {
if (!courseApi.course?.id || !exerciseId) {
throw new Error('Exercise is not ready for upload');
}
const initResult = await exerciseApi.initVideoRecordingUpload(courseApi.course.id, exerciseId, input.questionId, {
fileName: input.fileName,
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
size: input.size
});
if (!initResult?.data) {
throw new Error('Failed to get upload URL');
}
await axios.put(initResult.data.uploadUrl, input.blob, {
headers: { 'Content-Type': input.mimeType },
maxContentLength: Infinity,
maxBodyLength: Infinity
});
const completeResult = await exerciseApi.completeVideoRecordingUpload(
courseApi.course.id,
exerciseId,
input.questionId,
{
assetId: initResult.data.assetId,
storageKey: initResult.data.storageKey,
fileName: input.fileName,
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
size: input.size,
durationSeconds: input.durationSeconds,
recordedAt: input.recordedAt,
retakeCount: input.retakeCount
}
);
if (!completeResult?.data) {
throw new Error('Failed to complete upload');
}
return completeResult.data;
};
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
if (answerValue === null || answerValue === undefined) return false;
if (typeof answerValue === 'object' && 'type' in answerValue) {
@@ -256,6 +370,8 @@
}
case 'FILE_UPLOAD':
return !!answerValue.fileKey;
case 'VIDEO_RECORDING':
return !!answerValue.assetId && !!answerValue.storageKey;
case 'MATCHING':
return answerValue.pairs.length > 0;
case 'ORDERING':
@@ -271,16 +387,22 @@
return false;
}
function onSharedAnswerChange(answerValue) {
function onSharedAnswerChange(answerValue: AnswerData | null) {
if (!sharedCurrentQuestionKey) return;
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {
...metaData.answers,
[sharedCurrentQuestionKey]: answerValue
questionnaireMetaData.update((metaData) => {
const answers = { ...metaData.answers };
if (answerValue === null) {
delete answers[sharedCurrentQuestionKey];
} else {
answers[sharedCurrentQuestionKey] = answerValue;
}
}));
return {
...metaData,
answers
};
});
}
function onSharedNext(valueOverride?: AnswerData) {
@@ -300,7 +422,59 @@
onSubmit(sharedCurrentQuestionKey, valueToUse);
}
function beginCurrentSection() {
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 1,
sectionPhase: 'questions'
}));
}
function goBackFromSectionOverview() {
if ($questionnaireMetaData.currentSectionIndex <= 0) {
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 0,
sectionPhase: 'overview'
}));
return;
}
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: m.currentSectionIndex - 1,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
}
function onSectionQuestionAnswerChange(question: Question, answerValue: AnswerData) {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {
...metaData.answers,
[questionKey]: answerValue
}
}));
}
function completeAllQuestionsSection() {
const missingAnswer = currentSectionQuestions.find((question) => {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
return !hasAnswerValue($questionnaireMetaData.answers[questionKey]);
});
if (missingAnswer) {
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
evaluateSectionAfterBehavior();
}
function handleEnterKey(e: KeyboardEvent) {
if (hasSectionedExercise && $questionnaire.sectionDisplayMode === 'all_questions') return;
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
const target = e.target as HTMLElement;
@@ -374,9 +548,34 @@
});
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionedExercise = $derived(hasSections($questionnaire.sections ?? []));
const currentSection = $derived(
hasSectionedExercise ? activeSections[$questionnaireMetaData.currentSectionIndex] : null
);
const currentSectionQuestions = $derived(
currentSection ? getQuestionsForSection($questionnaire.questions, currentSection.id) : []
);
const currentSectionDisplayTitle = $derived(
currentSection
? getExerciseSectionDisplayTitle({
title: currentSection.title,
sectionNumber: $questionnaireMetaData.currentSectionIndex + 1,
sectionLabel: sectionFallbackTitle
})
: ''
);
const currentQuestionList = $derived(hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions);
const currentQuestion = $derived(currentQuestionList[$questionnaireMetaData.currentQuestionIndex - 1]);
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
const isFinished = $derived(
isExerciseLoaded &&
$questionnaireMetaData.currentQuestionIndex > 0 &&
!currentQuestion &&
(!hasSectionedExercise || $questionnaireMetaData.isFinished)
);
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
const sharedCurrentQuestionKey = $derived(
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
@@ -385,6 +584,33 @@
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
);
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions));
const sectionNavigationGroups = $derived(
activeSections.map((section, sectionIndex) => {
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
return {
id: section.id,
title: getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
}),
isCurrent: currentSection?.id === section.id,
questions: sectionQuestions.map((question, questionIndex) => {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
return {
key: questionKey,
label: `Q${questionIndex + 1}`,
isAnswered: hasAnswerValue($questionnaireMetaData.answers[questionKey]),
isCurrent:
sectionIndex === $questionnaireMetaData.currentSectionIndex &&
questionIndex === $questionnaireMetaData.currentQuestionIndex - 1 &&
$questionnaireMetaData.sectionPhase === 'questions'
};
})
};
})
);
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
const flyOutX = $derived(-flyX);
@@ -459,6 +685,8 @@
answers,
totalPossibleGrade,
currentQuestionIndex: $questionnaire.questions.length,
currentSectionIndex: activeSections.length > 0 ? activeSections.length - 1 : 0,
sectionPhase: 'questions',
isFinished: true,
status: submission.statusId ?? STATUS.PENDING,
finalTotalGrade,
@@ -470,7 +698,7 @@
$effect(() => {
if (prevExerciseId === exerciseId) return;
if (!!prevExerciseId) {
if (prevExerciseId) {
isSubmitting = false;
alreadyCheckedAutoSavedData = false;
skipHydrateFromSubmissions = false;
@@ -483,7 +711,12 @@
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
<div class="mb-6 flex min-w-0 items-center gap-3">
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
{#if hasSectionedExercise}
{Object.values($questionnaireMetaData.answers).filter((answer) => hasAnswerValue(answer))
.length}/{$questionnaire.questions.length}
{:else}
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
{/if}
</span>
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
</div>
@@ -493,7 +726,11 @@
{#if preview}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
<Preview
questions={filterOutDeleted($questionnaire.questions)}
sections={$questionnaire.sections}
questionnaireMetaData={$questionnaireMetaData}
/>
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Empty
@@ -621,6 +858,7 @@
questions={[...$questionnaire.questions].sort(
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
)}
sections={$questionnaire.sections}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
disableGrading={true}
@@ -636,44 +874,133 @@
{/if}
</RoleBasedSecurity>
{/if}
{:else if currentQuestion && currentQuestion?.id}
<div class="flex flex-col gap-4">
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
{#key currentQuestion.id}
<div
id="question"
class="min-w-0 [grid-area:slot]"
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
>
{#if sharedQuestionModel}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sharedQuestionModel,
answer: sharedCurrentAnswer,
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload
}}
onAnswerChange={onSharedAnswerChange}
/>
{/if}
</div>
{/key}
</div>
<div>
<ExerciseQuestion.QuestionNavigation
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
{isSubmitting}
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
{onPrevious}
onNext={onSharedNext}
{:else if hasSectionedExercise && currentSection && $questionnaireMetaData.sectionPhase === 'overview'}
<ExerciseQuestion.SectionOverview
sectionTitle={currentSectionDisplayTitle}
sectionDescription={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
colorTheme={currentSection.colorTheme}
onBegin={beginCurrentSection}
onBack={goBackFromSectionOverview}
labels={{
beginSection: $t('course.navItem.lessons.exercises.all_exercises.view_mode.begin_section'),
back: $t('course.navItem.lessons.exercises.all_exercises.view_mode.back'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points'),
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')
}}
/>
{:else if hasSectionedExercise && currentSection && $questionnaire.sectionDisplayMode === 'all_questions' && $questionnaireMetaData.sectionPhase === 'questions'}
<div class="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={currentSectionDisplayTitle}
description={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
colorTheme={currentSection.colorTheme}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{#each currentSectionQuestions as sectionQuestion, sectionQuestionIndex (sectionQuestion.id)}
{@const sectionQuestionModel = toExerciseQuestionModel(sectionQuestion)}
{@const sectionQuestionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(sectionQuestion))}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sectionQuestionModel,
answer: $questionnaireMetaData.answers[sectionQuestionKey],
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload,
onVideoRecordingUpload: handleVideoRecordingUpload
}}
questionNumber={sectionQuestionIndex + 1}
questionNumberActive={false}
onAnswerChange={(answerValue) => onSectionQuestionAnswerChange(sectionQuestion, answerValue)}
/>
{/each}
<div class="flex justify-end">
<Button type="button" onclick={completeAllQuestionsSection} disabled={isSubmitting} loading={isSubmitting}>
{$questionnaireMetaData.currentSectionIndex === activeSections.length - 1
? $t('course.navItem.lessons.exercises.all_exercises.finish')
: $t('course.navItem.lessons.exercises.all_exercises.view_mode.complete_section')}
</Button>
</div>
</section>
</div>
{:else if currentQuestion && currentQuestion?.id}
<div class={hasSectionedExercise ? 'grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]' : ''}>
{#if hasSectionedExercise}
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
{/if}
<div class="flex min-w-0 flex-col gap-4">
{#if hasSectionedExercise && currentSection}
<ExerciseQuestion.SectionHeader
title={currentSectionDisplayTitle}
description={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
colorTheme={currentSection.colorTheme}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{/if}
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
{#key currentQuestion.id}
<div
id="question"
class="min-w-0 [grid-area:slot]"
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
>
{#if sharedQuestionModel}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sharedQuestionModel,
answer: sharedCurrentAnswer,
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload,
onVideoRecordingUpload: handleVideoRecordingUpload
}}
questionNumber={$questionnaireMetaData.currentQuestionIndex}
onAnswerChange={onSharedAnswerChange}
/>
{/if}
</div>
{/key}
</div>
<div>
<ExerciseQuestion.QuestionNavigation
canGoBack={hasSectionedExercise || $questionnaireMetaData.currentQuestionIndex > 1}
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
isLast={$questionnaireMetaData.currentQuestionIndex === currentQuestionList.length}
{isSubmitting}
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
{onPrevious}
onNext={onSharedNext}
/>
</div>
</div>
</div>
{/if}
@@ -11,6 +11,7 @@
import { t } from '$lib/utils/functions/translations';
import { RoleBasedSecurity } from '$features/ui';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import { slugifyTitle } from '@cio/utils/validation';
interface Props {
mode: (typeof MODES)[keyof typeof MODES];
@@ -20,6 +21,10 @@
onTitleChange: (value: string) => void;
onToggleLock: () => void;
onDeleteLesson: () => Promise<void> | void;
slug?: string;
showSlugEditor?: boolean;
slugError?: string;
onSlugChange?: (value: string) => void;
}
let {
@@ -29,9 +34,15 @@
isDeletingLesson = false,
onTitleChange,
onToggleLock,
onDeleteLesson
onDeleteLesson,
slug = '',
showSlugEditor = false,
slugError,
onSlugChange = () => {}
}: Props = $props();
// Slug field: commented out via `{#if false && ...}` below — drop `false &&` to show again.
let openDeleteModal = $state(false);
const lockLabel = $derived(
@@ -40,7 +51,7 @@
</script>
<div class="flex w-full items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="min-w-0 flex-1 space-y-2">
{#if mode === MODES.edit && !$isOrgStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<InputField
@@ -49,6 +60,17 @@
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
/>
{#if false && showSlugEditor}
<InputField
className="max-w-xl"
label={$t('course.navItem.settings.slug.label')}
helperMessage={$t('course.navItem.settings.slug.description')}
value={slug}
placeholder={slugifyTitle(title || '')}
onInputChange={(e) => onSlugChange(e.currentTarget.value)}
errorMessage={slugError}
/>
{/if}
</RoleBasedSecurity>
{:else}
<Page.Title>{title}</Page.Title>
@@ -69,8 +91,9 @@
<IconButton
onclick={() => (openDeleteModal = true)}
disabled={isDeletingLesson}
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
title={$t('course.navItem.lessons.add_lesson.delete')}
aria-label={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
tooltip={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
tooltipSide="bottom"
>
{#if isDeletingLesson}
<Spinner class="h-4 w-4" />
@@ -17,13 +17,6 @@
let step = $state(0);
const options = [
{
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: 'LIVE_CLASS' as TCourseType,
isDisabled: false
},
{
id: 'self-paced',
title: $t('new_course_modal.self_paced_label'),
@@ -31,12 +24,26 @@
type: 'SELF_PACED' as TCourseType,
isDisabled: false
},
{
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: 'LIVE_CLASS' as TCourseType,
isDisabled: false
},
{
id: 'compliance',
title: $t('new_course_modal.compliance_label'),
subtitle: $t('new_course_modal.compliance_subtitle'),
type: 'COMPLIANCE' as TCourseType,
isDisabled: false
},
{
id: 'public',
title: $t('new_course_modal.public_label'),
subtitle: $t('new_course_modal.public_subtitle'),
type: 'PUBLIC' as TCourseType,
isDisabled: false
}
];
const courseTypeOptionsForGroup = options.map((o) => ({
@@ -75,7 +82,7 @@
{#snippet course_type_selector()}
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-2" />
{/snippet}
<Dialog.Root
@@ -14,6 +14,7 @@
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
pointsHint?: string | null;
onPointsChange?: any;
elementId?: string;
key?: string;
children?: import('svelte').Snippet;
}
@@ -27,6 +28,7 @@
errorMsg = null,
pointsHint = null,
onPointsChange = () => {},
elementId,
children
}: Props = $props();
@@ -44,6 +46,7 @@
</script>
<div
id={elementId}
bind:this={ref}
class="border-border border bg-white dark:bg-black {hasError ? 'border-red-700' : ''} root relative mb-6 rounded-md"
>
@@ -6,7 +6,7 @@
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import EyeIcon from '@lucide/svelte/icons/eye';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -14,7 +14,11 @@
import { ContentType } from '@cio/utils/constants/content';
import {
type ExerciseEditorErrors,
handleAddQuestion,
handleAddSection,
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireOrder,
reset,
@@ -36,21 +40,28 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import {
// RefreshPageData,
RoleBasedSecurity,
UnsavedChanges
} from '$features/ui';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import {
hydrateExercisePageData
// , refreshExercisePageData
} from '$features/course/utils/exercise-page-utils';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
getQuestionTypeKeyFromId
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
interface Props {
exerciseId?: string;
@@ -85,7 +96,32 @@
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let reorderQuestions = $state(false);
let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
function isTemporaryId(id: string | number | undefined) {
return typeof id === 'string' && id.includes('-form');
}
function hasDirtyQuestionnaire() {
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
});
const hasDirtySection = ($questionnaire.sections ?? []).some(
(section) => section.isDirty || Boolean(section.deletedAt)
);
return Boolean(
$questionnaire.isTitleDirty ||
$questionnaire.isDescriptionDirty ||
$questionnaire.isDueByDirty ||
hasDirtyQuestion ||
hasDirtySection
);
}
async function handleDeleteExercise() {
if (!courseApi.course?.id) return;
@@ -94,6 +130,7 @@
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
hasUnsavedChanges = false;
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
@@ -111,6 +148,25 @@
});
}
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
const sectionErrors: ExerciseEditorErrors = {};
const requiredFieldMessage = t.get('validations.generic.required_field');
for (const section of sections) {
const nextSectionErrors: ExerciseEditorErrors[string] = {};
if (!section.title?.trim()) {
nextSectionErrors.title = requiredFieldMessage;
}
if (Object.keys(nextSectionErrors).length === 0) continue;
sectionErrors[section.id] = nextSectionErrors;
}
return sectionErrors;
}
async function handleSave() {
if ($isOrgStudent || !courseApi.course?.id) return;
@@ -119,16 +175,33 @@
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: false
});
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
const sectionsPayload = activeSections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
}));
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
const sectionValidationErrors = getSectionValidationErrors(activeSections);
// Validate using Zod schema
const zodResult = ZExerciseUpdate.safeParse({
questions: transformedQuestions
questions: transformedQuestions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
if (!zodResult.success) {
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set(questionErrors);
questionnaireValidation.set({
...questionErrors,
...sectionValidationErrors
});
snackbar.error('Please fix all validation errors before saving');
return;
}
@@ -167,7 +240,9 @@
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions
questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
// Check if there are validation errors from the API
@@ -179,20 +254,10 @@
}
if (exerciseApi.success) {
questionnaire.update((q) => ({
...q,
isTitleDirty: false,
isDescriptionDirty: false,
questions:
exerciseApi.exercise?.questions?.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
questionTypeId: questionType.id,
questionType
};
}) || q.questions
}));
if (exerciseApi.exercise) {
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
}
hasUnsavedChanges = false;
patchExerciseListItemLocally();
snackbar.success('snackbar.exercise.success');
}
@@ -216,6 +281,17 @@
selectedTab = nextTab;
});
$effect(() => {
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
});
$effect(() => {
const hasActiveSections = hasSections($questionnaire.sections);
if (!hasActiveSections && reorderQuestions) {
reorderQuestions = false;
}
});
$effect(() => {
const currentTab = page.url.searchParams.get('tab') ?? '';
// Prevent self-navigation loops: only update URL when it actually changes.
@@ -250,6 +326,7 @@
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
@@ -265,11 +342,42 @@
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
function getFirstActiveSectionId() {
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
.filter((section) => !section.deletedAt)
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
return firstActiveSection?.id ?? null;
}
async function addQuestionFromHeader() {
preview = false;
const questionId = handleAddQuestion(getFirstActiveSectionId());
await scrollToExerciseElement(`exercise-question-${questionId}`);
}
async function addSectionFromHeader() {
preview = false;
const sectionId = handleAddSection();
await scrollToExerciseElement(`exercise-section-${sectionId}`);
}
</script>
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
<Page.HeaderContent>
<Page.Title class="flex flex-wrap items-center gap-2">
<Page.Title class="flex flex-col gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
@@ -298,7 +406,10 @@
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => (preview = !preview)}
onclick={() => {
preview = !preview;
reorderQuestions = false;
}}
tooltip={preview
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
@@ -310,13 +421,22 @@
<EyeIcon size={20} />
{/if}
</IconButton>
<IconButton
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
<CirclePlusIcon size={20} />
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="center" class="z-201!">
<DropdownMenu.Item onclick={addQuestionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
@@ -325,10 +445,19 @@
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
@@ -341,7 +470,7 @@
</div>
{/if}
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -377,7 +506,7 @@
<Badge variant="outline">{submissions.length}</Badge>
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="questions">
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
<UpdateDescription {preview} />
{#if !preview}
<EditMode
@@ -385,6 +514,8 @@
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
{isPublicCourse}
{reorderQuestions}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
@@ -430,3 +561,5 @@
</div>
{/snippet}
</Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />
@@ -73,6 +73,8 @@
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
const params = new SvelteURLSearchParams($page.url.searchParams);
@@ -202,7 +204,8 @@
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
slideUrl: lessonApi.lesson.slideUrl || undefined,
videos: lessonApi.lesson.videos || [],
documents: lessonApi.lesson.documents || []
documents: lessonApi.lesson.documents || [],
slug: isPublicCourse && lessonApi.lesson.slug ? lessonApi.lesson.slug : undefined
}),
saveOrUpdateTranslation(lessonApi.currentLocale, lessonId)
]);
@@ -218,6 +221,10 @@
lessonApi.updateLessonState('title', value);
}
function handleLessonSlugChange(value: string) {
lessonApi.updateLessonState('slug', value);
}
function handleToggleLessonLock() {
const currentValue = lessonApi.lesson?.isUnlocked ?? false;
lessonApi.updateLessonState('isUnlocked', !currentValue);
@@ -361,6 +368,10 @@
onTitleChange={handleLessonTitleChange}
onToggleLock={handleToggleLessonLock}
onDeleteLesson={handleDeleteLesson}
slug={lessonSlug}
showSlugEditor={isPublicCourse}
slugError={lessonApi.errors.slug}
onSlugChange={handleLessonSlugChange}
/>
</Page.HeaderContent>
<Page.Action>
@@ -5,6 +5,7 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ContentList from '$features/course/components/lesson/content-list.svelte';
import ContentSectionList from '$features/course/components/lesson/content-section-list.svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { getCourseContent } from '$features/course/utils/content';
@@ -28,6 +29,12 @@
contentItems.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
);
const sectionsTotal = $derived(
contentData.grouped ? contentData.sections.filter((section) => section.id !== 'ungrouped').length : 0
);
const lessonsTotal = $derived(contentItems.filter((item) => item.type === ContentType.Lesson).length);
const exercisesTotal = $derived(contentItems.filter((item) => item.type === ContentType.Exercise).length);
let isFetching: boolean = $state(false);
let hasHandledNext = $state(false);
@@ -65,6 +72,34 @@
variant="page"
/>
{:else if contentLength > 0}
<div
class="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3"
role="region"
aria-label={t.get('course.navItem.lessons.heading_v2')}
>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Section} size={14} />
<span>{$t('course.navItem.lessons.stats.sections')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{sectionsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Lesson} size={14} />
<span>{$t('course.navItem.lessons.stats.lessons')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{lessonsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Exercise} size={14} />
<span>{$t('course.navItem.lessons.stats.exercises')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{exercisesTotal}</p>
</div>
</div>
{#if reorder}
<p class="text-center text-xs text-gray-400 italic dark:text-white">
{$t('course.navItem.lessons.drag')}
@@ -16,7 +16,7 @@
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { BookIcon } from '@lucide/svelte';
import { ROLE } from '@cio/utils/constants';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
courseId: string;
@@ -48,13 +48,13 @@
await newsfeedApi.deleteComment(courseId, feedId, commentId);
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
@@ -170,7 +170,8 @@
metadata: metadataPayload,
slug: courseApi.course.slug ?? undefined,
compliance:
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
};
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
@@ -220,11 +221,46 @@
lessonDownload: !!course.metadata?.lessonDownload,
isPublished: !!course.isPublished,
allowNewStudents: !!course.metadata?.allowNewStudent,
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
callout: normalizeCallout(course.callout)
});
});
}
function sanitizeCalloutForSave(value: typeof $settings.callout) {
if (!value) return null;
const title = value.title.trim();
const description = value.description.trim();
const buttonLabel = value.buttonLabel.trim();
const buttonUrl = value.buttonUrl.trim();
if (!title && !description && !buttonLabel && !buttonUrl) return null;
return { title, description, buttonLabel, buttonUrl };
}
function normalizeCallout(value: unknown): typeof $settings.callout {
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
typeof candidate.buttonLabel !== 'string' ||
typeof candidate.buttonUrl !== 'string'
) {
return null;
}
return {
title: candidate.title,
description: candidate.description,
buttonLabel: candidate.buttonLabel,
buttonUrl: candidate.buttonUrl
};
}
// Initialize course from page data
$effect(() => {
const courseData = $page.data?.course;
@@ -240,6 +276,15 @@
}
});
$effect(() => {
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
settings.update((prev) => ({
...prev,
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
}));
}
});
$effect(() => {
const courseId = courseApi.course?.id;
@@ -525,21 +570,97 @@
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
<div class="mt-3 flex items-center space-x-2">
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
</div>
</RadioGroup.Root>
</Field.Field>
{#if courseApi.errors.type}
<div
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
role="alert"
>
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
</div>
{/if}
</Field.Set>
{#if $settings.type === 'PUBLIC' && $settings.callout}
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.callout.legend')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.callout.description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.title_label')}</Field.Label>
<InputField
bind:value={$settings.callout.title}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.title_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.description_label')}</Field.Label>
<TextareaField
bind:value={$settings.callout.description}
oninput={() => (hasUnsavedChanges = true)}
rows={3}
placeholder={$t('course.navItem.settings.callout.description_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonLabel}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_label_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_url_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonUrl}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_url_placeholder')}
type="url"
/>
</Field.Field>
</Field.Group>
<Field.Field>
<Button
variant="outline"
size="sm"
onclick={() => {
settings.update((prev) => ({ ...prev, callout: null }));
hasUnsavedChanges = true;
}}
>
{$t('course.navItem.settings.callout.clear')}
</Button>
</Field.Field>
</Field.Set>
{/if}
<Field.Separator />
<Field.Set>
@@ -33,6 +33,8 @@ export const defaultCourse: Course = {
bannerImage: null,
attendance: [],
group: null,
compliance: null,
callout: null,
content: {
grouped: false,
sections: [],
@@ -46,6 +46,7 @@ type QuestionOption = ApiOption & {
// Extended question type with client-side fields
export type Question = Omit<ApiQuestion, 'options'> & {
exerciseSectionId?: string | null;
isDirty?: boolean;
deletedAt?: string;
options: QuestionOption[];
@@ -8,6 +8,18 @@ interface OpenCoursePreviewOptions {
currentOrgDomain?: string;
}
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
if (typeof window === 'undefined') {
return '';
}
const trimmedDomain = currentOrgDomain?.trim();
const origin = trimmedDomain || window.location.origin;
return new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
@@ -19,9 +31,32 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
const link = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
if (!link) {
return false;
}
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
if (!url) {
return false;
}
try {
await navigator.clipboard.writeText(url);
snackbar.success('snackbar.public_course.url_copied');
return true;
} catch (error) {
console.error('copyPublicCoursePageUrl error:', error);
snackbar.error('snackbar.public_course.url_copy_failed');
return false;
}
}
@@ -1,28 +1,74 @@
import type { Exercise } from '$features/course/utils/types';
import type { Question } from '$features/course/types';
import { exerciseApi } from '$features/course/api';
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
import {
clearQuestionnaireValidation,
questionnaire,
questionnaireMetaData
} from '$features/course/components/exercise/store';
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
import type { Exercise } from '$features/course/utils/types';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import type { Question } from '$features/course/types';
import { UNTITLED_EXERCISE_SECTION_TITLE } from './exercise-section-utils';
import { exerciseApi } from '$features/course/api';
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
let questions: Question[] = [];
if (exercise.questions && Array.isArray(exercise.questions)) {
const mappedQuestions = exercise.questions.map((question) => {
const sections: ExerciseSectionState[] = Array.isArray(exercise.sections)
? exercise.sections
.map((section) => ({
id: section.id,
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme as ExerciseSectionColorTheme,
afterBehavior: section.afterBehavior as unknown as ExerciseSectionAfterBehavior
}))
.sort((left, right) => left.order - right.order)
: [];
const exerciseQuestions = [...(Array.isArray(exercise.questions) ? exercise.questions : [])];
if (exerciseQuestions.length > 0) {
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
exerciseSectionId: question.exerciseSectionId ?? null,
questionTypeId: questionType.id,
questionType
};
});
if (sections.length > 0) {
const unsectionedQuestions = mappedQuestions.filter(
(question) => !question.deletedAt && !question.exerciseSectionId
);
if (unsectionedQuestions.length > 0) {
const untitledSectionId = crypto.randomUUID();
const nextSectionOrder =
sections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
sections.push({
id: untitledSectionId,
title: UNTITLED_EXERCISE_SECTION_TITLE,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
});
for (const question of unsectionedQuestions) {
question.exerciseSectionId = untitledSectionId;
question.isDirty = true;
}
}
}
questions = normalizeQuestionOrder(mappedQuestions);
}
@@ -36,8 +82,11 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
isDescriptionDirty: false,
isDueByDirty: false,
questions,
sections,
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
totalSubmissions: 0,
allowMultipleAttempts: !!exercise.allowMultipleAttempts
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
slug: exercise.slug ?? ''
});
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
@@ -0,0 +1,15 @@
export const UNTITLED_EXERCISE_SECTION_TITLE = 'Untitled section';
export function getExerciseSectionDisplayTitle({
title,
sectionNumber,
sectionLabel
}: {
title: string;
sectionNumber: number;
sectionLabel: string;
}) {
if (title.trim() === UNTITLED_EXERCISE_SECTION_TITLE) return `${sectionLabel} ${sectionNumber}`;
return title;
}
@@ -0,0 +1,121 @@
import type {
PublicCourseSidebarSection,
PublicExerciseViewData,
PublicLessonViewData
} from '@cio/ui/custom/public-course';
import type { ExerciseQuestionModel, ExerciseQuestionTypeKey } from '@cio/question-types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
type GetPublicCourseSuccess = Extract<
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['$get']>,
{ success: true }
>;
type PublicCourseTree = GetPublicCourseSuccess['data'];
type GetPublicCourseItemSuccess = Extract<
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get']>,
{ success: true }
>;
type PublicItem = GetPublicCourseItemSuccess['data'];
/**
* Group the flat tree into sidebar sections. Items that aren't assigned to any
* section are grouped under a synthetic "Lessons" section so the UI never has
* orphaned rows.
*/
export function toPublicSidebarSections(tree: PublicCourseTree): PublicCourseSidebarSection[] {
const sectionLookup = new Map<string, PublicCourseSidebarSection>();
for (const section of tree.sections) {
sectionLookup.set(section.id, {
id: section.id,
title: section.title,
items: []
});
}
const orphanSection: PublicCourseSidebarSection = {
id: '__unsectioned',
title: 'Lessons',
items: []
};
for (const item of tree.items) {
const targetSection =
item.sectionId && sectionLookup.has(item.sectionId) ? sectionLookup.get(item.sectionId)! : orphanSection;
targetSection.items.push({
kind: item.kind,
id: item.id,
slug: item.slug,
title: item.title,
isUnlocked: item.isUnlocked
});
}
const ordered = Array.from(sectionLookup.values()).filter((section) => section.items.length > 0);
if (orphanSection.items.length > 0) {
ordered.push(orphanSection);
}
return ordered;
}
export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson' }>): PublicLessonViewData {
return {
kind: 'lesson',
title: lesson.title,
sectionTitle: lesson.sectionTitle,
body: lesson.body ?? '',
video: lesson.video,
isUnlocked: lesson.isUnlocked
};
}
type ApiQuestion = Extract<PublicItem, { kind: 'exercise' }>['questions']['unsectioned'][number];
/**
* Convert API question shapes into `ExerciseQuestionModel`s so the public
* exercise view can drive the same renderer pipeline as the authenticated
* student view (`ExerciseQuestion.QuestionList`). Question types that aren't
* auto-gradable are filtered out — the creator UI already blocks them on
* PUBLIC courses, but we double-check here so the page never ships answers
* for a question we can't grade in the browser.
*/
export function toPublicExerciseQuestions(questions: {
unsectioned: ApiQuestion[];
sections: Array<{ questions: ApiQuestion[] }>;
}): ExerciseQuestionModel[] {
const all = [...questions.unsectioned, ...questions.sections.flatMap((section) => section.questions)];
return all.map((question) => mapQuestion(question));
}
function mapQuestion(question: ApiQuestion): ExerciseQuestionModel {
return {
id: question.id,
title: question.title,
questionType: question.questionTypeKey as ExerciseQuestionTypeKey,
points: question.points,
options: question.options.map((option) => ({
id: option.id,
label: option.label,
isCorrect: option.isCorrect,
settings: (option.settings ?? {}) as Record<string, unknown>
})),
settings: (question.settings ?? {}) as Record<string, unknown>
};
}
export function toPublicExerciseView(exercise: Extract<PublicItem, { kind: 'exercise' }>): PublicExerciseViewData {
return {
kind: 'exercise',
title: exercise.title,
description: exercise.description,
sectionTitle: exercise.sectionTitle,
isUnlocked: exercise.isUnlocked,
questions: toPublicExerciseQuestions(exercise.questions)
};
}
@@ -1,4 +1,5 @@
import type { TCourseType } from '@cio/db/types';
import type { TCourseCallout } from '@cio/utils/validation/course';
import { writable } from 'svelte/store';
type CourseSettings = {
@@ -6,12 +7,13 @@ type CourseSettings = {
courseTitle: string;
courseDescription: string;
grading: boolean;
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
type: TCourseType;
allowNewStudents: boolean;
tabs: { id: number; name: string }[];
lessonDownload: boolean;
isPublished: boolean;
isContentGroupingEnabled: boolean;
callout: TCourseCallout | null;
};
export const settings = writable<CourseSettings>({
@@ -19,7 +21,7 @@ export const settings = writable<CourseSettings>({
courseTitle: '',
courseDescription: '',
grading: false,
type: 'LIVE_CLASS' as TCourseType,
type: 'SELF_PACED' as TCourseType,
allowNewStudents: false,
tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
@@ -29,5 +31,6 @@ export const settings = writable<CourseSettings>({
],
lessonDownload: false,
isPublished: false,
isContentGroupingEnabled: true
isContentGroupingEnabled: true,
callout: null
});
@@ -261,6 +261,21 @@ export type UpdateSubmissionGradesRequest =
export type CreateCourseRequest = typeof classroomio.course.$post;
export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get'];
export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get'];
export type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
export type GetCourseBySlugData = GetCourseBySlugSuccess['data'];
/** Org snippet returned with course slug payload for org-site flows */
export type CourseSlugPayloadOrg = {
id: string;
name: string;
siteName: string | null;
theme?: unknown;
};
/** Slug course shape including optional org (RPC unions omit `org` on some branches). */
export type CourseBySlugWithOrg = GetCourseBySlugData & {
org?: CourseSlugPayloadOrg | null;
};
export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put'];
export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete'];
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
@@ -444,3 +459,10 @@ export type VideoUploadPresignSuccess = Extract<InferResponseType<VideoUploadPre
export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post'];
export type VideoDownloadPresignSuccess = Extract<InferResponseType<VideoDownloadPresignRequest>, { success: true }>;
export type VideoRecordingUploadInitRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['init']['$post'];
export type VideoRecordingUploadCompleteRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['complete']['$post'];
export type VideoRecordingPlaybackRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission'][':submissionId']['question'][':questionId']['video-recording']['playback']['$get'];
@@ -22,7 +22,7 @@ class LicenseApi {
const deduped: LicenseFeatureId[] = [];
for (const feature of features) {
if (LICENSE_FEATURE_IDS.includes(feature)) {
if ((LICENSE_FEATURE_IDS as readonly string[]).includes(feature)) {
const typedFeature = feature as LicenseFeatureId;
if (!deduped.includes(typedFeature)) {
deduped.push(typedFeature);
@@ -13,9 +13,14 @@
import UserRoundIcon from '@lucide/svelte/icons/user-round';
import { t } from '$lib/utils/functions/translations';
import { classroomio } from '$lib/utils/services/api';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { coursesApi } from '$features/course/api';
import CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import {
getStudentCourseComplianceDate,
getStudentCourseComplianceStatusKey,
@@ -113,6 +118,44 @@
goto(`/courses/${id}/lessons?next=true`);
}
function showPublishedPublicCourseLinks(course: EnrolledCourse | null) {
if (!course || course.type !== 'PUBLIC') {
return false;
}
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
if (!publicSlug.trim()) {
return false;
}
return !!course.isPublished;
}
function openPublicCoursePage(course: EnrolledCourse | null) {
if (!course?.id || !showPublishedPublicCourseLinks(course)) {
return;
}
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
openCoursePreview({
courseId: course.id,
courseSlug: publicSlug,
currentOrgDomain: $currentOrgDomain
});
}
async function copyPublicCourseLink(course: EnrolledCourse | null) {
if (!course || !showPublishedPublicCourseLinks(course)) {
return;
}
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
await copyPublicCoursePageUrl(publicSlug, $currentOrgDomain);
}
</script>
<div class="space-y-6 pb-8">
@@ -132,17 +175,37 @@
</h2>
</div>
<Button
variant="outline"
class="shrink-0"
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
>
{#if currentCourse}
{$t('dashboard.continue_learning')}
{:else}
{$t('dashboard.view_courses')}
<div class="flex shrink-0 flex-wrap items-center gap-2">
{#if currentCourse && showPublishedPublicCourseLinks(currentCourse)}
<IconButton
variant="outline"
onclick={() => openPublicCoursePage(currentCourse)}
tooltip={$t('courses.course_card.context_menu.open_public_course')}
aria-label={$t('courses.course_card.context_menu.open_public_course')}
>
<ExternalLinkIcon class="size-4" />
</IconButton>
<IconButton
variant="outline"
onclick={() => void copyPublicCourseLink(currentCourse)}
tooltip={$t('courses.course_card.context_menu.copy_course_url')}
aria-label={$t('courses.course_card.context_menu.copy_course_url')}
>
<CopyIcon class="size-4" />
</IconButton>
{/if}
</Button>
<Button
variant="outline"
class="shrink-0"
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
>
{#if currentCourse}
{$t('dashboard.continue_learning')}
{:else}
{$t('dashboard.view_courses')}
{/if}
</Button>
</div>
</div>
</section>
</BlurFade>
@@ -215,7 +278,12 @@
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-base font-semibold">{course.title}</h3>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h3 class="truncate text-base font-semibold">{course.title}</h3>
{#if course.type === 'PUBLIC'}
<CoursePublicBadge class="shrink-0" />
{/if}
</div>
{#if course.description}
<p class="ui:text-muted-foreground mt-1 line-clamp-2 text-sm">{course.description}</p>
{/if}
@@ -233,9 +301,29 @@
</div>
</div>
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
{$t('dashboard.continue')}
</Button>
<div class="flex shrink-0 items-center gap-2">
{#if showPublishedPublicCourseLinks(course)}
<IconButton
variant="outline"
onclick={() => openPublicCoursePage(course)}
tooltip={$t('courses.course_card.context_menu.open_public_course')}
aria-label={$t('courses.course_card.context_menu.open_public_course')}
>
<ExternalLinkIcon class="size-4" />
</IconButton>
<IconButton
variant="outline"
onclick={() => void copyPublicCourseLink(course)}
tooltip={$t('courses.course_card.context_menu.copy_course_url')}
aria-label={$t('courses.course_card.context_menu.copy_course_url')}
>
<CopyIcon class="size-4" />
</IconButton>
{/if}
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
{$t('dashboard.continue')}
</Button>
</div>
</div>
<div class="mt-5 flex items-center gap-4">
@@ -0,0 +1,51 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type {
AiUsageData,
GetLeaderboardRequest,
GetPurchasedRequest,
GetUsageRequest,
LeaderboardData,
PurchasedSummaryData
} from '../utils/types';
class AiCreditsApi extends BaseApi {
usage = $state<AiUsageData | null>(null);
purchased = $state<PurchasedSummaryData | null>(null);
leaderboard = $state<LeaderboardData | null>(null);
async fetchAll() {
await Promise.all([this.fetchUsage(), this.fetchPurchased(), this.fetchLeaderboard()]);
}
async fetchUsage() {
await this.execute<GetUsageRequest>({
requestFn: () => classroomio.agent.usage.$get(),
logContext: 'fetching AI usage',
onSuccess: (result) => {
this.usage = result.data;
}
});
}
async fetchPurchased() {
await this.execute<GetPurchasedRequest>({
requestFn: () => classroomio.agent.usage.purchased.$get(),
logContext: 'fetching purchased summary',
onSuccess: (result) => {
this.purchased = result.data;
}
});
}
async fetchLeaderboard() {
await this.execute<GetLeaderboardRequest>({
requestFn: () => classroomio.agent.usage.leaderboard.$get(),
logContext: 'fetching AI usage leaderboard',
onSuccess: (result) => {
this.leaderboard = result.data;
}
});
}
}
export const aiCreditsApi = new AiCreditsApi();
@@ -0,0 +1,95 @@
<script lang="ts">
import { page } from '$app/state';
import { t } from '$lib/utils/functions/translations';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import { TOKEN_PACK } from '@cio/utils/plans';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
interface Props {
onBeforeRedirect?: () => void;
}
let { onBeforeRedirect }: Props = $props();
let quantity = $state(1);
let isRedirecting = $state(false);
const tokens = $derived(quantity * TOKEN_PACK.TOKENS_PER_UNIT);
const dollars = $derived((quantity * TOKEN_PACK.PRICE_USD_CENTS) / 100);
function bump(delta: number) {
quantity = Math.min(100, Math.max(1, quantity + delta));
}
function buy() {
if (!$profile.id || !$profile.email || !$currentOrg.id || !$currentOrg.siteName) {
snackbar.error('snackbar.ai_credits.checkout_failed');
return;
}
isRedirecting = true;
const checkoutURL = new URL('/api/polar/buy-tokens', page.url);
checkoutURL.searchParams.set('quantity', String(quantity));
checkoutURL.searchParams.set('customerEmail', $profile.email?.replace('@test.com', '+test@digdippa.com') ?? '');
if ($profile.fullname) {
checkoutURL.searchParams.set('customerName', $profile.fullname);
}
checkoutURL.searchParams.set(
'metadata',
JSON.stringify({
orgId: $currentOrg.id,
orgSlug: $currentOrg.siteName,
triggeredBy: $profile.id
})
);
onBeforeRedirect?.();
window.location.href = checkoutURL.toString();
}
</script>
<Card.Root id="buy-tokens">
<Card.Header>
<Card.Title>{$t('settings.ai_credits.buy_tokens.title')}</Card.Title>
<Card.Description>
{$t('settings.ai_credits.buy_tokens.description')}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button variant="outline" size="icon" onclick={() => bump(-1)} disabled={quantity <= 1}>
<MinusIcon class="size-4" />
</Button>
<span class="ui:text-muted-foreground min-w-[3rem] text-center text-sm font-medium tabular-nums">
{quantity}
</span>
<Button variant="outline" size="icon" onclick={() => bump(1)} disabled={quantity >= 100}>
<PlusIcon class="size-4" />
</Button>
<span class="ui:text-muted-foreground ml-2 text-xs">
{$t('settings.ai_credits.buy_tokens.quantity_hint')}
</span>
</div>
<p class="ui:text-muted-foreground text-sm">
{$t('settings.ai_credits.buy_tokens.preview', {
quantity: String(quantity),
tokens: tokens.toLocaleString(),
dollars: dollars.toFixed(2)
})}
</p>
<Button onclick={buy} loading={isRedirecting} class="w-fit">
{$t('settings.ai_credits.buy_tokens.buy_button', { dollars: dollars.toFixed(2) })}
</Button>
</Card.Content>
</Card.Root>
@@ -0,0 +1,84 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Table from '@cio/ui/base/table';
import * as Avatar from '@cio/ui/base/avatar';
import type { LeaderboardData } from '$features/settings/utils/types';
interface Props {
leaderboard: LeaderboardData | null;
}
let { leaderboard }: Props = $props();
const entries = $derived(leaderboard?.entries ?? []);
function initials(name: string | null, email: string | null) {
const source = (name?.trim() || email || '?').split(/\s+/).filter(Boolean);
return source
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
</script>
<div>
<div class="mb-3 flex items-baseline justify-between gap-2">
<h3 class="text-lg font-semibold tracking-tight">{$t('settings.ai_credits.leaderboard.title')}</h3>
<p class="ui:text-muted-foreground text-xs">{$t('settings.ai_credits.leaderboard.this_month')}</p>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-10">#</Table.Head>
<Table.Head>{$t('settings.ai_credits.leaderboard.user')}</Table.Head>
<Table.Head>{$t('settings.ai_credits.leaderboard.favorite_model')}</Table.Head>
<Table.Head class="text-right">{$t('settings.ai_credits.leaderboard.tokens')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each entries as entry, index (entry.userId)}
<Table.Row>
<Table.Cell class="ui:text-muted-foreground">{index + 1}</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-3">
<Avatar.Root class="size-8">
{#if entry.avatarUrl}
<Avatar.Image src={entry.avatarUrl} alt={entry.fullname ?? ''} loading="lazy" decoding="async" />
{/if}
<Avatar.Fallback aria-hidden="true">{initials(entry.fullname, entry.email)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex min-w-0 flex-col">
<span class="truncate text-sm font-medium">{entry.fullname ?? entry.email ?? '—'}</span>
<span class="ui:text-muted-foreground truncate text-xs">{entry.email ?? ''}</span>
</div>
</div>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground max-w-[12rem] truncate text-sm">
{entry.favoriteModel ?? '—'}
</Table.Cell>
<Table.Cell class="text-right">
<div class="inline-flex flex-col items-end gap-1">
<span class="text-sm font-medium">{entry.tokens.toLocaleString()}</span>
<div class="ui:bg-muted h-1 w-24 rounded-full">
<div
class="ui:bg-primary h-1 rounded-full"
style="width: {Math.min(100, Math.round(entry.percentage * 100))}%"
></div>
</div>
</div>
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell class="ui:text-muted-foreground py-8 text-center text-sm" colspan={4}>
{$t('settings.ai_credits.leaderboard.empty')}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
@@ -0,0 +1,59 @@
<script lang="ts">
import * as Chart from '@cio/ui/base/chart';
import { t } from '$lib/utils/functions/translations';
import type { AiUsageData } from '$features/settings/utils/types';
interface Props {
usage: AiUsageData | null;
}
let { usage }: Props = $props();
const data = $derived(usage?.history ?? []);
const totalTokens = $derived(data.reduce((sum, day) => sum + day.tokens, 0));
const totalRequests = $derived(usage?.requestsThisMonth ?? 0);
const chartConfig = $derived({
tokens: {
label: $t('settings.ai_credits.chart.tokens'),
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig);
const series = $derived([
{ key: 'tokens', value: 'tokens', label: chartConfig.tokens.label, color: 'var(--color-tokens)' }
]);
const hasData = $derived(data.some((day) => day.tokens > 0));
</script>
<div class="bg-card flex flex-col rounded-xl border p-3 md:p-5 dark:text-white">
<div class="mb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold tracking-tight">{$t('settings.ai_credits.chart.heading')}</h3>
<p class="ui:text-muted-foreground mt-1 text-sm">
{$t('settings.ai_credits.chart.subheading')}
</p>
</div>
<div class="flex flex-wrap gap-6 text-right">
<div>
<p class="ui:text-muted-foreground text-xs">{$t('settings.ai_credits.chart.total_tokens')}</p>
<p class="text-xl font-semibold">{totalTokens.toLocaleString()}</p>
</div>
<div>
<p class="ui:text-muted-foreground text-xs">{$t('settings.ai_credits.chart.total_requests')}</p>
<p class="text-xl font-semibold">{totalRequests.toLocaleString()}</p>
</div>
</div>
</div>
{#if hasData}
<Chart.ChartContainer class="h-[260px] w-full" config={chartConfig}>
<Chart.BarChart {data} x="date" axis="x" {series} />
</Chart.ChartContainer>
{:else}
<div class="ui:text-muted-foreground flex h-[260px] items-center justify-center text-sm">
{$t('settings.ai_credits.chart.empty')}
</div>
{/if}
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
import { SvelteDate } from 'svelte/reactivity';
import type { AiUsageData, PurchasedSummaryData } from '$features/settings/utils/types';
interface Props {
usage: AiUsageData | null;
purchased: PurchasedSummaryData | null;
}
let { usage, purchased }: Props = $props();
const includedPercent = $derived(
usage && usage.allowance > 0 ? Math.min(100, Math.round((usage.used / usage.allowance) * 100)) : 0
);
const resetDate = $derived.by(() => {
const nextReset = new SvelteDate();
nextReset.setMonth(nextReset.getMonth() + 1);
nextReset.setDate(1);
return nextReset.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
});
const dollarsSpent = $derived(purchased ? (purchased.totalSpentCents / 100).toFixed(2) : '0.00');
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Description>{$t('settings.ai_credits.included.title')}</Card.Description>
<Card.Title class="text-2xl">
{#if usage}
{usage.used.toLocaleString()} / {usage.allowance.toLocaleString()}
{:else}
—
{/if}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="ui:text-muted-foreground mb-3 text-xs">
{$t('settings.ai_credits.included.subtitle')}
</p>
<div class="ui:bg-muted h-2 w-full rounded-full">
<div
class="h-2 rounded-full transition-all {includedPercent > 90
? 'bg-red-500'
: includedPercent > 70
? 'bg-amber-500'
: 'ui:bg-primary'}"
style="width: {includedPercent}%"
></div>
</div>
<p class="ui:text-muted-foreground mt-2 text-xs">
{$t('settings.ai_credits.included.resets_on', { date: resetDate })}
</p>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>{$t('settings.ai_credits.purchased.title')}</Card.Description>
<Card.Title class="text-2xl">
{#if purchased}
{purchased.currentBalance.toLocaleString()}
{:else}
—
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<p class="ui:text-muted-foreground text-xs">
{$t('settings.ai_credits.purchased.subtitle')}
</p>
<p class="ui:text-muted-foreground text-xs">
{$t('settings.ai_credits.purchased.lifetime', {
tokens: purchased ? purchased.totalPurchasedTokens.toLocaleString() : '0',
dollars: dollarsSpent
})}
</p>
<Button variant="outline" size="sm" class="w-fit" href="#buy-tokens">
{$t('settings.ai_credits.purchased.buy_more')}
</Button>
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import BuyTokensCard from '$features/settings/components/ai-credits/buy-tokens-card.svelte';
import TeamLeaderboard from '$features/settings/components/ai-credits/team-leaderboard.svelte';
import UsageChart from '$features/settings/components/ai-credits/usage-chart.svelte';
import UsageSummaryCards from '$features/settings/components/ai-credits/usage-summary-cards.svelte';
import { aiCreditsApi } from '$features/settings/api/ai-credits.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let lastFetchedOrgId = $state<string | null>(null);
$effect(() => {
const orgId = $currentOrg.id;
if (!orgId || orgId === lastFetchedOrgId) {
return;
}
lastFetchedOrgId = orgId;
void aiCreditsApi.fetchAll();
});
onMount(() => {
if (page.url.searchParams.get('tokens') === 'success') {
snackbar.success('snackbar.ai_credits.purchase_success');
void aiCreditsApi.fetchAll();
}
});
</script>
<div class="ui:mx-auto flex w-full max-w-5xl flex-col gap-8 px-2 pb-12">
<UsageSummaryCards usage={aiCreditsApi.usage} purchased={aiCreditsApi.purchased} />
<p class="ui:text-muted-foreground -mt-4 text-sm">{$t('settings.ai_credits.cost_note')}</p>
<UsageChart usage={aiCreditsApi.usage} />
<TeamLeaderboard leaderboard={aiCreditsApi.leaderboard} />
<BuyTokensCard onBeforeRedirect={() => aiCreditsApi.fetchAll()} />
</div>
@@ -3,53 +3,14 @@
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrgPlan, isFreePlan, currentOrg } from '$lib/utils/store/org';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ReceiptIcon from '@lucide/svelte/icons/receipt';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
const PUBLIC_API_URL = import.meta.env.VITE_API_URL || '';
let isRedirecting = $state(false);
interface AiUsageData {
used: number;
allowance: number;
creditBalance: number;
remaining: number;
}
let aiUsage: AiUsageData | null = $state(null);
let aiUsageLoaded = $state(false);
$effect(() => {
if (!aiUsageLoaded && $currentOrg.id) {
aiUsageLoaded = true;
fetchAiUsage();
}
});
async function fetchAiUsage() {
try {
const response = await fetch(`${PUBLIC_API_URL}/agent/usage`, {
headers: { 'Cio-org-id': $currentOrg.id || '' },
credentials: 'include'
});
if (response.ok) {
const result = await response.json();
if (result.success) {
aiUsage = result.data;
}
}
} catch {
// Silently fail
}
}
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
@@ -119,39 +80,4 @@
</Item.Actions>
</Item.Root>
{/if}
{#if aiUsage}
<div class="mt-6">
<p class="text-md mb-3 font-medium">{$t('settings.billing.ai_credits')}</p>
<Item.Root variant="outline">
<Item.Media variant="icon">
<SparklesIcon />
</Item.Media>
<Item.Content>
<Item.Title>
{aiUsage.used.toLocaleString()} / {aiUsage.allowance.toLocaleString()}
{$t('settings.billing.ai_tokens_used')}
</Item.Title>
<Item.Description>
{aiUsage.creditBalance.toLocaleString()}
{$t('settings.billing.ai_bonus_credits')}
</Item.Description>
{@const usagePercent =
aiUsage.allowance > 0 ? Math.min(100, Math.round((aiUsage.used / aiUsage.allowance) * 100)) : 0}
<div class="ui:bg-muted mt-2 h-1.5 w-full rounded-full">
<div
class="h-1.5 rounded-full transition-all {usagePercent > 90
? 'bg-red-500'
: usagePercent > 70
? 'bg-amber-500'
: 'ui:bg-primary'}"
style="width: {usagePercent}%"
></div>
</div>
</Item.Content>
</Item.Root>
</div>
{/if}
</div>
@@ -6,6 +6,7 @@ export { default as OrgPage } from './org.svelte';
export { default as LandingpagePage } from './landingpage.svelte';
export { default as LandingpageEditor } from './landingpage-editor.svelte';
export { default as BillingPage } from './billing.svelte';
export { default as AiCreditsPage } from './ai-credits.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
export { default as AuthPage } from './auth.svelte';
// Keep old exports for backward compatibility
@@ -0,0 +1,14 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetUsageRequest = typeof classroomio.agent.usage.$get;
export type GetPurchasedRequest = (typeof classroomio.agent.usage)['purchased']['$get'];
export type GetLeaderboardRequest = (typeof classroomio.agent.usage)['leaderboard']['$get'];
type GetUsageSuccess = Extract<InferResponseType<GetUsageRequest>, { success: true }>;
type GetPurchasedSuccess = Extract<InferResponseType<GetPurchasedRequest>, { success: true }>;
type GetLeaderboardSuccess = Extract<InferResponseType<GetLeaderboardRequest>, { success: true }>;
export type AiUsageData = GetUsageSuccess['data'];
export type PurchasedSummaryData = GetPurchasedSuccess['data'];
export type LeaderboardData = GetLeaderboardSuccess['data'];
export type LeaderboardEntry = LeaderboardData['entries'][number];
@@ -4,6 +4,7 @@
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import type { Component } from 'svelte';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play';
import UserIcon from '@lucide/svelte/icons/user';
@@ -13,23 +14,20 @@
import ClockIcon from '@lucide/svelte/icons/clock';
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
import { BlurIn } from '@cio/ui/custom/animation/blurin';
import { DotPattern } from '@cio/ui/custom/animation/dot-pattern';
import { HeroVideoDialog } from '@cio/ui/custom/animation/hero-video-dialog';
import { Sparkle } from '@cio/ui/custom/animation/sparkle';
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
import { getLectureNo } from '$features/course/utils/functions';
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import { currentOrg } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import Navigation from '$lib/components/Navigation/index.svelte';
import { basePath } from '$lib/utils/store/app';
import { user } from '$lib/utils/store/user';
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { handleOpenWidget, reviewsModalStore } from './store';
import type { Course, CourseMetadata } from '$features/course/utils/types';
import { courseApi } from '$features/course/api';
import { formatYoutubeEmbedUrl, isYoutubeUrl } from '@cio/ui/custom/media-player';
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
import { Button } from '@cio/ui/base/button';
@@ -52,27 +50,110 @@
showStandaloneShell?: boolean;
}
let { editMode = false, courseData = $bindable(), showStandaloneShell = true }: Props = $props();
let { editMode = false, courseData = $bindable(), showStandaloneShell = false }: Props = $props();
/** Editor / standalone shell: show org-theme hero + nav from `currentOrg.landingpage`. Org-site embed omits (parent provides hero). */
const showMarketingHero = $derived(showStandaloneShell || editMode);
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
const heroShellClass = $derived(
landingSettings.theme === 'classic'
? 'ui:w-full ui:bg-muted/10 ui:text-foreground ui:font-sans'
: 'ui:w-full ui:bg-background ui:text-foreground ui:font-sans'
);
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
const authAction = $derived(
$user.isLoggedIn
? {
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
}
: {
label: t.get('navigation.login'),
href: '/login'
}
);
const courseHero = $derived.by(() => {
const slug = typeof courseData.slug === 'string' && courseData.slug.length > 0 ? courseData.slug : '';
const enrollHref = slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
const enrollmentsOpen = get(courseData, 'metadata.allowNewStudent') === true;
return {
...landingSettings.hero,
heading: get(courseData, 'title', '') || landingSettings.hero.heading,
subheading: get(courseData, 'description', '') || landingSettings.hero.subheading,
primaryAction: {
label: t.get('course.navItem.landing_page.start_course'),
href: enrollHref,
disabled: editMode || !enrollmentsOpen
},
image: get(courseData, 'logo', '') || landingSettings.hero.image
};
});
/** Matches API `buildCourseContent` — do not use `metadata.isContentGroupingEnabled` (dashboard normalizes undefined → true). */
const lessonsLayoutIsGrouped = $derived(courseData.content?.grouped === true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let NavComponent = $state<Component<any> | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let HeroComponent = $state<Component<any> | null>(null);
$effect(() => {
if (!showMarketingHero) {
NavComponent = null;
HeroComponent = null;
return;
}
const theme = landingSettings.theme;
let cancelled = false;
void Promise.all([
theme === 'bold'
? import('@cio/ui/custom/org-landing-page/bold/nav.svelte')
: theme === 'classic'
? import('@cio/ui/custom/org-landing-page/classic/nav.svelte')
: import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
theme === 'bold'
? import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
: theme === 'classic'
? import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
: import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
]).then(([navMod, heroMod]) => {
if (!cancelled) {
NavComponent = navMod.default;
HeroComponent = heroMod.default;
}
});
return () => {
cancelled = true;
};
});
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
const video = $derived(get(courseData, 'metadata.videoUrl'));
const lessons = $derived(getCourseLessons(courseData));
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
const averageRating = $derived(reviews?.length ? (totalRatings / reviews.length).toFixed(1) : null);
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
const bannerImage = $derived(get(courseData, 'logo'));
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
const instructor = $derived((get(courseData, 'metadata.instructor') || {}) as Record<string, unknown>);
const organizationName = $derived(get(courseData, 'org.name') || $currentOrg.name || '');
const instructorName = $derived.by(() => {
const name = get(instructor, 'name');
return typeof name === 'string' && name.trim() ? name : organizationName;
return typeof name === 'string' && name.trim().length > 0 ? name : organizationName;
});
const instructorImageUrl = $derived.by(() => {
const imageUrl = get(instructor, 'imgUrl');
return typeof imageUrl === 'string' && imageUrl.trim() ? imageUrl : '/images/avatar.svg';
return typeof imageUrl === 'string' && imageUrl.trim().length > 0 ? imageUrl : '/images/avatar.svg';
});
const certificate = $derived(
get(courseData, 'metadata.certificate', {
@@ -93,8 +174,6 @@
const totalLessons = $derived(getTotalLessons(courseSections));
const studentCount = $derived(courseData.group?.members?.length ?? 0);
const videoEmbedUrl = $derived(video ? (isYoutubeUrl(video) ? formatYoutubeEmbedUrl(video) : video) : '');
function locationHashChanged() {
activeNav = window.location.hash;
}
@@ -121,90 +200,38 @@
<PoweredBy />
{/if}
{#if !editMode && showStandaloneShell}
<Navigation
logo={$currentOrg.avatarUrl || '/logo-192.png'}
orgName={$currentOrg.name}
disableSignup={$currentOrg.disableSignup || ($currentOrg.settings?.signup?.inviteOnly ?? false)}
isOrgSite={$globalStore.isOrgSite}
customLinks={$currentOrg.landingpage?.customLinks}
/>
{/if}
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
{#if showStandaloneShell}
<!-- Hero Section -->
<header class="relative w-full overflow-hidden bg-[#040f2d] py-12 md:py-20">
<DotPattern
class="[mask-image:radial-gradient(ellipse_at_center,white,transparent_80%)] opacity-10"
fillColor="rgb(255 255 255 / 0.5)"
/>
<div
class="relative z-10 mx-auto flex w-full max-w-6xl flex-col-reverse items-center gap-8 px-6 md:flex-row md:gap-12"
>
<!-- Course Description -->
<div class="w-full py-4 md:w-1/2">
<BlurIn class="my-4 text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
{get(courseData, 'title', '')}
</BlurIn>
<BlurFade delay={0.2}>
<p class="mb-6 text-base leading-relaxed text-gray-300 md:text-lg">
{get(courseData, 'description', '')}
</p>
</BlurFade>
<BlurFade delay={0.3}>
<p class="mb-6 text-sm text-blue-300">
{instructorName}
</p>
</BlurFade>
<BlurFade delay={0.4}>
<Button
class="hidden h-12 px-8 text-base font-semibold sm:w-fit md:block"
onclick={() => {
if (editMode) return;
startCoursePayment = true;
}}
disabled={!allowNewStudent}
>
{$t('course.navItem.landing_page.start_course')}
</Button>
</BlurFade>
{#if $handleOpenWidget.open}
<UploadWidget
imageURL={courseApi.course?.logo}
onchange={(newLogo) => (courseApi.course!.logo = newLogo)}
/>
{/if}
</div>
<!-- Banner Image / Video -->
<div class="w-full md:w-1/2">
<BlurFade delay={0.3} yOffset={20}>
{#if video}
<HeroVideoDialog
videoSrc={videoEmbedUrl}
thumbnailSrc={bannerImage || '/images/classroomio-course-img-template.jpg'}
thumbnailAlt={get(courseData, 'title', 'Course video')}
animationStyle="from-center"
{#if showMarketingHero}
<div class={heroShellClass}>
{#if NavComponent && HeroComponent}
{#if landingSettings.theme === 'minimal'}
<HeroComponent hero={courseHero}>
{#snippet navigation()}
<NavComponent
orgName={orgNameForNav}
logoUrl={logoUrlForNav}
navItems={landingSettings.navItems}
{authAction}
/>
{:else}
<div class="overflow-hidden rounded-xl shadow-2xl">
<img
alt="Course banner"
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
class="h-auto w-full rounded-xl object-cover"
/>
</div>
{/if}
</BlurFade>
{/snippet}
</HeroComponent>
{:else}
<NavComponent
orgName={orgNameForNav}
logoUrl={logoUrlForNav}
navItems={landingSettings.navItems}
{authAction}
/>
<HeroComponent hero={courseHero} />
{/if}
{/if}
{#if editMode && $handleOpenWidget.open}
<div class="mx-auto w-full max-w-7xl px-6 py-2">
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
</div>
</div>
</header>
{/if}
</div>
<!-- Social Proof Strip -->
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
@@ -252,7 +279,7 @@
<!-- Course Details -->
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
<!-- Navigation -->
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 z-20 py-2">
<UnderlineTabs.Root bind:value={activeNav} class="py-2">
<UnderlineTabs.List>
{#each navItems as navItem (navItem.key)}
<UnderlineTabs.Trigger value={navItem.key}>
@@ -323,8 +350,8 @@
</NavSection>
{/if}
<!-- Sections - Lessons -->
{#if !courseData.metadata?.isContentGroupingEnabled}
<!-- Sections - Lessons (layout follows `content.grouped` from API, not client-normalized metadata) -->
{#if !lessonsLayoutIsGrouped}
<NavSection id="lessons">
<BlurFade delay={0.1}>
<div class="mb-3 flex w-full items-center justify-between">
@@ -352,7 +379,7 @@
</div>
</BlurFade>
</NavSection>
{:else if courseData.metadata?.isContentGroupingEnabled}
{:else}
<NavSection id="lessons">
<BlurFade delay={0.1}>
<!-- header -->
@@ -419,10 +446,10 @@
<p class="mb-0.5 font-medium">{review.name}</p>
<!-- ratings -->
<div class="mb-2 flex items-center gap-0.5">
{#each Array(5) as _, i}
{#each Array(5) as _, starIndex (starIndex)}
<StarIcon
size={14}
class={i < (review.rating || 0)
class={starIndex < (review.rating || 0)
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 dark:text-neutral-600'}
/>
@@ -496,10 +523,10 @@
<!-- ratings -->
<div class="flex flex-row items-center gap-1">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
{#each Array(5) as _, starIndex (starIndex)}
<StarIcon
size={14}
class={i < (review.rating || 0)
class={starIndex < (review.rating || 0)
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 dark:text-neutral-600'}
/>
@@ -5,6 +5,7 @@ import {
CourseIcon,
DashboardIcon,
GoalIcon,
HomeIcon,
LandingPageIcon,
PeopleIcon,
SettingsIcon,
@@ -63,10 +64,17 @@ export interface NestedRouteConfig {
export const baseNavConfig: NavItemConfig[] = [
{
group: 'home',
titleKey: 'org_navigation.dashboard',
titleKey: 'org_navigation.home',
path: '',
icon: HomeIcon,
matchPattern: '^/org/[^/]+/?$'
},
{
group: 'home',
titleKey: 'org_navigation.dashboard',
path: '/dash',
icon: DashboardIcon,
matchPattern: '^/org/[^/]+/?$' // Exact match only
matchPattern: '^/org/[^/]+/dash(/.*)?$'
},
{
group: 'home',
@@ -74,7 +82,7 @@ export const baseNavConfig: NavItemConfig[] = [
path: '/setup',
icon: SetupIcon,
requiresAdmin: true,
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
matchPattern: '^/org/[^/]+/setup(/.*)?$'
},
{
group: 'content',
@@ -183,6 +191,10 @@ export const baseNavConfig: NavItemConfig[] = [
titleKey: 'settings.tabs.billing_tab',
path: '/settings/billing'
},
{
titleKey: 'settings.tabs.ai_credits_tab',
path: '/settings/ai-credits'
},
{
titleKey: 'settings.tabs.auth_tab',
matchPattern: '^/org/[^/]+/settings/auth(/.*)?$',
@@ -191,6 +203,14 @@ export const baseNavConfig: NavItemConfig[] = [
}
],
nestedRoutes: [
{
path: 'billing',
titleKey: 'settings.tabs.billing_tab'
},
{
path: 'ai-credits',
titleKey: 'settings.tabs.ai_credits_tab'
},
{
path: 'customize-lms',
titleKey: 'settings.tabs.customize_lms_tab'
@@ -5,7 +5,8 @@ export const PREMIUM_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([
QUESTION_TYPE_KEY.FILE_UPLOAD,
QUESTION_TYPE_KEY.ORDERING,
QUESTION_TYPE_KEY.LINK,
QUESTION_TYPE_KEY.STAR
QUESTION_TYPE_KEY.STAR,
QUESTION_TYPE_KEY.VIDEO_RECORDING
]);
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
@@ -24,7 +25,9 @@ const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
[QUESTION_TYPE_KEY.HOTSPOT]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.hotspot',
[QUESTION_TYPE_KEY.LINK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.link',
[QUESTION_TYPE_KEY.WORD_BANK]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.word_bank',
[QUESTION_TYPE_KEY.STAR]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.star'
[QUESTION_TYPE_KEY.STAR]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.star',
[QUESTION_TYPE_KEY.VIDEO_RECORDING]:
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.video_recording'
};
// Temp hidden: MATCHING, HOTSPOT
@@ -14,7 +14,7 @@
import { setTheme } from '$lib/utils/functions/theme';
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
import { ComingSoon } from '$features/ui';
import ComingSoon from '$features/ui/coming-soon.svelte';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
const sidebar = useSidebar();
@@ -41,7 +41,7 @@
<!-- Self-hosted: show org name only, no switching -->
{#if variant === 'breadcrumb'}
<Breadcrumb.Link href={$currentOrgPath} class="flex items-center gap-2">
{#if $currentOrg.name}sfsf
{#if $currentOrg.name}
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>