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
@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { TCustomLinks } from './types';
import { Button } from '@cio/ui/base/button';
@@ -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', '[email protected]') ?? '');
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>
@@ -30,12 +30,12 @@ export const PUBLIC_ROUTES = [
ROUTE.RESET,
`^${ROUTE.PAGES}/.*`,
`^${ROUTE.COURSE}/[^/]+(/enroll)?/?$`,
`^${ROUTE.COURSE}/[^/]+/lesson/.*`,
`^${ROUTE.COURSES}/?$`,
'/404',
`^${ROUTE.VERIFY_EMAIL_ERROR}$`,
ROUTE.AUTH_FAILED,
'^/csp-report$',
'^/sticky-debug$'
'^/csp-report$'
];
export const PUBLIC_API_ROUTES = ['/api/polar', '/api/lmz', '/api/verify'];
@@ -1,10 +1,10 @@
import { createQuizModal, deleteModal } from '$lib/utils/store/org';
export function onEdit(quiz) {}
export function onEdit(_quiz: unknown) {}
export function onShare(quiz) {}
export function onShare(_quiz: unknown) {}
export function onReport(quiz) {}
export function onReport(_quiz: unknown) {}
export function onRename(quiz) {
createQuizModal.update((m) => ({
@@ -15,7 +15,7 @@ export function onRename(quiz) {
}));
}
export function onMakeCopy(quiz) {}
export function onMakeCopy(_quiz: unknown) {}
export function onDelete(quiz) {
deleteModal.update((m) => ({
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Indlægget er oprettet",
"post_updated": "Opslaget blev opdateret"
}
},
"ai_credits": {
"purchase_success": "Token-pakke føjet til din saldo.",
"checkout_failed": "Kunne ikke starte kassen. Prøv venligst igen."
},
"public_course": {
"url_copied": "Kursus-URL kopieret",
"url_copy_failed": "Kunne ikke kopiere URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Vælg tags",
"empty_groups": "Ingen tags tilgængelige endnu"
},
"compliance": "Overholdelse"
"compliance": "Overholdelse",
"public": "Offentlig",
"convert_to_public_blocked": "Dette kursus har spørgsmål, der ikke kan automatisk bedømmes, så det kan ikke gøres offentligt endnu.",
"fix_blocking_questions": "Ret blokerende spørgsmål",
"slug": {
"label": "URL-slug",
"description": "Det sidste segment af denne vares offentlige webadresse. Kun bogstaver, tal og bindestreger.",
"public_only": "Snegle kan kun redigeres på offentlige kurser.",
"in_use": "Den snegl bruges allerede af et andet element i dette kursus."
},
"callout": {
"legend": "Offentlig kursusopkald",
"description": "Vist i bunden af hver lektion og i stedet for låste genstande. Brug det til at lede elever til dit websted eller et betalt kursus.",
"title_label": "Titel",
"title_placeholder": "Vil du gå dybere ind i dette?",
"description_label": "Beskrivelse",
"description_placeholder": "Slut dig til hele kohorten for at sende et projekt i produktionsgrad om 4 uger.",
"button_label": "Knap etiket",
"button_label_placeholder": "Læs mere på min side",
"button_url_label": "Knap-URL",
"button_url_placeholder": "https://example.com",
"clear": "Ryd billedforklaring",
"public_only": "Infoforklaringer vises kun på offentlige kurser."
}
},
"analytics": {
"title": "Analytics"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Forsøg {current} af {total}",
"previous_try": "Tidligere forsøg",
"next_try": "Næste forsøg",
"try_again": "Prøv igen"
"try_again": "Prøv igen",
"back": "Tilbage",
"begin_section": "Start sektion",
"complete_section": "Fuldfør afsnit"
},
"edit_mode": {
"delete_modal": "Slet øvelse",
@@ -1193,7 +1227,8 @@
"hotspot": "Hotspot",
"link": "Links",
"word_bank": "Ordbank",
"star": "Stjernebedømmelse"
"star": "Stjernebedømmelse",
"video_recording": "Videooptagelse"
},
"select_type": "Vælg type",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Slet spørgsmål",
"sure": "Er du sikker på, at du vil slette dette spørgsmål",
"no": "Nej, lad venligst være",
"yes": "Ja, slet"
"yes": "Ja, slet",
"section_title": "Slet afsnit",
"section_sure": "Dette afsnit vil blive slettet. Dens spørgsmål vil blive flyttet til den nærmeste sektion."
},
"description": {
"title": "Titel",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "f.eks. 3",
"max_stars_placeholder": "f.eks. 5"
}
},
"video_recording": {
"submission": {
"no_video": "Ingen video indsendt.",
"unavailable": "Videoen er ikke tilgængelig.",
"duration": "Varighed",
"file_size": "Filstørrelse",
"recorded_at": "Optaget",
"uploaded_at": "Uploadet",
"retake_count": "Genoptager"
},
"preview": {
"max_duration_label": "Max varighed",
"manual_grading": "Eleverne optager et kort videosvar. Manuel klassificering påkrævet."
},
"take": {
"unsupported": "Videooptagelse er ikke understøttet i denne browser.",
"permission_prompt": "Giv kamera og mikrofon adgang til at optage dit svar.",
"permission_denied": "Adgang til kamera eller mikrofon blev blokeret. Tjek browsertilladelser.",
"start_camera": "Opsæt kamera",
"camera_label": "Kamera",
"camera_placeholder": "Vælg kamera",
"play_preview": "Afspil optagelseseksempel",
"pause_preview": "Sæt forhåndsvisning af optagelse på pause",
"start_recording": "Start optagelsen",
"stop_recording": "Stop optagelsen",
"elapsed": "Forløbet",
"remaining": "Tilbage",
"max_duration": "Maks",
"too_short": "Optag i mindst 1 sekund.",
"use_recording": "Brug optagelse",
"retake": "Gentag",
"delete": "Slet optagelse",
"retry_upload": "Prøv at uploade igen",
"uploading": "Uploader optagelse...",
"uploaded": "Optagelsen er uploadet",
"upload_failed": "Upload mislykkedes. Prøv igen.",
"replaces_previous": "Et nyt accepteret svar erstatter det tidligere svar efter upload."
},
"edit": {
"max_duration_label": "Max varighed",
"max_duration_helper": "Studerende optager i browseren og kan tage om, inden de afleverer. Maksimum er 2 minutter.",
"max_duration_placeholder": "60",
"manual_grading": "Kræver manuel klassificering"
}
}
},
"settings": "Indstillinger",
"settings_allow_multiple": "Tillad flere forsøg",
"settings_allow_multiple_helper": "Studerende kan indsende mere end én gang og gennemgå tidligere forsøg på resultatskærmen.",
"settings_save": "Gem indstillinger"
"settings_save": "Gem indstillinger",
"add": "Tilføj",
"add_section": "Tilføj sektion",
"reorder_done": "Færdig genbestilling",
"section": {
"fallback_title": "Sektion",
"questions": "spørgsmål",
"short_questions": "q",
"required": "påkrævet",
"add_question": "Tilføj spørgsmål",
"delete": "Slet afsnit",
"move_up": "Flyt afsnittet op",
"move_down": "Flyt sektionen ned",
"color": "Sektionsfarve",
"description_placeholder": "Beskriv dette afsnit",
"after_section": "Efter afsnit",
"continue": "Fortsæt til næste afsnit",
"go_to_section": "Gå til afsnittet",
"submit": "Indsend øvelse"
}
},
"new_exercise_modal": {
"heading": "Ny øvelse",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere.",
"saving": "Gemmer...",
"saved": "Gemt",
"draft_recovery": "Der blev fundet et ikke-gemt udkast til denne lektion. Vil du gendanne den?"
"draft_recovery": "Der blev fundet et ikke-gemt udkast til denne lektion. Vil du gendanne den?",
"stats": {
"sections": "Afsnit",
"lessons": "Lektioner",
"exercises": "Øvelser"
}
},
"news_feed": {
"heading": "Nyhedsfeed",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Synlig på dit offentlige kursussite"
},
"sidebar": {
"back_to_course": "Tilbage til kurset",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "Certificeringsøvelsen \"{exerciseTitle}\" har ingen point konfigureret; bed din instruktør om at ordne det.",
"email_message_label": "E-mail besked",
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!"
},
"creator": {
"heading": "Hvilket kursus vil du lave?",
"placeholder": "Bed ClassroomIO om at oprette et kursus til dig...",
"type": {
"self_paced": "Selv tempo",
"live_class": "Live klasse"
},
"level": {
"beginner": "Begynder",
"intermediate": "Mellemliggende",
"advanced": "Avanceret"
},
"drafting": {
"heading": "Udarbejdelse af din kursusidé",
"subtext": "Vi forvandler din prompt til en kursustitel og et arbejdsområde.",
"step_reading": "Læser din prompt",
"step_naming": "Navngiv dit kursus",
"step_building": "Oprettelse af dit arbejdsområde"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Upubliceret",
"continue_course": "Fortsæt",
"learn_more": "Få flere oplysninger",
"public_badge": "Offentlig",
"actions_menu_aria": "Kursehandlinger",
"tags_overflow_aria": "{count} flere tags",
"list_view": {
"title": "Titel",
@@ -1897,7 +2026,8 @@
"invite": "Inviter",
"delete": "Slet",
"open": "Åbn",
"open_course_site": "Åben kursusside"
"open_public_course": "Åbn offentligt kursus",
"copy_course_url": "Kopiér kursus-URL"
},
"due_date": "Forfalder",
"valid_until": "Gælder indtil kl"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Token Auth",
"customize_lms_tab": "Tilpas LMS",
"domains_tab": "Domæner",
"teams_tab": "Hold"
"teams_tab": "Hold",
"ai_credits_tab": "AI Credits"
},
"subheadings": {
"view_site": "Se webstedet"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "AI-kreditter",
"page_subtitle": "Spor brug af AI-assistent, teamforbrug og køb af tokenpakker.",
"cost_note": "Kreditforbrug vægtes efter modelomkostninger. Gemini 2.5 Flash og GPT-5.4 Mini tæller som 1 kreditenhed pr. token. Claude Sonnet 4.6 tæller som 36× pr. token, hvilket afspejler dens højere API-omkostninger.",
"buy_tokens": {
"title": "Køb flere tokens",
"description": "Hver pakke tilføjer 5.000.000 kreditter for $5 USD. Vælg, hvor mange pakker du har brug for.",
"quantity_hint": "pakker",
"preview": "{quantity} pakke(r) · {tokens} tokens · ${dollars}",
"buy_button": "Køb for ${dollars}"
},
"leaderboard": {
"title": "Teambrug",
"this_month": "Nuværende måned",
"user": "Bruger",
"favorite_model": "Yndlingsmodel",
"tokens": "Poletter",
"empty": "Ingen på dit hold har brugt AI-assistenten denne måned."
},
"chart": {
"heading": "Brug",
"subheading": "Dagligt tokenforbrug denne måned.",
"tokens": "Poletter",
"total_tokens": "Samlede tokens",
"total_requests": "Samlet antal anmodninger",
"empty": "Der er ikke registreret noget forbrug i denne måned endnu."
},
"purchased": {
"title": "Købt brug",
"subtitle": "Ekstra poletter, du har købt, ruller over, indtil de bliver brugt.",
"lifetime": "{tokens} tokens købt · ${dollars} brugt",
"buy_more": "Køb mere"
},
"included": {
"title": "Dit inkluderede forbrug",
"subtitle": "Månedlige tokens inkluderet i din plan.",
"resets_on": "Nulstiller {dato}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "Selv tempo",
"self_paced_subtitle": "Bedst til kurser, hvor eleverne kan tage lektioner i deres eget tempo",
"compliance_label": "Overholdelse",
"compliance_subtitle": "Tilbagevendende træning med gentagninger, påmindelser og fornyelsessporing"
"compliance_subtitle": "Tilbagevendende træning med gentagninger, påmindelser og fornyelsessporing",
"public_label": "Offentlig",
"public_subtitle": "Gratis kursussider uden tilmelding, du kan dele offentligt – fantastisk til leadgener"
},
"delete_modal": {
"label": "Slet",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Spørg AI-assistenten...",
"attach_document": "Vedhæft et dokument (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Opgrader for at uploade dokumenter",
"tokens_exhausted": "Månedlig AI-token-grænse nået. Køb flere kreditter i Indstillinger > Fakturering, eller vent på nulstilling.",
"tokens_exhausted": "Månedlig AI-token-grænse nået.",
"tokens_exhausted_buy_more": "Køb mere",
"chat_history": "Chat historie",
"no_conversations": "Ingen samtaler endnu",
"untitled": "Uden titel",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Motion",
"mention_section": "Sektion",
"mention_no_results": "Intet matchende indhold",
"uploading_document": "Uploader dokument..."
"uploading_document": "Uploader dokument...",
"agent_running_warning": "Ændringerne vises i kurset, når agenten er færdig. Genindlæs ikke siden."
},
"widgets": {
"heading": "Widgets",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Beitrag erfolgreich erstellt",
"post_updated": "Beitrag erfolgreich aktualisiert"
}
},
"ai_credits": {
"purchase_success": "Das Token-Paket wurde Ihrem Guthaben hinzugefügt.",
"checkout_failed": "Der Checkout konnte nicht gestartet werden. Bitte versuchen Sie es erneut."
},
"public_course": {
"url_copied": "Kurs-URL kopiert",
"url_copy_failed": "URL konnte nicht kopiert werden"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Wählen Sie Tags aus",
"empty_groups": "Noch keine Tags verfügbar"
},
"compliance": "Compliance"
"compliance": "Compliance",
"public": "Öffentlich",
"convert_to_public_blocked": "Dieser Kurs enthält Fragen, die nicht automatisch bewertet werden können, daher kann er noch nicht veröffentlicht werden.",
"fix_blocking_questions": "Blockierende Fragen beheben",
"slug": {
"label": "URL-Slug",
"description": "Das letzte Segment der öffentlichen URL dieses Artikels. Nur Buchstaben, Zahlen und Bindestriche.",
"public_only": "Slugs können nur in öffentlichen Kursen bearbeitet werden.",
"in_use": "Dieser Slug wird bereits von einem anderen Gegenstand in diesem Kurs verwendet."
},
"callout": {
"legend": "Hinweis zum öffentlichen Kurs",
"description": "Wird am Ende jeder Lektion und anstelle von gesperrten Gegenständen angezeigt. Nutzen Sie es, um Lernende auf Ihre Website oder einen kostenpflichtigen Kurs zu leiten.",
"title_label": "Titel",
"title_placeholder": "Möchten Sie näher darauf eingehen?",
"description_label": "Beschreibung",
"description_placeholder": "Schließen Sie sich der gesamten Kohorte an, um in 4 Wochen ein produktionstaugliches Projekt auszuliefern.",
"button_label": "Knopfbeschriftung",
"button_label_placeholder": "Lesen Sie mehr auf meiner Website",
"button_url_label": "Schaltflächen-URL",
"button_url_placeholder": "https://example.com",
"clear": "Klare Beschriftung",
"public_only": "Callouts erscheinen nur in öffentlichen Kursen."
}
},
"analytics": {
"title": "Analytik"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Versuch {current} von {total}",
"previous_try": "Vorheriger Versuch",
"next_try": "Nächster Versuch",
"try_again": "Versuchen Sie es erneut"
"try_again": "Versuchen Sie es erneut",
"back": "Zurück",
"begin_section": "Startabschnitt",
"complete_section": "Vollständiger Abschnitt"
},
"edit_mode": {
"delete_modal": "Übung löschen",
@@ -1193,7 +1227,8 @@
"hotspot": "Hotspot",
"link": "Links",
"word_bank": "Wortbank",
"star": "Sternebewertung"
"star": "Sternebewertung",
"video_recording": "Videoaufzeichnung"
},
"select_type": "Typ auswählen",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Frage löschen",
"sure": "Sind Sie sicher, dass Sie diese Frage löschen möchten?",
"no": "Nein, bitte nicht",
"yes": "Ja, löschen"
"yes": "Ja, löschen",
"section_title": "Abschnitt löschen",
"section_sure": "Dieser Abschnitt wird gelöscht. Die Fragen werden in den nächstgelegenen Abschnitt verschoben."
},
"description": {
"title": "Titel",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "z.B. 3",
"max_stars_placeholder": "z.B. 5"
}
},
"video_recording": {
"submission": {
"no_video": "Kein Video eingereicht.",
"unavailable": "Video nicht verfügbar.",
"duration": "Dauer",
"file_size": "Dateigröße",
"recorded_at": "Aufgezeichnet",
"uploaded_at": "Hochgeladen",
"retake_count": "Wiederholungen"
},
"preview": {
"max_duration_label": "Maximale Dauer",
"manual_grading": "Die Lernenden zeichnen eine kurze Videoantwort auf. Manuelle Bewertung erforderlich."
},
"take": {
"unsupported": "Die Videoaufzeichnung wird in diesem Browser nicht unterstützt.",
"permission_prompt": "Erlauben Sie den Zugriff auf Kamera und Mikrofon, um Ihre Antwort aufzuzeichnen.",
"permission_denied": "Der Kamera- oder Mikrofonzugriff wurde blockiert. Überprüfen Sie die Browserberechtigungen.",
"start_camera": "Kamera einrichten",
"camera_label": "Kamera",
"camera_placeholder": "Kamera auswählen",
"play_preview": "Aufnahmevorschau abspielen",
"pause_preview": "Aufnahmevorschau anhalten",
"start_recording": "Starten Sie die Aufnahme",
"stop_recording": "Stoppen Sie die Aufnahme",
"elapsed": "Vergangen",
"remaining": "Übrig",
"max_duration": "Max",
"too_short": "Nehmen Sie mindestens 1 Sekunde lang auf.",
"use_recording": "Verwenden Sie die Aufzeichnung",
"retake": "Wiederholen",
"delete": "Aufnahme löschen",
"retry_upload": "Versuchen Sie den Upload erneut",
"uploading": "Aufnahme wird hochgeladen...",
"uploaded": "Aufnahme hochgeladen",
"upload_failed": "Der Upload ist fehlgeschlagen. Versuchen Sie es erneut.",
"replaces_previous": "Eine neue akzeptierte Einstellung ersetzt die vorherige Antwort nach dem Hochladen."
},
"edit": {
"max_duration_label": "Maximale Dauer",
"max_duration_helper": "Die Schüler zeichnen im Browser auf und können sie vor dem Absenden wiederholen. Das Maximum beträgt 2 Minuten.",
"max_duration_placeholder": "60",
"manual_grading": "Erfordert eine manuelle Bewertung"
}
}
},
"settings": "Einstellungen",
"settings_allow_multiple": "Erlauben Sie mehrere Versuche",
"settings_allow_multiple_helper": "Studierende können mehr als einmal einreichen und frühere Versuche auf dem Ergebnisbildschirm überprüfen.",
"settings_save": "Einstellungen speichern"
"settings_save": "Einstellungen speichern",
"add": "Hinzufügen",
"add_section": "Abschnitt hinzufügen",
"reorder_done": "Nachbestellung abgeschlossen",
"section": {
"fallback_title": "Abschnitt",
"questions": "Fragen",
"short_questions": "q",
"required": "erforderlich",
"add_question": "Frage hinzufügen",
"delete": "Abschnitt löschen",
"move_up": "Abschnitt nach oben verschieben",
"move_down": "Abschnitt nach unten verschieben",
"color": "Abschnittsfarbe",
"description_placeholder": "Beschreiben Sie diesen Abschnitt",
"after_section": "Nach Abschnitt",
"continue": "Fahren Sie mit dem nächsten Abschnitt fort",
"go_to_section": "Gehen Sie zum Abschnitt",
"submit": "Übung einreichen"
}
},
"new_exercise_modal": {
"heading": "Neue Übung",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei.",
"saving": "Sparen…",
"saved": "Gespeichert",
"draft_recovery": "Für diese Lektion wurde ein nicht gespeicherter Entwurf gefunden. Möchten Sie es wiederherstellen?"
"draft_recovery": "Für diese Lektion wurde ein nicht gespeicherter Entwurf gefunden. Möchten Sie es wiederherstellen?",
"stats": {
"sections": "Abschnitte",
"lessons": "Lektionen",
"exercises": "Übungen"
}
},
"news_feed": {
"heading": "Newsfeed",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Sichtbar auf Ihrer öffentlichen Kurswebsite"
},
"sidebar": {
"back_to_course": "Zurück zum Kurs",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "Für die Zertifizierungsübung „{exerciseTitle}“ sind keine Punkte konfiguriert; Bitten Sie Ihren Lehrer, das Problem zu beheben.",
"email_message_label": "E-Mail-Nachricht",
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!"
},
"creator": {
"heading": "Welchen Kurs möchten Sie erstellen?",
"placeholder": "Bitten Sie ClassroomIO, einen Kurs für Sie zu erstellen ...",
"type": {
"self_paced": "Eigenständig",
"live_class": "Live-Klasse"
},
"level": {
"beginner": "Anfänger",
"intermediate": "Mittelstufe",
"advanced": "Fortgeschritten"
},
"drafting": {
"heading": "Ausarbeitung Ihrer Kursidee",
"subtext": "Wir verwandeln Ihre Eingabeaufforderung in einen Kurstitel und einen Arbeitsbereich.",
"step_reading": "Ich lese Ihre Aufforderung",
"step_naming": "Benennen Sie Ihren Kurs",
"step_building": "Erstellen Sie Ihren Arbeitsbereich"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Unveröffentlicht",
"continue_course": "Weiter",
"learn_more": "Erfahren Sie mehr",
"public_badge": "Öffentlich",
"actions_menu_aria": "Kursaktionen",
"tags_overflow_aria": "{count} weitere Tags",
"list_view": {
"title": "Titel",
@@ -1897,7 +2026,8 @@
"invite": "Einladen",
"delete": "Löschen",
"open": "Offen",
"open_course_site": "Kursseite öffnen"
"open_public_course": "Öffentlichen Kurs öffnen",
"copy_course_url": "Kurs-URL kopieren"
},
"due_date": "Fällig",
"valid_until": "Gültig bis"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Token-Auth",
"customize_lms_tab": "LMS anpassen",
"domains_tab": "Domänen",
"teams_tab": "Mannschaften"
"teams_tab": "Mannschaften",
"ai_credits_tab": "KI-Credits"
},
"subheadings": {
"view_site": "Website ansehen"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "KI-Credits",
"page_subtitle": "Verfolgen Sie die Nutzung von KI-Assistenten, den Teamverbrauch und den Kauf von Token-Paketen.",
"cost_note": "Die Credit-Nutzung wird nach Modellkosten gewichtet. Gemini 2.5 Flash und GPT-5.4 Mini zählen als 1 Krediteinheit pro Token. Claude Sonnet 4.6 zählt als 36× pro Token, was die höheren API-Kosten widerspiegelt.",
"buy_tokens": {
"title": "Kaufen Sie mehr Token",
"description": "Jedes Paket fügt 5.000.000 Credits für 5 USD hinzu. Wählen Sie, wie viele Packungen Sie benötigen.",
"quantity_hint": "Packungen",
"preview": "{quantity} Packung(en) · {tokens} Token · ${dollars}",
"buy_button": "Für ${dollars} kaufen"
},
"leaderboard": {
"title": "Teamnutzung",
"this_month": "Aktueller Monat",
"user": "Benutzer",
"favorite_model": "Lieblingsmodell",
"tokens": "Token",
"empty": "Niemand in Ihrem Team hat diesen Monat den KI-Assistenten verwendet."
},
"chart": {
"heading": "Nutzung",
"subheading": "Täglicher Tokenverbrauch in diesem Monat.",
"tokens": "Token",
"total_tokens": "Gesamtzahl der Token",
"total_requests": "Gesamtzahl der Anfragen",
"empty": "In diesem Monat wurde noch keine Nutzung registriert."
},
"purchased": {
"title": "Gekaufte Nutzung",
"subtitle": "Zusätzliche Token, die Sie gekauft haben, bleiben bestehen, bis sie verwendet werden.",
"lifetime": "{tokens} Token gekauft · ${dollars} ausgegeben",
"buy_more": "Kaufen Sie mehr"
},
"included": {
"title": "Ihr inbegriffener Verbrauch",
"subtitle": "Monatliche Token sind in Ihrem Plan enthalten.",
"resets_on": "Setzt {Datum} zurück"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "Eigenständig",
"self_paced_subtitle": "Am besten für Kurse geeignet, bei denen die Schüler den Unterricht in ihrem eigenen Tempo absolvieren können",
"compliance_label": "Compliance",
"compliance_subtitle": "Wiederkehrende Schulungen mit Wiederholungen, Erinnerungen und Verlängerungsverfolgung"
"compliance_subtitle": "Wiederkehrende Schulungen mit Wiederholungen, Erinnerungen und Verlängerungsverfolgung",
"public_label": "Öffentlich",
"public_subtitle": "Kostenlose Kursseiten ohne Anmeldung, die Sie öffentlich teilen können – ideal für die Lead-Generierung"
},
"delete_modal": {
"label": "Löschen",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Fragen Sie den KI-Assistenten...",
"attach_document": "Ein Dokument anhängen (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Upgrade zum Hochladen von Dokumenten",
"tokens_exhausted": "Das monatliche AI-Token-Limit wurde erreicht. Kaufen Sie mehr Credits unter Einstellungen > Abrechnung oder warten Sie auf das Zurücksetzen.",
"tokens_exhausted": "Das monatliche AI-Token-Limit wurde erreicht.",
"tokens_exhausted_buy_more": "Mehr kaufen",
"chat_history": "Chat-Verlauf",
"no_conversations": "Noch keine Gespräche",
"untitled": "Ohne Titel",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Übung",
"mention_section": "Abschnitt",
"mention_no_results": "Kein passender Inhalt",
"uploading_document": "Dokument wird hochgeladen..."
"uploading_document": "Dokument wird hochgeladen...",
"agent_running_warning": "Die Änderungen werden im Kurs angezeigt, sobald der Agent fertig ist. Laden Sie die Seite nicht neu."
},
"widgets": {
"heading": "Widgets",
+206 -11
View File
@@ -63,7 +63,32 @@
"self_paced_label": "Self Paced",
"self_paced_subtitle": "Best for courses where students can take lessons at their own pace",
"compliance_label": "Compliance",
"compliance_subtitle": "Recurring training with retakes, reminders, and renewal tracking"
"compliance_subtitle": "Recurring training with retakes, reminders, and renewal tracking",
"public_label": "Public",
"public_subtitle": "Free, no-signup course pages you can share publicly — great for lead gen"
},
"public_course": {
"header": {
"explore_courses": "Explore courses",
"sign_in": "Sign in"
},
"footer_nav": {
"previous": "Previous",
"next": "Next"
},
"powered_by": {
"label": "Powered by",
"brand": "ClassroomIO"
},
"exercise": {
"submit": "Check my answers",
"try_again": "Try again",
"practice_again": "Practice again",
"attempt_option": "Attempt {attemptNumber} - {correct}/{total}",
"attempts_select_aria": "Your attempts",
"privacy_hint": "Graded instantly in your browser — nothing is submitted.",
"summary_template": "You got [[correct]] / [[total]] correct."
}
},
"onboarding": {
"sell": "Sell courses online",
@@ -181,6 +206,7 @@
"organization_tab": "Organization",
"landing_page_tab": "Landing Page",
"billing_tab": "Billing",
"ai_credits_tab": "AI Credits",
"integrations_tab": "Integrations",
"language_tab": "Language",
"auth_tab": "Authentication",
@@ -579,10 +605,46 @@
"active": "You don't have any active billing",
"provider": "We use Polar to help manage your billing",
"open_billing": "Open billing",
"error": "An error occurred while opening billing",
"ai_credits": "AI Assistant Usage",
"ai_tokens_used": "tokens used this month",
"ai_bonus_credits": "bonus credits available"
"error": "An error occurred while opening billing"
},
"ai_credits": {
"sub_title": "AI credits",
"page_subtitle": "Track AI assistant usage, team consumption, and purchase token packs.",
"cost_note": "Credit usage is weighted by model cost. Gemini 2.5 Flash and GPT-5.4 Mini count as 1 credit unit per token. Claude Sonnet 4.6 counts as 36× per token, reflecting its higher API cost.",
"included": {
"title": "Your included usage",
"subtitle": "Monthly tokens included with your plan.",
"resets_on": "Resets {date}"
},
"purchased": {
"title": "Purchased usage",
"subtitle": "Extra tokens you bought roll over until used.",
"lifetime": "{tokens} tokens purchased · ${dollars} spent",
"buy_more": "Buy more"
},
"chart": {
"heading": "Usage",
"subheading": "Daily token consumption this month.",
"tokens": "Tokens",
"total_tokens": "Total tokens",
"total_requests": "Total requests",
"empty": "No usage recorded this month yet."
},
"leaderboard": {
"title": "Team usage",
"this_month": "Current month",
"user": "User",
"favorite_model": "Favorite model",
"tokens": "Tokens",
"empty": "Nobody on your team has used the AI assistant this month."
},
"buy_tokens": {
"title": "Buy more tokens",
"description": "Each pack adds 5,000,000 credits for $5 USD. Choose how many packs you need.",
"quantity_hint": "packs",
"preview": "{quantity} pack(s) · {tokens} tokens · ${dollars}",
"buy_button": "Buy for ${dollars}"
}
}
},
"media_manager": {
@@ -1416,7 +1478,8 @@
"share": "Share",
"invite": "Invite",
"delete": "Delete",
"open_course_site": "Open course site"
"open_public_course": "Open public course",
"copy_course_url": "Copy course URL"
},
"error_message": "An error occurred.",
"lessons_number": "lessons",
@@ -1426,6 +1489,8 @@
"unpublished": "Unpublished",
"continue_course": "Continue",
"learn_more": "Learn more",
"public_badge": "Public",
"actions_menu_aria": "Course actions",
"due_date": "Due",
"valid_until": "Valid until",
"tags_overflow_aria": "{count} more tags",
@@ -1464,6 +1529,26 @@
}
},
"course": {
"creator": {
"heading": "What course do you want to create?",
"placeholder": "Ask ClassroomIO to create a course for you...",
"drafting": {
"heading": "Drafting your course idea",
"subtext": "We're turning your prompt into a course title and workspace.",
"step_reading": "Reading your prompt",
"step_naming": "Naming your course",
"step_building": "Creating your workspace"
},
"level": {
"beginner": "Beginner",
"intermediate": "Intermediate",
"advanced": "Advanced"
},
"type": {
"self_paced": "Self Paced",
"live_class": "Live Class"
}
},
"navItems": {
"nav_news_feed": "News Feed",
"nav_content": "Content",
@@ -1528,7 +1613,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Visible on your public course site"
},
"navItem": {
"news_feed": {
@@ -1575,6 +1661,11 @@
"lessons": {
"heading": "Lessons",
"heading_v2": "Content",
"stats": {
"sections": "Sections",
"lessons": "Lessons",
"exercises": "Exercises"
},
"drag": "drag a card to reorder",
"no_lesson": "No lessons yet",
"content_locked_title": "This content is locked",
@@ -1604,6 +1695,7 @@
"save": "Save",
"edit": "Edit",
"delete": "Delete",
"delete_tooltip": "Delete this lesson",
"cancel": "Cancel"
},
"add_content": "Add content",
@@ -1812,8 +1904,11 @@
"submissions": "Submissions",
"save": "Save",
"preview": "Preview",
"add": "Add",
"add_question": "Add a question",
"add_section": "Add section",
"reorder": "Reorder Questions",
"reorder_done": "Done reordering",
"delete_exercise": "Delete Exercise",
"order_questions": "Order questions",
"write_your_answer": "Write your answer",
@@ -1848,9 +1943,27 @@
"delete_confirmation": {
"title": "Delete question",
"sure": "Are you sure you want to delete this question",
"section_title": "Delete section",
"section_sure": "This section will be deleted. Its questions will be moved to the nearest section.",
"no": "No, please don't",
"yes": "Yes, delete"
},
"section": {
"fallback_title": "Section",
"questions": "questions",
"short_questions": "q",
"required": "required",
"add_question": "Add question",
"delete": "Delete section",
"move_up": "Move section up",
"move_down": "Move section down",
"color": "Section color",
"description_placeholder": "Describe this section",
"after_section": "After section",
"continue": "Continue to next section",
"go_to_section": "Go to section",
"submit": "Submit exercise"
},
"edit_mode": {
"delete_modal": "Delete Exercise",
"sure": "Are you sure you want to delete this exercise?",
@@ -1879,7 +1992,8 @@
"hotspot": "Hotspot",
"link": "Links",
"word_bank": "Word bank",
"star": "Star rating"
"star": "Star rating",
"video_recording": "Video recording"
}
},
"view_mode": {
@@ -1908,7 +2022,10 @@
"status_graded": "Status: Review Completed",
"status_submitted": "Status: Submitted",
"checking_submission": "Checking your submission...",
"instructor_feedback": "Instructor feedback"
"instructor_feedback": "Instructor feedback",
"back": "Back",
"begin_section": "Start section",
"complete_section": "Complete section"
},
"analytics": {
"summary": {
@@ -2107,6 +2224,51 @@
"max_size_label": "Max size (MB)"
}
},
"video_recording": {
"edit": {
"max_duration_label": "Max duration",
"max_duration_helper": "Students record in the browser and can retake before submitting. Maximum is 2 minutes.",
"max_duration_placeholder": "60",
"manual_grading": "Requires manual grading"
},
"take": {
"unsupported": "Video recording is not supported in this browser.",
"permission_prompt": "Allow camera and microphone access to record your answer.",
"permission_denied": "Camera or microphone access was blocked. Check browser permissions.",
"start_camera": "Set up camera",
"camera_label": "Camera",
"camera_placeholder": "Select camera",
"play_preview": "Play recording preview",
"pause_preview": "Pause recording preview",
"start_recording": "Start recording",
"stop_recording": "Stop recording",
"elapsed": "Elapsed",
"remaining": "Remaining",
"max_duration": "Max",
"too_short": "Record for at least 1 second.",
"use_recording": "Use recording",
"retake": "Retake",
"delete": "Delete recording",
"retry_upload": "Retry upload",
"uploading": "Uploading recording...",
"uploaded": "Recording uploaded",
"upload_failed": "Upload failed. Try again.",
"replaces_previous": "A new accepted take replaces the previous answer after upload."
},
"preview": {
"max_duration_label": "Max duration",
"manual_grading": "Learners record a short video response. Manual grading required."
},
"submission": {
"no_video": "No video submitted.",
"unavailable": "Video unavailable.",
"duration": "Duration",
"file_size": "File size",
"recorded_at": "Recorded",
"uploaded_at": "Uploaded",
"retake_count": "Retakes"
}
},
"link": {
"edit": {
"instructions_placeholder": "Optional instructions for learners",
@@ -2686,6 +2848,29 @@
"live_class": "Live class",
"self_paced": "Self paced",
"compliance": "Compliance",
"public": "Public",
"convert_to_public_blocked": "This course has questions that can’t be auto-graded, so it can’t be made Public yet.",
"fix_blocking_questions": "Fix blocking questions",
"callout": {
"legend": "Public course callout",
"description": "Shown at the bottom of each lesson and in place of locked items. Use it to drive learners to your site or a paid course.",
"title_label": "Title",
"title_placeholder": "Want to go deeper on this?",
"description_label": "Description",
"description_placeholder": "Join the full cohort to ship a production-grade project in 4 weeks.",
"button_label": "Button label",
"button_label_placeholder": "Read more on my site",
"button_url_label": "Button URL",
"button_url_placeholder": "https://example.com",
"clear": "Clear callout",
"public_only": "Callouts only appear on Public courses."
},
"slug": {
"label": "URL slug",
"description": "The last segment of this item’s public URL. Only letters, numbers, and dashes.",
"public_only": "Slugs are only editable on Public courses.",
"in_use": "That slug is already used by another item in this course."
},
"tags": {
"title": "Tags",
"description": "Assign tags so learners can discover this course with filters.",
@@ -3027,6 +3212,14 @@
"activated": "Token auth enabled.",
"deactivated": "Token auth disabled."
},
"ai_credits": {
"purchase_success": "Token pack added to your balance.",
"checkout_failed": "Could not start checkout. Please try again."
},
"public_course": {
"url_copied": "Course URL copied",
"url_copy_failed": "Could not copy URL"
},
"something": "Something went wrong - Please try later",
"update": "Update",
"update_failed": "Update failed",
@@ -3423,7 +3616,8 @@
"input_placeholder": "Ask the AI assistant...",
"attach_document": "Attach a document (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Upgrade to upload documents",
"tokens_exhausted": "Monthly AI token limit reached. Buy more credits in Settings > Billing or wait for reset.",
"tokens_exhausted": "Monthly AI token limit reached.",
"tokens_exhausted_buy_more": "Buy more",
"chat_history": "Chat History",
"no_conversations": "No conversations yet",
"untitled": "Untitled",
@@ -3436,6 +3630,7 @@
"mention_exercise": "Exercise",
"mention_section": "Section",
"mention_no_results": "No matching content",
"uploading_document": "Uploading document..."
"uploading_document": "Uploading document...",
"agent_running_warning": "The changes will appear in the course once the agent is done. Don't reload the page."
}
}
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Publicación creada exitosamente",
"post_updated": "Publicación actualizada exitosamente"
}
},
"ai_credits": {
"purchase_success": "Paquete de tokens agregado a su saldo.",
"checkout_failed": "No se pudo iniciar el pago. Por favor inténtalo de nuevo."
},
"public_course": {
"url_copied": "URL del curso copiada",
"url_copy_failed": "No se pudo copiar la URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Seleccionar etiquetas",
"empty_groups": "Aún no hay etiquetas disponibles"
},
"compliance": "Cumplimiento"
"compliance": "Cumplimiento",
"public": "Público",
"convert_to_public_blocked": "Este curso tiene preguntas que no se pueden calificar automáticamente, por lo que aún no se puede hacer público.",
"fix_blocking_questions": "Corregir preguntas de bloqueo",
"slug": {
"label": "barra de URL",
"description": "El último segmento de la URL pública de este elemento. Sólo letras, números y guiones.",
"public_only": "Los slugs solo se pueden editar en cursos públicos.",
"in_use": "Ese slug ya lo utiliza otro elemento de este curso."
},
"callout": {
"legend": "Llamada del curso público",
"description": "Se muestra al final de cada lección y en lugar de los elementos bloqueados. Úselo para llevar a los estudiantes a su sitio o a un curso pago.",
"title_label": "Título",
"title_placeholder": "¿Quieres profundizar en esto?",
"description_label": "Descripción",
"description_placeholder": "Únase al grupo completo para enviar un proyecto de nivel de producción en 4 semanas.",
"button_label": "Etiqueta del botón",
"button_label_placeholder": "Leer más en mi sitio",
"button_url_label": "URL del botón",
"button_url_placeholder": "https://ejemplo.com",
"clear": "Borrar llamada",
"public_only": "Las notas solo aparecen en los cursos públicos."
}
},
"analytics": {
"title": "Analítica"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Intento {actual} de {total}",
"previous_try": "Intento anterior",
"next_try": "Próximo intento",
"try_again": "Inténtalo de nuevo"
"try_again": "Inténtalo de nuevo",
"back": "Atrás",
"begin_section": "Sección de inicio",
"complete_section": "Sección completa"
},
"edit_mode": {
"delete_modal": "Eliminar ejercicio",
@@ -1193,7 +1227,8 @@
"hotspot": "Punto de acceso",
"link": "Enlaces",
"word_bank": "banco de palabras",
"star": "Calificación de estrellas"
"star": "Calificación de estrellas",
"video_recording": "Grabación de vídeo"
},
"select_type": "Seleccionar tipo",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Eliminar pregunta",
"sure": "¿Estás seguro de que quieres eliminar esta pregunta?",
"no": "No, por favor no",
"yes": "Si, borrar"
"yes": "Si, borrar",
"section_title": "Eliminar sección",
"section_sure": "Esta sección será eliminada. Sus preguntas se trasladarán a la sección más cercana."
},
"description": {
"title": "Título",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "por ej. 3",
"max_stars_placeholder": "por ej. 5"
}
},
"video_recording": {
"submission": {
"no_video": "No se envió ningún vídeo.",
"unavailable": "Vídeo no disponible.",
"duration": "Duración",
"file_size": "Tamaño del archivo",
"recorded_at": "grabado",
"uploaded_at": "subido",
"retake_count": "Repeticiones"
},
"preview": {
"max_duration_label": "Duración máxima",
"manual_grading": "Los alumnos graban una breve respuesta en vídeo. Se requiere calificación manual."
},
"take": {
"unsupported": "La grabación de vídeo no es compatible con este navegador.",
"permission_prompt": "Permita el acceso a la cámara y al micrófono para grabar su respuesta.",
"permission_denied": "Se bloqueó el acceso a la cámara o al micrófono. Verifique los permisos del navegador.",
"start_camera": "Configurar la cámara",
"camera_label": "cámara",
"camera_placeholder": "Seleccionar cámara",
"play_preview": "Reproducir vista previa de grabación",
"pause_preview": "Pausar la vista previa de la grabación",
"start_recording": "Empezar a grabar",
"stop_recording": "dejar de grabar",
"elapsed": "Transcurrido",
"remaining": "restante",
"max_duration": "máx.",
"too_short": "Grabe durante al menos 1 segundo.",
"use_recording": "Usar grabación",
"retake": "Retomar",
"delete": "Eliminar grabación",
"retry_upload": "Reintentar subir",
"uploading": "Subiendo grabación...",
"uploaded": "Grabación cargada",
"upload_failed": "La carga falló. Intentar otra vez.",
"replaces_previous": "Una nueva toma aceptada reemplaza la respuesta anterior después de la carga."
},
"edit": {
"max_duration_label": "Duración máxima",
"max_duration_helper": "Los estudiantes registran en el navegador y pueden volver a tomarlo antes de enviarlo. El máximo es 2 minutos.",
"max_duration_placeholder": "60",
"manual_grading": "Requiere calificación manual"
}
}
},
"settings": "Configuración",
"settings_allow_multiple": "Permitir múltiples intentos",
"settings_allow_multiple_helper": "Los estudiantes pueden enviar más de una vez y revisar intentos anteriores en la pantalla de resultados.",
"settings_save": "Guardar configuración"
"settings_save": "Guardar configuración",
"add": "Añadir",
"add_section": "Agregar sección",
"reorder_done": "Hecho de reordenar",
"section": {
"fallback_title": "Sección",
"questions": "preguntas",
"short_questions": "q",
"required": "requerido",
"add_question": "Agregar pregunta",
"delete": "Eliminar sección",
"move_up": "Mover sección hacia arriba",
"move_down": "Mover sección hacia abajo",
"color": "Color de sección",
"description_placeholder": "Describe esta sección",
"after_section": "Después de la sección",
"continue": "Continuar a la siguiente sección",
"go_to_section": "Ir a la sección",
"submit": "Enviar ejercicio"
}
},
"new_exercise_modal": {
"heading": "Nuevo ejercicio",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde.",
"saving": "Guardando…",
"saved": "Guardado",
"draft_recovery": "Se encontró un borrador no guardado para esta lección. ¿Quieres restaurarlo?"
"draft_recovery": "Se encontró un borrador no guardado para esta lección. ¿Quieres restaurarlo?",
"stats": {
"sections": "Secciones",
"lessons": "Lecciones",
"exercises": "Ejercicios"
}
},
"news_feed": {
"heading": "Noticias",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Visible en el sitio público de tu curso"
},
"sidebar": {
"back_to_course": "volver al curso",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "El ejercicio de certificación \"{exerciseTitle}\" no tiene puntos configurados; Pídele a tu instructor que lo arregle.",
"email_message_label": "Mensaje de correo electrónico",
"email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!"
},
"creator": {
"heading": "¿Qué curso quieres crear?",
"placeholder": "Pídele a ClassroomIO que cree un curso para ti...",
"type": {
"self_paced": "a tu propio ritmo",
"live_class": "Clase en vivo"
},
"level": {
"beginner": "principiante",
"intermediate": "Intermedio",
"advanced": "Avanzado"
},
"drafting": {
"heading": "Redactando tu idea de curso",
"subtext": "Estamos convirtiendo su mensaje en un título de curso y un espacio de trabajo.",
"step_reading": "Leyendo tu mensaje",
"step_naming": "Nombrando tu curso",
"step_building": "Creando tu espacio de trabajo"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Inédito",
"continue_course": "Continuar",
"learn_more": "Más información",
"public_badge": "Público",
"actions_menu_aria": "Acciones del curso",
"tags_overflow_aria": "{count} etiquetas más",
"list_view": {
"title": "Título",
@@ -1897,7 +2026,8 @@
"invite": "invitar",
"delete": "Eliminar",
"open": "Abierto",
"open_course_site": "Abrir sitio del curso"
"open_public_course": "Abrir curso público",
"copy_course_url": "Copiar URL del curso"
},
"due_date": "debido",
"valid_until": "Válido hasta"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Autenticación de token",
"customize_lms_tab": "Personalizar LMS",
"domains_tab": "Dominios",
"teams_tab": "equipos"
"teams_tab": "equipos",
"ai_credits_tab": "Créditos de IA"
},
"subheadings": {
"view_site": "Ver sitio"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "créditos de IA",
"page_subtitle": "Realice un seguimiento del uso del asistente de IA, el consumo del equipo y compre paquetes de tokens.",
"cost_note": "El uso del crédito está ponderado por el costo del modelo. Gemini 2.5 Flash y GPT-5.4 Mini cuentan como 1 unidad de crédito por token. Claude Sonnet 4.6 cuenta como 36 veces por token, lo que refleja su mayor costo de API.",
"buy_tokens": {
"title": "Compra más fichas",
"description": "Cada paquete agrega 5.000.000 de créditos por $5 USD. Elige cuántos paquetes necesitas.",
"quantity_hint": "paquetes",
"preview": "{cantidad} paquete(s) · {tokens} tokens · ${dollars}",
"buy_button": "Compra por ${dolares}"
},
"leaderboard": {
"title": "Uso del equipo",
"this_month": "Mes actual",
"user": "Usuario",
"favorite_model": "modelo favorito",
"tokens": "Fichas",
"empty": "Nadie de tu equipo ha utilizado el asistente de IA este mes."
},
"chart": {
"heading": "Uso",
"subheading": "Consumo diario de tokens este mes.",
"tokens": "Fichas",
"total_tokens": "fichas totales",
"total_requests": "Solicitudes totales",
"empty": "Aún no se ha registrado ningún uso este mes."
},
"purchased": {
"title": "Uso comprado",
"subtitle": "Las fichas adicionales que compraste se acumulan hasta que se usan.",
"lifetime": "{tokens} tokens comprados · ${dollars} gastados",
"buy_more": "comprar más"
},
"included": {
"title": "Su uso incluido",
"subtitle": "Fichas mensuales incluidas en tu plan.",
"resets_on": "Restablece {fecha}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "a tu propio ritmo",
"self_paced_subtitle": "Lo mejor para cursos donde los estudiantes pueden tomar lecciones a su propio ritmo",
"compliance_label": "Cumplimiento",
"compliance_subtitle": "Capacitación recurrente con repeticiones, recordatorios y seguimiento de renovaciones."
"compliance_subtitle": "Capacitación recurrente con repeticiones, recordatorios y seguimiento de renovaciones.",
"public_label": "Público",
"public_subtitle": "Páginas de cursos gratuitas y sin registro que puedes compartir públicamente: ideales para la generación de leads"
},
"delete_modal": {
"label": "Eliminar",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Pregúntale al asistente de IA...",
"attach_document": "Adjunte un documento (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Actualizar para cargar documentos",
"tokens_exhausted": "Se alcanzó el límite mensual de tokens de IA. Compre más créditos en Configuración > Facturación o espere a que se restablezcan.",
"tokens_exhausted": "Se alcanzó el límite mensual de tokens de IA.",
"tokens_exhausted_buy_more": "Comprar más",
"chat_history": "Historial de chat",
"no_conversations": "Aún no hay conversaciones",
"untitled": "Sin título",
@@ -3237,7 +3410,8 @@
"mention_exercise": "ejercicio",
"mention_section": "Sección",
"mention_no_results": "No hay contenido coincidente",
"uploading_document": "Subiendo documento..."
"uploading_document": "Subiendo documento...",
"agent_running_warning": "Los cambios aparecerán en el curso una vez que el agente haya terminado. No recargues la página."
},
"widgets": {
"heading": "widgets",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Message créé avec succès",
"post_updated": "Message mis à jour avec succès"
}
},
"ai_credits": {
"purchase_success": "Pack de jetons ajouté à votre solde.",
"checkout_failed": "Impossible de démarrer le paiement. Veuillez réessayer."
},
"public_course": {
"url_copied": "URL du cours copiée",
"url_copy_failed": "Impossible de copier l’URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Sélectionnez les balises",
"empty_groups": "Aucun tag disponible pour l'instant"
},
"compliance": "Conformité"
"compliance": "Conformité",
"public": "Publique",
"convert_to_public_blocked": "Ce cours comporte des questions qui ne peuvent pas être notées automatiquement et ne peut donc pas encore être rendu public.",
"fix_blocking_questions": "Corriger les questions bloquantes",
"slug": {
"label": "Limace d'URL",
"description": "Le dernier segment de l’URL publique de cet élément. Uniquement des lettres, des chiffres et des tirets.",
"public_only": "Les Slugs ne sont modifiables que sur les cours publics.",
"in_use": "Ce slug est déjà utilisé par un autre élément de ce cours."
},
"callout": {
"legend": "Appel du cours public",
"description": "Affiché au bas de chaque leçon et à la place des éléments verrouillés. Utilisez-le pour diriger les apprenants vers votre site ou vers un cours payant.",
"title_label": "Titre",
"title_placeholder": "Vous voulez approfondir ce sujet ?",
"description_label": "Descriptif",
"description_placeholder": "Rejoignez la cohorte complète pour expédier un projet de qualité production en 4 semaines.",
"button_label": "Etiquette du bouton",
"button_label_placeholder": "En savoir plus sur mon site",
"button_url_label": "URL du bouton",
"button_url_placeholder": "https://exemple.com",
"clear": "Effacer la légende",
"public_only": "Les légendes n'apparaissent que dans les cours publics."
}
},
"analytics": {
"title": "Analyse"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Tentative {actuelle} de {total}",
"previous_try": "Tentative précédente",
"next_try": "Prochaine tentative",
"try_again": "Réessayez"
"try_again": "Réessayez",
"back": "Retour",
"begin_section": "Section de démarrage",
"complete_section": "Rubrique complète"
},
"edit_mode": {
"delete_modal": "Supprimer l'exercice",
@@ -1193,7 +1227,8 @@
"hotspot": "Point d'accès",
"link": "Liens",
"word_bank": "Banque de mots",
"star": "Nombre d'étoiles"
"star": "Nombre d'étoiles",
"video_recording": "Enregistrement vidéo"
},
"select_type": "Sélectionnez le type",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Supprimer la question",
"sure": "Êtes-vous sûr de vouloir supprimer cette question",
"no": "Non, s'il te plaît, ne le fais pas",
"yes": "Oui, supprimer"
"yes": "Oui, supprimer",
"section_title": "Supprimer la rubrique",
"section_sure": "Cette rubrique sera supprimée. Ses questions seront déplacées vers la section la plus proche."
},
"description": {
"title": "Titre",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "par ex. 3",
"max_stars_placeholder": "par ex. 5"
}
},
"video_recording": {
"submission": {
"no_video": "Aucune vidéo soumise.",
"unavailable": "Vidéo indisponible.",
"duration": "Durée",
"file_size": "Taille du fichier",
"recorded_at": "Enregistré",
"uploaded_at": "Téléchargé",
"retake_count": "Reprises"
},
"preview": {
"max_duration_label": "Durée maximale",
"manual_grading": "Les apprenants enregistrent une courte réponse vidéo. Classement manuel requis."
},
"take": {
"unsupported": "L'enregistrement vidéo n'est pas pris en charge dans ce navigateur.",
"permission_prompt": "Autorisez l’accès à la caméra et au microphone pour enregistrer votre réponse.",
"permission_denied": "L'accès à la caméra ou au microphone a été bloqué. Vérifiez les autorisations du navigateur.",
"start_camera": "Configurer la caméra",
"camera_label": "Appareil photo",
"camera_placeholder": "Sélectionnez la caméra",
"play_preview": "Lire l'aperçu de l'enregistrement",
"pause_preview": "Suspendre l'aperçu de l'enregistrement",
"start_recording": "Commencer l'enregistrement",
"stop_recording": "Arrêter l'enregistrement",
"elapsed": "Écoulé",
"remaining": "Restant",
"max_duration": "Max.",
"too_short": "Enregistrez pendant au moins 1 seconde.",
"use_recording": "Utiliser l'enregistrement",
"retake": "Reprendre",
"delete": "Supprimer l'enregistrement",
"retry_upload": "Réessayez de télécharger",
"uploading": "Téléchargement de l'enregistrement...",
"uploaded": "Enregistrement téléchargé",
"upload_failed": "Le téléchargement a échoué. Essayer à nouveau.",
"replaces_previous": "Une nouvelle prise acceptée remplace la réponse précédente après le téléchargement."
},
"edit": {
"max_duration_label": "Durée maximale",
"max_duration_helper": "Les étudiants enregistrent dans le navigateur et peuvent reprendre avant de soumettre. Le maximum est de 2 minutes.",
"max_duration_placeholder": "60",
"manual_grading": "Nécessite une notation manuelle"
}
}
},
"settings": "Paramètres",
"settings_allow_multiple": "Autoriser plusieurs tentatives",
"settings_allow_multiple_helper": "Les étudiants peuvent soumettre plus d'une fois et revoir leurs tentatives antérieures sur l'écran des résultats.",
"settings_save": "Enregistrer les paramètres"
"settings_save": "Enregistrer les paramètres",
"add": "Ajouter",
"add_section": "Ajouter une rubrique",
"reorder_done": "Réorganisation terminée",
"section": {
"fallback_title": "Section",
"questions": "des questions",
"short_questions": "q",
"required": "requis",
"add_question": "Ajouter une question",
"delete": "Supprimer la rubrique",
"move_up": "Déplacer la section vers le haut",
"move_down": "Déplacer la section vers le bas",
"color": "Couleur des sections",
"description_placeholder": "Décrivez cette rubrique",
"after_section": "Après la section",
"continue": "Passer à la section suivante",
"go_to_section": "Aller à la rubrique",
"submit": "Soumettre l'exercice"
}
},
"new_exercise_modal": {
"heading": "Nouvel exercice",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard.",
"saving": "Sauvegarde…",
"saved": "Enregistré",
"draft_recovery": "Un brouillon non enregistré a été trouvé pour cette leçon. Souhaitez-vous le restaurer ?"
"draft_recovery": "Un brouillon non enregistré a été trouvé pour cette leçon. Souhaitez-vous le restaurer ?",
"stats": {
"sections": "Sections",
"lessons": "Leçons",
"exercises": "Exercices"
}
},
"news_feed": {
"heading": "Fil d'actualité",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Visible sur votre site public de cours"
},
"sidebar": {
"back_to_course": "Retour au cours",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "L'exercice de certification « {exerciseTitle} » n'a aucun point configuré ; demandez à votre instructeur de le réparer.",
"email_message_label": "Message électronique",
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !"
},
"creator": {
"heading": "Quel cours souhaitez-vous créer ?",
"placeholder": "Demandez à ClassroomIO de créer un cours pour vous...",
"type": {
"self_paced": "À votre rythme",
"live_class": "Cours en direct"
},
"level": {
"beginner": "Débutant",
"intermediate": "Intermédiaire",
"advanced": "Avancé"
},
"drafting": {
"heading": "Rédiger votre idée de cours",
"subtext": "Nous transformons votre invite en titre de cours et en espace de travail.",
"step_reading": "Lire votre invite",
"step_naming": "Nommer votre cours",
"step_building": "Création de votre espace de travail"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Inédit",
"continue_course": "Continuer",
"learn_more": "En savoir plus",
"public_badge": "Public",
"actions_menu_aria": "Actions du cours",
"tags_overflow_aria": "{count} autres tags",
"list_view": {
"title": "Titre",
@@ -1897,7 +2026,8 @@
"invite": "Inviter",
"delete": "Supprimer",
"open": "Ouvert",
"open_course_site": "Site de cours ouvert"
"open_public_course": "Ouvrir le cours public",
"copy_course_url": "Copier l’URL du cours"
},
"due_date": "Due",
"valid_until": "Valable jusqu'au"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Authentification du jeton",
"customize_lms_tab": "Personnaliser le LMS",
"domains_tab": "Domaines",
"teams_tab": "Équipes"
"teams_tab": "Équipes",
"ai_credits_tab": "Crédits IA"
},
"subheadings": {
"view_site": "Voir le site"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "Crédits IA",
"page_subtitle": "Suivez l’utilisation de l’assistant IA, la consommation de l’équipe et achetez des packs de jetons.",
"cost_note": "L'utilisation du crédit est pondérée par le coût du modèle. Gemini 2.5 Flash et GPT-5.4 Mini comptent pour 1 unité de crédit par jeton. Claude Sonnet 4.6 compte pour 36 × par jeton, reflétant son coût API plus élevé.",
"buy_tokens": {
"title": "Achetez plus de jetons",
"description": "Chaque pack ajoute 5 000 000 de crédits pour 5 $ USD. Choisissez le nombre de packs dont vous avez besoin.",
"quantity_hint": "paquets",
"preview": "{quantité} pack(s) · {tokens} jetons · ${dollars}",
"buy_button": "Achetez pour ${dollars}"
},
"leaderboard": {
"title": "Utilisation de l'équipe",
"this_month": "Mois en cours",
"user": "Utilisateur",
"favorite_model": "Modèle préféré",
"tokens": "Jetons",
"empty": "Personne dans votre équipe n'a utilisé l'assistant IA ce mois-ci."
},
"chart": {
"heading": "Utilisation",
"subheading": "Consommation quotidienne de jetons ce mois-ci.",
"tokens": "Jetons",
"total_tokens": "Total des jetons",
"total_requests": "Total des demandes",
"empty": "Aucune utilisation enregistrée ce mois-ci pour l'instant."
},
"purchased": {
"title": "Utilisation achetée",
"subtitle": "Les jetons supplémentaires que vous avez achetés sont reportés jusqu'à ce qu'ils soient utilisés.",
"lifetime": "{tokens} jetons achetés · ${dollars} dépensés",
"buy_more": "Achetez plus"
},
"included": {
"title": "Votre utilisation incluse",
"subtitle": "Jetons mensuels inclus avec votre forfait.",
"resets_on": "Réinitialise {date}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "À votre rythme",
"self_paced_subtitle": "Idéal pour les cours où les étudiants peuvent suivre des cours à leur propre rythme",
"compliance_label": "Conformité",
"compliance_subtitle": "Formation récurrente avec reprises, rappels et suivi de renouvellement"
"compliance_subtitle": "Formation récurrente avec reprises, rappels et suivi de renouvellement",
"public_label": "Publique",
"public_subtitle": "Pages de cours gratuites et sans inscription que vous pouvez partager publiquement – idéales pour la génération de leads"
},
"delete_modal": {
"label": "Supprimer",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Demandez à l'assistant IA...",
"attach_document": "Joindre un document (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Mise à niveau pour télécharger des documents",
"tokens_exhausted": "Limite mensuelle de jetons IA atteinte. Achetez plus de crédits dans Paramètres > Facturation ou attendez la réinitialisation.",
"tokens_exhausted": "Limite mensuelle de jetons IA atteinte.",
"tokens_exhausted_buy_more": "Acheter plus",
"chat_history": "Historique des discussions",
"no_conversations": "Aucune conversation pour l'instant",
"untitled": "Sans titre",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Exercice",
"mention_section": "Section",
"mention_no_results": "Aucun contenu correspondant",
"uploading_document": "Téléchargement du document..."
"uploading_document": "Téléchargement du document...",
"agent_running_warning": "Les modifications apparaîtront dans le cours une fois que l'agent aura terminé. Ne rechargez pas la page."
},
"widgets": {
"heading": "Widgets",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "पोस्ट सफलतापूर्वक बनाई गई",
"post_updated": "पोस्ट सफलतापूर्वक अपडेट की गई"
}
},
"ai_credits": {
"purchase_success": "टोकन पैक आपके बैलेंस में जोड़ा गया।",
"checkout_failed": "चेकआउट प्रारंभ नहीं किया जा सका. कृपया पुन: प्रयास करें।"
},
"public_course": {
"url_copied": "कोर्स URL कॉपी हो गया",
"url_copy_failed": "URL कॉपी नहीं किया जा सका"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "टैग चुनें",
"empty_groups": "अभी तक कोई टैग उपलब्ध नहीं है"
},
"compliance": "अनुपालन"
"compliance": "अनुपालन",
"public": "सार्वजनिक",
"convert_to_public_blocked": "इस पाठ्यक्रम में ऐसे प्रश्न हैं जिन्हें स्वचालित रूप से वर्गीकृत नहीं किया जा सकता है, इसलिए इसे अभी तक सार्वजनिक नहीं किया जा सकता है।",
"fix_blocking_questions": "अवरुद्ध करने वाले प्रश्नों को ठीक करें",
"slug": {
"label": "यूआरएल स्लग",
"description": "इस आइटम के सार्वजनिक URL का अंतिम खंड. केवल अक्षर, संख्याएँ और डैश।",
"public_only": "स्लग केवल सार्वजनिक पाठ्यक्रमों पर संपादन योग्य हैं।",
"in_use": "वह स्लग इस पाठ्यक्रम में पहले से ही किसी अन्य आइटम द्वारा उपयोग किया जा चुका है।"
},
"callout": {
"legend": "सार्वजनिक पाठ्यक्रम कॉलआउट",
"description": "प्रत्येक पाठ के नीचे और लॉक की गई वस्तुओं के स्थान पर दिखाया गया है। इसका उपयोग शिक्षार्थियों को अपनी साइट या सशुल्क पाठ्यक्रम तक ले जाने के लिए करें।",
"title_label": "शीर्षक",
"title_placeholder": "क्या आप इस पर गहराई से जाना चाहते हैं?",
"description_label": "विवरण",
"description_placeholder": "4 सप्ताह में प्रोडक्शन-ग्रेड प्रोजेक्ट शिप करने के लिए पूरे समूह में शामिल हों।",
"button_label": "बटन लेबल",
"button_label_placeholder": "मेरी साइट पर और पढ़ें",
"button_url_label": "बटन यूआरएल",
"button_url_placeholder": "https://example.com",
"clear": "कॉलआउट साफ़ करें",
"public_only": "कॉलआउट केवल सार्वजनिक पाठ्यक्रमों पर दिखाई देते हैं।"
}
},
"analytics": {
"title": "विश्लेषिकी"
@@ -1167,7 +1198,10 @@
"attempt_counter": "{कुल} का {वर्तमान} प्रयास करें",
"previous_try": "पिछला प्रयास",
"next_try": "अगला प्रयास",
"try_again": "पुनः प्रयास करें"
"try_again": "पुनः प्रयास करें",
"back": "वापस",
"begin_section": "प्रारंभ अनुभाग",
"complete_section": "पूरा अनुभाग"
},
"edit_mode": {
"delete_modal": "व्यायाम हटाएँ",
@@ -1193,7 +1227,8 @@
"hotspot": "हॉटस्पॉट",
"link": "कड़ियाँ",
"word_bank": "वर्ड बैंक",
"star": "स्टार रेटिंग"
"star": "स्टार रेटिंग",
"video_recording": "वीडियो रिकार्डिंग"
},
"select_type": "प्रकार चुनें",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "प्रश्न हटाएँ",
"sure": "क्या आप वाकई इस प्रश्न को हटाना चाहते हैं?",
"no": "नहीं, कृपया ऐसा न करें",
"yes": "हाँ, हटाएँ"
"yes": "हाँ, हटाएँ",
"section_title": "अनुभाग हटाएँ",
"section_sure": "यह अनुभाग हटा दिया जाएगा. इसके प्रश्नों को निकटतम अनुभाग में ले जाया जाएगा।"
},
"description": {
"title": "शीर्षक",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "जैसे 3",
"max_stars_placeholder": "जैसे 5"
}
},
"video_recording": {
"submission": {
"no_video": "कोई वीडियो सबमिट नहीं किया गया.",
"unavailable": "वीडियो अनुपलब्ध.",
"duration": "अवधि",
"file_size": "फ़ाइल का आकार",
"recorded_at": "रिकार्ड किया गया",
"uploaded_at": "अपलोड किया गया",
"retake_count": "रीटेक"
},
"preview": {
"max_duration_label": "अधिकतम अवधि",
"manual_grading": "शिक्षार्थी एक लघु वीडियो प्रतिक्रिया रिकॉर्ड करते हैं। मैन्युअल ग्रेडिंग आवश्यक है."
},
"take": {
"unsupported": "इस ब्राउज़र में वीडियो रिकॉर्डिंग समर्थित नहीं है.",
"permission_prompt": "अपना उत्तर रिकॉर्ड करने के लिए कैमरे और माइक्रोफ़ोन तक पहुंच की अनुमति दें।",
"permission_denied": "कैमरा या माइक्रोफ़ोन पहुंच अवरुद्ध कर दी गई थी. ब्राउज़र अनुमतियाँ जांचें.",
"start_camera": "कैमरा सेट करें",
"camera_label": "कैमरा",
"camera_placeholder": "कैमरा चुनें",
"play_preview": "रिकॉर्डिंग पूर्वावलोकन चलाएं",
"pause_preview": "रिकॉर्डिंग पूर्वावलोकन रोकें",
"start_recording": "रिकॉर्डिंग प्रारंभ करें",
"stop_recording": "रिकॉर्डिंग बंद करो",
"elapsed": "बीत गया",
"remaining": "शेष",
"max_duration": "अधिकतम",
"too_short": "कम से कम 1 सेकंड के लिए रिकॉर्ड करें.",
"use_recording": "रिकॉर्डिंग का प्रयोग करें",
"retake": "पुनः लेना",
"delete": "रिकॉर्डिंग हटाएँ",
"retry_upload": "पुनः अपलोड करने का प्रयास करें",
"uploading": "रिकॉर्डिंग अपलोड हो रही है...",
"uploaded": "रिकॉर्डिंग अपलोड की गई",
"upload_failed": "अपलोड विफल रहा. पुनः प्रयास करें।",
"replaces_previous": "अपलोड के बाद एक नया स्वीकृत टेक पिछले उत्तर को प्रतिस्थापित कर देता है।"
},
"edit": {
"max_duration_label": "अधिकतम अवधि",
"max_duration_helper": "छात्र ब्राउज़र में रिकॉर्ड करते हैं और सबमिट करने से पहले दोबारा ले सकते हैं। अधिकतम 2 मिनट है.",
"max_duration_placeholder": "60",
"manual_grading": "मैन्युअल ग्रेडिंग की आवश्यकता है"
}
}
},
"settings": "सेटिंग्स",
"settings_allow_multiple": "एकाधिक प्रयासों की अनुमति दें",
"settings_allow_multiple_helper": "छात्र एक से अधिक बार सबमिट कर सकते हैं और परिणाम स्क्रीन पर पिछले प्रयासों की समीक्षा कर सकते हैं।",
"settings_save": "सेटिंग्स सहेजें"
"settings_save": "सेटिंग्स सहेजें",
"add": "जोड़ें",
"add_section": "अनुभाग जोड़ें",
"reorder_done": "पुनः क्रमित करना पूरा हुआ",
"section": {
"fallback_title": "धारा",
"questions": "प्रश्न",
"short_questions": "प्र",
"required": "आवश्यक",
"add_question": "प्रश्न जोड़ें",
"delete": "अनुभाग हटाएँ",
"move_up": "अनुभाग को ऊपर ले जाएँ",
"move_down": "अनुभाग को नीचे ले जाएँ",
"color": "अनुभाग का रंग",
"description_placeholder": "इस अनुभाग का वर्णन करें",
"after_section": "अनुभाग के बाद",
"continue": "अगले भाग पर जारी रखें",
"go_to_section": "अनुभाग पर जाएँ",
"submit": "व्यायाम सबमिट करें"
}
},
"new_exercise_modal": {
"heading": "नया व्यायाम",
@@ -1657,7 +1758,12 @@
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।",
"saving": "सहेजा जा रहा है...",
"saved": "सहेजा गया",
"draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?"
"draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?",
"stats": {
"sections": "अनुभाग",
"lessons": "सबक",
"exercises": "व्यायाम"
}
},
"news_feed": {
"heading": "समाचार फ़ीड",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "आपकी सार्वजनिक कोर्स साइट पर दृश्यमान"
},
"sidebar": {
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "प्रमाणन अभ्यास \"{exerciseTitle}\" में कोई बिंदु कॉन्फ़िगर नहीं किया गया है; अपने प्रशिक्षक से इसे ठीक करने के लिए कहें।",
"email_message_label": "ईमेल संदेश",
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!"
},
"creator": {
"heading": "आप कौन सा कोर्स बनाना चाहते हैं?",
"placeholder": "ClassroomIO से आपके लिए एक पाठ्यक्रम बनाने के लिए कहें...",
"type": {
"self_paced": "स्वयं गति",
"live_class": "लाइव क्लास"
},
"level": {
"beginner": "शुरुआती",
"intermediate": "इंटरमीडिएट",
"advanced": "उन्नत"
},
"drafting": {
"heading": "अपने पाठ्यक्रम का विचार तैयार करना",
"subtext": "हम आपके संकेत को पाठ्यक्रम शीर्षक और कार्यक्षेत्र में बदल रहे हैं।",
"step_reading": "आपका संकेत पढ़ रहा हूँ",
"step_naming": "अपने पाठ्यक्रम का नामकरण",
"step_building": "अपना कार्यक्षेत्र बनाना"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "अप्रकाशित",
"continue_course": "जारी रखें",
"learn_more": "और जानें",
"public_badge": "सार्वजनिक",
"actions_menu_aria": "कोर्स क्रियाएँ",
"tags_overflow_aria": "{count} और टैग",
"list_view": {
"title": "शीर्षक",
@@ -1897,7 +2026,8 @@
"invite": "आमंत्रित करें",
"delete": "हटाएँ",
"open": "खुला",
"open_course_site": "पाठ्यक्रम साइट खोलें"
"open_public_course": "सार्वजनिक कोर्स खोलें",
"copy_course_url": "कोर्स URL कॉपी करें"
},
"due_date": "देय",
"valid_until": "तक वैध है"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "टोकन प्रामाणिक",
"customize_lms_tab": "एलएमएस को अनुकूलित करें",
"domains_tab": "डोमेन",
"teams_tab": "टीमें"
"teams_tab": "टीमें",
"ai_credits_tab": "एआई क्रेडिट"
},
"subheadings": {
"view_site": "साइट देखें"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "एआई क्रेडिट",
"page_subtitle": "एआई सहायक उपयोग, टीम की खपत और टोकन पैक की खरीद को ट्रैक करें।",
"cost_note": "क्रेडिट उपयोग को मॉडल लागत के आधार पर भारित किया जाता है। जेमिनी 2.5 फ्लैश और जीपीटी-5.4 मिनी की गणना प्रति टोकन 1 क्रेडिट यूनिट के रूप में की जाती है। क्लाउड सॉनेट 4.6 की गणना 36× प्रति टोकन के रूप में की जाती है, जो इसकी उच्च एपीआई लागत को दर्शाता है।",
"buy_tokens": {
"title": "अधिक टोकन खरीदें",
"description": "प्रत्येक पैक $5 USD के लिए 5,000,000 क्रेडिट जोड़ता है। चुनें कि आपको कितने पैक चाहिए.",
"quantity_hint": "पैक",
"preview": "{मात्रा} पैक · {टोकन} टोकन · ${डॉलर}",
"buy_button": "${डॉलर} में खरीदें"
},
"leaderboard": {
"title": "टीम का उपयोग",
"this_month": "चालू माह",
"user": "उपयोगकर्ता",
"favorite_model": "पसंदीदा मॉडल",
"tokens": "टोकन",
"empty": "इस महीने आपकी टीम में किसी ने भी एआई असिस्टेंट का उपयोग नहीं किया है।"
},
"chart": {
"heading": "उपयोग",
"subheading": "इस महीने दैनिक टोकन खपत।",
"tokens": "टोकन",
"total_tokens": "कुल टोकन",
"total_requests": "कुल अनुरोध",
"empty": "इस महीने अभी तक कोई उपयोग दर्ज नहीं किया गया है।"
},
"purchased": {
"title": "खरीदा गया उपयोग",
"subtitle": "आपके द्वारा खरीदे गए अतिरिक्त टोकन उपयोग होने तक रोल ओवर रहेंगे।",
"lifetime": "{टोकन} टोकन खरीदे गए · ${डॉलर} खर्च किए गए",
"buy_more": "और अधिक खरीदें"
},
"included": {
"title": "आपका सम्मिलित उपयोग",
"subtitle": "आपकी योजना में मासिक टोकन शामिल हैं।",
"resets_on": "{दिनांक} रीसेट करता है"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "स्वयं गति",
"self_paced_subtitle": "उन पाठ्यक्रमों के लिए सर्वोत्तम जहां छात्र अपनी गति से पाठ ले सकते हैं",
"compliance_label": "अनुपालन",
"compliance_subtitle": "रीटेक, रिमाइंडर और नवीनीकरण ट्रैकिंग के साथ आवर्ती प्रशिक्षण"
"compliance_subtitle": "रीटेक, रिमाइंडर और नवीनीकरण ट्रैकिंग के साथ आवर्ती प्रशिक्षण",
"public_label": "सार्वजनिक",
"public_subtitle": "नि:शुल्क, बिना-साइनअप पाठ्यक्रम पृष्ठ जिन्हें आप सार्वजनिक रूप से साझा कर सकते हैं - लीड जनरल के लिए बढ़िया"
},
"delete_modal": {
"label": "हटाएँ",
@@ -3224,7 +3396,8 @@
"input_placeholder": "एआई सहायक से पूछें...",
"attach_document": "एक दस्तावेज़ संलग्न करें (पीडीएफ, DOCX, PPTX)",
"upgrade_to_upload": "दस्तावेज़ अपलोड करने के लिए अपग्रेड करें",
"tokens_exhausted": "मासिक एआई टोकन सीमा पूरी हो गई। सेटिंग्स > बिलिंग में अधिक क्रेडिट खरीदें या रीसेट की प्रतीक्षा करें।",
"tokens_exhausted": "मासिक एआई टोकन सीमा पूरी हो गई।",
"tokens_exhausted_buy_more": "और खरीदें",
"chat_history": "चैट इतिहास",
"no_conversations": "अभी तक कोई बातचीत नहीं",
"untitled": "शीर्षकहीन",
@@ -3237,7 +3410,8 @@
"mention_exercise": "व्यायाम",
"mention_section": "धारा",
"mention_no_results": "कोई मेल खाती सामग्री नहीं",
"uploading_document": "दस्तावेज़ अपलोड हो रहा है..."
"uploading_document": "दस्तावेज़ अपलोड हो रहा है...",
"agent_running_warning": "एजेंट का कार्य पूरा होते ही पाठ्यक्रम में परिवर्तन दिखाई देंगे। पृष्ठ पुनः लोड न करें."
},
"widgets": {
"heading": "विजेट्स",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Post został utworzony pomyślnie",
"post_updated": "Post został pomyślnie zaktualizowany"
}
},
"ai_credits": {
"purchase_success": "Pakiet tokenów dodany do Twojego salda.",
"checkout_failed": "Nie można rozpocząć realizacji transakcji. Spróbuj ponownie."
},
"public_course": {
"url_copied": "Skopiowano adres URL kursu",
"url_copy_failed": "Nie można skopiować adresu URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Wybierz tagi",
"empty_groups": "Brak dostępnych tagów"
},
"compliance": "Zgodność"
"compliance": "Zgodność",
"public": "Publiczne",
"convert_to_public_blocked": "Ten kurs zawiera pytania, których nie można oceniać automatycznie, dlatego nie można go jeszcze udostępnić publicznie.",
"fix_blocking_questions": "Napraw blokujące pytania",
"slug": {
"label": "Błąd adresu URL",
"description": "Ostatni segment publicznego adresu URL tego elementu. Tylko litery, cyfry i myślniki.",
"public_only": "Ślimaki można edytować wyłącznie na kursach publicznych.",
"in_use": "Ten ślimak jest już używany przez inny przedmiot w tym kursie."
},
"callout": {
"legend": "Objaśnienie dotyczące kursu publicznego",
"description": "Wyświetlane na dole każdej lekcji oraz w miejscu zablokowanych elementów. Użyj go, aby przyciągnąć uczniów do swojej witryny lub płatnego kursu.",
"title_label": "Tytuł",
"title_placeholder": "Chcesz zgłębić ten temat?",
"description_label": "Opis",
"description_placeholder": "Dołącz do pełnej kohorty, aby wysłać projekt na poziomie produkcyjnym w ciągu 4 tygodni.",
"button_label": "Etykieta przycisku",
"button_label_placeholder": "Przeczytaj więcej na mojej stronie",
"button_url_label": "Adres URL przycisku",
"button_url_placeholder": "https://przyklad.com",
"clear": "Wyczyść objaśnienie",
"public_only": "Objaśnienia pojawiają się tylko na kursach publicznych."
}
},
"analytics": {
"title": "Analityka"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Próba {bieżąca} z {total}",
"previous_try": "Poprzednia próba",
"next_try": "Następna próba",
"try_again": "Spróbuj ponownie"
"try_again": "Spróbuj ponownie",
"back": "Powrót",
"begin_section": "Sekcja startowa",
"complete_section": "Kompletna sekcja"
},
"edit_mode": {
"delete_modal": "Usuń ćwiczenie",
@@ -1193,7 +1227,8 @@
"hotspot": "Hotspot",
"link": "Linki",
"word_bank": "Bank słów",
"star": "Ocena gwiazdkowa"
"star": "Ocena gwiazdkowa",
"video_recording": "Nagrywanie wideo"
},
"select_type": "Wybierz typ",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Usuń pytanie",
"sure": "Czy na pewno chcesz usunąć to pytanie?",
"no": "Nie, proszę nie",
"yes": "Tak, usuń"
"yes": "Tak, usuń",
"section_title": "Usuń sekcję",
"section_sure": "Ta sekcja zostanie usunięta. Jej pytania zostaną przeniesione do najbliższego działu."
},
"description": {
"title": "Tytuł",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "np. 3",
"max_stars_placeholder": "np. 5"
}
},
"video_recording": {
"submission": {
"no_video": "Nie przesłano żadnego filmu.",
"unavailable": "Film niedostępny.",
"duration": "Czas trwania",
"file_size": "Rozmiar pliku",
"recorded_at": "Nagrano",
"uploaded_at": "Przesłano",
"retake_count": "Powtarza"
},
"preview": {
"max_duration_label": "Maksymalny czas trwania",
"manual_grading": "Uczniowie nagrywają krótką odpowiedź wideo. Wymagane ręczne sortowanie."
},
"take": {
"unsupported": "Ta przeglądarka nie obsługuje nagrywania wideo.",
"permission_prompt": "Zezwól na dostęp do kamery i mikrofonu w celu nagrania odpowiedzi.",
"permission_denied": "Dostęp do kamery lub mikrofonu został zablokowany. Sprawdź uprawnienia przeglądarki.",
"start_camera": "Skonfiguruj kamerę",
"camera_label": "Kamera",
"camera_placeholder": "Wybierz kamerę",
"play_preview": "Odtwórz podgląd nagrania",
"pause_preview": "Wstrzymaj podgląd nagrywania",
"start_recording": "Rozpocznij nagrywanie",
"stop_recording": "Zatrzymaj nagrywanie",
"elapsed": "Upłynął",
"remaining": "Pozostało",
"max_duration": "Maks",
"too_short": "Nagrywaj przez co najmniej 1 sekundę.",
"use_recording": "Skorzystaj z nagrania",
"retake": "Powtórz",
"delete": "Usuń nagranie",
"retry_upload": "Spróbuj przesłać ponownie",
"uploading": "Przesyłanie nagrania...",
"uploaded": "Nagranie przesłane",
"upload_failed": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
"replaces_previous": "Nowe zaakceptowane ujęcie zastępuje poprzednią odpowiedź po przesłaniu."
},
"edit": {
"max_duration_label": "Maksymalny czas trwania",
"max_duration_helper": "Uczniowie nagrywają w przeglądarce i mogą powtórzyć przed przesłaniem. Maksymalnie 2 minuty.",
"max_duration_placeholder": "60",
"manual_grading": "Wymaga ręcznego oceniania"
}
}
},
"settings": "Ustawienia",
"settings_allow_multiple": "Zezwalaj na wiele prób",
"settings_allow_multiple_helper": "Uczniowie mogą przesłać więcej niż raz i przejrzeć wcześniejsze próby na ekranie wyników.",
"settings_save": "Zapisz ustawienia"
"settings_save": "Zapisz ustawienia",
"add": "Dodaj",
"add_section": "Dodaj sekcję",
"reorder_done": "Zakończono zmianę kolejności",
"section": {
"fallback_title": "Sekcja",
"questions": "pytania",
"short_questions": "q",
"required": "wymagane",
"add_question": "Dodaj pytanie",
"delete": "Usuń sekcję",
"move_up": "Przesuń sekcję w górę",
"move_down": "Przesuń sekcję w dół",
"color": "Kolor sekcji",
"description_placeholder": "Opisz tę sekcję",
"after_section": "Po sekcji",
"continue": "Przejdź do następnej sekcji",
"go_to_section": "Przejdź do sekcji",
"submit": "Prześlij ćwiczenie"
}
},
"new_exercise_modal": {
"heading": "Nowe ćwiczenie",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później.",
"saving": "Zapisuję…",
"saved": "Zapisano",
"draft_recovery": "Znaleziono niezapisaną wersję roboczą tej lekcji. Czy chcesz go przywrócić?"
"draft_recovery": "Znaleziono niezapisaną wersję roboczą tej lekcji. Czy chcesz go przywrócić?",
"stats": {
"sections": "Sekcje",
"lessons": "Lekcje",
"exercises": "Ćwiczenia"
}
},
"news_feed": {
"heading": "Kanał wiadomości",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Widoczny na publicznej stronie kursu"
},
"sidebar": {
"back_to_course": "Powrót na kurs",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "W ćwiczeniu certyfikacyjnym „{exerciseTitle}” nie skonfigurowano żadnych punktów; poproś instruktora, żeby to naprawił.",
"email_message_label": "Wiadomość e-mail",
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!"
},
"creator": {
"heading": "Jaki kurs chcesz stworzyć?",
"placeholder": "Poproś ClassroomIO o utworzenie kursu dla Ciebie...",
"type": {
"self_paced": "Samodzielne tempo",
"live_class": "Zajęcia na żywo"
},
"level": {
"beginner": "Początkujący",
"intermediate": "Średnio zaawansowany",
"advanced": "Zaawansowane"
},
"drafting": {
"heading": "Opracowanie pomysłu na kurs",
"subtext": "Zamieniamy Twój monit w tytuł kursu i obszar roboczy.",
"step_reading": "Czytanie Twojego monitu",
"step_naming": "Nazywanie kursu",
"step_building": "Tworzenie przestrzeni roboczej"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Niepublikowane",
"continue_course": "Kontynuuj",
"learn_more": "Dowiedz się więcej",
"public_badge": "Publiczny",
"actions_menu_aria": "Akcje kursu",
"tags_overflow_aria": "Jeszcze {count} tagów",
"list_view": {
"title": "Tytuł",
@@ -1897,7 +2026,8 @@
"invite": "Zaproś",
"delete": "Usuń",
"open": "Otwórz",
"open_course_site": "Otwarta strona kursu"
"open_public_course": "Otwórz publiczny kurs",
"copy_course_url": "Kopiuj adres URL kursu"
},
"due_date": "Termin",
"valid_until": "Ważne do"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Autoryzacja tokenu",
"customize_lms_tab": "Dostosuj LMS-a",
"domains_tab": "Domeny",
"teams_tab": "Zespoły"
"teams_tab": "Zespoły",
"ai_credits_tab": "Kredyty AI"
},
"subheadings": {
"view_site": "Zobacz witrynę"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "Kredyty AI",
"page_subtitle": "Śledź wykorzystanie asystenta AI, wykorzystanie zespołu i kupuj pakiety tokenów.",
"cost_note": "Wykorzystanie kredytu jest ważone kosztem modelu. Gemini 2.5 Flash i GPT-5.4 Mini liczą się jako 1 jednostka kredytowa na token. Claude Sonnet 4.6 liczy się jako 36× na token, co odzwierciedla wyższy koszt interfejsu API.",
"buy_tokens": {
"title": "Kup więcej tokenów",
"description": "Każdy pakiet dodaje 5 000 000 kredytów za 5 USD. Wybierz, ile opakowań potrzebujesz.",
"quantity_hint": "paczki",
"preview": "{ilość} paczek · {tokens} tokenów · ${dolary}",
"buy_button": "Kup za ${dolarów}"
},
"leaderboard": {
"title": "Wykorzystanie zespołu",
"this_month": "Bieżący miesiąc",
"user": "Użytkownik",
"favorite_model": "Ulubiony model",
"tokens": "Żetony",
"empty": "Nikt w Twoim zespole nie korzystał w tym miesiącu z asystenta AI."
},
"chart": {
"heading": "Użycie",
"subheading": "Dzienne zużycie tokenów w tym miesiącu.",
"tokens": "Żetony",
"total_tokens": "Suma tokenów",
"total_requests": "Łączna liczba żądań",
"empty": "W tym miesiącu nie zarejestrowano jeszcze żadnego użycia."
},
"purchased": {
"title": "Zakupione użycie",
"subtitle": "Dodatkowe zakupione żetony przechodzą na inne, dopóki nie zostaną wykorzystane.",
"lifetime": "Zakupiono {tokens} tokenów · Wydano ${dolarów}",
"buy_more": "Kup więcej"
},
"included": {
"title": "Twoje uwzględnione wykorzystanie",
"subtitle": "Tokeny miesięczne zawarte w Twoim planie.",
"resets_on": "Resetuje {data}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "Samodzielne tempo",
"self_paced_subtitle": "Najlepsze rozwiązanie w przypadku kursów, w których uczniowie mogą brać udział w lekcjach we własnym tempie",
"compliance_label": "Zgodność",
"compliance_subtitle": "Szkolenia cykliczne z powtórzeniami, przypomnieniami i śledzeniem odnowień"
"compliance_subtitle": "Szkolenia cykliczne z powtórzeniami, przypomnieniami i śledzeniem odnowień",
"public_label": "Publiczne",
"public_subtitle": "Bezpłatne strony kursów bez rejestracji, które możesz udostępniać publicznie — idealne dla lead gen"
},
"delete_modal": {
"label": "Usuń",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Zapytaj asystenta AI...",
"attach_document": "Załącz dokument (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Uaktualnij, aby przesyłać dokumenty",
"tokens_exhausted": "Osiągnięto miesięczny limit tokenów AI. Kup więcej kredytów w Ustawieniach > Rozliczenia lub poczekaj na reset.",
"tokens_exhausted": "Osiągnięto miesięczny limit tokenów AI.",
"tokens_exhausted_buy_more": "Kup więcej",
"chat_history": "Historia czatu",
"no_conversations": "Nie ma jeszcze żadnych rozmów",
"untitled": "Bez tytułu",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Ćwicz",
"mention_section": "Sekcja",
"mention_no_results": "Brak pasującej treści",
"uploading_document": "Przesyłanie dokumentu..."
"uploading_document": "Przesyłanie dokumentu...",
"agent_running_warning": "Zmiany pojawią się w kursie po zakończeniu działania agenta. Nie przeładowuj strony."
},
"widgets": {
"heading": "Widżety",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Postagem criada com sucesso",
"post_updated": "Postagem atualizada com sucesso"
}
},
"ai_credits": {
"purchase_success": "Pacote de tokens adicionado ao seu saldo.",
"checkout_failed": "Não foi possível iniciar a finalização da compra. Por favor, tente novamente."
},
"public_course": {
"url_copied": "URL do curso copiada",
"url_copy_failed": "Não foi possível copiar a URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Selecione tags",
"empty_groups": "Nenhuma tag disponível ainda"
},
"compliance": "Conformidade"
"compliance": "Conformidade",
"public": "Público",
"convert_to_public_blocked": "Este curso tem perguntas que não podem ser avaliadas automaticamente, portanto ainda não pode ser tornado público.",
"fix_blocking_questions": "Corrigir perguntas de bloqueio",
"slug": {
"label": "Slug de URL",
"description": "O último segmento do URL público deste item. Apenas letras, números e travessões.",
"public_only": "Slugs só são editáveis ​​em cursos públicos.",
"in_use": "Esse slug já é usado por outro item deste curso."
},
"callout": {
"legend": "Chamada pública do curso",
"description": "Mostrado na parte inferior de cada lição e no lugar dos itens bloqueados. Use-o para direcionar os alunos ao seu site ou a um curso pago.",
"title_label": "Título",
"title_placeholder": "Quer se aprofundar nisso?",
"description_label": "Descrição",
"description_placeholder": "Junte-se ao grupo completo para enviar um projeto de nível de produção em 4 semanas.",
"button_label": "Etiqueta do botão",
"button_label_placeholder": "Leia mais no meu site",
"button_url_label": "URL do botão",
"button_url_placeholder": "https://example.com",
"clear": "Limpar frase de destaque",
"public_only": "As frases de destaque aparecem apenas em cursos públicos."
}
},
"analytics": {
"title": "Análise"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Tentativa {atual} de {total}",
"previous_try": "Tentativa anterior",
"next_try": "Próxima tentativa",
"try_again": "Tente novamente"
"try_again": "Tente novamente",
"back": "Voltar",
"begin_section": "Seção inicial",
"complete_section": "Seção completa"
},
"edit_mode": {
"delete_modal": "Excluir exercício",
@@ -1193,7 +1227,8 @@
"hotspot": "Ponto de acesso",
"link": "Ligações",
"word_bank": "Banco de palavras",
"star": "Classificação por estrelas"
"star": "Classificação por estrelas",
"video_recording": "Gravação de vídeo"
},
"select_type": "Selecione o tipo",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Excluir pergunta",
"sure": "Tem certeza de que deseja excluir esta pergunta",
"no": "Não, por favor, não",
"yes": "Sim, excluir"
"yes": "Sim, excluir",
"section_title": "Excluir seção",
"section_sure": "Esta seção será excluída. Suas perguntas serão movidas para a seção mais próxima."
},
"description": {
"title": "Título",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "por exemplo 3",
"max_stars_placeholder": "por exemplo 5"
}
},
"video_recording": {
"submission": {
"no_video": "Nenhum vídeo enviado.",
"unavailable": "Vídeo indisponível.",
"duration": "Duração",
"file_size": "Tamanho do arquivo",
"recorded_at": "Gravado",
"uploaded_at": "Enviado",
"retake_count": "Repetições"
},
"preview": {
"max_duration_label": "Duração máxima",
"manual_grading": "Os alunos gravam uma breve resposta em vídeo. É necessária classificação manual."
},
"take": {
"unsupported": "A gravação de vídeo não é compatível com este navegador.",
"permission_prompt": "Permita o acesso à câmera e ao microfone para gravar sua resposta.",
"permission_denied": "O acesso à câmera ou ao microfone foi bloqueado. Verifique as permissões do navegador.",
"start_camera": "Configurar câmera",
"camera_label": "Câmera",
"camera_placeholder": "Selecione a câmera",
"play_preview": "Reproduzir pré-visualização da gravação",
"pause_preview": "Pausar visualização da gravação",
"start_recording": "Comece a gravar",
"stop_recording": "Pare de gravar",
"elapsed": "Decorrido",
"remaining": "Restante",
"max_duration": "Máx.",
"too_short": "Grave por pelo menos 1 segundo.",
"use_recording": "Usar gravação",
"retake": "Retomar",
"delete": "Excluir gravação",
"retry_upload": "Tentar enviar novamente",
"uploading": "Carregando gravação...",
"uploaded": "Gravação enviada",
"upload_failed": "Falha no upload. Tente novamente.",
"replaces_previous": "Uma nova tomada aceita substitui a resposta anterior após o upload."
},
"edit": {
"max_duration_label": "Duração máxima",
"max_duration_helper": "Os alunos gravam no navegador e podem refazer antes de enviar. O máximo é 2 minutos.",
"max_duration_placeholder": "60",
"manual_grading": "Requer classificação manual"
}
}
},
"settings": "Configurações",
"settings_allow_multiple": "Permitir múltiplas tentativas",
"settings_allow_multiple_helper": "Os alunos podem enviar mais de uma vez e revisar as tentativas anteriores na tela de resultados.",
"settings_save": "Salvar configurações"
"settings_save": "Salvar configurações",
"add": "Adicionar",
"add_section": "Adicionar seção",
"reorder_done": "Reordenação concluída",
"section": {
"fallback_title": "Seção",
"questions": "perguntas",
"short_questions": "q",
"required": "obrigatório",
"add_question": "Adicionar pergunta",
"delete": "Excluir seção",
"move_up": "Mover seção para cima",
"move_down": "Mover seção para baixo",
"color": "Cor da seção",
"description_placeholder": "Descreva esta seção",
"after_section": "Depois da seção",
"continue": "Continuar para a próxima seção",
"go_to_section": "Vá para a seção",
"submit": "Enviar exercício"
}
},
"new_exercise_modal": {
"heading": "Novo exercício",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde.",
"saving": "Salvando…",
"saved": "Salvo",
"draft_recovery": "Um rascunho não salvo foi encontrado para esta lição. Você gostaria de restaurá-lo?"
"draft_recovery": "Um rascunho não salvo foi encontrado para esta lição. Você gostaria de restaurá-lo?",
"stats": {
"sections": "Seções",
"lessons": "Lições",
"exercises": "Exercícios"
}
},
"news_feed": {
"heading": "Feed de notícias",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Visível no site público do seu curso"
},
"sidebar": {
"back_to_course": "De volta ao curso",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "O exercício de certificação \"{exerciseTitle}\" não possui pontos configurados; peça ao seu instrutor para consertar.",
"email_message_label": "Mensagem de e-mail",
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!"
},
"creator": {
"heading": "Qual curso você deseja criar?",
"placeholder": "Peça ao ClassroomIO para criar um curso para você...",
"type": {
"self_paced": "Individualizado",
"live_class": "Aula ao vivo"
},
"level": {
"beginner": "Iniciante",
"intermediate": "Intermediário",
"advanced": "Avançado"
},
"drafting": {
"heading": "Elaborando sua ideia de curso",
"subtext": "Estamos transformando seu prompt em um título de curso e espaço de trabalho.",
"step_reading": "Lendo seu prompt",
"step_naming": "Nomeando seu curso",
"step_building": "Criando seu espaço de trabalho"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Não publicado",
"continue_course": "Continuar",
"learn_more": "Saiba mais",
"public_badge": "Público",
"actions_menu_aria": "Ações do curso",
"tags_overflow_aria": "Mais {count} tags",
"list_view": {
"title": "Título",
@@ -1897,7 +2026,8 @@
"invite": "Convidar",
"delete": "Excluir",
"open": "Abrir",
"open_course_site": "Abrir site do curso"
"open_public_course": "Abrir curso público",
"copy_course_url": "Copiar URL do curso"
},
"due_date": "Devido",
"valid_until": "Válido até"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Autenticação de token",
"customize_lms_tab": "Personalizar LMS",
"domains_tab": "Domínios",
"teams_tab": "Equipes"
"teams_tab": "Equipes",
"ai_credits_tab": "Créditos de IA"
},
"subheadings": {
"view_site": "Ver site"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "Créditos de IA",
"page_subtitle": "Rastreie o uso do assistente de IA, o consumo da equipe e compre pacotes de tokens.",
"cost_note": "O uso do crédito é ponderado pelo custo do modelo. Gemini 2.5 Flash e GPT-5.4 Mini contam como 1 unidade de crédito por token. Claude Sonnet 4.6 conta como 36× por token, refletindo seu custo mais alto de API.",
"buy_tokens": {
"title": "Compre mais fichas",
"description": "Cada pacote adiciona 5.000.000 de créditos por US$ 5. Escolha quantos pacotes você precisa.",
"quantity_hint": "pacotes",
"preview": "{quantidade} pacote(s) · {tokens} tokens · ${dollars}",
"buy_button": "Compre por ${dólares}"
},
"leaderboard": {
"title": "Uso da equipe",
"this_month": "Mês atual",
"user": "Usuário",
"favorite_model": "Modelo favorito",
"tokens": "Fichas",
"empty": "Ninguém na sua equipe usou o assistente de IA este mês."
},
"chart": {
"heading": "Uso",
"subheading": "Consumo diário de tokens este mês.",
"tokens": "Fichas",
"total_tokens": "Totais de fichas",
"total_requests": "Total de solicitações",
"empty": "Nenhum uso registrado neste mês ainda."
},
"purchased": {
"title": "Uso adquirido",
"subtitle": "Tokens extras que você comprou acumulam até serem usados.",
"lifetime": "{tokens} tokens comprados · ${dollars} gastos",
"buy_more": "Compre mais"
},
"included": {
"title": "Seu uso incluído",
"subtitle": "Tokens mensais incluídos no seu plano.",
"resets_on": "Reinicializações em {data}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "Individualizado",
"self_paced_subtitle": "Melhor para cursos onde os alunos podem ter aulas em seu próprio ritmo",
"compliance_label": "Conformidade",
"compliance_subtitle": "Treinamento recorrente com repetições, lembretes e acompanhamento de renovação"
"compliance_subtitle": "Treinamento recorrente com repetições, lembretes e acompanhamento de renovação",
"public_label": "Público",
"public_subtitle": "Páginas de cursos gratuitas e sem inscrição que você pode compartilhar publicamente - ótimas para geração de leads"
},
"delete_modal": {
"label": "Excluir",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Pergunte ao assistente de IA...",
"attach_document": "Anexe um documento (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Atualize para fazer upload de documentos",
"tokens_exhausted": "Limite mensal de tokens de IA atingido. Compre mais créditos em Configurações > Faturamento ou aguarde a redefinição.",
"tokens_exhausted": "Limite mensal de tokens de IA atingido.",
"tokens_exhausted_buy_more": "Comprar mais",
"chat_history": "Histórico de bate-papo",
"no_conversations": "Ainda não há conversas",
"untitled": "Sem título",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Exercício",
"mention_section": "Seção",
"mention_no_results": "Nenhum conteúdo correspondente",
"uploading_document": "Carregando documento..."
"uploading_document": "Carregando documento...",
"agent_running_warning": "As alterações aparecerão no curso assim que o agente terminar. Não recarregue a página."
},
"widgets": {
"heading": "Widgets",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Сообщение успешно создано",
"post_updated": "Сообщение успешно обновлено"
}
},
"ai_credits": {
"purchase_success": "Пакет токенов добавлен на ваш баланс.",
"checkout_failed": "Не удалось начать оформление заказа. Пожалуйста, попробуйте еще раз."
},
"public_course": {
"url_copied": "URL курса скопирован",
"url_copy_failed": "Не удалось скопировать URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Выберите теги",
"empty_groups": "Тегов пока нет"
},
"compliance": "Соответствие"
"compliance": "Соответствие",
"public": "Общественный",
"convert_to_public_blocked": "В этом курсе есть вопросы, которые нельзя оценить автоматически, поэтому его пока нельзя опубликовать.",
"fix_blocking_questions": "Исправить блокирующие вопросы",
"slug": {
"label": "URL-адрес",
"description": "Последний сегмент общедоступного URL-адреса этого элемента. Только буквы, цифры и тире.",
"public_only": "Слаги доступны для редактирования только на общедоступных курсах.",
"in_use": "Этот пул уже используется другим предметом в этом курсе."
},
"callout": {
"legend": "Уведомление об общедоступном курсе",
"description": "Отображается внизу каждого урока вместо заблокированных элементов. Используйте его, чтобы привлечь учащихся на ваш сайт или на платный курс.",
"title_label": "Название",
"title_placeholder": "Хотите углубиться в это?",
"description_label": "Описание",
"description_placeholder": "Присоединяйтесь к полной группе, чтобы реализовать проект промышленного уровня за 4 недели.",
"button_label": "Метка кнопки",
"button_label_placeholder": "Подробнее читайте на моем сайте",
"button_url_label": "URL-адрес кнопки",
"button_url_placeholder": "https://example.com",
"clear": "Очистить выноску",
"public_only": "Уточнения появляются только на общедоступных курсах."
}
},
"analytics": {
"title": "Аналитика"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Попытка {текущая} из {total}",
"previous_try": "Предыдущая попытка",
"next_try": "Следующая попытка",
"try_again": "Попробуйте еще раз"
"try_again": "Попробуйте еще раз",
"back": "Назад",
"begin_section": "Начальный раздел",
"complete_section": "Полный раздел"
},
"edit_mode": {
"delete_modal": "Удалить упражнение",
@@ -1193,7 +1227,8 @@
"hotspot": "Точка доступа",
"link": "Ссылки",
"word_bank": "Банк слов",
"star": "Звездный рейтинг"
"star": "Звездный рейтинг",
"video_recording": "Запись видео"
},
"select_type": "Выберите тип",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Удалить вопрос",
"sure": "Вы уверены, что хотите удалить этот вопрос?",
"no": "Нет, пожалуйста, не надо",
"yes": "Да, удалить"
"yes": "Да, удалить",
"section_title": "Удалить раздел",
"section_sure": "Этот раздел будет удален. Его вопросы будут перенесены в ближайший раздел."
},
"description": {
"title": "Название",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "например 3",
"max_stars_placeholder": "например 5"
}
},
"video_recording": {
"submission": {
"no_video": "Видео не представлено.",
"unavailable": "Видео недоступно.",
"duration": "Продолжительность",
"file_size": "Размер файла",
"recorded_at": "Записано",
"uploaded_at": "Загружено",
"retake_count": "Пересдача"
},
"preview": {
"max_duration_label": "Максимальная продолжительность",
"manual_grading": "Учащиеся записывают короткий видеоответ. Требуется ручная оценка."
},
"take": {
"unsupported": "Запись видео не поддерживается в этом браузере.",
"permission_prompt": "Разрешите доступ к камере и микрофону, чтобы записать ваш ответ.",
"permission_denied": "Доступ к камере или микрофону заблокирован. Проверьте разрешения браузера.",
"start_camera": "Настроить камеру",
"camera_label": "Камера",
"camera_placeholder": "Выберите камеру",
"play_preview": "Воспроизвести предварительный просмотр записи",
"pause_preview": "Приостановить предварительный просмотр записи",
"start_recording": "Начать запись",
"stop_recording": "Остановить запись",
"elapsed": "Прошедшее",
"remaining": "Осталось",
"max_duration": "Макс",
"too_short": "Запись не менее 1 секунды.",
"use_recording": "Использовать запись",
"retake": "Пересдать",
"delete": "Удалить запись",
"retry_upload": "Повторить загрузку",
"uploading": "Загрузка записи...",
"uploaded": "Запись загружена",
"upload_failed": "Загрузка не удалась. Попробуйте еще раз.",
"replaces_previous": "Новый принятый вариант заменяет предыдущий ответ после загрузки."
},
"edit": {
"max_duration_label": "Максимальная продолжительность",
"max_duration_helper": "Учащиеся делают запись в браузере и могут пересдать ее перед отправкой. Максимум — 2 минуты.",
"max_duration_placeholder": "60",
"manual_grading": "Требуется ручная оценка"
}
}
},
"settings": "Настройки",
"settings_allow_multiple": "Разрешить несколько попыток",
"settings_allow_multiple_helper": "Учащиеся могут отправлять заявки несколько раз и просматривать предыдущие попытки на экране результатов.",
"settings_save": "Сохранить настройки"
"settings_save": "Сохранить настройки",
"add": "Добавить",
"add_section": "Добавить раздел",
"reorder_done": "Изменение заказа завершено",
"section": {
"fallback_title": "Раздел",
"questions": "вопросы",
"short_questions": "д",
"required": "требуется",
"add_question": "Добавить вопрос",
"delete": "Удалить раздел",
"move_up": "Переместить раздел вверх",
"move_down": "Переместить раздел вниз",
"color": "Цвет раздела",
"description_placeholder": "Опишите этот раздел",
"after_section": "После раздела",
"continue": "Перейти к следующему разделу",
"go_to_section": "Перейти в раздел",
"submit": "Отправить упражнение"
}
},
"new_exercise_modal": {
"heading": "Новое упражнение",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже.",
"saving": "Сохранение…",
"saved": "Сохранено",
"draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?"
"draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?",
"stats": {
"sections": "Разделы",
"lessons": "Уроки",
"exercises": "Упражнения"
}
},
"news_feed": {
"heading": "Лента новостей",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Доступен на публичном сайте вашего курса"
},
"sidebar": {
"back_to_course": "Вернуться к курсу",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "Для сертификационного упражнения «{exercisionTitle}» не настроены баллы; попросите инструктора это исправить.",
"email_message_label": "Сообщение электронной почты",
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!"
},
"creator": {
"heading": "Какой курс вы хотите создать?",
"placeholder": "Попросите ClassroomIO создать для вас курс...",
"type": {
"self_paced": "Самостоятельный темп",
"live_class": "Живой урок"
},
"level": {
"beginner": "Новичок",
"intermediate": "Средний",
"advanced": "Расширенный"
},
"drafting": {
"heading": "Продумываем идею вашего курса",
"subtext": "Мы превратим ваше приглашение в название курса и рабочую область.",
"step_reading": "Читаю вашу подсказку",
"step_naming": "Название вашего курса",
"step_building": "Создание своего рабочего пространства"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Неопубликовано",
"continue_course": "Продолжить",
"learn_more": "Узнать больше",
"public_badge": "Публичный",
"actions_menu_aria": "Действия с курсом",
"tags_overflow_aria": "Ещё {count} тегов",
"list_view": {
"title": "Название",
@@ -1897,7 +2026,8 @@
"invite": "Пригласить",
"delete": "Удалить",
"open": "Открыть",
"open_course_site": "Открытый сайт курса"
"open_public_course": "Открыть публичный курс",
"copy_course_url": "Копировать URL курса"
},
"due_date": "Срок погашения",
"valid_until": "Действительно до"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Токен аутентификации",
"customize_lms_tab": "Настроить систему управления обучением",
"domains_tab": "Домены",
"teams_tab": "Команды"
"teams_tab": "Команды",
"ai_credits_tab": "Кредиты ИИ"
},
"subheadings": {
"view_site": "Посмотреть сайт"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "кредиты ИИ",
"page_subtitle": "Отслеживайте использование ИИ-помощника, потребление командой и покупайте пакеты токенов.",
"cost_note": "Использование кредита взвешивается по стоимости модели. Gemini 2.5 Flash и GPT-5.4 Mini считаются за 1 кредитную единицу за токен. Claude Sonnet 4.6 считается 36× на токен, что отражает более высокую стоимость API.",
"buy_tokens": {
"title": "Купить больше жетонов",
"description": "Каждый пакет добавляет 5 000 000 кредитов за 5 долларов США. Выберите, сколько упаковок вам нужно.",
"quantity_hint": "пакеты",
"preview": "Пакет(ы) {quantity} · жетоны {tokens} · ${доллары}",
"buy_button": "Купить за ${долларов}"
},
"leaderboard": {
"title": "Использование команды",
"this_month": "Текущий месяц",
"user": "Пользователь",
"favorite_model": "Любимая модель",
"tokens": "Токены",
"empty": "Никто из вашей команды в этом месяце не пользовался ИИ-помощником."
},
"chart": {
"heading": "Использование",
"subheading": "Ежедневное потребление токенов в этом месяце.",
"tokens": "Токены",
"total_tokens": "Всего токенов",
"total_requests": "Всего запросов",
"empty": "В этом месяце использования еще не зафиксировано."
},
"purchased": {
"title": "Приобретенное использование",
"subtitle": "Дополнительные жетоны, которые вы купили, переносятся до тех пор, пока не будут использованы.",
"lifetime": "Токенов: {tokens} куплено · потрачено ${dollars}",
"buy_more": "Купить больше"
},
"included": {
"title": "Ваше включенное использование",
"subtitle": "Ежемесячные токены включены в ваш план.",
"resets_on": "Сбрасывает {дата}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "Самостоятельный темп",
"self_paced_subtitle": "Лучше всего подходит для курсов, где студенты могут брать уроки в удобном для них темпе.",
"compliance_label": "Соответствие",
"compliance_subtitle": "Регулярное обучение с повторной сдачей, напоминаниями и отслеживанием продлений"
"compliance_subtitle": "Регулярное обучение с повторной сдачей, напоминаниями и отслеживанием продлений",
"public_label": "Общественный",
"public_subtitle": "Бесплатные страницы курсов без регистрации, которыми вы можете поделиться публично — отлично подходит для привлечения потенциальных клиентов."
},
"delete_modal": {
"label": "Удалить",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Спросите ИИ-помощника...",
"attach_document": "Прикрепите документ (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Обновите, чтобы загружать документы",
"tokens_exhausted": "Достигнут месячный лимит токенов AI. Купите больше кредитов в «Настройки» > «Оплата» или дождитесь сброса.",
"tokens_exhausted": "Достигнут месячный лимит токенов AI.",
"tokens_exhausted_buy_more": "Купить ещё",
"chat_history": "История чата",
"no_conversations": "Пока нет разговоров",
"untitled": "Без названия",
@@ -3237,7 +3410,8 @@
"mention_exercise": "Упражнение",
"mention_section": "Раздел",
"mention_no_results": "Нет подходящего контента",
"uploading_document": "Загрузка документа..."
"uploading_document": "Загрузка документа...",
"agent_running_warning": "Изменения появятся в курсе, как только агент завершит работу. Не перезагружайте страницу."
},
"widgets": {
"heading": "Виджеты",
+186 -12
View File
@@ -391,6 +391,14 @@
"post_created": "Bài viết được tạo thành công",
"post_updated": "Đã cập nhật bài đăng thành công"
}
},
"ai_credits": {
"purchase_success": "Gói mã thông báo được thêm vào số dư của bạn.",
"checkout_failed": "Không thể bắt đầu thanh toán. Vui lòng thử lại."
},
"public_course": {
"url_copied": "Đã sao chép URL khóa học",
"url_copy_failed": "Không thể sao chép URL"
}
},
"explore": {
@@ -656,7 +664,30 @@
"popover_title": "Chọn thẻ",
"empty_groups": "Chưa có thẻ nào"
},
"compliance": "Tuân thủ"
"compliance": "Tuân thủ",
"public": "công cộng",
"convert_to_public_blocked": "Khóa học này có các câu hỏi không thể tự động chấm điểm nên chưa thể công khai.",
"fix_blocking_questions": "Sửa câu hỏi chặn",
"slug": {
"label": "sên URL",
"description": "Đoạn cuối cùng của URL công khai của mục này. Chỉ có chữ cái, số và dấu gạch ngang.",
"public_only": "Slug chỉ có thể chỉnh sửa được trên các khóa học Công khai.",
"in_use": "Slug đó đã được sử dụng bởi một mục khác trong khóa học này."
},
"callout": {
"legend": "Chú thích khóa học công khai",
"description": "Hiển thị ở cuối mỗi bài học và thay cho các đồ vật bị khóa. Sử dụng nó để thu hút người học đến trang web của bạn hoặc một khóa học trả phí.",
"title_label": "Tiêu đề",
"title_placeholder": "Bạn muốn đi sâu hơn về điều này?",
"description_label": "Mô tả",
"description_placeholder": "Tham gia nhóm đầy đủ để hoàn thành một dự án cấp sản xuất trong 4 tuần.",
"button_label": "Nhãn nút",
"button_label_placeholder": "Đọc thêm trên trang web của tôi",
"button_url_label": "URL nút",
"button_url_placeholder": "https://example.com",
"clear": "Xóa chú thích",
"public_only": "Chú thích chỉ xuất hiện trên các khóa học Công khai."
}
},
"analytics": {
"title": "Phân tích"
@@ -1167,7 +1198,10 @@
"attempt_counter": "Hãy thử {current} trong tổng số {total}",
"previous_try": "Lần thử trước",
"next_try": "Lần thử tiếp theo",
"try_again": "Thử lại"
"try_again": "Thử lại",
"back": "Quay lại",
"begin_section": "Phần bắt đầu",
"complete_section": "Hoàn thành phần"
},
"edit_mode": {
"delete_modal": "Xóa bài tập",
@@ -1193,7 +1227,8 @@
"hotspot": "Điểm phát sóng",
"link": "Liên kết",
"word_bank": "Ngân hàng từ",
"star": "Xếp hạng sao"
"star": "Xếp hạng sao",
"video_recording": "Quay video"
},
"select_type": "Chọn loại",
"question_type_auto_gradable": "Auto",
@@ -1204,7 +1239,9 @@
"title": "Xóa câu hỏi",
"sure": "Bạn có chắc chắn muốn xóa câu hỏi này",
"no": "Không, làm ơn đừng",
"yes": "Có, xóa"
"yes": "Có, xóa",
"section_title": "Xóa phần",
"section_sure": "Phần này sẽ bị xóa. Các câu hỏi của nó sẽ được chuyển đến phần gần nhất."
},
"description": {
"title": "Tiêu đề",
@@ -1464,12 +1501,76 @@
"correct_value_placeholder": "ví dụ: 3",
"max_stars_placeholder": "ví dụ: 5"
}
},
"video_recording": {
"submission": {
"no_video": "Không có video nào được gửi.",
"unavailable": "Video không có sẵn.",
"duration": "Thời lượng",
"file_size": "Kích thước tệp",
"recorded_at": "Đã ghi lại",
"uploaded_at": "Đã tải lên",
"retake_count": "Thi lại"
},
"preview": {
"max_duration_label": "Thời lượng tối đa",
"manual_grading": "Người học ghi lại một đoạn video ngắn phản hồi. Yêu cầu phân loại thủ công."
},
"take": {
"unsupported": "Quay video không được hỗ trợ trong trình duyệt này.",
"permission_prompt": "Cho phép truy cập vào máy ảnh và micrô để ghi lại câu trả lời của bạn.",
"permission_denied": "Quyền truy cập vào máy ảnh hoặc micrô đã bị chặn. Kiểm tra quyền của trình duyệt.",
"start_camera": "Thiết lập máy ảnh",
"camera_label": "Máy ảnh",
"camera_placeholder": "Chọn máy ảnh",
"play_preview": "Phát bản xem trước bản ghi",
"pause_preview": "Tạm dừng xem trước bản ghi",
"start_recording": "Bắt đầu ghi",
"stop_recording": "Dừng ghi",
"elapsed": "Đã qua",
"remaining": "Còn lại",
"max_duration": "Tối đa",
"too_short": "Ghi ít nhất 1 giây.",
"use_recording": "Sử dụng ghi âm",
"retake": "Thi lại",
"delete": "Xóa bản ghi",
"retry_upload": "Thử tải lên lại",
"uploading": "Đang tải bản ghi lên...",
"uploaded": "Đã tải lên bản ghi",
"upload_failed": "Tải lên không thành công. Hãy thử lại.",
"replaces_previous": "Một câu trả lời mới được chấp nhận sẽ thay thế câu trả lời trước đó sau khi tải lên."
},
"edit": {
"max_duration_label": "Thời lượng tối đa",
"max_duration_helper": "Học sinh ghi bài trên trình duyệt và có thể thi lại trước khi nộp. Tối đa là 2 phút.",
"max_duration_placeholder": "60",
"manual_grading": "Yêu cầu phân loại thủ công"
}
}
},
"settings": "Cài đặt",
"settings_allow_multiple": "Cho phép thử nhiều lần",
"settings_allow_multiple_helper": "Học sinh có thể gửi nhiều lần và xem lại các lần thử trước đây trên màn hình kết quả.",
"settings_save": "Lưu cài đặt"
"settings_save": "Lưu cài đặt",
"add": "Thêm",
"add_section": "Thêm phần",
"reorder_done": "Sắp xếp lại xong",
"section": {
"fallback_title": "Phần",
"questions": "câu hỏi",
"short_questions": "q",
"required": "bắt buộc",
"add_question": "Thêm câu hỏi",
"delete": "Xóa phần",
"move_up": "Di chuyển phần lên",
"move_down": "Di chuyển phần xuống",
"color": "Màu phần",
"description_placeholder": "Mô tả phần này",
"after_section": "Phần sau",
"continue": "Tiếp tục đến phần tiếp theo",
"go_to_section": "Đi đến phần",
"submit": "Gửi bài tập"
}
},
"new_exercise_modal": {
"heading": "Bài tập mới",
@@ -1657,7 +1758,12 @@
"content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau.",
"saving": "Đang lưu…",
"saved": "Đã lưu",
"draft_recovery": "Đã tìm thấy một bản nháp chưa được lưu cho bài học này. Bạn có muốn khôi phục nó không?"
"draft_recovery": "Đã tìm thấy một bản nháp chưa được lưu cho bài học này. Bạn có muốn khôi phục nó không?",
"stats": {
"sections": "Phần",
"lessons": "Bài học",
"exercises": "Bài tập"
}
},
"news_feed": {
"heading": "Nguồn cấp tin tức",
@@ -1782,7 +1888,8 @@
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
"preview_missing_slug": "Generate and save a course slug before previewing.",
"public_course_helper": "Hiển thị trên trang khóa học công khai của bạn"
},
"sidebar": {
"back_to_course": "Quay lại khóa học",
@@ -1841,6 +1948,26 @@
"blocker_final_misconfigured": "Bài tập chứng nhận \"{exerciseTitle}\" không có cấu hình điểm nào; nhờ người hướng dẫn khắc phục.",
"email_message_label": "Tin nhắn email",
"email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!"
},
"creator": {
"heading": "Bạn muốn tạo khóa học nào?",
"placeholder": "Hãy yêu cầu ClassroomIO tạo một khóa học cho bạn...",
"type": {
"self_paced": "tự nhịp độ",
"live_class": "Lớp học trực tiếp"
},
"level": {
"beginner": "Người mới bắt đầu",
"intermediate": "Trung cấp",
"advanced": "Nâng cao"
},
"drafting": {
"heading": "Soạn thảo ý tưởng khóa học của bạn",
"subtext": "Chúng tôi đang biến lời nhắc của bạn thành tiêu đề khóa học và không gian làm việc.",
"step_reading": "Đọc lời nhắc của bạn",
"step_naming": "Đặt tên cho khóa học của bạn",
"step_building": "Tạo không gian làm việc của bạn"
}
}
},
"courses": {
@@ -1881,6 +2008,8 @@
"unpublished": "Chưa được xuất bản",
"continue_course": "Tiếp tục",
"learn_more": "Tìm hiểu thêm",
"public_badge": "Công khai",
"actions_menu_aria": "Thao tác khóa học",
"tags_overflow_aria": "Thêm {count} thẻ",
"list_view": {
"title": "Tiêu đề",
@@ -1897,7 +2026,8 @@
"invite": "Mời",
"delete": "Xóa",
"open": "Mở",
"open_course_site": "Mở trang web khóa học"
"open_public_course": "Mở khóa học công khai",
"copy_course_url": "Sao chép URL khóa học"
},
"due_date": "Đến hạn",
"valid_until": "Có hiệu lực cho đến khi"
@@ -2599,7 +2729,8 @@
"token_auth_tab": "Xác thực mã thông báo",
"customize_lms_tab": "Tùy chỉnh LMS",
"domains_tab": "Tên miền",
"teams_tab": "Đội"
"teams_tab": "Đội",
"ai_credits_tab": "Tín dụng AI"
},
"subheadings": {
"view_site": "Xem trang web"
@@ -2730,6 +2861,45 @@
},
"teams": {
"page_subtitle": "Invite teammates and control who helps run this organization."
},
"ai_credits": {
"sub_title": "tín dụng AI",
"page_subtitle": "Theo dõi việc sử dụng trợ lý AI, mức tiêu thụ của nhóm và mua gói mã thông báo.",
"cost_note": "Việc sử dụng tín dụng được tính theo chi phí mô hình. Gemini 2.5 Flash và GPT-5.4 Mini được tính là 1 đơn vị tín dụng cho mỗi mã thông báo. Claude Sonnet 4.6 được tính là 36× mỗi mã thông báo, phản ánh chi phí API cao hơn.",
"buy_tokens": {
"title": "Mua thêm token",
"description": "Mỗi gói bổ sung thêm 5.000.000 tín dụng với giá 5 USD. Chọn số lượng gói bạn cần.",
"quantity_hint": "gói",
"preview": "{quantity} gói · {tokens} token · ${dollars}",
"buy_button": "Mua với giá ${dollars}"
},
"leaderboard": {
"title": "Cách sử dụng nhóm",
"this_month": "Tháng hiện tại",
"user": "người dùng",
"favorite_model": "Người mẫu yêu thích",
"tokens": "Mã thông báo",
"empty": "Không ai trong nhóm của bạn sử dụng trợ lý AI trong tháng này."
},
"chart": {
"heading": "Cách sử dụng",
"subheading": "Mức tiêu thụ token hàng ngày trong tháng này.",
"tokens": "Mã thông báo",
"total_tokens": "Tổng số token",
"total_requests": "Tổng số yêu cầu",
"empty": "Chưa có mức sử dụng nào được ghi lại trong tháng này."
},
"purchased": {
"title": "Mức sử dụng đã mua",
"subtitle": "Các token bổ sung bạn đã mua sẽ được sử dụng tiếp cho đến khi sử dụng.",
"lifetime": "Đã mua {tokens} token · chi tiêu ${dollars}",
"buy_more": "Mua thêm"
},
"included": {
"title": "Mức sử dụng đi kèm của bạn",
"subtitle": "Mã thông báo hàng tháng đi kèm với gói của bạn.",
"resets_on": "Đặt lại {ngày}"
}
}
},
"profileMenu": {
@@ -2839,7 +3009,9 @@
"self_paced_label": "tự nhịp độ",
"self_paced_subtitle": "Tốt nhất cho các khóa học mà sinh viên có thể học theo tốc độ của riêng mình",
"compliance_label": "Tuân thủ",
"compliance_subtitle": "Đào tạo định kỳ với tính năng thi lại, nhắc nhở và theo dõi gia hạn"
"compliance_subtitle": "Đào tạo định kỳ với tính năng thi lại, nhắc nhở và theo dõi gia hạn",
"public_label": "công cộng",
"public_subtitle": "Các trang khóa học miễn phí, không cần đăng ký mà bạn có thể chia sẻ công khai - rất tốt cho thế hệ khách hàng tiềm năng"
},
"delete_modal": {
"label": "Xóa",
@@ -3224,7 +3396,8 @@
"input_placeholder": "Hãy hỏi trợ lý AI...",
"attach_document": "Đính kèm tài liệu (PDF, DOCX, PPTX)",
"upgrade_to_upload": "Nâng cấp để tải lên tài liệu",
"tokens_exhausted": "Đã đạt đến giới hạn mã thông báo AI hàng tháng. Mua thêm tín dụng trong Cài đặt > Thanh toán hoặc đợi đặt lại.",
"tokens_exhausted": "Đã đạt đến giới hạn mã thông báo AI hàng tháng.",
"tokens_exhausted_buy_more": "Mua thêm",
"chat_history": "Lịch sử trò chuyện",
"no_conversations": "Chưa có cuộc trò chuyện nào",
"untitled": "Không có tiêu đề",
@@ -3237,7 +3410,8 @@
"mention_exercise": "tập thể dục",
"mention_section": "Phần",
"mention_no_results": "Không có nội dung phù hợp",
"uploading_document": "Đang tải tài liệu lên..."
"uploading_document": "Đang tải tài liệu lên...",
"agent_running_warning": "Những thay đổi sẽ xuất hiện trong khóa học sau khi tác nhân hoàn tất. Đừng tải lại trang."
},
"widgets": {
"heading": "Widget",
+10
View File
@@ -35,6 +35,7 @@ export interface OrgLandingPageHero {
primaryAction: {
label: string;
href: string;
disabled?: boolean;
};
secondaryAction?: {
label: string;
@@ -71,6 +72,15 @@ export interface OrgAudience {
date_joined: string;
}
/** Vercel project domain GET (subset used by domain helpers). */
export interface DomainResponse {
name?: string;
verified?: boolean;
apexName?: string;
projectId?: string;
error?: { code: string; message: string };
}
export interface DomainConfigResponse {
/** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */
configuredBy?: ('CNAME' | 'A' | 'http') | null;
+31 -3
View File
@@ -78,7 +78,7 @@ interface Product {
attachedCustomFields: any[];
}
interface SubscriptionData {
export interface SubscriptionData {
createdAt: string;
modifiedAt: string | null;
id: string;
@@ -104,6 +104,7 @@ interface SubscriptionData {
orgId: string;
orgSlug: string;
triggeredBy: string;
kind?: string;
};
customFieldData: Record<string, any>;
customer: Customer;
@@ -114,7 +115,34 @@ interface SubscriptionData {
discount: any | null;
}
export interface PolarWebhookPayload {
type: string;
export interface PolarOrderWebhookData {
id: string;
amount?: number;
totalAmount?: number;
currency?: string;
metadata?: Record<string, string>;
items?: Array<{ quantity?: number; amount?: number }>;
[key: string]: unknown;
}
export interface PolarOrderWebhookPayload {
type: 'order.paid' | 'order.created' | 'order.refunded';
data: PolarOrderWebhookData;
}
export interface PolarSubscriptionWebhookPayload {
type:
| 'checkout.created'
| 'checkout.updated'
| 'subscription.created'
| 'subscription.updated'
| 'subscription.active'
| 'subscription.revoked'
| 'subscription.canceled';
data: SubscriptionData;
}
export type PolarWebhookPayload =
| PolarSubscriptionWebhookPayload
| PolarOrderWebhookPayload
| { type: string; data: Record<string, unknown> };
@@ -14,6 +14,8 @@
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
import { AiCourseChat } from '$features/ai-assistant';
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
@@ -98,6 +100,10 @@
}
hasLoadedSidebarWidth = true;
if (get(initialChatPrompt)) {
openAiAssistant();
}
});
$effect(() => {
@@ -180,5 +186,5 @@
{/if}
</Sidebar.Inset>
<AiCourseChat />
<AiCourseChat courseId={data.courseId} />
</Sidebar.Provider>
@@ -31,7 +31,7 @@
});
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
{#if isLockedForStudent}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -30,7 +30,7 @@ export const load = async ({ params, url, cookies }) => {
if (!orgId) {
const apiKeyHeaders = getApiKeyHeaders();
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
console.log('org', org);
if (org?.id) {
orgId = org.id;
@@ -1,62 +1,5 @@
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
// TODO - Replace with actual cache
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
function loginActivityDataFromSettled(
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
): LoginActivityData {
if (result.status === 'rejected') {
return [];
}
if (!result.value.ok) {
return [];
}
return result.value.body.data;
}
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const siteName = params.slug;
if (!orgId) {
return {
orgName: siteName,
stats: null,
loginActivity: [] as LoginActivityData
};
}
if (orgId in cache) {
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
);
return {
orgName: siteName,
stats: cache[orgId],
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
};
}
const [statsResult, loginActivityResult] = await Promise.allSettled([
safeServerApi<DashStatsSuccess>(() =>
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
),
safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
)
]);
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
export const load = async ({ params }) => {
return {
orgName: siteName,
stats: cache[orgId],
loginActivity: loginActivityDataFromSettled(loginActivityResult)
orgName: params.slug
};
};
@@ -1,239 +1,198 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import AwardIcon from '@lucide/svelte/icons/award';
import BookIcon from '@lucide/svelte/icons/book';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { Skeleton } from '@cio/ui/base/skeleton';
import UsersIcon from '@lucide/svelte/icons/users';
import { resolve } from '$app/paths';
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
import CircleIcon from '@lucide/svelte/icons/circle';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { CourseCreator } from '@cio/ui/custom/course-creator';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { courseApi } from '$features/course/api';
import { setInitialChatModel, setInitialChatPrompt } from '$features/ai-assistant/utils/store';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
import type { TCourseType } from '@cio/db/types';
import {
AGENT_MODELS,
DEFAULT_PICKER_MODEL_ID,
UI_PICKER_MODEL_IDS,
type AgentModelId
} from '@cio/utils/agent-models';
import { CreateCourseButton } from '$features/course/components';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { Progress } from '@cio/ui/base/progress';
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$features/onboarding/components';
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
type CreatingStep = 'reading' | 'naming' | 'building';
const { data } = $props();
const steps: { key: CreatingStep; label: string }[] = [
{ key: 'reading', label: t.get('course.creator.drafting.step_reading') },
{ key: 'naming', label: t.get('course.creator.drafting.step_naming') },
{ key: 'building', label: t.get('course.creator.drafting.step_building') }
];
const stats = $derived(data.stats);
const loginActivity = $derived(data.loginActivity);
const recentCertifications = $derived(stats?.recentCertifications || []);
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
const totalCertificates = $derived(stats?.totalCertificates || 0);
const totalStudents = $derived(stats?.totalStudents || 0);
const topCourses = $derived(stats?.topCourses || []);
let creatingState: 'idle' | 'creating' = $state('idle');
let creatingStep: CreatingStep = $state('reading');
let draftingPrompt = $state('');
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && (UI_PICKER_MODEL_IDS as readonly string[]).includes(value);
}
function stepStatus(key: CreatingStep): 'done' | 'active' | 'pending' {
const order: CreatingStep[] = ['reading', 'naming', 'building'];
const current = order.indexOf(creatingStep);
const target = order.indexOf(key);
if (target < current) return 'done';
if (target === current) return 'active';
return 'pending';
}
function handleModelChange(model: string) {
if (!isAgentModelId(model)) return;
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
selectedModel = model;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, model);
} catch {
// localStorage unavailable
}
}
function handleLockedModelSelect() {
openUpgradeModal();
}
$effect(() => {
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
return;
}
selectedModel = DEFAULT_PICKER_MODEL_ID;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
} catch {
// localStorage unavailable
}
});
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
if (isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
handleModelChange(model);
}
draftingPrompt = prompt;
creatingState = 'creating';
creatingStep = 'reading';
await new Promise((r) => setTimeout(r, 700));
creatingStep = 'naming';
const meta = await aiAssistantApi.generateCourseMeta(prompt);
const title = meta?.title ?? prompt.slice(0, 80);
const description = meta?.description ?? prompt.slice(0, 150);
creatingStep = 'building';
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
setInitialChatModel(selectedModel);
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
goto(resolve(`/courses/${courseId}`, {}));
});
}
onMount(() => {
try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
if (isAgentModelId(storedModel)) {
if ($isFreePlan && paidModelIds.includes(storedModel)) {
selectedModel = DEFAULT_PICKER_MODEL_ID;
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
return;
}
selectedModel = storedModel;
}
} catch {
// localStorage unavailable
}
});
</script>
<svelte:head>
<title>Dashboard - ClassroomIO</title>
<title>Home - ClassroomIO</title>
</svelte:head>
<WelcomeModal />
{#if creatingState === 'creating'}
<div class="flex min-h-[80vh] flex-col items-center justify-center gap-6 px-4">
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-2xl font-semibold">{$t('course.creator.drafting.heading')}</h1>
<p class="ui:text-muted-foreground text-sm">
{$t('course.creator.drafting.subtext')}
</p>
</div>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<div class="ui:border w-full max-w-lg rounded-2xl p-5">
<p class="mb-5 text-sm text-neutral-300 italic">"{draftingPrompt}"</p>
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<ActivityCard
activity={{
icon: AwardIcon,
title: $t('dashboard.certificates_earned'),
percentage: totalCertificates ?? 0,
description: $t('dashboard.certificates_earned_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}}
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold tracking-tight">
{$t('dashboard.top_courses')}
</h3>
</div>
<div class="flex h-full flex-col">
{#if !stats}
<div class="space-y-3">
{#each Array(5) as _, i (i)}
<Skeleton class="h-16 w-full rounded-lg" />
{/each}
</div>
{:else if topCourses.length === 0}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
<div class="flex flex-col gap-3">
{#each steps as step (step.key)}
{@const status = stepStatus(step.key)}
<div class="flex items-center gap-3">
{#if status === 'done'}
<CheckCircleIcon class="h-4 w-4 shrink-0 text-green-500" />
{:else if status === 'active'}
<LoaderCircleIcon class="h-4 w-4 shrink-0 animate-spin text-white" />
{:else}
<ul class="ui:divide-border -mx-2 divide-y">
{#each topCourses as course, i (course.id)}
{@const rank = i + 1}
<li>
<a
href={`/courses/${course.id}`}
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
>
<span
class={[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
rank === 1 &&
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
rank === 2 &&
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
rank === 3 &&
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
]}
>
{rank}
</span>
<div class="min-w-0 flex-1">
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
{course.title}
</p>
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UsersIcon class="h-3 w-3" />
<span class="tabular-nums">{course.enrollments}</span>
<span>
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</span>
</div>
</div>
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.completion')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
</div>
<Progress value={course.completion} class="ui:h-1.5" />
</div>
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.certification_rate')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
</div>
<Progress value={course.certification} class="ui:h-1.5" />
</div>
</div>
<ChevronRightIcon
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
/>
</a>
</li>
{/each}
</ul>
<CircleIcon class="h-4 w-4 shrink-0 text-neutral-600" />
{/if}
<span
class="text-sm {status === 'done'
? 'text-neutral-400 line-through'
: status === 'active'
? 'ui:text-foreground'
: 'ui:text-muted-foreground'}"
>
{step.label}
</span>
</div>
</div>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
<h3 class="mb-4 text-lg">
{$t('dashboard.recent_certifications')}
</h3>
<div class="h-full space-y-6">
{#if !stats}
{#each Array(5) as _, i (i)}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm leading-none capitalize">{certification.name}</p>
<p class="ui:text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(certification.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{certification.course}
</p>
</a>
</div>
</div>
{:else}
<Empty
title={$t('dashboard.no_certifications_yet')}
description={$t('dashboard.no_certifications_yet_description')}
icon={AwardIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.view_courses')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
{/each}
</div>
<div class="mt-4">
<LoginActivityChart data={loginActivity} />
</div>
{/snippet}
</Page.Body>
</Page.Root>
</div>
</div>
{:else}
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
<div class="w-full max-w-3xl">
<CourseCreator
heading={$t('course.creator.heading')}
placeholder={$t('course.creator.placeholder')}
model={selectedModel}
levelOptions={[
{ value: 'beginner', label: $t('course.creator.level.beginner') },
{ value: 'intermediate', label: $t('course.creator.level.intermediate') },
{ value: 'advanced', label: $t('course.creator.level.advanced') }
]}
lockedModelIds={$isFreePlan ? paidModelIds : []}
onModelChange={handleModelChange}
onLockedModelSelect={handleLockedModelSelect}
onsubmit={handleCreate}
/>
</div>
</div>
{/if}
@@ -0,0 +1,62 @@
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
// TODO - Replace with actual cache
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
function loginActivityDataFromSettled(
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
): LoginActivityData {
if (result.status === 'rejected') {
return [];
}
if (!result.value.ok) {
return [];
}
return result.value.body.data;
}
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const siteName = params.slug;
if (!orgId) {
return {
orgName: siteName,
stats: null,
loginActivity: [] as LoginActivityData
};
}
if (orgId in cache) {
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
);
return {
orgName: siteName,
stats: cache[orgId],
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
};
}
const [statsResult, loginActivityResult] = await Promise.allSettled([
safeServerApi<DashStatsSuccess>(() =>
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
),
safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
)
]);
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
return {
orgName: siteName,
stats: cache[orgId],
loginActivity: loginActivityDataFromSettled(loginActivityResult)
};
};
@@ -0,0 +1,236 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AwardIcon from '@lucide/svelte/icons/award';
import BookIcon from '@lucide/svelte/icons/book';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import UsersIcon from '@lucide/svelte/icons/users';
import { Skeleton } from '@cio/ui/base/skeleton';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { CreateCourseButton } from '$features/course/components';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { Progress } from '@cio/ui/base/progress';
import { Button } from '@cio/ui/base/button';
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
import { profile } from '$lib/utils/store/user';
import { getGreeting } from '$lib/utils/functions/date';
const { data } = $props();
const stats = $derived(data.stats);
const loginActivity = $derived(data.loginActivity);
const recentCertifications = $derived(stats?.recentCertifications || []);
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
const totalCertificates = $derived(stats?.totalCertificates || 0);
const totalStudents = $derived(stats?.totalStudents || 0);
const topCourses = $derived(stats?.topCourses || []);
</script>
<svelte:head>
<title>Dashboard - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<ActivityCard
activity={{
icon: AwardIcon,
title: $t('dashboard.certificates_earned'),
percentage: totalCertificates ?? 0,
description: $t('dashboard.certificates_earned_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}}
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold tracking-tight">
{$t('dashboard.top_courses')}
</h3>
</div>
<div class="flex h-full flex-col">
{#if !stats}
<div class="space-y-3">
{#each Array(5) as _, i (i)}
<Skeleton class="h-16 w-full rounded-lg" />
{/each}
</div>
{:else if topCourses.length === 0}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
{:else}
<ul class="ui:divide-border -mx-2 divide-y">
{#each topCourses as course, i (course.id)}
{@const rank = i + 1}
<li>
<a
href={`/courses/${course.id}`}
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
>
<span
class={[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
rank === 1 &&
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
rank === 2 &&
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
rank === 3 &&
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
]}
>
{rank}
</span>
<div class="min-w-0 flex-1">
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
{course.title}
</p>
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UsersIcon class="h-3 w-3" />
<span class="tabular-nums">{course.enrollments}</span>
<span>
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</span>
</div>
</div>
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.completion')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
</div>
<Progress value={course.completion} class="ui:h-1.5" />
</div>
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.certification_rate')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
</div>
<Progress value={course.certification} class="ui:h-1.5" />
</div>
</div>
<ChevronRightIcon
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
/>
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
<h3 class="mb-4 text-lg">
{$t('dashboard.recent_certifications')}
</h3>
<div class="h-full space-y-6">
{#if !stats}
{#each Array(5) as _, i (i)}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm leading-none capitalize">{certification.name}</p>
<p class="ui:text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(certification.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{certification.course}
</p>
</a>
</div>
</div>
{:else}
<Empty
title={$t('dashboard.no_certifications_yet')}
description={$t('dashboard.no_certifications_yet_description')}
icon={AwardIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.view_courses')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
</div>
<div class="mt-4">
<LoginActivityChart data={loginActivity} />
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,21 @@
<script>
import { AiCreditsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>AI Credits - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.ai_credits.sub_title')}</Page.Title>
<Page.Subtitle>{$t('settings.ai_credits.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AiCreditsPage />
{/snippet}
</Page.Body>
@@ -19,6 +19,7 @@
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import type { OrgCourses } from '$features/course/types';
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
@@ -51,7 +52,7 @@
description: item.course.description ?? '',
lessonCount: 0,
totalStudents: 0,
isPublished: item.course.isPublished ?? false,
isPublished: item.course.status === 'ACTIVE',
tags: []
}))
);
@@ -160,7 +161,7 @@
<p>{item.course.description}</p>
</Table.Cell>
<Table.Cell>
<CoursePublishBadge isPublished={item.course.isPublished ?? false} />
<CoursePublishBadge isPublished={item.course.status === 'ACTIVE'} />
</Table.Cell>
<Table.Cell class="text-center">
<Button
@@ -183,7 +184,7 @@
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each courseCards as course (course.id)}
<CourseCard {course} href={`/courses/${course.id}`}>
<CourseCard course={course as unknown as OrgCourses[number]} href={`/courses/${course.id}`}>
{#snippet actions()}
<Button
variant="outline"
@@ -14,7 +14,7 @@
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import PinIcon from '@lucide/svelte/icons/pin';
import { BookIcon } from '@lucide/svelte';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
data: {
@@ -75,13 +75,13 @@
await programNewsfeedApi.deleteComment(programId, 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 = programNewsfeedApi.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 };
@@ -1,15 +1,30 @@
import type { MetaTagsProps } from 'svelte-meta-tags';
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
import type { GetOrganizationRequest } from '$features/org/utils/types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
export const load = async ({ params = { slug: '' }, parent }) => {
const { org: parentOrg } = await parent();
// PUBLIC courses are served by the anonymous org-site endpoint and bypass the
// regular landing page entirely — redirect straight to the first item.
const publicTreeResult = await safeServerApi<GetPublicCourseSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
);
if (publicTreeResult.ok && publicTreeResult.body.data.items.length > 0) {
const firstItem = publicTreeResult.body.data.items[0]!;
throw redirect(302, `/course/${params.slug}/lesson/${firstItem.slug}`);
}
const courseResult = await safeServerApi<GetCourseBySlugSuccess>(() =>
classroomio.course.slug[':slug'].$get(
{
@@ -30,14 +45,16 @@ export const load = async ({ params = { slug: '' }, parent }) => {
};
}
const course = courseResult.body.data;
const course = courseResult.body.data as CourseBySlugWithOrg;
let org = parentOrg ?? null;
if (!org && course.org?.siteName) {
const courseOrgSiteName = course.org?.siteName;
if (!org && courseOrgSiteName) {
const organizationResult = await safeServerApi<GetOrganizationSuccess>(() =>
classroomio.organization.$get(
{
query: { siteName: course.org.siteName }
query: { siteName: courseOrgSiteName }
},
getApiKeyHeaders()
)
@@ -7,7 +7,7 @@
import { user } from '$lib/utils/store/user';
import { CourseLandingPage } from '$features/ui';
import { PoweredBy } from '$features/ui';
import { normalizeLandingPageSettings, importThemeComponent } from '$features/org/utils/landing-page';
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page';
import type { Component } from 'svelte';
@@ -39,13 +39,16 @@
return landingSettings.hero;
}
const enrollmentsOpen = data.course.metadata?.allowNewStudent === true;
return {
...landingSettings.hero,
heading: data.course.title,
subheading: data.course.description || landingSettings.hero.subheading,
primaryAction: {
label: t.get('course.navItem.landing_page.start_course'),
href: resolve(`/course/${data.course.slug}/enroll`, {})
href: resolve(`/course/${data.course.slug}/enroll`, {}),
disabled: !enrollmentsOpen
},
image: data.course.logo || landingSettings.hero.image
};
@@ -97,7 +100,7 @@
{/if}
{/if}
<CourseLandingPage courseData={data.course} showStandaloneShell={false} />
<CourseLandingPage courseData={data.course} />
<OrgLandingPageFooter
{orgName}
@@ -1,4 +1,4 @@
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { calcCourseDiscount } from '$lib/utils/functions/course';
@@ -66,7 +66,7 @@ export const load = async ({ params, url }) => {
throw error(404, 'Not found');
}
const courseData = courseResult.body.data;
const courseData = courseResult.body.data as CourseBySlugWithOrg;
const discount = (courseData.metadata as { discount?: number } | null)?.discount ?? 0;
const showDiscount = (courseData.metadata as { showDiscount?: boolean } | null)?.showDiscount ?? false;
const calculatedCost = calcCourseDiscount(discount, Number(courseData.cost ?? 0), showDiscount);
@@ -103,13 +103,17 @@
$effect(() => {
if (!data.currentOrg) return;
setTheme(data.currentOrg?.theme);
const rawTheme = data.currentOrg.theme;
const themeString = typeof rawTheme === 'string' ? rawTheme : '';
setTheme(themeString);
currentOrg.update((o) => ({
...o,
id: data.currentOrg?.id ?? o.id,
name: data.currentOrg?.name ?? o.name,
siteName: data.currentOrg?.siteName ?? o.siteName,
theme: data.currentOrg?.theme ?? o.theme
theme: themeString || o.theme
}));
});
</script>
@@ -0,0 +1,66 @@
import type { MetaTagsProps } from 'svelte-meta-tags';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { error } from '@sveltejs/kit';
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
type GetPublicCourseItemRequest =
(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get'];
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
type GetPublicCourseItemSuccess = Extract<InferResponseType<GetPublicCourseItemRequest>, { success: true }>;
export const load = async ({ params = { slug: '', itemSlug: '' } }) => {
const [treeResult, itemResult] = await Promise.all([
safeServerApi<GetPublicCourseSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
),
safeServerApi<GetPublicCourseItemSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].$get(
{ param: { courseSlug: params.slug, itemSlug: params.itemSlug } },
getApiKeyHeaders()
)
)
]);
if (!treeResult.ok || !itemResult.ok) {
throw error(404, 'Public course or lesson not found');
}
const tree = treeResult.body.data;
const item = itemResult.body.data;
const itemTitle = 'title' in item ? item.title : tree.course.title;
const itemSummary =
item.kind === 'lesson' && typeof item.body === 'string'
? item.body.replace(/<[^>]+>/g, '').slice(0, 155)
: (tree.course.description ?? '');
const pageMetaTags = Object.freeze({
title: `${itemTitle} · ${tree.course.title}`,
description: itemSummary || tree.course.description || '',
openGraph: {
title: itemTitle,
description: itemSummary || tree.course.description || '',
images: tree.course.bannerImage
? [
{
url: tree.course.bannerImage,
alt: tree.course.title,
width: 1200,
height: 630,
secureUrl: tree.course.bannerImage,
type: 'image/jpeg'
}
]
: []
}
}) satisfies MetaTagsProps;
return {
tree,
item,
pageMetaTags
};
};
@@ -0,0 +1,98 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { PublicCourse } from '@cio/ui';
import {
toPublicExerciseView,
toPublicLessonView,
toPublicSidebarSections
} from '$features/course/utils/public-course-mappers';
import {
publicExerciseAttemptsStorageKey,
type PublicCourseSidebarItem,
type PublicCourseSidebarSection,
type PublicLessonViewData,
type PublicExerciseViewData
} from '@cio/ui/custom/public-course';
import { getExerciseQuestionLabels } from '$features/course/components/exercise/question-labels';
import { t } from '$lib/utils/functions/translations';
const exerciseLabels = $derived(getExerciseQuestionLabels());
let { data } = $props();
const sections: PublicCourseSidebarSection[] = $derived(toPublicSidebarSections(data.tree));
const flatItems = $derived(sections.flatMap((section) => section.items));
const itemSlug = $derived(data.item.slug);
const activeIndex = $derived(flatItems.findIndex((entry) => entry.slug === itemSlug));
const activeItem = $derived<PublicCourseSidebarItem | null>(flatItems[activeIndex] ?? null);
const prevItem = $derived(activeIndex > 0 ? flatItems[activeIndex - 1] : null);
const nextItem = $derived(activeIndex >= 0 && activeIndex < flatItems.length - 1 ? flatItems[activeIndex + 1] : null);
const callout = $derived(data.tree.course.callout ?? null);
const courseTitle = $derived(data.tree.course.title);
const org = $derived(data.tree.course.org ?? null);
const hrefFor = (item: PublicCourseSidebarItem) => `/course/${data.tree.course.slug}/lesson/${item.slug}`;
async function navigateTo(item: PublicCourseSidebarItem | null) {
if (!item) return;
await goto(hrefFor(item));
}
const lessonView = $derived<PublicLessonViewData | null>(
data.item.kind === 'lesson' ? toPublicLessonView(data.item) : null
);
const exerciseView = $derived<PublicExerciseViewData | null>(
data.item.kind === 'exercise' ? toPublicExerciseView(data.item) : null
);
</script>
<PublicCourse.PublicCourseShell
{sections}
{courseTitle}
{org}
activeSlug={itemSlug}
{activeItem}
activeFlatIndex={activeIndex >= 0 ? activeIndex : null}
totalItems={flatItems.length}
hasPrev={!!prevItem}
hasNext={!!nextItem}
{prevItem}
{nextItem}
{hrefFor}
exploreHref="/courses"
signInHref="/login"
exploreLabel={$t('public_course.header.explore_courses')}
signInLabel={$t('public_course.header.sign_in')}
footerPrevLabel={$t('public_course.footer_nav.previous')}
footerNextLabel={$t('public_course.footer_nav.next')}
courseSlug={data.tree.course.slug}
poweredByLabel={$t('public_course.powered_by.label')}
poweredByBrand={$t('public_course.powered_by.brand')}
onItemClick={navigateTo}
onPrev={() => navigateTo(prevItem)}
onNext={() => navigateTo(nextItem)}
>
{#if lessonView}
<PublicCourse.PublicLessonView lesson={lessonView} {callout} />
{:else if exerciseView}
{#key itemSlug}
<PublicCourse.PublicExerciseView
exercise={exerciseView}
{callout}
labels={exerciseLabels}
attemptsPersistenceKey={publicExerciseAttemptsStorageKey(data.tree.course.slug, itemSlug)}
formatAttemptOption={({ attemptNumber, correct, total }) =>
t.get('public_course.exercise.attempt_option', { attemptNumber, correct, total })}
newAttemptOptionLabel={$t('public_course.exercise.practice_again')}
attemptsSelectAriaLabel={$t('public_course.exercise.attempts_select_aria')}
submitLabel={$t('public_course.exercise.submit')}
tryAgainLabel={$t('public_course.exercise.try_again')}
privacyHint={$t('public_course.exercise.privacy_hint')}
summaryTemplate={$t('public_course.exercise.summary_template')}
/>
{/key}
{/if}
</PublicCourse.PublicCourseShell>
@@ -0,0 +1,64 @@
import { Polar } from '@polar-sh/sdk';
import { TOKEN_PACK } from '@cio/utils/plans';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
export const GET = async ({ url }: { url: URL }) => {
const quantityRaw = Number(url.searchParams.get('quantity') ?? '1');
const quantity = Math.min(100, Math.max(1, Number.isFinite(quantityRaw) ? Math.trunc(quantityRaw) : 1));
let metadata: { orgId?: string; orgSlug?: string; triggeredBy?: string };
try {
metadata = JSON.parse(url.searchParams.get('metadata') ?? '{}');
} catch {
return new Response('Invalid metadata JSON', { status: 400 });
}
if (!metadata.orgId || !metadata.orgSlug) {
return new Response('Missing orgId / orgSlug in metadata', { status: 400 });
}
const productId = env.POLAR_TOKEN_PACK_PRODUCT_ID;
if (!productId) {
return new Response('POLAR_TOKEN_PACK_PRODUCT_ID is not configured', { status: 500 });
}
if (!env.POLAR_ACCESS_TOKEN) {
return new Response('POLAR_ACCESS_TOKEN is not configured', { status: 500 });
}
const polar = new Polar({
accessToken: env.POLAR_ACCESS_TOKEN,
server: dev ? 'sandbox' : 'production'
});
const totalPriceCents = TOKEN_PACK.PRICE_USD_CENTS * quantity;
const checkout = await polar.checkouts.create({
products: [productId],
prices: {
[productId]: [
{
amountType: 'fixed',
priceAmount: totalPriceCents,
priceCurrency: 'usd'
}
]
},
customerEmail: url.searchParams.get('customerEmail') ?? undefined,
customerName: url.searchParams.get('customerName') ?? undefined,
successUrl: `${url.origin}/org/${metadata.orgSlug}/settings/ai-credits?tokens=success`,
metadata: {
kind: 'token_pack',
orgId: metadata.orgId,
orgSlug: metadata.orgSlug,
triggeredBy: metadata.triggeredBy ?? '',
tokensPerUnit: String(TOKEN_PACK.TOKENS_PER_UNIT),
quantity: String(quantity)
}
});
return Response.redirect(checkout.url, 303);
};
@@ -1,6 +1,12 @@
import { OrgPlanApiServer } from '$features/org/api/org-plan.server';
import { PLAN } from '@cio/utils/plans';
import type { PolarWebhookPayload } from '$lib/utils/types/polar';
import { CreditPurchaseApiServer } from '$features/agent/api/credit-purchase.server';
import { PLAN, TOKEN_PACK } from '@cio/utils/plans';
import type {
PolarOrderWebhookPayload,
PolarSubscriptionWebhookPayload,
PolarWebhookPayload,
SubscriptionData
} from '$lib/utils/types/polar';
import { Webhooks } from '@polar-sh/sveltekit';
import { env } from '$env/dynamic/private';
@@ -9,24 +15,86 @@ export const POST = Webhooks({
onPayload
});
function isSubscriptionPayload(payload: PolarWebhookPayload): payload is PolarSubscriptionWebhookPayload {
return (
payload.type === 'checkout.created' ||
payload.type === 'checkout.updated' ||
payload.type.startsWith('subscription.')
);
}
function isOrderPayload(payload: PolarWebhookPayload): payload is PolarOrderWebhookPayload {
return payload.type === 'order.paid' || payload.type === 'order.created' || payload.type === 'order.refunded';
}
async function onPayload(payload: PolarWebhookPayload) {
const metadata = payload.data.metadata;
console.log('Polar webhook', payload.type);
if (isOrderPayload(payload)) {
if (payload.type !== 'order.paid') {
return;
}
const order = payload.data;
const md = order.metadata ?? {};
if (md.kind !== 'token_pack' || !md.orgId) {
return;
}
const quantityFromItems = order.items?.[0]?.quantity;
const quantityParsed = Number(md.quantity ?? '1');
const quantity =
quantityFromItems != null && quantityFromItems > 0
? quantityFromItems
: Number.isFinite(quantityParsed) && quantityParsed > 0
? Math.trunc(quantityParsed)
: 1;
const tokensPerUnit = Number(md.tokensPerUnit ?? '') || TOKEN_PACK.TOKENS_PER_UNIT;
const tokens = quantity * tokensPerUnit;
const totalCents = order.totalAmount ?? order.amount ?? 0;
const unitPriceCents = quantity > 0 ? Math.round(totalCents / quantity) : totalCents;
const triggeredByRaw = md.triggeredBy?.trim();
let triggeredBy: string | undefined;
if (
triggeredByRaw &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(triggeredByRaw)
) {
triggeredBy = triggeredByRaw;
}
try {
await CreditPurchaseApiServer.recordPurchase({
orgId: md.orgId,
triggeredBy,
providerOrderId: order.id,
tokens,
quantity,
unitPriceCents,
currency: order.currency ?? 'USD',
payload: order as unknown as Record<string, unknown>
});
} catch (error) {
console.error('Error recording credit purchase', error);
}
return;
}
if (!isSubscriptionPayload(payload)) {
console.log('Unknown Polar event', payload.type);
return;
}
const data = payload.data as SubscriptionData;
const metadata = data.metadata;
const orgId = metadata.orgId;
const triggeredBy = metadata.triggeredBy;
const subscriptionId = payload.data.id;
const isSubscriptionActive = payload.data.status === 'active';
console.log({
orgId,
triggeredBy,
isSubscriptionActive,
subscriptionId
});
if (!orgId) {
console.error('Org ID is required');
throw new Error('Org ID is required');
}
const subscriptionId = data.id;
const isSubscriptionActive = data.status === 'active';
switch (payload.type) {
case 'checkout.created':
@@ -34,14 +102,24 @@ async function onPayload(payload: PolarWebhookPayload) {
case 'checkout.updated':
break;
case 'subscription.created':
if (metadata.kind === 'token_pack') {
break;
}
if (!orgId || triggeredBy === undefined || triggeredBy === '') {
console.error('subscription.created missing org metadata');
break;
}
if (isSubscriptionActive) {
try {
const planData = {
orgId,
triggeredBy: parseInt(triggeredBy),
triggeredBy: parseInt(triggeredBy, 10),
planName: PLAN.EARLY_ADOPTER as 'EARLY_ADOPTER' | 'ENTERPRISE' | 'BASIC',
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
payload: data as unknown as Record<string, unknown>
};
const result = await OrgPlanApiServer.createOrgPlan(planData);
@@ -50,31 +128,31 @@ async function onPayload(payload: PolarWebhookPayload) {
console.error('Error creating org plan', error);
}
}
break;
case 'subscription.updated':
if (!isSubscriptionActive) {
// Handle Cancel Subscription
try {
const result = await OrgPlanApiServer.cancelOrgPlan({
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
payload: data as unknown as Record<string, unknown>
});
console.log('Subscription canceled', result);
} catch (error) {
console.error('Error canceling org plan', error);
}
} else {
// Handle Update Subscription
try {
const result = await OrgPlanApiServer.updateOrgPlan({
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
payload: data as unknown as Record<string, unknown>
});
console.log('Subscription updated', result);
} catch (error) {
console.error('Error updating org plan', error);
}
}
break;
case 'subscription.active':
break;
@@ -83,7 +161,9 @@ async function onPayload(payload: PolarWebhookPayload) {
case 'subscription.canceled':
break;
default:
console.log('Unknown event', payload.type);
// Exhaustive over `PolarSubscriptionWebhookPayload`; kept for future Polar event types.
console.log('Unknown subscription-related event');
break;
}
}
@@ -0,0 +1,18 @@
{
"type": "order.paid",
"data": {
"id": "ord_fixture_001",
"amount": 500,
"totalAmount": 1500,
"currency": "usd",
"metadata": {
"kind": "token_pack",
"orgId": "00000000-0000-4000-8000-000000000001",
"orgSlug": "demo-org",
"triggeredBy": "00000000-0000-4000-8000-000000000002",
"tokensPerUnit": "2000000",
"quantity": "3"
},
"items": [{ "quantity": 3, "amount": 1500 }]
}
}