chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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>
|
||||
+12
-7
@@ -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 > 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>
|
||||
|
||||
|
||||
+26
-1
@@ -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'
|
||||
),
|
||||
|
||||
+66
@@ -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) {
|
||||
|
||||
+72
-3
@@ -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}
|
||||
|
||||
+59
-18
@@ -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}
|
||||
|
||||
+27
-4
@@ -11,6 +11,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
|
||||
import { slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
interface Props {
|
||||
mode: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -20,6 +21,10 @@
|
||||
onTitleChange: (value: string) => void;
|
||||
onToggleLock: () => void;
|
||||
onDeleteLesson: () => Promise<void> | void;
|
||||
slug?: string;
|
||||
showSlugEditor?: boolean;
|
||||
slugError?: string;
|
||||
onSlugChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -29,9 +34,15 @@
|
||||
isDeletingLesson = false,
|
||||
onTitleChange,
|
||||
onToggleLock,
|
||||
onDeleteLesson
|
||||
onDeleteLesson,
|
||||
slug = '',
|
||||
showSlugEditor = false,
|
||||
slugError,
|
||||
onSlugChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
// Slug field: commented out via `{#if false && ...}` below — drop `false &&` to show again.
|
||||
|
||||
let openDeleteModal = $state(false);
|
||||
|
||||
const lockLabel = $derived(
|
||||
@@ -40,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
{#if mode === MODES.edit && !$isOrgStudent}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<InputField
|
||||
@@ -49,6 +60,17 @@
|
||||
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
|
||||
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
|
||||
/>
|
||||
{#if false && showSlugEditor}
|
||||
<InputField
|
||||
className="max-w-xl"
|
||||
label={$t('course.navItem.settings.slug.label')}
|
||||
helperMessage={$t('course.navItem.settings.slug.description')}
|
||||
value={slug}
|
||||
placeholder={slugifyTitle(title || '')}
|
||||
onInputChange={(e) => onSlugChange(e.currentTarget.value)}
|
||||
errorMessage={slugError}
|
||||
/>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
{:else}
|
||||
<Page.Title>{title}</Page.Title>
|
||||
@@ -69,8 +91,9 @@
|
||||
<IconButton
|
||||
onclick={() => (openDeleteModal = true)}
|
||||
disabled={isDeletingLesson}
|
||||
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
|
||||
title={$t('course.navItem.lessons.add_lesson.delete')}
|
||||
aria-label={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
|
||||
tooltip={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
{#if isDeletingLesson}
|
||||
<Spinner class="h-4 w-4" />
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
let step = $state(0);
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: 'live-class',
|
||||
title: $t('new_course_modal.live_class_label'),
|
||||
subtitle: $t('new_course_modal.live_class_subtitle'),
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'self-paced',
|
||||
title: $t('new_course_modal.self_paced_label'),
|
||||
@@ -31,12 +24,26 @@
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'live-class',
|
||||
title: $t('new_course_modal.live_class_label'),
|
||||
subtitle: $t('new_course_modal.live_class_subtitle'),
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
title: $t('new_course_modal.compliance_label'),
|
||||
subtitle: $t('new_course_modal.compliance_subtitle'),
|
||||
type: 'COMPLIANCE' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
title: $t('new_course_modal.public_label'),
|
||||
subtitle: $t('new_course_modal.public_subtitle'),
|
||||
type: 'PUBLIC' as TCourseType,
|
||||
isDisabled: false
|
||||
}
|
||||
];
|
||||
const courseTypeOptionsForGroup = options.map((o) => ({
|
||||
@@ -75,7 +82,7 @@
|
||||
{#snippet course_type_selector()}
|
||||
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
|
||||
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-2" />
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
|
||||
pointsHint?: string | null;
|
||||
onPointsChange?: any;
|
||||
elementId?: string;
|
||||
key?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
@@ -27,6 +28,7 @@
|
||||
errorMsg = null,
|
||||
pointsHint = null,
|
||||
onPointsChange = () => {},
|
||||
elementId,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -44,6 +46,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={elementId}
|
||||
bind:this={ref}
|
||||
class="border-border border bg-white dark:bg-black {hasError ? 'border-red-700' : ''} root relative mb-6 rounded-md"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, onMount, tick, untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
@@ -14,7 +14,11 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
import {
|
||||
type ExerciseEditorErrors,
|
||||
handleAddQuestion,
|
||||
handleAddSection,
|
||||
getQuestionsForSection,
|
||||
hasSections,
|
||||
questionnaire,
|
||||
questionnaireOrder,
|
||||
reset,
|
||||
@@ -36,21 +40,28 @@
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import {
|
||||
// RefreshPageData,
|
||||
RoleBasedSecurity,
|
||||
UnsavedChanges
|
||||
} from '$features/ui';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import {
|
||||
hydrateExercisePageData
|
||||
// , refreshExercisePageData
|
||||
} from '$features/course/utils/exercise-page-utils';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId,
|
||||
getQuestionTypeOptionById
|
||||
getQuestionTypeKeyFromId
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
@@ -85,7 +96,32 @@
|
||||
let shouldDeleteExercise = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let reorderQuestions = $state(false);
|
||||
let selectedTab = $state<ExerciseTab>('questions');
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
function isTemporaryId(id: string | number | undefined) {
|
||||
return typeof id === 'string' && id.includes('-form');
|
||||
}
|
||||
|
||||
function hasDirtyQuestionnaire() {
|
||||
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
|
||||
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
|
||||
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
|
||||
});
|
||||
const hasDirtySection = ($questionnaire.sections ?? []).some(
|
||||
(section) => section.isDirty || Boolean(section.deletedAt)
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
$questionnaire.isTitleDirty ||
|
||||
$questionnaire.isDescriptionDirty ||
|
||||
$questionnaire.isDueByDirty ||
|
||||
hasDirtyQuestion ||
|
||||
hasDirtySection
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteExercise() {
|
||||
if (!courseApi.course?.id) return;
|
||||
@@ -94,6 +130,7 @@
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
@@ -111,6 +148,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
|
||||
const sectionErrors: ExerciseEditorErrors = {};
|
||||
const requiredFieldMessage = t.get('validations.generic.required_field');
|
||||
|
||||
for (const section of sections) {
|
||||
const nextSectionErrors: ExerciseEditorErrors[string] = {};
|
||||
|
||||
if (!section.title?.trim()) {
|
||||
nextSectionErrors.title = requiredFieldMessage;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSectionErrors).length === 0) continue;
|
||||
|
||||
sectionErrors[section.id] = nextSectionErrors;
|
||||
}
|
||||
|
||||
return sectionErrors;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if ($isOrgStudent || !courseApi.course?.id) return;
|
||||
|
||||
@@ -119,16 +175,33 @@
|
||||
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
|
||||
shouldFilterEmptyLabels: false
|
||||
});
|
||||
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
|
||||
const sectionsPayload = activeSections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
|
||||
}));
|
||||
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
|
||||
const sectionValidationErrors = getSectionValidationErrors(activeSections);
|
||||
|
||||
// Validate using Zod schema
|
||||
const zodResult = ZExerciseUpdate.safeParse({
|
||||
questions: transformedQuestions
|
||||
questions: transformedQuestions,
|
||||
sections: shouldSyncSections ? sectionsPayload : undefined,
|
||||
sectionDisplayMode: $questionnaire.sectionDisplayMode
|
||||
});
|
||||
|
||||
if (!zodResult.success) {
|
||||
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
|
||||
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
|
||||
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
|
||||
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
|
||||
questionnaireValidation.set(questionErrors);
|
||||
questionnaireValidation.set({
|
||||
...questionErrors,
|
||||
...sectionValidationErrors
|
||||
});
|
||||
snackbar.error('Please fix all validation errors before saving');
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +240,9 @@
|
||||
title: $questionnaire.title ?? '',
|
||||
description: $questionnaire.description ?? '',
|
||||
dueBy: $questionnaire.dueBy ?? '',
|
||||
questions: questions
|
||||
questions: questions,
|
||||
sections: shouldSyncSections ? sectionsPayload : undefined,
|
||||
sectionDisplayMode: $questionnaire.sectionDisplayMode
|
||||
});
|
||||
|
||||
// Check if there are validation errors from the API
|
||||
@@ -179,20 +254,10 @@
|
||||
}
|
||||
|
||||
if (exerciseApi.success) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
questions:
|
||||
exerciseApi.exercise?.questions?.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
}) || q.questions
|
||||
}));
|
||||
if (exerciseApi.exercise) {
|
||||
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
patchExerciseListItemLocally();
|
||||
snackbar.success('snackbar.exercise.success');
|
||||
}
|
||||
@@ -216,6 +281,17 @@
|
||||
selectedTab = nextTab;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const hasActiveSections = hasSections($questionnaire.sections);
|
||||
if (!hasActiveSections && reorderQuestions) {
|
||||
reorderQuestions = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const currentTab = page.url.searchParams.get('tab') ?? '';
|
||||
// Prevent self-navigation loops: only update URL when it actually changes.
|
||||
@@ -250,6 +326,7 @@
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
@@ -265,11 +342,42 @@
|
||||
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
|
||||
async function scrollToExerciseElement(elementId: string) {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFirstActiveSectionId() {
|
||||
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
|
||||
.filter((section) => !section.deletedAt)
|
||||
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
|
||||
|
||||
return firstActiveSection?.id ?? null;
|
||||
}
|
||||
|
||||
async function addQuestionFromHeader() {
|
||||
preview = false;
|
||||
const questionId = handleAddQuestion(getFirstActiveSectionId());
|
||||
await scrollToExerciseElement(`exercise-question-${questionId}`);
|
||||
}
|
||||
|
||||
async function addSectionFromHeader() {
|
||||
preview = false;
|
||||
const sectionId = handleAddSection();
|
||||
await scrollToExerciseElement(`exercise-section-${sectionId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
|
||||
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<Page.Title class="flex flex-col gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
@@ -298,7 +406,10 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onclick={() => (preview = !preview)}
|
||||
onclick={() => {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}}
|
||||
tooltip={preview
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
@@ -310,13 +421,22 @@
|
||||
<EyeIcon size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={handleAddQuestion}
|
||||
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<CirclePlusIcon size={20} />
|
||||
</IconButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<CirclePlusIcon size={20} />
|
||||
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="center" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addQuestionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
@@ -325,10 +445,19 @@
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
@@ -341,7 +470,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
|
||||
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
@@ -377,7 +506,7 @@
|
||||
<Badge variant="outline">{submissions.length}</Badge>
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode
|
||||
@@ -385,6 +514,8 @@
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
selfPacedCourse={isSelfPacedCourse}
|
||||
{isPublicCourse}
|
||||
{reorderQuestions}
|
||||
/>
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
@@ -430,3 +561,5 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
|
||||
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
|
||||
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
|
||||
const params = new SvelteURLSearchParams($page.url.searchParams);
|
||||
@@ -202,7 +204,8 @@
|
||||
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
|
||||
slideUrl: lessonApi.lesson.slideUrl || undefined,
|
||||
videos: lessonApi.lesson.videos || [],
|
||||
documents: lessonApi.lesson.documents || []
|
||||
documents: lessonApi.lesson.documents || [],
|
||||
slug: isPublicCourse && lessonApi.lesson.slug ? lessonApi.lesson.slug : undefined
|
||||
}),
|
||||
saveOrUpdateTranslation(lessonApi.currentLocale, lessonId)
|
||||
]);
|
||||
@@ -218,6 +221,10 @@
|
||||
lessonApi.updateLessonState('title', value);
|
||||
}
|
||||
|
||||
function handleLessonSlugChange(value: string) {
|
||||
lessonApi.updateLessonState('slug', value);
|
||||
}
|
||||
|
||||
function handleToggleLessonLock() {
|
||||
const currentValue = lessonApi.lesson?.isUnlocked ?? false;
|
||||
lessonApi.updateLessonState('isUnlocked', !currentValue);
|
||||
@@ -361,6 +368,10 @@
|
||||
onTitleChange={handleLessonTitleChange}
|
||||
onToggleLock={handleToggleLessonLock}
|
||||
onDeleteLesson={handleDeleteLesson}
|
||||
slug={lessonSlug}
|
||||
showSlugEditor={isPublicCourse}
|
||||
slugError={lessonApi.errors.slug}
|
||||
onSlugChange={handleLessonSlugChange}
|
||||
/>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ContentList from '$features/course/components/lesson/content-list.svelte';
|
||||
import ContentSectionList from '$features/course/components/lesson/content-section-list.svelte';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
@@ -28,6 +29,12 @@
|
||||
contentItems.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const sectionsTotal = $derived(
|
||||
contentData.grouped ? contentData.sections.filter((section) => section.id !== 'ungrouped').length : 0
|
||||
);
|
||||
const lessonsTotal = $derived(contentItems.filter((item) => item.type === ContentType.Lesson).length);
|
||||
const exercisesTotal = $derived(contentItems.filter((item) => item.type === ContentType.Exercise).length);
|
||||
|
||||
let isFetching: boolean = $state(false);
|
||||
let hasHandledNext = $state(false);
|
||||
|
||||
@@ -65,6 +72,34 @@
|
||||
variant="page"
|
||||
/>
|
||||
{:else if contentLength > 0}
|
||||
<div
|
||||
class="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3"
|
||||
role="region"
|
||||
aria-label={t.get('course.navItem.lessons.heading_v2')}
|
||||
>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Section} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.sections')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{sectionsTotal}</p>
|
||||
</div>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.lessons')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{lessonsTotal}</p>
|
||||
</div>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.exercises')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{exercisesTotal}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if reorder}
|
||||
<p class="text-center text-xs text-gray-400 italic dark:text-white">
|
||||
{$t('course.navItem.lessons.drag')}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -48,13 +48,13 @@
|
||||
await newsfeedApi.deleteComment(courseId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
metadata: metadataPayload,
|
||||
slug: courseApi.course.slug ?? undefined,
|
||||
compliance:
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
|
||||
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
@@ -220,11 +221,46 @@
|
||||
lessonDownload: !!course.metadata?.lessonDownload,
|
||||
isPublished: !!course.isPublished,
|
||||
allowNewStudents: !!course.metadata?.allowNewStudent,
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||
callout: normalizeCallout(course.callout)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeCalloutForSave(value: typeof $settings.callout) {
|
||||
if (!value) return null;
|
||||
|
||||
const title = value.title.trim();
|
||||
const description = value.description.trim();
|
||||
const buttonLabel = value.buttonLabel.trim();
|
||||
const buttonUrl = value.buttonUrl.trim();
|
||||
|
||||
if (!title && !description && !buttonLabel && !buttonUrl) return null;
|
||||
|
||||
return { title, description, buttonLabel, buttonUrl };
|
||||
}
|
||||
|
||||
function normalizeCallout(value: unknown): typeof $settings.callout {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.title !== 'string' ||
|
||||
typeof candidate.description !== 'string' ||
|
||||
typeof candidate.buttonLabel !== 'string' ||
|
||||
typeof candidate.buttonUrl !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: candidate.title,
|
||||
description: candidate.description,
|
||||
buttonLabel: candidate.buttonLabel,
|
||||
buttonUrl: candidate.buttonUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize course from page data
|
||||
$effect(() => {
|
||||
const courseData = $page.data?.course;
|
||||
@@ -240,6 +276,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
|
||||
settings.update((prev) => ({
|
||||
...prev,
|
||||
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
@@ -525,21 +570,97 @@
|
||||
}}
|
||||
>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
|
||||
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
|
||||
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center space-x-2">
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
|
||||
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
|
||||
</div>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
|
||||
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
|
||||
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.errors.type}
|
||||
<div
|
||||
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
|
||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
{#if $settings.type === 'PUBLIC' && $settings.callout}
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.callout.legend')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.callout.description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.title_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.title}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.title_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.description_label')}</Field.Label>
|
||||
<TextareaField
|
||||
bind:value={$settings.callout.description}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
rows={3}
|
||||
placeholder={$t('course.navItem.settings.callout.description_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.button_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.buttonLabel}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.button_label_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.button_url_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.buttonUrl}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.button_url_placeholder')}
|
||||
type="url"
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
|
||||
<Field.Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
settings.update((prev) => ({ ...prev, callout: null }));
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.settings.callout.clear')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
|
||||
@@ -33,6 +33,8 @@ export const defaultCourse: Course = {
|
||||
bannerImage: null,
|
||||
attendance: [],
|
||||
group: null,
|
||||
compliance: null,
|
||||
callout: null,
|
||||
content: {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
|
||||
@@ -46,6 +46,7 @@ type QuestionOption = ApiOption & {
|
||||
|
||||
// Extended question type with client-side fields
|
||||
export type Question = Omit<ApiQuestion, 'options'> & {
|
||||
exerciseSectionId?: string | null;
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
options: QuestionOption[];
|
||||
|
||||
@@ -8,6 +8,18 @@ interface OpenCoursePreviewOptions {
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
|
||||
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmedDomain = currentOrgDomain?.trim();
|
||||
const origin = trimmedDomain || window.location.origin;
|
||||
|
||||
return new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
}
|
||||
|
||||
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
@@ -19,9 +31,32 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain || window.location.origin;
|
||||
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
const link = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
snackbar.success('snackbar.public_course.url_copied');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('copyPublicCoursePageUrl error:', error);
|
||||
snackbar.error('snackbar.public_course.url_copy_failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
|
||||
import {
|
||||
clearQuestionnaireValidation,
|
||||
questionnaire,
|
||||
questionnaireMetaData
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
|
||||
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { UNTITLED_EXERCISE_SECTION_TITLE } from './exercise-section-utils';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
|
||||
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
|
||||
let questions: Question[] = [];
|
||||
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mappedQuestions = exercise.questions.map((question) => {
|
||||
const sections: ExerciseSectionState[] = Array.isArray(exercise.sections)
|
||||
? exercise.sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme as ExerciseSectionColorTheme,
|
||||
afterBehavior: section.afterBehavior as unknown as ExerciseSectionAfterBehavior
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
: [];
|
||||
|
||||
const exerciseQuestions = [...(Array.isArray(exercise.questions) ? exercise.questions : [])];
|
||||
|
||||
if (exerciseQuestions.length > 0) {
|
||||
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
|
||||
return {
|
||||
...question,
|
||||
exerciseSectionId: question.exerciseSectionId ?? null,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
|
||||
if (sections.length > 0) {
|
||||
const unsectionedQuestions = mappedQuestions.filter(
|
||||
(question) => !question.deletedAt && !question.exerciseSectionId
|
||||
);
|
||||
|
||||
if (unsectionedQuestions.length > 0) {
|
||||
const untitledSectionId = crypto.randomUUID();
|
||||
const nextSectionOrder =
|
||||
sections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
|
||||
sections.push({
|
||||
id: untitledSectionId,
|
||||
title: UNTITLED_EXERCISE_SECTION_TITLE,
|
||||
description: null,
|
||||
order: nextSectionOrder,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
});
|
||||
|
||||
for (const question of unsectionedQuestions) {
|
||||
question.exerciseSectionId = untitledSectionId;
|
||||
question.isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
questions = normalizeQuestionOrder(mappedQuestions);
|
||||
}
|
||||
|
||||
@@ -36,8 +82,11 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions,
|
||||
sections,
|
||||
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
|
||||
slug: exercise.slug ?? ''
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const UNTITLED_EXERCISE_SECTION_TITLE = 'Untitled section';
|
||||
|
||||
export function getExerciseSectionDisplayTitle({
|
||||
title,
|
||||
sectionNumber,
|
||||
sectionLabel
|
||||
}: {
|
||||
title: string;
|
||||
sectionNumber: number;
|
||||
sectionLabel: string;
|
||||
}) {
|
||||
if (title.trim() === UNTITLED_EXERCISE_SECTION_TITLE) return `${sectionLabel} ${sectionNumber}`;
|
||||
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
PublicCourseSidebarSection,
|
||||
PublicExerciseViewData,
|
||||
PublicLessonViewData
|
||||
} from '@cio/ui/custom/public-course';
|
||||
import type { ExerciseQuestionModel, ExerciseQuestionTypeKey } from '@cio/question-types';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type GetPublicCourseSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicCourseTree = GetPublicCourseSuccess['data'];
|
||||
|
||||
type GetPublicCourseItemSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicItem = GetPublicCourseItemSuccess['data'];
|
||||
|
||||
/**
|
||||
* Group the flat tree into sidebar sections. Items that aren't assigned to any
|
||||
* section are grouped under a synthetic "Lessons" section so the UI never has
|
||||
* orphaned rows.
|
||||
*/
|
||||
export function toPublicSidebarSections(tree: PublicCourseTree): PublicCourseSidebarSection[] {
|
||||
const sectionLookup = new Map<string, PublicCourseSidebarSection>();
|
||||
for (const section of tree.sections) {
|
||||
sectionLookup.set(section.id, {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
const orphanSection: PublicCourseSidebarSection = {
|
||||
id: '__unsectioned',
|
||||
title: 'Lessons',
|
||||
items: []
|
||||
};
|
||||
|
||||
for (const item of tree.items) {
|
||||
const targetSection =
|
||||
item.sectionId && sectionLookup.has(item.sectionId) ? sectionLookup.get(item.sectionId)! : orphanSection;
|
||||
|
||||
targetSection.items.push({
|
||||
kind: item.kind,
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
isUnlocked: item.isUnlocked
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = Array.from(sectionLookup.values()).filter((section) => section.items.length > 0);
|
||||
|
||||
if (orphanSection.items.length > 0) {
|
||||
ordered.push(orphanSection);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson' }>): PublicLessonViewData {
|
||||
return {
|
||||
kind: 'lesson',
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
type ApiQuestion = Extract<PublicItem, { kind: 'exercise' }>['questions']['unsectioned'][number];
|
||||
|
||||
/**
|
||||
* Convert API question shapes into `ExerciseQuestionModel`s so the public
|
||||
* exercise view can drive the same renderer pipeline as the authenticated
|
||||
* student view (`ExerciseQuestion.QuestionList`). Question types that aren't
|
||||
* auto-gradable are filtered out — the creator UI already blocks them on
|
||||
* PUBLIC courses, but we double-check here so the page never ships answers
|
||||
* for a question we can't grade in the browser.
|
||||
*/
|
||||
export function toPublicExerciseQuestions(questions: {
|
||||
unsectioned: ApiQuestion[];
|
||||
sections: Array<{ questions: ApiQuestion[] }>;
|
||||
}): ExerciseQuestionModel[] {
|
||||
const all = [...questions.unsectioned, ...questions.sections.flatMap((section) => section.questions)];
|
||||
|
||||
return all.map((question) => mapQuestion(question));
|
||||
}
|
||||
|
||||
function mapQuestion(question: ApiQuestion): ExerciseQuestionModel {
|
||||
return {
|
||||
id: question.id,
|
||||
title: question.title,
|
||||
questionType: question.questionTypeKey as ExerciseQuestionTypeKey,
|
||||
points: question.points,
|
||||
options: question.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
isCorrect: option.isCorrect,
|
||||
settings: (option.settings ?? {}) as Record<string, unknown>
|
||||
})),
|
||||
settings: (question.settings ?? {}) as Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicExerciseView(exercise: Extract<PublicItem, { kind: 'exercise' }>): PublicExerciseViewData {
|
||||
return {
|
||||
kind: 'exercise',
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
sectionTitle: exercise.sectionTitle,
|
||||
isUnlocked: exercise.isUnlocked,
|
||||
questions: toPublicExerciseQuestions(exercise.questions)
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import type { TCourseCallout } from '@cio/utils/validation/course';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type CourseSettings = {
|
||||
@@ -6,12 +7,13 @@ type CourseSettings = {
|
||||
courseTitle: string;
|
||||
courseDescription: string;
|
||||
grading: boolean;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
|
||||
type: TCourseType;
|
||||
allowNewStudents: boolean;
|
||||
tabs: { id: number; name: string }[];
|
||||
lessonDownload: boolean;
|
||||
isPublished: boolean;
|
||||
isContentGroupingEnabled: boolean;
|
||||
callout: TCourseCallout | null;
|
||||
};
|
||||
|
||||
export const settings = writable<CourseSettings>({
|
||||
@@ -19,7 +21,7 @@ export const settings = writable<CourseSettings>({
|
||||
courseTitle: '',
|
||||
courseDescription: '',
|
||||
grading: false,
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
allowNewStudents: false,
|
||||
tabs: [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
@@ -29,5 +31,6 @@ export const settings = writable<CourseSettings>({
|
||||
],
|
||||
lessonDownload: false,
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true
|
||||
isContentGroupingEnabled: true,
|
||||
callout: null
|
||||
});
|
||||
|
||||
@@ -261,6 +261,21 @@ export type UpdateSubmissionGradesRequest =
|
||||
export type CreateCourseRequest = typeof classroomio.course.$post;
|
||||
export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get'];
|
||||
export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get'];
|
||||
export type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
export type GetCourseBySlugData = GetCourseBySlugSuccess['data'];
|
||||
|
||||
/** Org snippet returned with course slug payload for org-site flows */
|
||||
export type CourseSlugPayloadOrg = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
theme?: unknown;
|
||||
};
|
||||
|
||||
/** Slug course shape including optional org (RPC unions omit `org` on some branches). */
|
||||
export type CourseBySlugWithOrg = GetCourseBySlugData & {
|
||||
org?: CourseSlugPayloadOrg | null;
|
||||
};
|
||||
export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put'];
|
||||
export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete'];
|
||||
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
|
||||
@@ -444,3 +459,10 @@ export type VideoUploadPresignSuccess = Extract<InferResponseType<VideoUploadPre
|
||||
|
||||
export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post'];
|
||||
export type VideoDownloadPresignSuccess = Extract<InferResponseType<VideoDownloadPresignRequest>, { success: true }>;
|
||||
|
||||
export type VideoRecordingUploadInitRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['init']['$post'];
|
||||
export type VideoRecordingUploadCompleteRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['complete']['$post'];
|
||||
export type VideoRecordingPlaybackRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission'][':submissionId']['question'][':questionId']['video-recording']['playback']['$get'];
|
||||
|
||||
@@ -22,7 +22,7 @@ class LicenseApi {
|
||||
const deduped: LicenseFeatureId[] = [];
|
||||
|
||||
for (const feature of features) {
|
||||
if (LICENSE_FEATURE_IDS.includes(feature)) {
|
||||
if ((LICENSE_FEATURE_IDS as readonly string[]).includes(feature)) {
|
||||
const typedFeature = feature as LicenseFeatureId;
|
||||
if (!deduped.includes(typedFeature)) {
|
||||
deduped.push(typedFeature);
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
import UserRoundIcon from '@lucide/svelte/icons/user-round';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
@@ -113,6 +118,44 @@
|
||||
|
||||
goto(`/courses/${id}/lessons?next=true`);
|
||||
}
|
||||
|
||||
function showPublishedPublicCourseLinks(course: EnrolledCourse | null) {
|
||||
if (!course || course.type !== 'PUBLIC') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
|
||||
|
||||
if (!publicSlug.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!course.isPublished;
|
||||
}
|
||||
|
||||
function openPublicCoursePage(course: EnrolledCourse | null) {
|
||||
if (!course?.id || !showPublishedPublicCourseLinks(course)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
|
||||
|
||||
openCoursePreview({
|
||||
courseId: course.id,
|
||||
courseSlug: publicSlug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function copyPublicCourseLink(course: EnrolledCourse | null) {
|
||||
if (!course || !showPublishedPublicCourseLinks(course)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicSlug = typeof course.slug === 'string' ? course.slug : '';
|
||||
|
||||
await copyPublicCoursePageUrl(publicSlug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 pb-8">
|
||||
@@ -132,17 +175,37 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
|
||||
>
|
||||
{#if currentCourse}
|
||||
{$t('dashboard.continue_learning')}
|
||||
{:else}
|
||||
{$t('dashboard.view_courses')}
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{#if currentCourse && showPublishedPublicCourseLinks(currentCourse)}
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onclick={() => openPublicCoursePage(currentCourse)}
|
||||
tooltip={$t('courses.course_card.context_menu.open_public_course')}
|
||||
aria-label={$t('courses.course_card.context_menu.open_public_course')}
|
||||
>
|
||||
<ExternalLinkIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onclick={() => void copyPublicCourseLink(currentCourse)}
|
||||
tooltip={$t('courses.course_card.context_menu.copy_course_url')}
|
||||
aria-label={$t('courses.course_card.context_menu.copy_course_url')}
|
||||
>
|
||||
<CopyIcon class="size-4" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
|
||||
>
|
||||
{#if currentCourse}
|
||||
{$t('dashboard.continue_learning')}
|
||||
{:else}
|
||||
{$t('dashboard.view_courses')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BlurFade>
|
||||
@@ -215,7 +278,12 @@
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-base font-semibold">{course.title}</h3>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 class="truncate text-base font-semibold">{course.title}</h3>
|
||||
{#if course.type === 'PUBLIC'}
|
||||
<CoursePublicBadge class="shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if course.description}
|
||||
<p class="ui:text-muted-foreground mt-1 line-clamp-2 text-sm">{course.description}</p>
|
||||
{/if}
|
||||
@@ -233,9 +301,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if showPublishedPublicCourseLinks(course)}
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onclick={() => openPublicCoursePage(course)}
|
||||
tooltip={$t('courses.course_card.context_menu.open_public_course')}
|
||||
aria-label={$t('courses.course_card.context_menu.open_public_course')}
|
||||
>
|
||||
<ExternalLinkIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onclick={() => void copyPublicCourseLink(course)}
|
||||
tooltip={$t('courses.course_card.context_menu.copy_course_url')}
|
||||
aria-label={$t('courses.course_card.context_menu.copy_course_url')}
|
||||
>
|
||||
<CopyIcon class="size-4" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AiUsageData,
|
||||
GetLeaderboardRequest,
|
||||
GetPurchasedRequest,
|
||||
GetUsageRequest,
|
||||
LeaderboardData,
|
||||
PurchasedSummaryData
|
||||
} from '../utils/types';
|
||||
|
||||
class AiCreditsApi extends BaseApi {
|
||||
usage = $state<AiUsageData | null>(null);
|
||||
purchased = $state<PurchasedSummaryData | null>(null);
|
||||
leaderboard = $state<LeaderboardData | null>(null);
|
||||
|
||||
async fetchAll() {
|
||||
await Promise.all([this.fetchUsage(), this.fetchPurchased(), this.fetchLeaderboard()]);
|
||||
}
|
||||
|
||||
async fetchUsage() {
|
||||
await this.execute<GetUsageRequest>({
|
||||
requestFn: () => classroomio.agent.usage.$get(),
|
||||
logContext: 'fetching AI usage',
|
||||
onSuccess: (result) => {
|
||||
this.usage = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetchPurchased() {
|
||||
await this.execute<GetPurchasedRequest>({
|
||||
requestFn: () => classroomio.agent.usage.purchased.$get(),
|
||||
logContext: 'fetching purchased summary',
|
||||
onSuccess: (result) => {
|
||||
this.purchased = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetchLeaderboard() {
|
||||
await this.execute<GetLeaderboardRequest>({
|
||||
requestFn: () => classroomio.agent.usage.leaderboard.$get(),
|
||||
logContext: 'fetching AI usage leaderboard',
|
||||
onSuccess: (result) => {
|
||||
this.leaderboard = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const aiCreditsApi = new AiCreditsApi();
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { TOKEN_PACK } from '@cio/utils/plans';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
onBeforeRedirect?: () => void;
|
||||
}
|
||||
|
||||
let { onBeforeRedirect }: Props = $props();
|
||||
|
||||
let quantity = $state(1);
|
||||
let isRedirecting = $state(false);
|
||||
|
||||
const tokens = $derived(quantity * TOKEN_PACK.TOKENS_PER_UNIT);
|
||||
const dollars = $derived((quantity * TOKEN_PACK.PRICE_USD_CENTS) / 100);
|
||||
|
||||
function bump(delta: number) {
|
||||
quantity = Math.min(100, Math.max(1, quantity + delta));
|
||||
}
|
||||
|
||||
function buy() {
|
||||
if (!$profile.id || !$profile.email || !$currentOrg.id || !$currentOrg.siteName) {
|
||||
snackbar.error('snackbar.ai_credits.checkout_failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
isRedirecting = true;
|
||||
|
||||
const checkoutURL = new URL('/api/polar/buy-tokens', page.url);
|
||||
checkoutURL.searchParams.set('quantity', String(quantity));
|
||||
checkoutURL.searchParams.set('customerEmail', $profile.email?.replace('@test.com', '+test@digdippa.com') ?? '');
|
||||
|
||||
if ($profile.fullname) {
|
||||
checkoutURL.searchParams.set('customerName', $profile.fullname);
|
||||
}
|
||||
|
||||
checkoutURL.searchParams.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
orgId: $currentOrg.id,
|
||||
orgSlug: $currentOrg.siteName,
|
||||
triggeredBy: $profile.id
|
||||
})
|
||||
);
|
||||
|
||||
onBeforeRedirect?.();
|
||||
window.location.href = checkoutURL.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root id="buy-tokens">
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('settings.ai_credits.buy_tokens.title')}</Card.Title>
|
||||
<Card.Description>
|
||||
{$t('settings.ai_credits.buy_tokens.description')}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onclick={() => bump(-1)} disabled={quantity <= 1}>
|
||||
<MinusIcon class="size-4" />
|
||||
</Button>
|
||||
<span class="ui:text-muted-foreground min-w-[3rem] text-center text-sm font-medium tabular-nums">
|
||||
{quantity}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onclick={() => bump(1)} disabled={quantity >= 100}>
|
||||
<PlusIcon class="size-4" />
|
||||
</Button>
|
||||
<span class="ui:text-muted-foreground ml-2 text-xs">
|
||||
{$t('settings.ai_credits.buy_tokens.quantity_hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('settings.ai_credits.buy_tokens.preview', {
|
||||
quantity: String(quantity),
|
||||
tokens: tokens.toLocaleString(),
|
||||
dollars: dollars.toFixed(2)
|
||||
})}
|
||||
</p>
|
||||
|
||||
<Button onclick={buy} loading={isRedirecting} class="w-fit">
|
||||
{$t('settings.ai_credits.buy_tokens.buy_button', { dollars: dollars.toFixed(2) })}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
+84
@@ -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>
|
||||
+88
@@ -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];
|
||||
+131
-104
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user