feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } 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';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart,
|
||||
type AgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Chat } from '@ai-sdk/svelte';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
import { getMentionableContent } from '$features/course/utils/content';
|
||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
|
||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||
import { 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 {
|
||||
AI_CHAT_MODEL_STORAGE_KEY,
|
||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
AI_COURSE_CHAT_MAX_WIDTH,
|
||||
AI_COURSE_CHAT_MIN_WIDTH,
|
||||
AI_COURSE_CHAT_STORAGE_KEY
|
||||
} from '$features/ai-assistant/utils/constants';
|
||||
import { AGENT_MODEL_IDS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
// Extract current lessonId/exerciseId from route params
|
||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||
const currentExerciseId = $derived(page.params?.exerciseId as string | undefined);
|
||||
|
||||
let inputValue = $state('');
|
||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
|
||||
let usageFetched = $state(false);
|
||||
let conversationsLoaded = $state(false);
|
||||
let activeConversationId: string | null = $state(null);
|
||||
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
|
||||
let hasLoadedChatWidth = $state(false);
|
||||
let isChatResizing = $state(false);
|
||||
let stopChatResize: (() => void) | null = null;
|
||||
let chatShellElement: HTMLDivElement | null = null;
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function handleSelectModel(id: AgentModelId) {
|
||||
selectedModel = id;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, id);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
|
||||
|
||||
function clampChatWidth(width: number) {
|
||||
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function clearChatResizeListeners() {
|
||||
stopChatResize?.();
|
||||
stopChatResize = null;
|
||||
}
|
||||
|
||||
function getStorageKey(courseId: string) {
|
||||
return `ai-chat-active-${courseId}`;
|
||||
}
|
||||
|
||||
function getActiveConversationId(courseId: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(courseId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveConversationId(courseId: string, conversationId: string | null) {
|
||||
activeConversationId = conversationId;
|
||||
|
||||
try {
|
||||
if (conversationId) {
|
||||
localStorage.setItem(getStorageKey(courseId), conversationId);
|
||||
} else {
|
||||
localStorage.removeItem(getStorageKey(courseId));
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.deleteConversation(conversationId);
|
||||
|
||||
if (activeConversationId === conversationId) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when panel opens
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if ($isAiAssistantOpen && !usageFetched && courseId) {
|
||||
usageFetched = true;
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
|
||||
conversationsLoaded = true;
|
||||
|
||||
const savedId = getActiveConversationId(courseId);
|
||||
|
||||
aiAssistantApi.listConversations(courseId).then(() => {
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$isAiAssistantOpen) {
|
||||
usageFetched = false;
|
||||
conversationsLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
const chat = new Chat({
|
||||
id: 'ai-assistant',
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${getRequestBaseUrl()}/agent/chat`,
|
||||
credentials: 'include',
|
||||
body: () => ({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
model: selectedModel,
|
||||
context: {
|
||||
lessonId: page.params?.lessonId,
|
||||
exerciseId: page.params?.exerciseId,
|
||||
documentId: uploadedDocument?.id
|
||||
}
|
||||
}),
|
||||
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);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
'Upload a document to create a course',
|
||||
'Draft a lesson',
|
||||
'Generate questions from this lesson',
|
||||
"Improve this lesson's text",
|
||||
'Summarize this lesson'
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||
|
||||
const text = inputValue;
|
||||
const messageAttachment = uploadedDocument
|
||||
? {
|
||||
documentId: uploadedDocument.id,
|
||||
name: uploadedDocument.name
|
||||
}
|
||||
: undefined;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
// Create conversation eagerly before sending the first message
|
||||
if (!activeConversationId) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text,
|
||||
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureActiveConversation(courseId: string): Promise<string | null> {
|
||||
if (activeConversationId) return activeConversationId;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (!created) return null;
|
||||
|
||||
setActiveConversationId(courseId, created.id);
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
|
||||
if (!conversationId) return;
|
||||
|
||||
isUploading = true;
|
||||
const result = await aiAssistantApi.uploadDocument(file, courseId, conversationId);
|
||||
isUploading = false;
|
||||
|
||||
if (result) {
|
||||
uploadedDocument = { id: result.documentId, name: result.fileName };
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveDocument() {
|
||||
uploadedDocument = null;
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
inputValue = action;
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
chat.stop();
|
||||
}
|
||||
|
||||
function handleImplementPlan(editedPlan: unknown) {
|
||||
if (chat.status === 'streaming') return;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!activeConversationId) {
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text: 'Implement this plan.',
|
||||
metadata: {
|
||||
plan: {
|
||||
action: 'implement_course_plan',
|
||||
payload: editedPlan
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAskPlanChanges() {
|
||||
inputValue = '';
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = 'Continue implementing the plan from where you left off.';
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleMentionClick(route: string) {
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
|
||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||
|
||||
const mentionItems = $derived(
|
||||
getMentionableContent(courseApi.course).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
type: item.type
|
||||
}))
|
||||
);
|
||||
|
||||
// Show an activity card whenever the agent calls any tool.
|
||||
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
|
||||
const planExecutionState = $derived.by(() => {
|
||||
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
|
||||
if (!lastAssistantMsg) return null;
|
||||
|
||||
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
|
||||
isAgentToolPart(part)
|
||||
) as AgentToolPart[];
|
||||
|
||||
// generate_course_plan renders its own PlanView — exclude it from the card
|
||||
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
|
||||
|
||||
if (toolParts.length === 0) return null;
|
||||
|
||||
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
|
||||
if (!toolName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
||||
const status = getAgentToolStatus(part);
|
||||
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
|
||||
|
||||
return [{ label, status }];
|
||||
});
|
||||
|
||||
const allDone = steps.every((s) => s.status === 'completed');
|
||||
const isStopped = !isStreaming && !allDone;
|
||||
|
||||
// Hide the card once the agent finishes cleanly — the text response takes over
|
||||
if (allDone && !isStreaming) return null;
|
||||
|
||||
const hasMutations = toolParts.some((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
|
||||
});
|
||||
const title = hasMutations ? 'Applying changes' : 'Working';
|
||||
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
|
||||
|
||||
return { steps, currentAction, isStopped, title };
|
||||
});
|
||||
|
||||
const conversationTitle = $derived(
|
||||
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
||||
);
|
||||
|
||||
function handleChatResizePointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
clearChatResizeListeners();
|
||||
|
||||
const startWidth = chatWidth;
|
||||
|
||||
stopChatResize = startResizablePanelDrag({
|
||||
event,
|
||||
startWidth,
|
||||
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
|
||||
onPreview: (width) => {
|
||||
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
|
||||
},
|
||||
onCommit: ({ width }) => {
|
||||
chatWidth = width;
|
||||
},
|
||||
onDragStart: () => {
|
||||
isChatResizing = true;
|
||||
},
|
||||
onDragEnd: () => {
|
||||
isChatResizing = false;
|
||||
stopChatResize = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
|
||||
|
||||
if (Number.isFinite(storedWidth)) {
|
||||
chatWidth = clampChatWidth(storedWidth);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
try {
|
||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||
|
||||
if (isAgentModelId(storedModel)) {
|
||||
selectedModel = storedModel;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
hasLoadedChatWidth = true;
|
||||
|
||||
return () => {
|
||||
clearChatResizeListeners();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearChatResizeListeners();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedChatWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet chatPanelContent()}
|
||||
<ChatHeader
|
||||
{tokenUsage}
|
||||
{conversationTitle}
|
||||
conversations={aiAssistantApi.conversations}
|
||||
{activeConversationId}
|
||||
isNewChatDisabled={chat.messages.length === 0}
|
||||
onNewChat={startNewChat}
|
||||
onLoadConversation={loadConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
{isStreaming}
|
||||
courseId={courseApi.course?.id ?? ''}
|
||||
{planExecutionState}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
onAskPlanChanges={handleAskPlanChanges}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if $isAiAssistantOpen}
|
||||
<div
|
||||
bind:this={chatShellElement}
|
||||
data-ai-chat-resizing={isChatResizing}
|
||||
class="contents"
|
||||
style={`--ai-chat-width: ${chatWidth}px;`}
|
||||
>
|
||||
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
|
||||
|
||||
<div
|
||||
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize AI assistant panel"
|
||||
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
|
||||
onpointerdown={handleChatResizePointerDown}
|
||||
>
|
||||
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
|
||||
</button>
|
||||
|
||||
{@render chatPanelContent()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global([data-ai-chat-resizing='true'] *) {
|
||||
transition-property: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage
|
||||
} from '../utils/types';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
status: AgentStatusData | null = $state(null);
|
||||
conversations: AgentConversationSummary[] = $state([]);
|
||||
currentConversation: AgentConversation | null = $state(null);
|
||||
|
||||
async fetchStatus(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.status.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.status.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'fetching agent status',
|
||||
onSuccess: (result) => {
|
||||
this.status = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listConversations(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.history.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'listing conversations',
|
||||
onSuccess: (result) => {
|
||||
this.conversations = result.data as AgentConversationSummary[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$get({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'loading conversation',
|
||||
onSuccess: (result) => {
|
||||
this.currentConversation = {
|
||||
...(result.data as AgentConversation),
|
||||
messages: ((result.data as AgentConversation).messages ?? []) as AiAssistantMessage[]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createConversation(courseId: string, title?: string): Promise<{ id: string } | null> {
|
||||
let created: { id: string } | null = null;
|
||||
|
||||
await this.execute<typeof classroomio.agent.history.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$post({
|
||||
json: { courseId, title }
|
||||
}),
|
||||
logContext: 'creating conversation',
|
||||
onSuccess: (result) => {
|
||||
const newConversation = result.data as AgentConversationCreateData;
|
||||
created = newConversation;
|
||||
|
||||
this.conversations = [
|
||||
{ ...newConversation, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
...this.conversations
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async saveMessages(conversationId: string, messages: AiAssistantMessage[], title?: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$put({
|
||||
param: { conversationId },
|
||||
json: { messages, title }
|
||||
}),
|
||||
logContext: 'saving messages'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$delete({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'deleting conversation',
|
||||
onSuccess: () => {
|
||||
this.conversations = this.conversations.filter((c) => c.id !== conversationId);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadDocument(
|
||||
file: File,
|
||||
courseId: string,
|
||||
conversationId: string
|
||||
): Promise<{ documentId: string; fileName: string; wordCount: number; truncated: boolean } | null> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const url =
|
||||
`${getRequestBaseUrl()}/agent/upload` +
|
||||
`?courseId=${encodeURIComponent(courseId)}` +
|
||||
`&conversationId=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const response = await apiClient.request(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: { documentId: string; fileName: string; wordCount: number; truncated: boolean };
|
||||
};
|
||||
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading document:', error);
|
||||
this.error = 'Failed to upload document';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async generateTitle(conversationId: string, firstMessageText: string): Promise<string | null> {
|
||||
let generatedTitle: string | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['generate-title']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId']['generate-title'].$post({
|
||||
param: { conversationId },
|
||||
json: { firstMessageText }
|
||||
}),
|
||||
logContext: 'generating title',
|
||||
onSuccess: (result) => {
|
||||
generatedTitle = (result.data as { title: string }).title;
|
||||
|
||||
// Update the title in the local conversations list
|
||||
this.conversations = this.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, title: generatedTitle } : c
|
||||
);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = { ...this.currentConversation, title: generatedTitle };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return generatedTitle;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import ChatHistoryPopover from '$features/ai-assistant/chat-history-popover.svelte';
|
||||
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TokenUsage {
|
||||
used: number;
|
||||
allowance: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tokenUsage: TokenUsage | null;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
isNewChatDisabled: boolean;
|
||||
onNewChat: () => void;
|
||||
onLoadConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tokenUsage,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
conversationTitle,
|
||||
isNewChatDisabled,
|
||||
onNewChat,
|
||||
onLoadConversation,
|
||||
onDeleteConversation
|
||||
}: Props = $props();
|
||||
|
||||
let historyPopoverOpen = $state(false);
|
||||
|
||||
function handleLoadConversation(id: string) {
|
||||
onLoadConversation(id);
|
||||
historyPopoverOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SparklesIcon size={18} class="ui:text-primary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
|
||||
{#if conversationTitle && conversationTitle !== 'New conversation'}
|
||||
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
onclick={onNewChat}
|
||||
disabled={isNewChatDisabled}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('ai_assistant.new_chat')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<Popover.Root bind:open={historyPopoverOpen}>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
|
||||
<HistoryIcon size={16} />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="ui:p-0! w-72" align="end">
|
||||
<ChatHistoryPopover
|
||||
{conversations}
|
||||
{activeConversationId}
|
||||
onLoad={handleLoadConversation}
|
||||
onDelete={onDeleteConversation}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<IconButton onclick={closeAiAssistant} variant="outline" size="icon-xs">
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{#if tokenUsage && tokenUsage.allowance > 0}
|
||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
|
||||
<div class="mt-2">
|
||||
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||
<span
|
||||
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
|
||||
{$t('ai_assistant.tokens_label')}</span
|
||||
>
|
||||
<span>{usagePercent}%</span>
|
||||
</div>
|
||||
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all {usagePercent > 90
|
||||
? 'bg-red-500'
|
||||
: usagePercent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'ui:bg-primary'}"
|
||||
style="width: {usagePercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onLoad: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
let { conversations, activeConversationId, onLoad, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-b px-3 py-2">
|
||||
<p class="text-xs font-medium">{$t('ai_assistant.chat_history')}</p>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if conversations.length === 0}
|
||||
<p class="ui:text-muted-foreground px-3 py-4 text-center text-xs">{$t('ai_assistant.no_conversations')}</p>
|
||||
{:else}
|
||||
{#each conversations as conversation (conversation.id)}
|
||||
<div
|
||||
class="group flex items-center gap-2 border-b px-3 py-2 last:border-b-0 {conversation.id ===
|
||||
activeConversationId
|
||||
? 'ui:bg-muted'
|
||||
: 'hover:ui:bg-muted/50'} transition-colors"
|
||||
>
|
||||
<button onclick={() => onLoad(conversation.id)} class="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<MessageSquareIcon size={14} class="ui:text-muted-foreground flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium">{conversation.title || $t('ai_assistant.untitled')}</p>
|
||||
<p class="ui:text-muted-foreground text-[10px]">
|
||||
{new Date(conversation.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => onDelete(conversation.id)}
|
||||
class="ui:text-muted-foreground flex-shrink-0 rounded p-1 opacity-0 transition-all group-hover:opacity-100 hover:text-red-500"
|
||||
>
|
||||
<TrashIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
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 { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputValue: string;
|
||||
isStreaming: boolean;
|
||||
isExhausted: boolean;
|
||||
isUploading: boolean;
|
||||
error: Error | null | undefined;
|
||||
mentionItems: MentionItem[];
|
||||
uploadedDocument: UploadedDocument | null;
|
||||
selectedModel: AgentModelId;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveDocument: () => void;
|
||||
onSelectModel: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
inputValue = $bindable(),
|
||||
isStreaming,
|
||||
isExhausted,
|
||||
isUploading,
|
||||
error,
|
||||
mentionItems,
|
||||
uploadedDocument,
|
||||
selectedModel,
|
||||
onSend,
|
||||
onStop,
|
||||
onFileSelect,
|
||||
onRemoveDocument,
|
||||
onSelectModel
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
let wasBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const isBusy = isStreaming || isUploading;
|
||||
|
||||
if (isBusy) {
|
||||
wasBusy = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasBusy || isExhausted || !chatTextareaRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
wasBusy = false;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
chatTextareaRef?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function handlePaperclipClick() {
|
||||
if ($isFreePlan) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
fileInputEl?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
if (item.type === 'EXERCISE') return t.get('ai_assistant.mention_exercise');
|
||||
if (item.type === 'SECTION') return t.get('ai_assistant.mention_section');
|
||||
|
||||
return t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
class="hidden"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
{$t('ai_assistant.tokens_exhausted')}
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isUploading}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
|
||||
</div>
|
||||
{:else if uploadedDocument}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
|
||||
<button
|
||||
onclick={onRemoveDocument}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<ChatTextarea
|
||||
bind:ref={chatTextareaRef}
|
||||
bind:value={inputValue}
|
||||
{mentionItems}
|
||||
onSubmit={onSend}
|
||||
placeholder={$t('ai_assistant.input_placeholder')}
|
||||
disabled={isStreaming || isUploading}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
rows={2}
|
||||
class="flex-1"
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else if item.type === 'SECTION'}
|
||||
<TableOfContentsIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<button
|
||||
onclick={handlePaperclipClick}
|
||||
disabled={isUploading}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if isStreaming}
|
||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else}
|
||||
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface PlanExecutionState {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
isStopped: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
status: string;
|
||||
isStreaming: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
quickActions: string[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
status,
|
||||
isStreaming,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
onAskPlanChanges,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let lastMessageCount = $state(0);
|
||||
let lastStepsCount = $state(0);
|
||||
let lastCurrentAction = $state('');
|
||||
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || messages.length === 0) return;
|
||||
|
||||
if (messages.length === lastMessageCount) return;
|
||||
|
||||
lastMessageCount = messages.length;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || !planExecutionState) return;
|
||||
|
||||
const stepsCount = planExecutionState.steps.length;
|
||||
if (stepsCount !== lastStepsCount) {
|
||||
lastStepsCount = stepsCount;
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = planExecutionState.currentAction ?? '';
|
||||
if (action !== lastCurrentAction) {
|
||||
lastCurrentAction = action;
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer}>
|
||||
{#if isEmpty}
|
||||
<!-- Empty state with quick actions -->
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<SparklesIcon size={32} class="ui:text-muted-foreground" />
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('ai_assistant.empty_state')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
{#each quickActions as action (action)}
|
||||
<button
|
||||
onclick={() => onQuickAction(action)}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Message list -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each messages as message (message.id)}
|
||||
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
|
||||
{/each}
|
||||
|
||||
{#if status === 'submitted' && isStreaming}
|
||||
<div class="flex items-start">
|
||||
<div class="ui:bg-muted rounded-lg px-3 py-2">
|
||||
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planExecutionState}
|
||||
<div class="mt-2">
|
||||
<ProgressCard
|
||||
title={planExecutionState.title}
|
||||
steps={planExecutionState.steps}
|
||||
currentAction={planExecutionState.currentAction}
|
||||
onStop={isStreaming ? onStop : undefined}
|
||||
isStopped={planExecutionState.isStopped}
|
||||
/>
|
||||
{#if planExecutionState.isStopped}
|
||||
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
|
||||
>{$t('ai_assistant.resume')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AiCourseChat } from './ai-course-chat.svelte';
|
||||
export { default as PlanView } from './plan-view.svelte';
|
||||
export { default as ProgressCard } from './progress-card.svelte';
|
||||
export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store';
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { MentionPopover, type MentionItem } from '@cio/ui/custom/mention-popover';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ContentMentionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: ContentMentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (item: ContentMentionItem) => void;
|
||||
}
|
||||
|
||||
let { items, query, selectedIndex, onSelect }: Props = $props();
|
||||
|
||||
const mentionItems: MentionItem[] = $derived(
|
||||
items.map((item) => ({ id: item.id, label: item.title, type: item.type }))
|
||||
);
|
||||
|
||||
function handleSelect(mentionItem: MentionItem) {
|
||||
const original = items.find((item) => item.id === mentionItem.id);
|
||||
|
||||
if (original) {
|
||||
onSelect(original);
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
return item.type === 'EXERCISE' ? t.get('ai_assistant.mention_exercise') : t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<MentionPopover
|
||||
items={mentionItems}
|
||||
{query}
|
||||
{selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MentionPopover>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import { renderMarkdown } from '$features/ai-assistant/utils/markdown';
|
||||
import { renderMentions } from '$features/ai-assistant/utils/mentions';
|
||||
import PlanView from '$features/ai-assistant/plan-view.svelte';
|
||||
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
message: AiAssistantMessage;
|
||||
courseId: string;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
|
||||
|
||||
function handleBubbleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const mentionLink = target.closest('[data-mention-route]') as HTMLElement | null;
|
||||
|
||||
if (mentionLink) {
|
||||
event.preventDefault();
|
||||
const route = mentionLink.dataset.mentionRoute;
|
||||
|
||||
if (route) {
|
||||
onMentionClick(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
|
||||
|
||||
function isDeferredPlanPart(part: Record<string, unknown>) {
|
||||
if (!isAgentToolPart(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getAgentToolName(part) === 'generate_course_plan';
|
||||
}
|
||||
|
||||
function truncateErrorText(errorText: string): string {
|
||||
// Show just the summary before the raw Value JSON dump
|
||||
const valueIndex = errorText.indexOf(' Value: ');
|
||||
if (valueIndex !== -1) {
|
||||
return errorText.slice(0, valueIndex);
|
||||
}
|
||||
|
||||
return errorText.length > 120 ? errorText.slice(0, 120) + '…' : errorText;
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function getPartErrorText(part: unknown): string | undefined {
|
||||
return (part as { errorText?: string }).errorText;
|
||||
}
|
||||
|
||||
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
|
||||
const deferredPlanParts = $derived(
|
||||
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-role={message.role} class="flex flex-col gap-1 {message.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
|
||||
? 'ui:bg-primary ui:text-primary-foreground'
|
||||
: 'ui:bg-muted'}"
|
||||
onclick={handleBubbleClick}
|
||||
>
|
||||
{#if messageAttachment}
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
|
||||
? 'bg-white/15 text-white/90'
|
||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
||||
>
|
||||
<FileTextIcon size={12} class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each inlineParts as part, partIndex (partIndex)}
|
||||
{#if part.type === 'text'}
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none {message.role === 'user' && 'ui:text-primary-foreground!'}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
|
||||
</div>
|
||||
{:else if isAgentToolPart(part)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<PlanView plan={toolResult} 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}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each deferredPlanParts as part, partIndex (partIndex)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<div class="mt-3">
|
||||
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
{#if toolStatus === 'completed' && toolName}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.mention-link) {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
border-radius: 0.125rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
:global([data-role='user'] a) {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:global(.mention-link:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/** Token cost estimation heuristics (tokens per item) */
|
||||
const TOKEN_COST_ESTIMATES = {
|
||||
SECTION_CREATE: 500,
|
||||
LESSON_CREATE_AND_CONTENT: 3000,
|
||||
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;
|
||||
onAskChanges: () => void;
|
||||
remainingTokens?: number;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
|
||||
|
||||
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
|
||||
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
|
||||
function clonePlan(input: CoursePlan): CoursePlan {
|
||||
return JSON.parse(JSON.stringify(input)) as CoursePlan;
|
||||
}
|
||||
|
||||
// Local editable copy of the plan
|
||||
let editablePlan: CoursePlan = $state(clonePlan(plan));
|
||||
|
||||
// Reset editable plan when the prop changes (e.g., agent revises the plan)
|
||||
$effect(() => {
|
||||
editablePlan = clonePlan(plan);
|
||||
});
|
||||
|
||||
let expandedSections = new SvelteSet<number>();
|
||||
|
||||
$effect(() => {
|
||||
expandedSections.clear();
|
||||
|
||||
for (let i = 0; i < editablePlan.sections.length; i++) {
|
||||
expandedSections.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
// Track which field is being edited: "section-0-title", "lesson-1-2-title", "lesson-1-2-desc"
|
||||
let editingField: string | null = $state(null);
|
||||
|
||||
function toggleSection(index: number) {
|
||||
if (expandedSections.has(index)) {
|
||||
expandedSections.delete(index);
|
||||
} else {
|
||||
expandedSections.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(fieldKey: string) {
|
||||
editingField = fieldKey;
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingField = null;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
stopEditing();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
|
||||
const totalLessons = $derived(
|
||||
editablePlan.sections.reduce((sum, s) => sum + s.items.filter((i) => i.type === 'lesson').length, 0)
|
||||
);
|
||||
const totalExercises = $derived(
|
||||
editablePlan.sections.reduce(
|
||||
(sum, s) => sum + s.items.filter((i) => i.type === 'exercise' || i.hasExercise).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const estimatedTokens = $derived(
|
||||
editablePlan.sections.length * TOKEN_COST_ESTIMATES.SECTION_CREATE +
|
||||
totalLessons * TOKEN_COST_ESTIMATES.LESSON_CREATE_AND_CONTENT +
|
||||
totalExercises * TOKEN_COST_ESTIMATES.EXERCISE_CREATE
|
||||
);
|
||||
|
||||
const costPercentage = $derived(remainingTokens ? Math.round((estimatedTokens / remainingTokens) * 100) : null);
|
||||
const isHighCost = $derived(costPercentage !== null && costPercentage > 50);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<!-- Plan header -->
|
||||
<div class="border-b px-3 py-2">
|
||||
{#if editingField === 'plan-title'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
{editablePlan.title}
|
||||
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
|
||||
? `, ${totalExercises} exercises`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sections tree -->
|
||||
<div class="max-h-[300px] overflow-y-auto p-2">
|
||||
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
|
||||
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<ChevronDownIcon size={12} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingField === `section-${sectionIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`section-${sectionIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left"
|
||||
>
|
||||
Section {section.order}: {section.title}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<div class="ml-5 space-y-1">
|
||||
{#each section.items as item, itemIndex (item.order)}
|
||||
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
|
||||
{#if item.type === 'exercise'}
|
||||
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
|
||||
{:else}
|
||||
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
|
||||
>
|
||||
{#if item.type === 'exercise'}
|
||||
<span class="ui:text-primary">{item.title}</span>
|
||||
{:else}
|
||||
{item.title}
|
||||
{/if}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
|
||||
<textarea
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
rows={2}
|
||||
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
|
||||
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
|
||||
>
|
||||
<span class="line-clamp-2">{item.description}</span>
|
||||
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if item.type === 'lesson' && item.hasExercise}
|
||||
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<FileQuestionIcon size={10} />
|
||||
<span>Exercise included</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cost estimate + actions -->
|
||||
<div class="space-y-2 border-t px-3 py-2">
|
||||
{#if estimatedTokens > 0}
|
||||
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
|
||||
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
|
||||
{#if costPercentage !== null}
|
||||
({costPercentage}% of remaining)
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="flex-1">Implement Plan</Button>
|
||||
<Button size="sm" variant="outline" onclick={onAskChanges} class="flex-1">Ask for Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
|
||||
|
||||
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
|
||||
const totalCount = $derived(steps.length);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<div class="border-b px-3 py-2">
|
||||
<h4 class="text-sm font-semibold">{title}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{completedCount} / {totalCount} steps completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#each steps as step, i (`${i}-${step.label}`)}
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
|
||||
{#if step.status === 'completed'}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
{:else if step.status === 'in_progress'}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
{:else if step.status === 'failed'}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 border-t px-3 py-2">
|
||||
{#if error}
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentAction && !isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{currentAction}</p>
|
||||
{/if}
|
||||
|
||||
{#if isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
|
||||
{/if}
|
||||
|
||||
{#if onStop && !isStopped && completedCount < totalCount}
|
||||
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
|
||||
<SquareIcon size={12} class="mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
let { toolName, state, result }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background/50 flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||
{#if state === 'call' || state === 'partial-call'}
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">
|
||||
{getToolLabel(toolName)}...
|
||||
</span>
|
||||
{:else if state === 'result'}
|
||||
<CheckIcon size={12} class="ui:text-primary" />
|
||||
<span>
|
||||
{getToolResultLabel(toolName, result)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360;
|
||||
export const AI_COURSE_CHAT_MIN_WIDTH = 280;
|
||||
export const AI_COURSE_CHAT_MAX_WIDTH = 520;
|
||||
export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width';
|
||||
export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Configure marked for safe, minimal output
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert markdown text to sanitized HTML for rendering in the chat.
|
||||
* Agent responses come as markdown — this makes them render properly.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
return marked.parse(text, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type MentionType = 'lesson' | 'exercise' | 'section';
|
||||
|
||||
/**
|
||||
* Mention format: @[Title](type:id)
|
||||
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
|
||||
*/
|
||||
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
|
||||
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
|
||||
|
||||
function normalizeMentionType(type: string): MentionType | null {
|
||||
const normalizedType = type.toLowerCase();
|
||||
|
||||
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMentionRoute(courseId: string, type: MentionType, id: string) {
|
||||
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
|
||||
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
|
||||
|
||||
return `/courses/${courseId}/lessons/${id}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function buildMentionLink(route: string, label: string, title: string) {
|
||||
return `<a href="${route}" data-mention-route="${route}" class="mention-link" title="${escapeHtml(title)}">${label}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace AI lesson/exercise mentions with real in-app links.
|
||||
* Handles both raw mention syntax and markdown-generated anchor tags.
|
||||
*/
|
||||
export function renderMentions(html: string, courseId: string): string {
|
||||
const linksWithRoutes = html.replace(
|
||||
HTML_LINK_MENTION_REGEX,
|
||||
(match, _quote: string, type: string, id: string, label: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, label, `${mentionType}: ${id}`);
|
||||
}
|
||||
);
|
||||
|
||||
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, escapeHtml(title), `${mentionType}: ${title}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/** Controls whether the AI assistant right sidebar Sheet is open. */
|
||||
export const isAiAssistantOpen = writable(false);
|
||||
|
||||
export function openAiAssistant() {
|
||||
isAiAssistantOpen.set(true);
|
||||
}
|
||||
|
||||
export function closeAiAssistant() {
|
||||
isAiAssistantOpen.set(false);
|
||||
}
|
||||
|
||||
export function toggleAiAssistant() {
|
||||
isAiAssistantOpen.update((open) => !open);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_course_structure: 'Reading course structure',
|
||||
get_lesson_content: 'Reading lesson content',
|
||||
get_exercise_details: 'Reading exercise',
|
||||
create_section: 'Creating section',
|
||||
update_section: 'Updating section',
|
||||
create_lesson: 'Creating lesson',
|
||||
update_lesson: 'Updating lesson',
|
||||
update_lesson_content: 'Writing lesson content',
|
||||
create_exercise: 'Creating exercise',
|
||||
update_exercise: 'Updating exercise',
|
||||
add_questions: 'Adding questions',
|
||||
update_questions: 'Updating questions',
|
||||
reorder_content: 'Reordering content',
|
||||
update_course_landing_page: 'Updating landing page',
|
||||
check_course_go_live_readiness: 'Checking go-live readiness',
|
||||
go_live_course: 'Publishing course',
|
||||
generate_course_plan: 'Generating course plan'
|
||||
};
|
||||
|
||||
export function getToolLabel(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] || toolName;
|
||||
}
|
||||
|
||||
export function getToolResultLabel(toolName: string, result: unknown): string {
|
||||
const r = result as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case 'create_section':
|
||||
return `Created section: ${r.title}`;
|
||||
case 'update_section':
|
||||
return `Updated section: ${r.title}`;
|
||||
case 'create_lesson':
|
||||
return `Created lesson: ${r.title}`;
|
||||
case 'update_lesson':
|
||||
return `Updated lesson: ${r.title}`;
|
||||
case 'update_lesson_content':
|
||||
return `Wrote ${r.contentLength} chars`;
|
||||
case 'create_exercise':
|
||||
return `Created exercise with ${r.questionCount} questions`;
|
||||
case 'update_exercise':
|
||||
return `Updated exercise: ${r.title}`;
|
||||
case 'add_questions':
|
||||
return `Added ${r.addedCount} questions`;
|
||||
case 'update_questions':
|
||||
return `Updated ${r.updatedCount} questions`;
|
||||
case 'get_course_structure':
|
||||
return 'Course structure loaded';
|
||||
case 'get_lesson_content':
|
||||
return 'Lesson content loaded';
|
||||
case 'get_exercise_details':
|
||||
return 'Exercise loaded';
|
||||
case 'reorder_content':
|
||||
return 'Content reordered';
|
||||
case 'update_course_landing_page':
|
||||
return `Updated landing page: ${r.title}`;
|
||||
case 'check_course_go_live_readiness': {
|
||||
const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0;
|
||||
const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0;
|
||||
|
||||
return blockerCount === 0
|
||||
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
|
||||
: `${blockerCount} blockers before go-live`;
|
||||
}
|
||||
case 'go_live_course':
|
||||
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
|
||||
case 'generate_course_plan':
|
||||
return 'Course plan generated';
|
||||
default:
|
||||
return 'Done';
|
||||
}
|
||||
}
|
||||
|
||||
export const MUTATION_TOOLS = [
|
||||
'create_section',
|
||||
'update_section',
|
||||
'create_lesson',
|
||||
'update_lesson',
|
||||
'update_lesson_content',
|
||||
'create_exercise',
|
||||
'update_exercise',
|
||||
'add_questions',
|
||||
'update_questions',
|
||||
'reorder_content',
|
||||
'update_course_landing_page',
|
||||
'go_live_course'
|
||||
];
|
||||
|
||||
export interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProgressStep } from './tool-labels';
|
||||
|
||||
export interface AgentToolPart {
|
||||
type: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
toolInvocation?: {
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof part.type === 'string' && part.type.startsWith('tool-');
|
||||
}
|
||||
|
||||
export function getAgentToolName(part: AgentToolPart): string | null {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.toolName ?? null;
|
||||
}
|
||||
|
||||
if (!part.type.startsWith('tool-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return part.type.slice(5);
|
||||
}
|
||||
|
||||
export function getAgentToolResult(part: AgentToolPart): unknown {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.result;
|
||||
}
|
||||
|
||||
return part.output;
|
||||
}
|
||||
|
||||
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
|
||||
if (part.state === 'result' || part.state === 'output-available') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (part.state === 'output-error') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (
|
||||
part.state === 'call' ||
|
||||
part.state === 'partial-call' ||
|
||||
part.state === 'input-streaming' ||
|
||||
part.state === 'input-available'
|
||||
) {
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { UIMessage } from 'ai';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageAttachment {
|
||||
documentId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
|
||||
export type AgentStatusRequest = typeof classroomio.agent.status.$get;
|
||||
export type AgentStatusSuccess = Extract<InferResponseType<AgentStatusRequest>, { success: true }>;
|
||||
export type AgentStatusData = AgentStatusSuccess['data'];
|
||||
|
||||
export type AgentUsageRequest = typeof classroomio.agent.usage.$get;
|
||||
export type AgentUsageSuccess = Extract<InferResponseType<AgentUsageRequest>, { success: true }>;
|
||||
export type AgentUsageData = AgentUsageSuccess['data'];
|
||||
|
||||
export type AgentHistoryGetRequest = typeof classroomio.agent.history.$get;
|
||||
export type AgentHistoryGetSuccess = Extract<InferResponseType<AgentHistoryGetRequest>, { success: true }>;
|
||||
export type AgentHistoryData = AgentHistoryGetSuccess['data'];
|
||||
|
||||
export type AgentConversationSummary = AgentHistoryData[number];
|
||||
|
||||
export type AgentConversationRequest = (typeof classroomio.agent.history)[':conversationId']['$get'];
|
||||
export type AgentConversationSuccess = Extract<InferResponseType<AgentConversationRequest>, { success: true }>;
|
||||
export type AgentConversation = Omit<AgentConversationSuccess['data'], 'messages'> & {
|
||||
messages: AiAssistantMessage[];
|
||||
};
|
||||
|
||||
export type AgentConversationCreateRequest = typeof classroomio.agent.history.$post;
|
||||
export type AgentConversationCreateSuccess = Extract<
|
||||
InferResponseType<AgentConversationCreateRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type AgentConversationCreateData = AgentConversationCreateSuccess['data'];
|
||||
|
||||
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
|
||||
|
||||
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
|
||||
@@ -127,38 +127,36 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if programs.length > 0}
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CourseComplianceOverview,
|
||||
ExtendCourseComplianceRequest,
|
||||
GetCourseComplianceOverviewRequest,
|
||||
GetLearnerComplianceHistoryRequest,
|
||||
LearnerComplianceHistory,
|
||||
ResetCourseComplianceRequest,
|
||||
WaiveCourseComplianceRequest
|
||||
} from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import {
|
||||
ZCourseComplianceExtend,
|
||||
ZCourseComplianceReset,
|
||||
ZCourseComplianceWaive,
|
||||
type TCourseComplianceExtend,
|
||||
type TCourseComplianceReset,
|
||||
type TCourseComplianceWaive
|
||||
} from '@cio/utils/validation/course';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export class ComplianceApi extends BaseApiWithErrors {
|
||||
overview = $state<CourseComplianceOverview | null>(null);
|
||||
learnerHistory = $state<LearnerComplianceHistory | null>(null);
|
||||
|
||||
private overviewKey = $state<string | null>(null);
|
||||
private learnerHistoryKey = $state<string | null>(null);
|
||||
|
||||
async getOverview(courseId: string) {
|
||||
const result = await this.execute<GetCourseComplianceOverviewRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching course compliance overview',
|
||||
onSuccess: (response) => {
|
||||
this.overview = response.data;
|
||||
this.overviewKey = courseId;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.overview_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureOverview(courseId: string) {
|
||||
if (this.overviewKey === courseId && this.overview) {
|
||||
return this.overview;
|
||||
}
|
||||
|
||||
return this.getOverview(courseId);
|
||||
}
|
||||
|
||||
async getLearnerHistory(courseId: string, profileId: string) {
|
||||
const result = await this.execute<GetLearnerComplianceHistoryRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.learners[':profileId'].$get({
|
||||
param: { courseId, profileId }
|
||||
}),
|
||||
logContext: 'fetching learner compliance history',
|
||||
onSuccess: (response) => {
|
||||
this.learnerHistory = response.data;
|
||||
this.learnerHistoryKey = `${courseId}:${profileId}`;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.history_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureLearnerHistory(courseId: string, profileId: string) {
|
||||
const requestKey = `${courseId}:${profileId}`;
|
||||
if (this.learnerHistoryKey === requestKey && this.learnerHistory) {
|
||||
return this.learnerHistory;
|
||||
}
|
||||
|
||||
return this.getLearnerHistory(courseId, profileId);
|
||||
}
|
||||
|
||||
async resetCourseCompliance(courseId: string, fields: TCourseComplianceReset) {
|
||||
const result = ZCourseComplianceReset.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ResetCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.reset.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'resetting course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.reset_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.reset_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async extendCourseCompliance(courseId: string, fields: TCourseComplianceExtend) {
|
||||
const result = ZCourseComplianceExtend.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ExtendCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.extend.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'extending course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.extend_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.extend_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async waiveCourseCompliance(courseId: string, fields: TCourseComplianceWaive) {
|
||||
const result = ZCourseComplianceWaive.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<WaiveCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.waive.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'waiving course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.waive_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.waive_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
override reset() {
|
||||
super.reset();
|
||||
this.overview = null;
|
||||
this.learnerHistory = null;
|
||||
this.overviewKey = null;
|
||||
this.learnerHistoryKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const complianceApi = new ComplianceApi();
|
||||
@@ -26,9 +26,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to update content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to update content');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,9 +50,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to delete content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to delete content');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '../utils/types';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -357,7 +358,8 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
title: fields.title,
|
||||
description: fields.description,
|
||||
type: fields.type,
|
||||
organizationId: org.id
|
||||
organizationId: org.id,
|
||||
compliance: fields.type === 'COMPLIANCE' ? DEFAULT_COMPLIANCE_SETTINGS : undefined
|
||||
};
|
||||
|
||||
// Client-side validation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './attendance.svelte';
|
||||
export * from './compliance.svelte';
|
||||
export * from './course-clone.svelte';
|
||||
export * from './course.svelte';
|
||||
export * from './courses.svelte';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -12,9 +13,10 @@
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
let { id, title, description }: Props = $props();
|
||||
let { id, title, description, isPublished = false }: Props = $props();
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
@@ -40,6 +42,10 @@
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourseSite() {
|
||||
window.open(`/courses/${id}`, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -52,6 +58,13 @@
|
||||
</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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
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 ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
@@ -17,6 +19,11 @@
|
||||
import { calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
getStudentCourseComplianceStatusVariant
|
||||
} from '$features/course/utils/compliance-utils';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
@@ -117,6 +124,12 @@
|
||||
icon: UserIcon,
|
||||
iconStyle: 'custom ui:text-primary'
|
||||
},
|
||||
['COMPLIANCE']: {
|
||||
style: '',
|
||||
label: $t('course.navItem.settings.compliance'),
|
||||
icon: ShieldCheckIcon,
|
||||
iconStyle: 'custom text-emerald-700'
|
||||
},
|
||||
SPECIALIZATION: {
|
||||
style: '',
|
||||
label: $t('specialization.course_tag'),
|
||||
@@ -126,9 +139,21 @@
|
||||
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
let courseUrl = $derived.by(() => {
|
||||
if (href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (isOnLandingPage || isExplore) {
|
||||
if (!slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/course/${slug}`;
|
||||
}
|
||||
|
||||
return `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
|
||||
});
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
@@ -139,9 +164,46 @@
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const complianceStatusKey = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
const complianceStatusVariant = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
|
||||
: 'outline'
|
||||
);
|
||||
const complianceDate = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceDate(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
<CourseCard
|
||||
href={courseUrl ? resolve(courseUrl, {}) : undefined}
|
||||
{title}
|
||||
{description}
|
||||
{typeBadge}
|
||||
class="group relative"
|
||||
>
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
@@ -150,7 +212,7 @@
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
<CardDropdown {id} {title} {description} {isPublished} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -195,6 +257,22 @@
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
|
||||
{#if type === 'COMPLIANCE'}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{#if complianceStatusKey}
|
||||
<Badge variant={complianceStatusVariant}>
|
||||
{$t(complianceStatusKey)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if complianceDate?.value}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
courseCount: number;
|
||||
courseCount?: number;
|
||||
}
|
||||
|
||||
interface FilterTagGroup {
|
||||
@@ -34,8 +35,14 @@
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
isFiltering?: boolean;
|
||||
hasActiveFilters?: boolean;
|
||||
publishedStatus?: 'all' | 'published' | 'unpublished';
|
||||
courseType?: string;
|
||||
courseTypeOptions?: { value: string; label: string }[];
|
||||
onToggleTag?: (tagSlug: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
onPublishedStatusChange?: (status: 'all' | 'published' | 'unpublished') => void;
|
||||
onCourseTypeChange?: (type: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,8 +52,14 @@
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
isFiltering = false,
|
||||
hasActiveFilters: hasActiveFiltersOverride = undefined,
|
||||
publishedStatus = $bindable('all'),
|
||||
courseType = $bindable('all'),
|
||||
courseTypeOptions = [],
|
||||
onToggleTag = () => {},
|
||||
onClearFilters = () => {}
|
||||
onClearFilters = () => {},
|
||||
onPublishedStatusChange,
|
||||
onCourseTypeChange
|
||||
}: Props = $props();
|
||||
|
||||
const translatedSortOptions = $derived(
|
||||
@@ -54,12 +67,28 @@
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
hasActiveFiltersOverride !== undefined
|
||||
? hasActiveFiltersOverride
|
||||
: sortKey !== DEFAULT_COURSE_SORT ||
|
||||
selectedOrder !== DEFAULT_SORT_ORDER ||
|
||||
selectedTags.length > 0 ||
|
||||
publishedStatus !== 'all' ||
|
||||
courseType !== 'all'
|
||||
);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
}
|
||||
|
||||
function setPublishedStatus(status: 'all' | 'published' | 'unpublished') {
|
||||
publishedStatus = status;
|
||||
onPublishedStatusChange?.(status);
|
||||
}
|
||||
|
||||
function setCourseType(type: string) {
|
||||
courseType = type;
|
||||
onCourseTypeChange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SortPopover
|
||||
@@ -74,45 +103,95 @@
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.status')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'published' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('published')}>{$t('widgets.status.published')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'unpublished' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('unpublished')}>{$t('widgets.filter.unpublished')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if courseTypeOptions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.course_type')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
{#each courseTypeOptions as opt (opt.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === opt.value ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType(opt.value)}>{opt.label}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tagGroups.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
{#if tag.courseCount}
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tagGroups.length === 0 && courseTypeOptions.length === 0 && publishedStatus === undefined}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { 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';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
@@ -56,6 +59,13 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if !$isFreePlan}
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import DeleteConfirmationModal from './delete-confirmation.svelte';
|
||||
@@ -57,7 +56,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
@@ -181,54 +179,12 @@
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { toggleConfetti } from '$features/ui/confetti/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
@@ -152,6 +154,9 @@
|
||||
}
|
||||
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);
|
||||
@@ -573,7 +578,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if courseApi.course?.type === 'SELF_PACED'}
|
||||
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
|
||||
<Badge
|
||||
variant="success"
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import ContentRow from './content-row.svelte';
|
||||
import ContentActions from './content-actions.svelte';
|
||||
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const isStudentView = $derived($isOrgStudent === true);
|
||||
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const metaChipClass =
|
||||
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
|
||||
|
||||
@@ -262,7 +263,7 @@
|
||||
/>
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
class="mx-auto w-full p-3 lg:w-11/12 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
use:dndzone={{
|
||||
items: contentItems,
|
||||
flipDurationMs,
|
||||
|
||||
+1
-3
@@ -63,9 +63,7 @@
|
||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||
|
||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||
const isLessonLocked = $derived(
|
||||
$isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
|
||||
+5
-3
@@ -283,6 +283,7 @@
|
||||
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
|
||||
if (wasDeleted) {
|
||||
removeSection(deletingData.id);
|
||||
snackbar.success('snackbar.lessons.section_deleted');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -438,7 +439,7 @@
|
||||
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||
class="mx-auto w-full p-3 lg:w-11/12"
|
||||
use:dndzone={{
|
||||
items: sections,
|
||||
flipDurationMs,
|
||||
@@ -462,7 +463,8 @@
|
||||
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
|
||||
: undefined}
|
||||
<div
|
||||
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
|
||||
id={isUngrouped ? undefined : `section-${section.id}`}
|
||||
class="border-border m-auto mb-3 max-w-xl scroll-mt-24 rounded-md border transition dark:bg-neutral-800"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
>
|
||||
<SectionHeader
|
||||
@@ -518,7 +520,7 @@
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
<div
|
||||
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
|
||||
class="ui:border mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md px-3 py-1 transition"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{:else}
|
||||
<div class="flex w-4/6 items-center gap-2">
|
||||
<CourseContentIcon type={ContentType.Section} size={16} />
|
||||
<p class="font-semibold">{section.title}</p>
|
||||
<p class="text-sm">{section.title}</p>
|
||||
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
subtitle: $t('new_course_modal.self_paced_subtitle'),
|
||||
type: 'SELF_PACED' 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
|
||||
}
|
||||
];
|
||||
const courseTypeOptionsForGroup = options.map((o) => ({
|
||||
@@ -68,7 +75,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-2" />
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
id={feed.id}
|
||||
class="flex flex-col justify-between gap-2 {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'border border-gray-200'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
: 'ui:border'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="border-t border-gray-200 p-3">
|
||||
<section class="ui:border-t p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -201,7 +201,7 @@
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
class="ui:border w-full rounded-3xl bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { HTMLRender, RoleBasedSecurity } from '$features/ui';
|
||||
@@ -25,19 +24,7 @@
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<Avatar.Root class="h-full w-full">
|
||||
<Avatar.Image
|
||||
src={feed.authorAvatarUrl ?? ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
<UserIcon class="text-muted-foreground h-5 w-5" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
<UserAvatar src={feed.authorAvatarUrl} />
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
|
||||
<div class="ui:border mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
|
||||
<Skeleton class="h-4 w-[30%]" />
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-3">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
@@ -208,13 +208,14 @@
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
@@ -4,10 +4,17 @@ export const NAV_IDS = {
|
||||
ATTENDANCE: 'ATTENDANCE',
|
||||
SUBMISSIONS: 'SUBMISSIONS',
|
||||
MARKS: 'MARKS',
|
||||
COMPLIANCE: 'COMPLIANCE',
|
||||
CERTIFICATES: 'CERTIFICATES',
|
||||
LANDING_PAGE: 'LANDING_PAGE',
|
||||
PEOPLE: 'PEOPLE',
|
||||
ANALYTICS: 'ANALYTICS',
|
||||
SETTINGS: 'SETTINGS',
|
||||
SECTION: 'SECTION'
|
||||
SECTION: 'SECTION',
|
||||
AI_ASSISTANT: 'AI_ASSISTANT'
|
||||
};
|
||||
|
||||
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 256;
|
||||
export const COURSE_SIDEBAR_MIN_WIDTH = 220;
|
||||
export const COURSE_SIDEBAR_MAX_WIDTH = 420;
|
||||
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
||||
|
||||
+90
-1
@@ -2,6 +2,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { BackButton } from '@cio/ui';
|
||||
@@ -27,7 +28,7 @@
|
||||
import ContentCountBadges from '../content-count-badges.svelte';
|
||||
|
||||
import { NAV_IDS } from './constants';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
@@ -35,6 +36,7 @@
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
@@ -61,6 +63,7 @@
|
||||
};
|
||||
});
|
||||
const showContentCount = $derived(sidebar.open && !sidebar.isMobile && contentCount.total > 0);
|
||||
const studentComplianceRecord = $derived(complianceApi.learnerHistory?.currentRecord ?? null);
|
||||
|
||||
const navItems = $derived(
|
||||
[
|
||||
@@ -130,6 +133,16 @@
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.MARKS)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.COMPLIANCE,
|
||||
title: $t('course.navItems.nav_compliance'),
|
||||
url: getNavItemRoute(id, 'compliance'),
|
||||
isActive: (path || page.url.pathname) === getNavItemRoute(id, 'compliance'),
|
||||
show() {
|
||||
return courseApi.course?.type === 'COMPLIANCE';
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.COMPLIANCE)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.CERTIFICATES,
|
||||
title: $t('course.navItems.nav_certificates'),
|
||||
@@ -176,6 +189,14 @@
|
||||
].filter((item) => !item.show || item.show())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!isStudent || courseApi.course?.type !== 'COMPLIANCE' || !courseApi.course?.id || !$profile.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void complianceApi.ensureLearnerHistory(courseApi.course.id, $profile.id);
|
||||
});
|
||||
|
||||
function openContentModal(courseId: string, sectionId = '') {
|
||||
goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
contentEditingStore.set(undefined);
|
||||
@@ -213,6 +234,8 @@
|
||||
return SubmissionIcon;
|
||||
} else if (id === NAV_IDS.MARKS) {
|
||||
return MarksIcon;
|
||||
} else if (id === NAV_IDS.COMPLIANCE) {
|
||||
return CertificateIcon;
|
||||
} else if (id === NAV_IDS.PEOPLE) {
|
||||
return PeopleIcon;
|
||||
} else if (id === NAV_IDS.ANALYTICS) {
|
||||
@@ -227,11 +250,77 @@
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string | null | undefined) {
|
||||
if (status === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'expiring_soon' || status === 'in_progress' || status === 'in_grace_period') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (status === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
|
||||
|
||||
{#if isStudent && courseApi.course?.type === 'COMPLIANCE'}
|
||||
<div
|
||||
class="mx-2 mb-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold tracking-wide text-emerald-900 uppercase dark:text-emerald-100">
|
||||
{$t('course.sidebar.compliance.title')}
|
||||
</p>
|
||||
{#if studentComplianceRecord?.status}
|
||||
<Badge variant={getStatusVariant(studentComplianceRecord.status)}>
|
||||
{$t(`course.navItem.compliance.status.${studentComplianceRecord.status}`)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-xs text-emerald-900 dark:text-emerald-100">
|
||||
{#if studentComplianceRecord?.dueDate}
|
||||
<p>
|
||||
{$t('course.sidebar.compliance.due_date')}: {formatDate(studentComplianceRecord.dueDate)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if studentComplianceRecord?.validUntil}
|
||||
<p>
|
||||
{$t('course.sidebar.compliance.valid_until')}: {formatDate(studentComplianceRecord.validUntil)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
{#if item.isLesson}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
@@ -12,6 +13,8 @@
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
|
||||
@@ -23,12 +26,86 @@
|
||||
id: string;
|
||||
/** If false, sidebar nav shows loading skeletons. Omit or true = show real nav. */
|
||||
isCourseReady?: boolean;
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthPreview?: (width: number) => void;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
}
|
||||
|
||||
let { path, id, isCourseReady = true }: Props = $props();
|
||||
let {
|
||||
path,
|
||||
id,
|
||||
isCourseReady = true,
|
||||
sidebarWidth = COURSE_SIDEBAR_DEFAULT_WIDTH,
|
||||
onSidebarWidthPreview = () => {},
|
||||
onSidebarWidthChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
let shouldIgnoreRailClick = $state(false);
|
||||
let stopSidebarResize: (() => void) | null = null;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function clearSidebarResizeListeners() {
|
||||
stopSidebarResize?.();
|
||||
stopSidebarResize = null;
|
||||
}
|
||||
|
||||
function handleRailPointerDown(event: PointerEvent) {
|
||||
if (sidebar.isMobile || !sidebar.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
clearSidebarResizeListeners();
|
||||
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
stopSidebarResize = startResizablePanelDrag({
|
||||
event,
|
||||
startWidth,
|
||||
resolveWidth: ({ startWidth, deltaX }) => clampSidebarWidth(startWidth + deltaX),
|
||||
onPreview: onSidebarWidthPreview,
|
||||
onCommit: ({ hasDragged, width }) => {
|
||||
if (hasDragged) {
|
||||
onSidebarWidthChange(width);
|
||||
}
|
||||
},
|
||||
onDragStart: () => {
|
||||
document.body.dataset.courseSidebarResizing = 'true';
|
||||
},
|
||||
onDragEnd: ({ hasDragged }) => {
|
||||
shouldIgnoreRailClick = hasDragged;
|
||||
delete document.body.dataset.courseSidebarResizing;
|
||||
stopSidebarResize = null;
|
||||
|
||||
if (hasDragged) {
|
||||
window.setTimeout(() => {
|
||||
shouldIgnoreRailClick = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRailClick(event: MouseEvent) {
|
||||
if (shouldIgnoreRailClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
sidebar.toggle();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearSidebarResizeListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
@@ -62,7 +139,7 @@
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Rail />
|
||||
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Footer>
|
||||
@@ -74,3 +151,10 @@
|
||||
{/if}
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-gap']),
|
||||
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-container']) {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as Tabs from '@cio/ui/base/tabs';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { CourseComplianceLearner, LearnerComplianceRecord } from '$features/course/utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type ComplianceStatus = CourseComplianceLearner['status'] | LearnerComplianceRecord['status'] | 'no_record';
|
||||
type StaffAction = 'reset' | 'extend' | 'waive' | null;
|
||||
|
||||
let lastRequestKey = $state<string | null>(null);
|
||||
let staffTab = $state<'overview' | 'learners'>('overview');
|
||||
let activeAction = $state<StaffAction>(null);
|
||||
let bulkActionType = $state<Exclude<StaffAction, null> | null>(null);
|
||||
let isBulkActionModalOpen = $state(false);
|
||||
let actionFields = $state({
|
||||
dueDate: '',
|
||||
waiverReason: '',
|
||||
waiverExpiresAt: ''
|
||||
});
|
||||
|
||||
let selectedLearnerIds = new SvelteSet<string>();
|
||||
|
||||
const currentMember = $derived(courseApi.group.people.find((person) => person.profileId === $profile.id));
|
||||
const currentRoleId = $derived(currentMember ? Number(currentMember.roleId) : null);
|
||||
const isComplianceCourse = $derived(courseApi.course?.type === 'COMPLIANCE');
|
||||
const isStudent = $derived(currentRoleId === 3);
|
||||
const overview = $derived(complianceApi.overview);
|
||||
const learnerHistory = $derived(complianceApi.learnerHistory);
|
||||
const historyRecords = $derived(learnerHistory?.history ?? []);
|
||||
const currentRecord = $derived(learnerHistory?.currentRecord ?? null);
|
||||
const learnerRows = $derived(overview?.learners ?? []);
|
||||
const selectedProfileIds = $derived(Array.from(selectedLearnerIds));
|
||||
const allVisibleSelected = $derived(
|
||||
learnerRows.length > 0 &&
|
||||
learnerRows.every((learner) => (learner.profileId ? selectedLearnerIds.has(learner.profileId) : false))
|
||||
);
|
||||
const someVisibleSelected = $derived(
|
||||
learnerRows.some((learner) => learner.profileId && selectedLearnerIds.has(learner.profileId))
|
||||
);
|
||||
const summaryCards = $derived.by(() => {
|
||||
if (!overview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ labelKey: 'course.navItem.compliance.summary.total_learners', value: overview.summary.totalLearners },
|
||||
{ labelKey: 'course.navItem.compliance.summary.no_record', value: overview.summary.noRecord },
|
||||
{ labelKey: 'course.navItem.compliance.summary.not_started', value: overview.summary.notStarted },
|
||||
{ labelKey: 'course.navItem.compliance.summary.in_progress', value: overview.summary.inProgress },
|
||||
{ labelKey: 'course.navItem.compliance.summary.compliant', value: overview.summary.compliant },
|
||||
{ labelKey: 'course.navItem.compliance.summary.expiring_soon', value: overview.summary.expiringSoon },
|
||||
{ labelKey: 'course.navItem.compliance.summary.in_grace_period', value: overview.summary.inGracePeriod },
|
||||
{ labelKey: 'course.navItem.compliance.summary.non_compliant', value: overview.summary.nonCompliant },
|
||||
{ labelKey: 'course.navItem.compliance.summary.waived', value: overview.summary.waived }
|
||||
];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!courseApi.course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isComplianceCourse) {
|
||||
complianceApi.reset();
|
||||
lastRequestKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const courseId = courseApi.course.id;
|
||||
if (isStudent && currentMember?.profileId) {
|
||||
const requestKey = `${courseId}:${currentMember.profileId}:learner`;
|
||||
if (lastRequestKey === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestKey = requestKey;
|
||||
void complianceApi.ensureLearnerHistory(courseId, currentMember.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${courseId}:overview`;
|
||||
if (lastRequestKey === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestKey = requestKey;
|
||||
void complianceApi.ensureOverview(courseId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const deadline = courseApi.course?.certificate?.deadline;
|
||||
if (!deadline || actionFields.dueDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localDeadline = toLocalDateTimeInput(deadline);
|
||||
if (!localDeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionFields.dueDate = localDeadline;
|
||||
});
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return `${value}%`;
|
||||
}
|
||||
|
||||
function formatCount(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
function toLocalDateTimeInput(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const timezoneOffsetMilliseconds = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - timezoneOffsetMilliseconds).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: ComplianceStatus) {
|
||||
return `course.navItem.compliance.status.${status}`;
|
||||
}
|
||||
|
||||
function getStatusVariant(status: ComplianceStatus) {
|
||||
if (status === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'expiring_soon' || status === 'in_progress' || status === 'in_grace_period') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (status === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function toggleLearner(profileId: string) {
|
||||
if (selectedLearnerIds.has(profileId)) {
|
||||
selectedLearnerIds.delete(profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLearnerIds.add(profileId);
|
||||
}
|
||||
|
||||
function toggleAllVisibleLearners() {
|
||||
if (allVisibleSelected) {
|
||||
for (const learner of learnerRows) {
|
||||
if (!learner.profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedLearnerIds.delete(learner.profileId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const learner of learnerRows) {
|
||||
if (!learner.profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedLearnerIds.add(learner.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedLearnerIds.clear();
|
||||
}
|
||||
|
||||
function resetActionFields() {
|
||||
actionFields.dueDate = '';
|
||||
actionFields.waiverReason = '';
|
||||
actionFields.waiverExpiresAt = '';
|
||||
}
|
||||
|
||||
function openBulkActionModal(action: Exclude<StaffAction, null>) {
|
||||
bulkActionType = action;
|
||||
complianceApi.resetErrors();
|
||||
isBulkActionModalOpen = true;
|
||||
}
|
||||
|
||||
function handleBulkActionModalChange(isOpen: boolean) {
|
||||
isBulkActionModalOpen = isOpen;
|
||||
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
bulkActionType = null;
|
||||
complianceApi.resetErrors();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
function closeBulkActionModal() {
|
||||
handleBulkActionModalChange(false);
|
||||
}
|
||||
|
||||
function getBulkActionTitle() {
|
||||
if (bulkActionType === 'reset') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.reset_action');
|
||||
}
|
||||
|
||||
if (bulkActionType === 'extend') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.extend_action');
|
||||
}
|
||||
|
||||
if (bulkActionType === 'waive') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.waive_action');
|
||||
}
|
||||
|
||||
return t.get('course.navItem.compliance.bulk_actions.title');
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
const courseId = courseApi.course?.id;
|
||||
const dueDate = toIsoDateTime(actionFields.dueDate);
|
||||
if (!courseId || selectedProfileIds.length === 0 || !dueDate) {
|
||||
snackbar.error('snackbar.course.compliance.reset_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'reset';
|
||||
const response = await complianceApi.resetCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
dueDate
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
async function handleExtend() {
|
||||
const courseId = courseApi.course?.id;
|
||||
const dueDate = toIsoDateTime(actionFields.dueDate);
|
||||
if (!courseId || selectedProfileIds.length === 0 || !dueDate) {
|
||||
snackbar.error('snackbar.course.compliance.extend_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'extend';
|
||||
const response = await complianceApi.extendCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
dueDate
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
async function handleWaive() {
|
||||
const courseId = courseApi.course?.id;
|
||||
if (!courseId || selectedProfileIds.length === 0) {
|
||||
snackbar.error('snackbar.course.compliance.waive_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'waive';
|
||||
const response = await complianceApi.waiveCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
waiverReason: actionFields.waiverReason.trim() || undefined,
|
||||
waiverExpiresAt: toIsoDateTime(actionFields.waiverExpiresAt) ?? undefined
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isComplianceCourse}
|
||||
<Empty
|
||||
title={$t('course.navItem.compliance.not_compliance_title')}
|
||||
description={$t('course.navItem.compliance.not_compliance_description')}
|
||||
icon={ShieldCheckIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else if complianceApi.isLoading && !overview && !learnerHistory}
|
||||
<Empty title={$t('course.navItem.compliance.loading')} icon={ShieldCheckIcon} variant="page" />
|
||||
{:else if isStudent}
|
||||
<Field.Group class="space-y-8">
|
||||
<Field.Set class="gap-4!">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<Field.Legend>{$t('course.navItem.compliance.current_cycle')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.compliance.current_cycle_description')}</Field.Description>
|
||||
</div>
|
||||
{#if currentRecord}
|
||||
<Badge variant={getStatusVariant(currentRecord.status)}>
|
||||
{$t(getStatusLabelKey(currentRecord.status))}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentRecord}
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.cycle')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{currentRecord.cycleNumber}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.due_date')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.dueDate)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.completed_at')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.completedAt)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.valid_until')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.validUntil)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.score')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatPercent(currentRecord.score)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.attempts')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatCount(currentRecord.attempts)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.compliance.no_active_cycle')}
|
||||
description={$t('course.navItem.compliance.empty_history')}
|
||||
icon={ShieldCheckIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set class="gap-4!">
|
||||
<Field.Legend>{$t('course.navItem.compliance.history')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.compliance.history_description')}</Field.Description>
|
||||
|
||||
{#if historyRecords.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.cycle')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.status')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.due_date')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.completed_at')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.valid_until')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.score')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each historyRecords as record}
|
||||
<Table.Row>
|
||||
<Table.Cell>{record.cycleNumber}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getStatusVariant(record.status)}>
|
||||
{$t(getStatusLabelKey(record.status))}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.dueDate)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.completedAt)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.validUntil)}</Table.Cell>
|
||||
<Table.Cell>{formatPercent(record.score)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_history')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
{:else if overview}
|
||||
<Tabs.Root bind:value={staffTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="overview">{$t('course.navItem.compliance.tabs.overview')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="learners">{$t('course.navItem.compliance.tabs.learners')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="overview" class="mt-4">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each summaryCards as card}
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">{$t(card.labelKey)}</p>
|
||||
<p class="mt-2 text-lg font-semibold">{card.value}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="learners" class="mt-4 space-y-4">
|
||||
<div class="ui:bg-muted/40 flex flex-wrap items-center justify-between gap-3 rounded-lg p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={someVisibleSelected && !allVisibleSelected}
|
||||
onclick={toggleAllVisibleLearners}
|
||||
/>
|
||||
<span class="text-sm font-medium">{$t('course.navItem.compliance.bulk_actions.select_all')}</span>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline">
|
||||
{$t('course.navItem.compliance.bulk_actions.selected_count', { count: selectedProfileIds.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onclick={clearSelection} disabled={selectedProfileIds.length === 0}>
|
||||
{$t('course.navItem.compliance.bulk_actions.clear_selection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('reset')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.reset_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('extend')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.extend_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('waive')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.waive_action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if learnerRows.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-12">
|
||||
<span class="sr-only">{$t('course.navItem.compliance.bulk_actions.select_all')}</span>
|
||||
</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.learner')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.email')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.status')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.due_date')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.valid_until')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.score')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each learnerRows as learner}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
{#if learner.profileId}
|
||||
<Checkbox
|
||||
checked={selectedLearnerIds.has(learner.profileId)}
|
||||
onclick={() => toggleLearner(learner.profileId!)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
>{learner.fullname || learner.username || $t('course.navItem.compliance.not_available')}</Table.Cell
|
||||
>
|
||||
<Table.Cell>{learner.email || $t('course.navItem.compliance.not_available')}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getStatusVariant(learner.status ?? 'no_record')}>
|
||||
{$t(getStatusLabelKey(learner.status ?? 'no_record'))}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(learner.dueDate)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(learner.validUntil)}</Table.Cell>
|
||||
<Table.Cell>{formatPercent(learner.score)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
|
||||
<Dialog.Root bind:open={isBulkActionModalOpen} onOpenChange={handleBulkActionModalChange}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{getBulkActionTitle()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.description', { count: selectedProfileIds.length })}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-2">
|
||||
{#if bulkActionType === 'reset' || bulkActionType === 'extend'}
|
||||
<InputField
|
||||
label={$t('course.navItem.compliance.bulk_actions.due_date_label')}
|
||||
type="datetime-local"
|
||||
bind:value={actionFields.dueDate}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.due_date_helper')}
|
||||
errorMessage={complianceApi.errors.dueDate}
|
||||
autoFocus
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if bulkActionType === 'waive'}
|
||||
<div class="space-y-4">
|
||||
<TextareaField
|
||||
label={$t('course.navItem.compliance.bulk_actions.waiver_reason_label')}
|
||||
bind:value={actionFields.waiverReason}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.waiver_reason_helper')}
|
||||
errorMessage={complianceApi.errors.waiverReason}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label={$t('course.navItem.compliance.bulk_actions.waiver_expires_at_label')}
|
||||
type="datetime-local"
|
||||
bind:value={actionFields.waiverExpiresAt}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.waiver_expires_at_helper')}
|
||||
errorMessage={complianceApi.errors.waiverExpiresAt}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeBulkActionModal}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.cancel')}
|
||||
</Button>
|
||||
|
||||
{#if bulkActionType === 'reset'}
|
||||
<Button onclick={handleReset} loading={activeAction === 'reset'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{:else if bulkActionType === 'extend'}
|
||||
<Button onclick={handleExtend} loading={activeAction === 'extend'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{:else if bulkActionType === 'waive'}
|
||||
<Button onclick={handleWaive} loading={activeAction === 'waive'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -37,7 +37,9 @@
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import { RefreshPageData, RoleBasedSecurity } 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 { Empty } from '@cio/ui/custom/empty';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
@@ -48,6 +50,7 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
@@ -81,8 +84,23 @@
|
||||
let preview: boolean = $state(false);
|
||||
let shouldDeleteExercise = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let selectedTab = $state<ExerciseTab>('questions');
|
||||
|
||||
async function handleDeleteExercise() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
shouldDeleteExercise = false;
|
||||
}
|
||||
|
||||
function patchExerciseListItemLocally() {
|
||||
const nonDeletedQuestionsCount = ($questionnaire.questions || []).filter((question) => !question.deletedAt).length;
|
||||
|
||||
@@ -231,7 +249,7 @@
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
@@ -255,7 +273,7 @@
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="success" class="font-normal">
|
||||
<Badge variant="outline" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
|
||||
</Badge>
|
||||
{:else if teacherAutoGradeBadge === 'manual'}
|
||||
@@ -323,7 +341,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RefreshPageData />
|
||||
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
@@ -356,14 +374,13 @@
|
||||
<UnderlineTabs.Trigger value="submissions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
|
||||
|
||||
<Badge>{submissions.length}</Badge>
|
||||
<Badge variant="outline">{submissions.length}</Badge>
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode
|
||||
bind:shouldDeleteExercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
@@ -380,6 +397,35 @@
|
||||
<Submissions bind:exerciseId {submissions} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={(e) => e.preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDeleteExercise} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as AnalyticsPage } from './analytics.svelte';
|
||||
export { default as AttendancePage } from './attendance.svelte';
|
||||
export { default as CertificatesPage } from './certificates.svelte';
|
||||
export { default as CompliancePage } from './compliance.svelte';
|
||||
export { default as CourseSettingsPage } from './settings.svelte';
|
||||
export { default as CoursesPage } from './courses.svelte';
|
||||
export { default as ExercisePage } from './exercise.svelte';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { isObject } from '$lib/utils/functions/isObject';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { tagApi } from '$features/tag/api';
|
||||
@@ -147,6 +148,10 @@
|
||||
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if ($settings.isPublished && !courseApi.course.slug) {
|
||||
courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true });
|
||||
}
|
||||
|
||||
const metadataPayload = {
|
||||
...(isObject(courseApi.course.metadata) ? courseApi.course.metadata : {}),
|
||||
lessonTabsOrder: $settings.tabs,
|
||||
@@ -163,7 +168,9 @@
|
||||
logo: logoUrl,
|
||||
isPublished: $settings.isPublished,
|
||||
metadata: metadataPayload,
|
||||
slug: courseApi.course.slug ?? undefined
|
||||
slug: courseApi.course.slug ?? undefined,
|
||||
compliance:
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
@@ -525,6 +532,10 @@
|
||||
<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">
|
||||
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
|
||||
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import type { TComplianceSettings } from '@cio/utils/validation/course';
|
||||
import type { UserEnrolledCourses } from '$features/course/types';
|
||||
|
||||
export const DEFAULT_COMPLIANCE_SETTINGS = {
|
||||
retakeIntervalMonths: 12,
|
||||
gracePeriodDays: 0,
|
||||
reminderDaysBefore: [30, 7, 1],
|
||||
isMandatory: true,
|
||||
framework: null,
|
||||
maxRetakeAttempts: null,
|
||||
passingScore: 80
|
||||
} satisfies TComplianceSettings;
|
||||
|
||||
export function isSelfPacedLikeCourse(courseType: TCourseType | null | undefined): boolean {
|
||||
return courseType === 'SELF_PACED' || courseType === 'COMPLIANCE';
|
||||
}
|
||||
|
||||
type StudentComplianceStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'compliant'
|
||||
| 'expiring_soon'
|
||||
| 'in_grace_period'
|
||||
| 'non_compliant'
|
||||
| 'waived'
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type StudentEnrolledCourse = UserEnrolledCourses[number];
|
||||
|
||||
function getCourseContentProgress(course: StudentEnrolledCourse) {
|
||||
const exercisesCompleted = typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
|
||||
const totalExercises = typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
|
||||
const totalItems = (course.lessonCount ?? 0) + totalExercises;
|
||||
|
||||
if (totalItems <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const completedItems = (course.progressRate ?? 0) + exercisesCompleted;
|
||||
return Math.round((completedItems / totalItems) * 100);
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatus(course: StudentEnrolledCourse): StudentComplianceStatus {
|
||||
if (course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('complianceStatus' in course)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return course.complianceStatus as StudentComplianceStatus;
|
||||
}
|
||||
|
||||
export function isStudentCourseComplete(course: StudentEnrolledCourse) {
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
return complianceStatus === 'compliant' || complianceStatus === 'waived';
|
||||
}
|
||||
|
||||
return getCourseContentProgress(course) >= 100;
|
||||
}
|
||||
|
||||
export function getStudentCourseProgressPercent(course: StudentEnrolledCourse) {
|
||||
return getCourseContentProgress(course);
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceDate(course: StudentEnrolledCourse) {
|
||||
if (course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
const validUntil = 'complianceValidUntil' in course ? (course.complianceValidUntil ?? null) : null;
|
||||
const dueDate = 'complianceDueDate' in course ? (course.complianceDueDate ?? null) : null;
|
||||
|
||||
if ((complianceStatus === 'compliant' || complianceStatus === 'waived') && validUntil) {
|
||||
return {
|
||||
labelKey: 'courses.course_card.valid_until',
|
||||
value: validUntil
|
||||
};
|
||||
}
|
||||
|
||||
if (dueDate) {
|
||||
return {
|
||||
labelKey: 'courses.course_card.due_date',
|
||||
value: dueDate
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCourse) {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null;
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
if (!complianceStatus) {
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (
|
||||
complianceStatus === 'expiring_soon' ||
|
||||
complianceStatus === 'in_progress' ||
|
||||
complianceStatus === 'in_grace_period'
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
export type ContentItem = CourseContentItem;
|
||||
export type ContentSection = CourseContentSection;
|
||||
|
||||
export type MentionableContent = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: CourseContentType;
|
||||
};
|
||||
|
||||
export const CONTENT_DEFINITIONS = {
|
||||
[CourseContentType.Section]: {
|
||||
label: 'Section',
|
||||
@@ -28,7 +34,7 @@ const EMPTY_CONTENT = {
|
||||
items: [] as ContentItem[]
|
||||
};
|
||||
|
||||
export function getContentRoute(courseId: string, item: ContentItem) {
|
||||
export function getContentRoute(courseId: string, item: { id: string; type: string }) {
|
||||
if (item.type === CourseContentType.Lesson) {
|
||||
return `/courses/${courseId}/lessons/${item.id}`;
|
||||
}
|
||||
@@ -37,6 +43,10 @@ export function getContentRoute(courseId: string, item: ContentItem) {
|
||||
return `/courses/${courseId}/exercises/${item.id}`;
|
||||
}
|
||||
|
||||
if (item.type === CourseContentType.Section) {
|
||||
return `/courses/${courseId}/lessons#section-${item.id}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -53,3 +63,33 @@ export function getOrderedNavigableContent(course: Course | null): ContentItem[]
|
||||
NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat list of mentionable content for the AI chat @ menu.
|
||||
* Includes sections (when grouped) interleaved with their lessons/exercises in display order.
|
||||
*/
|
||||
export function getMentionableContent(course: Course | null): MentionableContent[] {
|
||||
const content = getCourseContent(course);
|
||||
|
||||
if (!content.grouped) {
|
||||
return content.items
|
||||
.filter((item) => NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number]))
|
||||
.map((item) => ({ id: item.id, title: item.title, type: item.type as CourseContentType }));
|
||||
}
|
||||
|
||||
const result: MentionableContent[] = [];
|
||||
|
||||
for (const section of content.sections) {
|
||||
if (section.id !== 'ungrouped' && section.title) {
|
||||
result.push({ id: section.id, title: section.title, type: CourseContentType.Section });
|
||||
}
|
||||
|
||||
for (const item of section.items) {
|
||||
if (NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])) {
|
||||
result.push({ id: item.id, title: item.title, type: item.type as CourseContentType });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import {
|
||||
clearQuestionnaireValidation,
|
||||
questionnaire,
|
||||
questionnaireMetaData
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
|
||||
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 questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
|
||||
questions = normalizeQuestionOrder(mappedQuestions);
|
||||
}
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
|
||||
questionnaire.set({
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: exercise.dueBy,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions,
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
|
||||
}
|
||||
|
||||
export async function refreshExercisePageData(courseId: string, exerciseId: string) {
|
||||
await exerciseApi.get(courseId, exerciseId);
|
||||
|
||||
if (!exerciseApi.exercise) return null;
|
||||
|
||||
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
|
||||
|
||||
return exerciseApi.exercise;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ type CourseSettings = {
|
||||
courseTitle: string;
|
||||
courseDescription: string;
|
||||
grading: boolean;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED';
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
|
||||
allowNewStudents: boolean;
|
||||
tabs: { id: number; name: string }[];
|
||||
lessonDownload: boolean;
|
||||
|
||||
@@ -376,6 +376,31 @@ export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsReq
|
||||
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;
|
||||
export type CourseAnalytics = GetCourseAnalyticsSuccess['data'];
|
||||
|
||||
export type GetCourseComplianceOverviewRequest = (typeof classroomio.course)[':courseId']['compliance']['$get'];
|
||||
export type GetCourseComplianceOverviewResponse = InferResponseType<GetCourseComplianceOverviewRequest>;
|
||||
export type GetCourseComplianceOverviewSuccess = Extract<GetCourseComplianceOverviewResponse, { success: true }>;
|
||||
export type CourseComplianceOverview = GetCourseComplianceOverviewSuccess['data'];
|
||||
export type CourseComplianceLearner = CourseComplianceOverview['learners'][number];
|
||||
|
||||
export type GetLearnerComplianceHistoryRequest =
|
||||
(typeof classroomio.course)[':courseId']['compliance']['learners'][':profileId']['$get'];
|
||||
export type GetLearnerComplianceHistoryResponse = InferResponseType<GetLearnerComplianceHistoryRequest>;
|
||||
export type GetLearnerComplianceHistorySuccess = Extract<GetLearnerComplianceHistoryResponse, { success: true }>;
|
||||
export type LearnerComplianceHistory = GetLearnerComplianceHistorySuccess['data'];
|
||||
export type LearnerComplianceRecord = LearnerComplianceHistory['history'][number];
|
||||
|
||||
export type ResetCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['reset']['$post'];
|
||||
export type ResetCourseComplianceResponse = InferResponseType<ResetCourseComplianceRequest>;
|
||||
export type ResetCourseComplianceSuccess = Extract<ResetCourseComplianceResponse, { success: true }>;
|
||||
|
||||
export type ExtendCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['extend']['$post'];
|
||||
export type ExtendCourseComplianceResponse = InferResponseType<ExtendCourseComplianceRequest>;
|
||||
export type ExtendCourseComplianceSuccess = Extract<ExtendCourseComplianceResponse, { success: true }>;
|
||||
|
||||
export type WaiveCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['waive']['$post'];
|
||||
export type WaiveCourseComplianceResponse = InferResponseType<WaiveCourseComplianceRequest>;
|
||||
export type WaiveCourseComplianceSuccess = Extract<WaiveCourseComplianceResponse, { success: true }>;
|
||||
|
||||
// Derived types from Lesson
|
||||
export type LessonVideoType = NonNullable<Lesson['videos']>[number]['type'];
|
||||
export type LessonDocument = NonNullable<Lesson['documents']>[number];
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import { calcCourseProgress } from '$features/course/utils/functions';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
getStudentCourseComplianceStatusVariant,
|
||||
getStudentCourseProgressPercent
|
||||
} from '$features/course/utils/compliance-utils';
|
||||
|
||||
const gotoCourse = (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
goto(`/courses/${id}/lessons?next=true`);
|
||||
};
|
||||
|
||||
let last3Courses = $derived(coursesApi.enrolledCourses.length > 0 ? coursesApi.enrolledCourses.slice(0, 3) : []);
|
||||
let highlightedCourses = $derived.by(() => {
|
||||
const courses = [...coursesApi.enrolledCourses];
|
||||
|
||||
function getCourseProgress(course: (typeof coursesApi.enrolledCourses)[number]): number {
|
||||
const exercisesCompleted =
|
||||
'exercisesCompleted' in course && typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
|
||||
const totalExercises =
|
||||
'exerciseCount' in course && typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
|
||||
return calcCourseProgress({
|
||||
lessonsCompleted: course.progressRate ?? 0,
|
||||
totalLessons: course.lessonCount ?? 0,
|
||||
exercisesCompleted,
|
||||
totalExercises
|
||||
});
|
||||
return courses
|
||||
.sort((leftCourse, rightCourse) => {
|
||||
const leftComplianceDate = getStudentCourseComplianceDate(leftCourse)?.value;
|
||||
const rightComplianceDate = getStudentCourseComplianceDate(rightCourse)?.value;
|
||||
const leftTimestamp = leftComplianceDate ? new Date(leftComplianceDate).getTime() : Number.POSITIVE_INFINITY;
|
||||
const rightTimestamp = rightComplianceDate ? new Date(rightComplianceDate).getTime() : Number.POSITIVE_INFINITY;
|
||||
|
||||
return leftTimestamp - rightTimestamp;
|
||||
})
|
||||
.slice(0, 3);
|
||||
});
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,9 +57,9 @@
|
||||
<div
|
||||
class="flex w-full flex-col items-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] dark:bg-neutral-800"
|
||||
>
|
||||
{#if last3Courses.length > 0}
|
||||
{#if highlightedCourses.length > 0}
|
||||
<div class="flex h-full w-full flex-col justify-start overflow-y-auto">
|
||||
{#each last3Courses as course}
|
||||
{#each highlightedCourses as course}
|
||||
<div class="p-5">
|
||||
<span class="flex flex-col items-start gap-3 pb-5 lg:flex-row">
|
||||
<img
|
||||
@@ -51,12 +72,30 @@
|
||||
<p class="line-clamp-2 text-xs font-normal text-[#656565] dark:text-white">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
{#if course.type === 'COMPLIANCE'}
|
||||
{@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)}
|
||||
{@const complianceDate = getStudentCourseComplianceDate(course)}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{#if complianceStatusKey}
|
||||
<Badge variant={getStudentCourseComplianceStatusVariant(course)}>
|
||||
{$t(complianceStatusKey)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if complianceDate?.value}
|
||||
<p class="ui:text-muted-foreground text-xs dark:text-white">
|
||||
{$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => gotoCourse(course.id)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
</span>
|
||||
<Progress value={getCourseProgress(course)} max={100} class="ui:h-1" />
|
||||
<Progress value={getStudentCourseProgressPercent(course)} max={100} class="ui:h-1" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,97 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Learning } from '$features/lms';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { ActivityCard } from '$features/ui';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ClipboardCheckIcon from '@lucide/svelte/icons/clipboard-check';
|
||||
import FlameIcon from '@lucide/svelte/icons/flame';
|
||||
import TargetIcon from '@lucide/svelte/icons/target';
|
||||
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 { profile } from '$lib/utils/store/user';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import { resolve } from '$app/paths';
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
getStudentCourseComplianceStatusVariant,
|
||||
getStudentCourseProgressPercent,
|
||||
isStudentCourseComplete
|
||||
} from '$features/course/utils/compliance-utils';
|
||||
|
||||
type EnrolledCourse = (typeof coursesApi.enrolledCourses)[number];
|
||||
|
||||
let loginStreak = $state<number | null>(null);
|
||||
let hasLoadedLoginStreak = $state(false);
|
||||
|
||||
let totalCompleted = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, cur) => {
|
||||
const exercises =
|
||||
'exercisesCompleted' in cur && typeof cur.exercisesCompleted === 'number' ? cur.exercisesCompleted : 0;
|
||||
return acc + (cur.progressRate || 0) + exercises;
|
||||
}, 0)
|
||||
coursesApi.enrolledCourses.reduce((acc, course) => acc + getCourseCompletedItems(course), 0)
|
||||
);
|
||||
|
||||
let totalLessons = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, cur) => {
|
||||
const exercises = 'exerciseCount' in cur && typeof cur.exerciseCount === 'number' ? cur.exerciseCount : 0;
|
||||
return acc + (cur.lessonCount || 0) + exercises;
|
||||
}, 0)
|
||||
);
|
||||
let totalLessons = $derived(coursesApi.enrolledCourses.reduce((acc, course) => acc + getCourseTotalItems(course), 0));
|
||||
|
||||
let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0);
|
||||
let complianceCourses = $derived(coursesApi.enrolledCourses.filter((course) => course.type === 'COMPLIANCE'));
|
||||
let completedComplianceCourses = $derived(complianceCourses.filter((course) => isStudentCourseComplete(course)));
|
||||
let complianceScore = $derived(
|
||||
complianceCourses.length > 0
|
||||
? Math.round((completedComplianceCourses.length / complianceCourses.length) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course)));
|
||||
let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses));
|
||||
let currentCourse = $derived(highlightedCourses[0] ?? coursesApi.enrolledCourses[0] ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
coursesApi.getEnrolledCourses();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$profile.id || hasLoadedLoginStreak) return;
|
||||
|
||||
hasLoadedLoginStreak = true;
|
||||
loadLoginStreak();
|
||||
});
|
||||
|
||||
function getCourseCompletedItems(course: EnrolledCourse) {
|
||||
const exercisesCompleted =
|
||||
'exercisesCompleted' in course && typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
|
||||
|
||||
return (course.progressRate || 0) + exercisesCompleted;
|
||||
}
|
||||
|
||||
function getCourseTotalItems(course: EnrolledCourse) {
|
||||
const exercises = 'exerciseCount' in course && typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
|
||||
|
||||
return (course.lessonCount || 0) + exercises;
|
||||
}
|
||||
|
||||
function getHighlightedCourses(courses: EnrolledCourse[]) {
|
||||
return [...courses]
|
||||
.sort((leftCourse, rightCourse) => {
|
||||
const leftComplianceDate = getStudentCourseComplianceDate(leftCourse)?.value;
|
||||
const rightComplianceDate = getStudentCourseComplianceDate(rightCourse)?.value;
|
||||
|
||||
if (leftComplianceDate || rightComplianceDate) {
|
||||
const leftTimestamp = leftComplianceDate ? new Date(leftComplianceDate).getTime() : Number.POSITIVE_INFINITY;
|
||||
const rightTimestamp = rightComplianceDate
|
||||
? new Date(rightComplianceDate).getTime()
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
return leftTimestamp - rightTimestamp;
|
||||
}
|
||||
|
||||
return getStudentCourseProgressPercent(rightCourse) - getStudentCourseProgressPercent(leftCourse);
|
||||
})
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
async function loadLoginStreak() {
|
||||
try {
|
||||
const response = await classroomio.dash['login-streak'].$get();
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
loginStreak = result.data.daysActive;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load login streak', error);
|
||||
loginStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function gotoCourse(id: string | undefined) {
|
||||
if (!id) return;
|
||||
|
||||
goto(`/courses/${id}/lessons?next=true`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<BlurFade delay={0} once>
|
||||
<div
|
||||
class="flex h-fit w-full flex-col-reverse justify-between rounded-md border px-4 py-2 md:flex-row md:items-center"
|
||||
>
|
||||
<div class="w-full md:w-[75%] lg:w-[80%]">
|
||||
<p class="mb-5 w-4/6 font-normal lg:text-xl">
|
||||
{$currentOrg.customization.dashboard.bannerText
|
||||
? $currentOrg.customization.dashboard.bannerText
|
||||
: $t('dashboard.lms_dashboard_hero')}
|
||||
</p>
|
||||
<Button onclick={() => goto(resolve('/lms/mylearning', {}))}>
|
||||
{$t('dashboard.continue_learning')}
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={$currentOrg.customization.dashboard.bannerImage
|
||||
? $currentOrg.customization.dashboard.bannerImage
|
||||
: '/images/student-learning.svg'}
|
||||
alt="student Learning Pictogram"
|
||||
class="mb-3 aspect-square size-48 md:mb-0 md:block"
|
||||
/>
|
||||
</div>
|
||||
</BlurFade>
|
||||
<section class="flex w-full flex-col md:gap-4 lg:flex-row">
|
||||
<BlurFade delay={0.1} once class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<Learning />
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.2} once class="mt-10 w-full lg:w-[50%] xl:mt-2">
|
||||
<p class="pb-3 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.your_progress')}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-fit items-center justify-center gap-2 rounded border border-[#EAEAEA] p-3 lg:h-[40vh] lg:overflow-y-auto dark:bg-neutral-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-between gap-5 sm:flex-row lg:items-center lg:justify-around"
|
||||
>
|
||||
<div>
|
||||
<img src="/images/target.svg" alt="student Learning score" />
|
||||
</div>
|
||||
<span class="text-center xl:text-start">
|
||||
<p class="py-2 text-base font-semibold text-[#040F2D] dark:text-white">
|
||||
{$t('dashboard.your_progress')}
|
||||
<div class="space-y-6 pb-8">
|
||||
<BlurFade delay={0.05} once>
|
||||
<section class="ui:border-primary/20 ui:bg-primary/10 rounded border p-4 md:p-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="max-w-xl space-y-2">
|
||||
<p class="ui:text-primary text-xs font-semibold tracking-[0.18em] uppercase">
|
||||
{$t('dashboard.pick_up_where_you_left_off')}
|
||||
</p>
|
||||
{#if totalLessons > 0}
|
||||
<p class="text-xs font-normal text-[#656565] dark:text-white">
|
||||
{totalCompleted}/{totalLessons}
|
||||
{$t('dashboard.items_completed')}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold tracking-tight">
|
||||
{#if currentCourse}
|
||||
{$t('dashboard.course_awaits_you', { title: currentCourse.title })}
|
||||
{:else}
|
||||
{$t('dashboard.learning_awaits_you')}
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
|
||||
>
|
||||
{#if currentCourse}
|
||||
{$t('dashboard.continue_learning')}
|
||||
{:else}
|
||||
<p class="text-xs font-normal text-[#656565] dark:text-white">
|
||||
{$t('dashboard.no_courses_started')}
|
||||
</p>
|
||||
{$t('dashboard.view_courses')}
|
||||
{/if}
|
||||
</span>
|
||||
<h1 class="my-0 text-5xl whitespace-nowrap text-[#262626] lg:text-6xl dark:text-white">
|
||||
{progressPercentage} %
|
||||
</h1>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BlurFade>
|
||||
</section>
|
||||
|
||||
<section class="mb-10 flex flex-wrap items-start">
|
||||
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<BlurFade delay={0.1} once>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: TargetIcon,
|
||||
title: $t('dashboard.progress'),
|
||||
percentage: progressPercentage,
|
||||
description: $t('dashboard.items_done', { completed: totalCompleted, total: totalLessons })
|
||||
}}
|
||||
/>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.15} once>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: ClipboardCheckIcon,
|
||||
title: $t('dashboard.compliance'),
|
||||
percentage: complianceScore ?? '--',
|
||||
description: $t('dashboard.compliance_met', {
|
||||
completed: completedComplianceCourses.length,
|
||||
total: complianceCourses.length
|
||||
}),
|
||||
hidePercentage: complianceScore === null
|
||||
}}
|
||||
/>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.2} once>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: FlameIcon,
|
||||
title: $t('dashboard.streak'),
|
||||
percentage: loginStreak ?? 0,
|
||||
description: $t('dashboard.days_active'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.72fr)]">
|
||||
<section class="flex h-full flex-col space-y-4">
|
||||
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
|
||||
{$t('dashboard.currently_learning')}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-1 flex-col space-y-3">
|
||||
{#if highlightedCourses.length > 0}
|
||||
{#each highlightedCourses as course}
|
||||
{@const courseProgress = getStudentCourseProgressPercent(course)}
|
||||
{@const totalItems = getCourseTotalItems(course)}
|
||||
{@const completedItems = getCourseCompletedItems(course)}
|
||||
<article class="ui:bg-card ui:text-card-foreground rounded border p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center lg:flex-col lg:items-stretch xl:flex-row xl:items-center"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-start gap-4">
|
||||
<div class="ui:bg-primary/10 flex size-12 shrink-0 items-center justify-center rounded">
|
||||
{#if course.logo}
|
||||
<img src={course.logo} alt="" class="size-full rounded object-cover" />
|
||||
{:else}
|
||||
<UserRoundIcon class="ui:text-primary size-5" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-base font-semibold">{course.title}</h3>
|
||||
{#if course.description}
|
||||
<p class="ui:text-muted-foreground mt-1 line-clamp-2 text-sm">{course.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if course.type === 'COMPLIANCE'}
|
||||
{@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)}
|
||||
<div class="mt-3">
|
||||
{#if complianceStatusKey}
|
||||
<Badge variant={getStudentCourseComplianceStatusVariant(course)}>
|
||||
{$t(complianceStatusKey)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-2 flex items-center justify-between text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{$t('dashboard.items_done', { completed: completedItems, total: totalItems })}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground">{courseProgress}%</span>
|
||||
</div>
|
||||
<Progress value={courseProgress} max={100} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="ui:bg-card rounded border p-4">
|
||||
<Empty title={$t('dashboard.no_courses')} description={$t('dashboard.start_course')} icon={BookOpenIcon} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex h-full flex-col space-y-4">
|
||||
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
|
||||
{$t('dashboard.compliance')}
|
||||
</h2>
|
||||
|
||||
<article class="ui:bg-card ui:text-card-foreground flex flex-1 rounded border p-4">
|
||||
<div class="flex items-center justify-between gap-4 lg:flex-col lg:items-start xl:flex-row xl:items-center">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="ui:bg-primary/10 flex size-12 shrink-0 items-center justify-center rounded">
|
||||
<ClipboardCheckIcon class="ui:text-primary size-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold">{$t('dashboard.compliance_score')}</h3>
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">
|
||||
{#if complianceCourses.length > 0}
|
||||
{$t('dashboard.required_courses_completed', {
|
||||
completed: completedComplianceCourses.length,
|
||||
total: complianceCourses.length
|
||||
})}
|
||||
{:else}
|
||||
{$t('dashboard.compliance_score_empty')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ui:text-primary shrink-0 text-2xl font-semibold">
|
||||
{#if complianceScore === null}
|
||||
--
|
||||
{:else}
|
||||
{complianceScore}%
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,12 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import { calcCourseProgress } from '$features/course/utils/functions';
|
||||
import { isStudentCourseComplete } from '$features/course/utils/compliance-utils';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
function isCourseComplete(course: (typeof coursesApi.enrolledCourses)[number]): boolean {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
const progress = calcCourseProgress({
|
||||
lessonsCompleted: course.progressRate ?? 0,
|
||||
totalLessons: course.lessonCount ?? 0,
|
||||
exercisesCompleted: typeof c.exercisesCompleted === 'number' ? c.exercisesCompleted : 0,
|
||||
totalExercises: typeof c.exerciseCount === 'number' ? c.exerciseCount : 0
|
||||
});
|
||||
return progress >= 100;
|
||||
return isStudentCourseComplete(course);
|
||||
}
|
||||
|
||||
const coursesInProgress = $derived(coursesApi.enrolledCourses.filter((course) => !isCourseComplete(course)));
|
||||
|
||||
@@ -489,8 +489,8 @@ class OrgApi extends BaseApiWithErrors {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
if ('error' in result) {
|
||||
snackbar.error(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LoginActivityData } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
data: LoginActivityData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const chartConfig = $derived({
|
||||
count: {
|
||||
label: $t('dashboard.login_activity_logins'),
|
||||
color: 'var(--chart-1)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig);
|
||||
|
||||
const series = $derived([
|
||||
{
|
||||
key: 'count',
|
||||
value: 'count',
|
||||
label: chartConfig.count.label,
|
||||
color: 'var(--color-count)'
|
||||
}
|
||||
]);
|
||||
|
||||
const hasData = $derived(data !== null && data.some((d) => d.count > 0));
|
||||
</script>
|
||||
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.login_activity_title')}
|
||||
</h3>
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">
|
||||
{$t('dashboard.login_activity_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col justify-center">
|
||||
{#if hasData}
|
||||
<Chart.ChartContainer class="h-[260px] w-full" config={chartConfig}>
|
||||
<Chart.BarChart {data} x="day" axis="x" {series} />
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground flex h-[260px] items-center justify-center text-sm">
|
||||
{$t('dashboard.login_activity_no_data')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,8 +319,7 @@ export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses):
|
||||
const tags = Array.isArray(courseRecord.tags)
|
||||
? (courseRecord.tags as OrgLandingPageProps['courses'][number]['tags'])
|
||||
: undefined;
|
||||
const courseSlug =
|
||||
typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : course.id;
|
||||
const courseSlug = typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : '';
|
||||
const courseCost = typeof courseRecord.cost === 'number' ? courseRecord.cost : undefined;
|
||||
const courseCurrency = typeof courseRecord.currency === 'string' ? courseRecord.currency : undefined;
|
||||
const lessonCount = typeof courseRecord.lessonCount === 'number' ? courseRecord.lessonCount : undefined;
|
||||
@@ -356,7 +355,7 @@ export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses):
|
||||
: undefined,
|
||||
tags,
|
||||
image,
|
||||
link: `/course/${courseSlug}`,
|
||||
link: courseSlug ? `/course/${courseSlug}` : undefined,
|
||||
price,
|
||||
duration,
|
||||
level
|
||||
|
||||
@@ -57,6 +57,10 @@ export type GetDashStatsRequest = typeof classroomio.dash.stats.$get;
|
||||
export type DashStatsResponse = InferResponseType<GetDashStatsRequest> | null;
|
||||
export type DashStatsSuccess = Extract<InferResponseType<GetDashStatsRequest>, { success: true }>;
|
||||
|
||||
export type GetLoginActivityRequest = (typeof classroomio.dash)['login-activity']['$get'];
|
||||
export type LoginActivitySuccess = Extract<InferResponseType<GetLoginActivityRequest>, { success: true }>;
|
||||
export type LoginActivityData = LoginActivitySuccess['data'];
|
||||
|
||||
// invite team types
|
||||
export type InviteTeamRequest = (typeof classroomio.organization)['team']['invite']['$post'];
|
||||
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamRequest>, { success: true }>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
@@ -42,27 +43,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<p class="mb-2 text-base font-semibold">{$t(titleKey)}</p>
|
||||
<p class="ui:text-muted-foreground mb-2 text-sm">
|
||||
{$t(descriptionKey)}
|
||||
</p>
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField
|
||||
className="mt-2"
|
||||
name="send-invite-emails"
|
||||
label={$t('audience.import.send_email')}
|
||||
bind:checked={sendInviteEmails}
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t(titleKey)}</Field.Legend>
|
||||
<Field.Description>{$t(descriptionKey)}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField name="send-invite-emails" label={$t('audience.import.send_email')} bind:checked={sendInviteEmails} />
|
||||
</Field.Group>
|
||||
|
||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</Field.Set>
|
||||
|
||||
@@ -143,9 +143,7 @@
|
||||
|
||||
<CheckboxField name="notify-existing-students" label={$t(notifyLabelKey)} bind:checked={sendEmail} />
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div>
|
||||
{#if selectedTutors.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each selectedTutors as tutor}
|
||||
{#each selectedTutors as tutor (tutor.id)}
|
||||
<Badge variant="secondary" class="flex items-center gap-1">
|
||||
{tutor.text}
|
||||
<Button
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each tutors as tutor}
|
||||
{#each tutors as tutor (tutor.id)}
|
||||
<Select.Item value={tutor.id.toString()}>
|
||||
{tutor.text}
|
||||
</Select.Item>
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-3">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
@@ -233,7 +233,7 @@
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
id={feed.id}
|
||||
class="mb-7 flex w-full max-w-3xl flex-col justify-between gap-2 rounded-md {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'border border-gray-200'}"
|
||||
: 'ui:border'}"
|
||||
>
|
||||
<Header
|
||||
{feed}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
GroupedSearchResults,
|
||||
SearchLmsData,
|
||||
SearchLmsRequest,
|
||||
SearchOrgData,
|
||||
SearchOrgRequest,
|
||||
SearchResultItem,
|
||||
SearchScope
|
||||
} from '../utils/types';
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
|
||||
function emptyResults(): GroupedSearchResults {
|
||||
return {
|
||||
course: [],
|
||||
program: [],
|
||||
widget: [],
|
||||
tag: [],
|
||||
audience: [],
|
||||
nav: [],
|
||||
settings: []
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(value?: string | null) {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
class SearchApi extends BaseApi {
|
||||
query = $state('');
|
||||
results = $state<GroupedSearchResults>(emptyResults());
|
||||
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private activeSearchController: AbortController | null = null;
|
||||
|
||||
clearResults() {
|
||||
this.results = emptyResults();
|
||||
}
|
||||
|
||||
cancelSearch() {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
this.debounceTimer = null;
|
||||
this.activeSearchController?.abort();
|
||||
this.activeSearchController = null;
|
||||
}
|
||||
|
||||
runSearch(query: string, currentOrgPath: string, scope: SearchScope) {
|
||||
this.query = query;
|
||||
this.cancelSearch();
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
this.clearResults();
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
void this.fetchSearchResults(trimmedQuery, currentOrgPath, scope);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async fetchSearchResults(query: string, currentOrgPath: string, scope: SearchScope) {
|
||||
const requestController = new AbortController();
|
||||
this.activeSearchController = requestController;
|
||||
|
||||
if (scope === 'lms') {
|
||||
await this.execute<SearchLmsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.search.lms.$get(
|
||||
{
|
||||
query: {
|
||||
q: query,
|
||||
limit: '5'
|
||||
}
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestController.signal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'searching LMS',
|
||||
onSuccess: (response) => {
|
||||
this.results = mapLmsSearchResults(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.activeSearchController === requestController) {
|
||||
this.activeSearchController = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<SearchOrgRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.search.$get(
|
||||
{
|
||||
query: {
|
||||
q: query,
|
||||
limit: '5'
|
||||
}
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestController.signal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'searching organization',
|
||||
onSuccess: (response) => {
|
||||
this.results = mapSearchResults(response.data, currentOrgPath);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.activeSearchController === requestController) {
|
||||
this.activeSearchController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapSearchResults(data: SearchOrgData, currentOrgPath: string): GroupedSearchResults {
|
||||
const results = emptyResults();
|
||||
|
||||
results.course = data.courses.map(
|
||||
(course): SearchResultItem => ({
|
||||
kind: 'course',
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
subtitle: summarize(course.description),
|
||||
url: `/courses/${course.id}`
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
results.widget = data.widgets.map(
|
||||
(widget): SearchResultItem => ({
|
||||
kind: 'widget',
|
||||
id: widget.id,
|
||||
title: widget.name,
|
||||
subtitle: widget.status,
|
||||
url: `/widgets/${widget.id}`
|
||||
})
|
||||
);
|
||||
|
||||
results.tag = data.tags.map(
|
||||
(tag): SearchResultItem => ({
|
||||
kind: 'tag',
|
||||
id: tag.id,
|
||||
title: tag.name,
|
||||
subtitle: tag.kind === 'tag' ? tag.groupName : undefined,
|
||||
url: `${currentOrgPath}/tags`
|
||||
})
|
||||
);
|
||||
|
||||
results.audience = data.audience.map(
|
||||
(member): SearchResultItem => ({
|
||||
kind: 'audience',
|
||||
id: String(member.memberId),
|
||||
title: member.name,
|
||||
subtitle: member.email,
|
||||
url: `${currentOrgPath}/audience?search=${encodeURIComponent(member.email || member.name)}`
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function mapLmsSearchResults(data: SearchLmsData): GroupedSearchResults {
|
||||
const results = emptyResults();
|
||||
|
||||
results.course = data.courses.map(
|
||||
(course): SearchResultItem => ({
|
||||
kind: 'course',
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
subtitle: summarize(course.description),
|
||||
url: `/courses/${course.id}`
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const searchApi = new SearchApi();
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Command from '@cio/ui/base/command';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { BookOpen, Boxes, Clock, Goal, LayoutGrid, Settings, Tag, UserRound } from '@lucide/svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { searchApi } from '../api/search.svelte';
|
||||
import { searchStore } from '../store/search-store.svelte';
|
||||
import { addRecent, clearRecents, getRecents } from '../utils/recents';
|
||||
import { buildLmsStaticCatalog, buildStaticCatalog, filterStaticCatalog } from '../utils/static-catalog';
|
||||
import type { SearchResultItem } from '../utils/types';
|
||||
import SearchResultGroup from './search-result-group.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let recents = $state<SearchResultItem[]>([]);
|
||||
|
||||
const scope = $derived(searchStore.scope);
|
||||
const translate = (key: string) => t.get(key);
|
||||
const staticCatalog = $derived(
|
||||
scope === 'lms'
|
||||
? buildLmsStaticCatalog(translate, $currentOrg)
|
||||
: buildStaticCatalog(translate, $currentOrgPath, $isOrgAdmin)
|
||||
);
|
||||
const placeholder = $derived(
|
||||
scope === 'lms'
|
||||
? $t('app.search.command_palette.lms_placeholder')
|
||||
: $t('app.search.command_palette.org_placeholder')
|
||||
);
|
||||
const description = $derived(
|
||||
scope === 'lms' ? $t('app.search.command_palette.lms_description') : $t('app.search.command_palette.description')
|
||||
);
|
||||
const filteredStaticCatalog = $derived(filterStaticCatalog(staticCatalog, query));
|
||||
const filteredRecents = $derived(filterStaticCatalog(recents, query));
|
||||
const hasQuery = $derived(query.trim().length > 0);
|
||||
const hasServerResults = $derived(
|
||||
searchApi.results.course.length > 0 ||
|
||||
searchApi.results.program.length > 0 ||
|
||||
searchApi.results.widget.length > 0 ||
|
||||
searchApi.results.tag.length > 0 ||
|
||||
searchApi.results.audience.length > 0
|
||||
);
|
||||
const hasVisibleResults = $derived(
|
||||
hasQuery
|
||||
? hasServerResults || filteredStaticCatalog.length > 0
|
||||
: filteredRecents.length > 0 || filteredStaticCatalog.length > 0
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!searchStore.isOpen) {
|
||||
searchApi.cancelSearch();
|
||||
searchApi.clearResults();
|
||||
query = '';
|
||||
return;
|
||||
}
|
||||
|
||||
recents = getRecents($currentOrg.id, scope);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!searchStore.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchApi.runSearch(query, $currentOrgPath, scope);
|
||||
});
|
||||
|
||||
function handleSelect(item: SearchResultItem) {
|
||||
addRecent($currentOrg.id, item, scope);
|
||||
recents = getRecents($currentOrg.id, scope);
|
||||
searchStore.close();
|
||||
void goto(resolve(item.url, {}));
|
||||
}
|
||||
|
||||
function handleClearRecents() {
|
||||
clearRecents($currentOrg.id, scope);
|
||||
recents = [];
|
||||
}
|
||||
|
||||
function groupItems(kind: SearchResultItem['kind']) {
|
||||
return searchApi.results[kind];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Command.Dialog
|
||||
bind:open={searchStore.isOpen}
|
||||
shouldFilter={false}
|
||||
title={$t('app.search.command_palette.title')}
|
||||
{description}
|
||||
>
|
||||
<Command.Input bind:value={query} {placeholder} />
|
||||
|
||||
<Command.List class="ui:max-h-[460px] ui:p-2">
|
||||
{#if searchApi.isLoading}
|
||||
<div class="ui:text-muted-foreground ui:flex ui:items-center ui:gap-2 ui:px-3 ui:py-3 ui:text-sm">
|
||||
<Spinner class="ui:size-4" />
|
||||
{$t('app.search.command_palette.loading')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasVisibleResults && !searchApi.isLoading}
|
||||
<Command.Empty>{$t('app.search.command_palette.empty')}</Command.Empty>
|
||||
{/if}
|
||||
|
||||
{#if !hasQuery && filteredRecents.length > 0}
|
||||
<Command.Group heading={$t('app.search.command_palette.groups.recent')}>
|
||||
<div class="ui:flex ui:items-center ui:justify-end ui:px-2 ui:pb-1">
|
||||
<Button variant="ghost" size="sm" class="ui:h-6 ui:px-2 ui:text-xs" onclick={handleClearRecents}>
|
||||
{$t('app.search.command_palette.clear_recents')}
|
||||
</Button>
|
||||
</div>
|
||||
{#each filteredRecents as item (item.kind + item.id)}
|
||||
<Command.Item value={`${item.kind}:${item.id}`} onSelect={() => handleSelect(item)}>
|
||||
<Clock class="ui:size-4" />
|
||||
<div class="ui:min-w-0 ui:flex-1">
|
||||
<div class="ui:truncate ui:font-medium">{item.title}</div>
|
||||
{#if item.subtitle}
|
||||
<div class="ui:text-muted-foreground ui:truncate ui:text-xs">{item.subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{/if}
|
||||
|
||||
{#if hasQuery}
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.courses')}
|
||||
items={groupItems('course')}
|
||||
fallbackIcon={BookOpen}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.programs')}
|
||||
items={groupItems('program')}
|
||||
fallbackIcon={Goal}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{#if scope === 'org'}
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.widgets')}
|
||||
items={groupItems('widget')}
|
||||
fallbackIcon={LayoutGrid}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.tags')}
|
||||
items={groupItems('tag')}
|
||||
fallbackIcon={Tag}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.students')}
|
||||
items={groupItems('audience')}
|
||||
fallbackIcon={UserRound}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{/if}
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.settings')}
|
||||
items={filteredStaticCatalog.filter((item) => item.kind === 'settings')}
|
||||
fallbackIcon={Settings}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{:else}
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.suggested')}
|
||||
items={filteredStaticCatalog}
|
||||
fallbackIcon={Boxes}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { searchStore } from '../store/search-store.svelte';
|
||||
|
||||
const scope = $derived(page.url.pathname.startsWith('/lms') ? 'lms' : 'org');
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
searchStore.toggle(scope);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import * as Command from '@cio/ui/base/command';
|
||||
import type { Component } from 'svelte';
|
||||
import type { SearchResultItem } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
items: SearchResultItem[];
|
||||
fallbackIcon: Component;
|
||||
onSelect: (item: SearchResultItem) => void;
|
||||
}
|
||||
|
||||
let { heading, items, fallbackIcon, onSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if items.length > 0}
|
||||
<Command.Group {heading}>
|
||||
{#each items as item (item.kind + item.id)}
|
||||
{@const Icon = item.icon ?? fallbackIcon}
|
||||
<Command.Item value={`${item.kind}:${item.id}`} onSelect={() => onSelect(item)}>
|
||||
<Icon class="ui:size-4" />
|
||||
<div class="ui:min-w-0 ui:flex-1">
|
||||
<div class="ui:truncate ui:font-medium">{item.title}</div>
|
||||
{#if item.subtitle}
|
||||
<div class="ui:text-muted-foreground ui:truncate ui:text-xs">{item.subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CommandPalette } from './components/command-palette.svelte';
|
||||
export { default as KeyboardShortcutListener } from './components/keyboard-shortcut-listener.svelte';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SearchScope } from '../utils/types';
|
||||
|
||||
class SearchStore {
|
||||
isOpen = $state(false);
|
||||
scope: SearchScope = $state('org');
|
||||
|
||||
open(scope: SearchScope = 'org') {
|
||||
this.scope = scope;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
toggle(scope: SearchScope = 'org') {
|
||||
this.scope = scope;
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
export const searchStore = new SearchStore();
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { SearchResultItem, SearchScope } from './types';
|
||||
|
||||
const MAX_RECENT_ITEMS = 8;
|
||||
const STORAGE_PREFIX = 'cio:search:recents';
|
||||
|
||||
type StoredSearchResultItem = Omit<SearchResultItem, 'icon'>;
|
||||
|
||||
function getStorageKey(orgId: string, scope: SearchScope) {
|
||||
return `${STORAGE_PREFIX}:${scope}:${orgId}`;
|
||||
}
|
||||
|
||||
function canUseStorage() {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage);
|
||||
}
|
||||
|
||||
export function getRecents(orgId?: string | null, scope: SearchScope = 'org'): SearchResultItem[] {
|
||||
if (!orgId || !canUseStorage()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(getStorageKey(orgId, scope));
|
||||
if (!storedValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(storedValue) as StoredSearchResultItem[];
|
||||
} catch (error) {
|
||||
console.error('getRecents error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecent(orgId: string | null | undefined, item: SearchResultItem, scope: SearchScope = 'org') {
|
||||
if (!orgId || !canUseStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { icon: _icon, ...storedItem } = item;
|
||||
const existingItems = getRecents(orgId, scope);
|
||||
const nextItems = [
|
||||
storedItem,
|
||||
...existingItems.filter((existingItem) => existingItem.id !== item.id || existingItem.kind !== item.kind)
|
||||
].slice(0, MAX_RECENT_ITEMS);
|
||||
|
||||
window.localStorage.setItem(getStorageKey(orgId, scope), JSON.stringify(nextItems));
|
||||
} catch (error) {
|
||||
console.error('addRecent error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRecents(orgId?: string | null, scope: SearchScope = 'org') {
|
||||
if (!orgId || !canUseStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(getStorageKey(orgId, scope));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { baseNavConfig as lmsNavConfig } from '$features/ui/navigation/lms-navigation';
|
||||
import { baseNavConfig as orgNavConfig } from '$features/ui/navigation/org-navigation';
|
||||
import type { SearchResultItem } from './types';
|
||||
|
||||
function resolveTitle(titleKey: string, translate: (key: string) => string) {
|
||||
return titleKey.includes('.') ? translate(titleKey) : titleKey;
|
||||
}
|
||||
|
||||
export function buildStaticCatalog(
|
||||
translate: (key: string) => string,
|
||||
currentOrgPath: string,
|
||||
isOrgAdmin: boolean | null
|
||||
): SearchResultItem[] {
|
||||
const items: SearchResultItem[] = [];
|
||||
|
||||
for (const navItem of orgNavConfig) {
|
||||
if (navItem.requiresAdmin && !isOrgAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = navItem.path === '' ? currentOrgPath : `${currentOrgPath}${navItem.path}`;
|
||||
|
||||
if (!navItem.useHashUrl && !navItem.disabled) {
|
||||
items.push({
|
||||
kind: 'nav',
|
||||
id: `nav:${navItem.path || '/'}`,
|
||||
title: resolveTitle(navItem.titleKey, translate),
|
||||
subtitle: translate('app.search.command_palette.result_types.page'),
|
||||
url,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
|
||||
for (const subItem of navItem.items ?? []) {
|
||||
if (subItem.requiresAdmin && !isOrgAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
kind: 'settings',
|
||||
id: `settings:${subItem.path}`,
|
||||
title: resolveTitle(subItem.titleKey, translate),
|
||||
subtitle: resolveTitle(navItem.titleKey, translate),
|
||||
url: `${currentOrgPath}${subItem.path}`,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
|
||||
for (const nestedRoute of navItem.nestedRoutes ?? []) {
|
||||
items.push({
|
||||
kind: navItem.path === '/settings' ? 'settings' : 'nav',
|
||||
id: `${navItem.path || 'nav'}:${nestedRoute.path}`,
|
||||
title: resolveTitle(nestedRoute.titleKey, translate),
|
||||
subtitle: resolveTitle(navItem.titleKey, translate),
|
||||
url: `${currentOrgPath}${navItem.path}/${nestedRoute.path}`,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildLmsStaticCatalog(
|
||||
translate: (key: string) => string,
|
||||
currentOrg: AccountOrg | null
|
||||
): SearchResultItem[] {
|
||||
const items: SearchResultItem[] = [];
|
||||
|
||||
for (const navItem of lmsNavConfig) {
|
||||
if (navItem.show && !navItem.show(currentOrg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = navItem.path === '' ? '/lms' : `/lms${navItem.path}`;
|
||||
|
||||
if (!navItem.useHashUrl) {
|
||||
items.push({
|
||||
kind: 'nav',
|
||||
id: `lms-nav:${navItem.path || '/'}`,
|
||||
title: resolveTitle(navItem.titleKey, translate),
|
||||
subtitle: translate('app.search.command_palette.result_types.page'),
|
||||
url,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
|
||||
for (const subItem of navItem.items ?? []) {
|
||||
items.push({
|
||||
kind: 'settings',
|
||||
id: `lms-settings:${subItem.path}`,
|
||||
title: resolveTitle(subItem.titleKey, translate),
|
||||
subtitle: resolveTitle(navItem.titleKey, translate),
|
||||
url: `/lms${subItem.path}`,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
|
||||
for (const nestedRoute of navItem.nestedRoutes ?? []) {
|
||||
items.push({
|
||||
kind: 'nav',
|
||||
id: `lms:${navItem.path || 'nav'}:${nestedRoute.path}`,
|
||||
title: resolveTitle(nestedRoute.titleKey, translate),
|
||||
subtitle: resolveTitle(navItem.titleKey, translate),
|
||||
url: `/lms${navItem.path}/${nestedRoute.path}`,
|
||||
icon: navItem.icon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function filterStaticCatalog(items: SearchResultItem[], query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
const haystack = `${item.title} ${item.subtitle ?? ''}`.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type SearchOrgRequest = (typeof classroomio.organization)['search']['$get'];
|
||||
export type SearchOrgResponse = InferResponseType<SearchOrgRequest>;
|
||||
export type SearchOrgSuccess = Extract<SearchOrgResponse, { success: true }>;
|
||||
export type SearchOrgData = SearchOrgSuccess['data'];
|
||||
|
||||
export type SearchLmsRequest = (typeof classroomio.organization)['search']['lms']['$get'];
|
||||
export type SearchLmsResponse = InferResponseType<SearchLmsRequest>;
|
||||
export type SearchLmsSuccess = Extract<SearchLmsResponse, { success: true }>;
|
||||
export type SearchLmsData = SearchLmsSuccess['data'];
|
||||
|
||||
export type SearchResultKind = 'course' | 'program' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
|
||||
export type SearchScope = 'org' | 'lms';
|
||||
|
||||
export interface SearchResultItem {
|
||||
kind: SearchResultKind;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
icon?: Component;
|
||||
}
|
||||
|
||||
export type GroupedSearchResults = Record<SearchResultKind, SearchResultItem[]>;
|
||||
@@ -1,16 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
|
||||
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
|
||||
@@ -80,4 +119,39 @@
|
||||
</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>
|
||||
|
||||
@@ -27,19 +27,19 @@
|
||||
const themeCards = [
|
||||
{
|
||||
value: 'minimal',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/minimal.png',
|
||||
preview: '/templates/minimal.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.minimal.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.minimal.description'
|
||||
},
|
||||
{
|
||||
value: 'bold',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/bold.png',
|
||||
preview: '/templates/bold.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.bold.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.bold.description'
|
||||
},
|
||||
{
|
||||
value: 'classic',
|
||||
preview: 'https://assets.cdn.clsrio.com/www/templates/classic.png',
|
||||
preview: '/templates/classic.png',
|
||||
titleKey: 'settings.landing_page.theme.cards.classic.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.classic.description'
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
Upgrade
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-background mb-4 overflow-hidden rounded-xl">
|
||||
<div class="bg-background mb-4 overflow-hidden rounded-t-xl">
|
||||
<img src={themeCard.preview} alt={$t(themeCard.titleKey)} class="h-50 w-full object-cover object-top" />
|
||||
</div>
|
||||
<div class="space-y-1 px-2 pb-2">
|
||||
|
||||
@@ -65,6 +65,15 @@
|
||||
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
|
||||
const bannerImage = $derived(get(courseData, 'logo'));
|
||||
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
|
||||
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;
|
||||
});
|
||||
const instructorImageUrl = $derived.by(() => {
|
||||
const imageUrl = get(instructor, 'imgUrl');
|
||||
return typeof imageUrl === 'string' && imageUrl.trim() ? imageUrl : '/images/avatar.svg';
|
||||
});
|
||||
const certificate = $derived(
|
||||
get(courseData, 'metadata.certificate', {
|
||||
templateUrl: '/images/certificate-template.svg'
|
||||
@@ -148,7 +157,7 @@
|
||||
|
||||
<BlurFade delay={0.3}>
|
||||
<p class="mb-6 text-sm text-blue-300">
|
||||
{get(courseData, 'metadata.instructor.name', '')}
|
||||
{instructorName}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
@@ -528,11 +537,11 @@
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
|
||||
src={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
|
||||
src={instructorImageUrl}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-base font-medium dark:text-white">
|
||||
{get(instructor, 'name', $currentOrg.name)}
|
||||
{instructorName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{get(instructor, 'role', '')}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if !hideSearch}
|
||||
<Search />
|
||||
<Search scope="lms" />
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface NavItemConfig {
|
||||
}
|
||||
|
||||
// Base navigation configuration structure
|
||||
const baseNavConfig: NavItemConfig[] = [
|
||||
export const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
titleKey: 'lms_navigation.home',
|
||||
path: '',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CourseIcon,
|
||||
DashboardIcon,
|
||||
GoalIcon,
|
||||
LandingPageIcon,
|
||||
PeopleIcon,
|
||||
SettingsIcon,
|
||||
SetupIcon,
|
||||
@@ -13,8 +14,8 @@ import {
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type { Component } from 'svelte';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import type { Component } from 'svelte';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface NavItem {
|
||||
@@ -59,7 +60,7 @@ export interface NestedRouteConfig {
|
||||
}
|
||||
|
||||
// Base navigation configuration structure
|
||||
const baseNavConfig: NavItemConfig[] = [
|
||||
export const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
@@ -104,6 +105,13 @@ const baseNavConfig: NavItemConfig[] = [
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/tags(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.widgets',
|
||||
path: '/widgets',
|
||||
icon: LandingPageIcon,
|
||||
matchPattern: '^(/org/[^/]+/widgets(/.*)?|/widgets/[^/]+(/.*)?)$'
|
||||
},
|
||||
{
|
||||
group: 'people',
|
||||
titleKey: 'org_navigation.community',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { searchStore } from '$features/search/store/search-store.svelte';
|
||||
import type { SearchScope } from '$features/search/utils/types';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
@@ -12,6 +14,7 @@
|
||||
onFocus?: (event: FocusEvent) => void;
|
||||
onBlur?: (event: FocusEvent) => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
scope?: SearchScope;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -21,17 +24,46 @@
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeydown,
|
||||
scope = 'org',
|
||||
...restProps
|
||||
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
|
||||
|
||||
function openCommandPalette() {
|
||||
searchStore.open(scope);
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
onFocus?.(event);
|
||||
openCommandPalette();
|
||||
}
|
||||
|
||||
function handlePointerdown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
onKeydown?.(event);
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup.Root class={`hidden! max-w-56 md:flex! ${className}`} {...restProps}>
|
||||
<InputGroup.Root class={`hidden! max-w-56 md:flex! ${className}`} onpointerdown={handlePointerdown} {...restProps}>
|
||||
<InputGroup.Input
|
||||
placeholder={placeholder || $t('app.search.placeholder')}
|
||||
bind:value
|
||||
on:focus={onFocus}
|
||||
readonly
|
||||
role="button"
|
||||
aria-haspopup="dialog"
|
||||
on:focus={handleFocus}
|
||||
on:blur={onBlur}
|
||||
on:keydown={onKeydown}
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
|
||||
@@ -15,8 +15,17 @@
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
function getRoleLabel(roleId: number): string {
|
||||
if (roleId === ROLE.ADMIN) return $t('course.navItem.people.roles.admin');
|
||||
if (roleId === ROLE.TUTOR) return $t('course.navItem.people.roles.tutor');
|
||||
if (roleId === ROLE.STUDENT) return $t('course.navItem.people.roles.student');
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -32,10 +41,22 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet triggeravatar()}
|
||||
<UserAvatar src={$profile.avatarUrl} alt={$profile.fullname} />
|
||||
<div class="grid flex-1 text-left text-sm leading-tight font-normal">
|
||||
<span class="truncate">{$profile.fullname}</span>
|
||||
<span class="truncate text-xs">{getRoleLabel($currentOrg.roleId)}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet usertrigger()}
|
||||
<DropdownMenu.Label class="p-0">
|
||||
<a href={resolve(`${$basePath}/settings`, {})} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
{@render avatarblock()}
|
||||
<UserAvatar src={$profile.avatarUrl} alt={$profile.fullname} />
|
||||
<div class="grid flex-1 text-left text-sm leading-tight font-normal">
|
||||
<span class="truncate">{$profile.fullname}</span>
|
||||
<span class="truncate text-xs">{$profile.email}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenu.Label>
|
||||
{/snippet}
|
||||
@@ -62,7 +83,7 @@
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
{...props}
|
||||
>
|
||||
{@render avatarblock()}
|
||||
{@render triggeravatar()}
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { PLAN_NAMES, PLAN } from '@cio/utils/plans';
|
||||
|
||||
const planName = $derived($currentOrg.plans?.[0]?.planName || 'FREE');
|
||||
const plan = $derived($currentOrg.plans?.[0]?.planName || PLAN.BASIC);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
<span class="truncate font-normal">ClassroomIO</span>
|
||||
<Badge variant="outline" class="capitalize">
|
||||
{planName.toLowerCase()}
|
||||
{PLAN_NAMES[plan] || plan}
|
||||
</Badge>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -24,49 +24,82 @@
|
||||
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
{#if item.disabled}
|
||||
<span
|
||||
{...props}
|
||||
aria-disabled="true"
|
||||
class="flex cursor-not-allowed items-center gap-2 opacity-50"
|
||||
>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
{#if item.items}
|
||||
{#if item.items}
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
{#if item.disabled}
|
||||
<span
|
||||
{...props}
|
||||
aria-disabled="true"
|
||||
class="flex cursor-not-allowed items-center gap-2 opacity-50"
|
||||
>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
<ChevronRightIcon
|
||||
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{:else}
|
||||
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
{#if item.disabled}
|
||||
<span
|
||||
{...props}
|
||||
aria-disabled="true"
|
||||
class="flex cursor-not-allowed items-center gap-2 opacity-50"
|
||||
>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{/if}
|
||||
{#if item.items}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
|
||||
@@ -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}
|
||||
{#if $currentOrg.name}sfsf
|
||||
<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>
|
||||
|
||||
@@ -92,33 +92,35 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => setSortKey(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
{#if sortOptions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => setSortKey(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root type="single" variant="outline" size="sm" value={selectedOrder} onValueChange={setOrder}>
|
||||
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root type="single" variant="outline" size="sm" value={selectedOrder} onValueChange={setOrder}>
|
||||
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render additionalContent?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { Button, type ButtonVariant } from '@cio/ui/base/button';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
@@ -20,7 +21,22 @@
|
||||
variant = 'default'
|
||||
}: Props = $props();
|
||||
|
||||
let href = $derived(isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain);
|
||||
let href = $derived.by(() => {
|
||||
const origin = $currentOrgDomain || (browser ? window.location.origin : '');
|
||||
|
||||
if (!origin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const pathname = isLMS && $user.isLoggedIn ? '/home' : '/';
|
||||
const url = new URL(pathname, origin);
|
||||
|
||||
if (browser && window.location.host.includes('localhost') && $currentOrg.siteName) {
|
||||
url.searchParams.set('org', $currentOrg.siteName);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Button {href} target="_blank" {variant} class={`ml-2 ${className}`.trim()}>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateWidgetInput,
|
||||
CreateWidgetRequest,
|
||||
DeleteWidgetRequest,
|
||||
GetWidgetDetailRequest,
|
||||
GetWidgetsRequest,
|
||||
PreviewWidgetInput,
|
||||
PreviewWidgetRequest,
|
||||
PublishWidgetRequest,
|
||||
RollbackWidgetRequest,
|
||||
UpdateWidgetInput,
|
||||
UpdateWidgetRequest,
|
||||
WidgetDetail,
|
||||
WidgetListItem
|
||||
} from '../utils/types';
|
||||
import { ZCreateWidget, ZUpdateWidget } from '@cio/utils/validation/widget';
|
||||
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
class WidgetApi extends BaseApiWithErrors {
|
||||
widgets = $state<WidgetListItem[]>([]);
|
||||
widgetDetail = $state<WidgetDetail | null>(null);
|
||||
|
||||
async getWidgets() {
|
||||
return this.execute<GetWidgetsRequest>({
|
||||
requestFn: () => classroomio.organization.widgets.$get(),
|
||||
logContext: 'fetching widgets',
|
||||
onSuccess: (response) => {
|
||||
this.widgets = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getWidget(widgetId: string) {
|
||||
return this.execute<GetWidgetDetailRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].$get({
|
||||
param: { widgetId }
|
||||
}),
|
||||
logContext: 'fetching widget detail',
|
||||
onSuccess: (response) => {
|
||||
this.widgetDetail = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createWidget(fields: CreateWidgetInput) {
|
||||
const result = ZCreateWidget.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await this.execute<CreateWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets.$post({
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating widget',
|
||||
onSuccess: (response) => {
|
||||
this.widgets = [response.data, ...this.widgets];
|
||||
snackbar.success('widgets.notifications.created');
|
||||
}
|
||||
});
|
||||
|
||||
return response?.data ?? null;
|
||||
}
|
||||
|
||||
async updateWidget(widgetId: string, fields: UpdateWidgetInput, silent = false) {
|
||||
const result = ZUpdateWidget.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await this.execute<UpdateWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].$put({
|
||||
param: { widgetId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating widget',
|
||||
onSuccess: (response) => {
|
||||
if (!silent) snackbar.success('widgets.notifications.saved');
|
||||
this.widgets = this.widgets.map((widget) => (widget.id === widgetId ? response.data : widget));
|
||||
}
|
||||
});
|
||||
|
||||
return response?.data ?? null;
|
||||
}
|
||||
|
||||
async previewWidget(widgetId: string, fields: PreviewWidgetInput) {
|
||||
const response = await this.execute<PreviewWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].preview.$post({
|
||||
param: { widgetId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'previewing widget'
|
||||
});
|
||||
|
||||
return response?.data ?? null;
|
||||
}
|
||||
|
||||
async publishWidget(widgetId: string) {
|
||||
return this.execute<PublishWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].publish.$post({
|
||||
param: { widgetId }
|
||||
}),
|
||||
logContext: 'publishing widget',
|
||||
onSuccess: () => {
|
||||
snackbar.success('widgets.notifications.published');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async rollbackWidget(widgetId: string, versionId: string) {
|
||||
return this.execute<RollbackWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].rollback.$post({
|
||||
param: { widgetId },
|
||||
json: { versionId }
|
||||
}),
|
||||
logContext: 'rolling back widget',
|
||||
onSuccess: () => {
|
||||
snackbar.success('widgets.notifications.rolled_back');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWidget(widgetId: string) {
|
||||
return this.execute<DeleteWidgetRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.widgets[':widgetId'].$delete({
|
||||
param: { widgetId }
|
||||
}),
|
||||
logContext: 'deleting widget',
|
||||
onSuccess: () => {
|
||||
this.widgets = this.widgets.filter((widget) => widget.id !== widgetId);
|
||||
snackbar.success('widgets.notifications.deleted');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const widgetApi = new WidgetApi();
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import type { FilterPublished } from '../utils/widget-editor-utils';
|
||||
import type { WidgetDetail } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
activeFilterCount: number;
|
||||
filterPublished: FilterPublished;
|
||||
filterCourseType: string;
|
||||
filterTagSlugs: string[];
|
||||
availableTags: WidgetDetail['availableTags'];
|
||||
courseTypeOptions: { value: string; label: string }[];
|
||||
onClearAll: () => void;
|
||||
onFilterPublishedChange: (value: FilterPublished) => void;
|
||||
onFilterCourseTypeChange: (value: string) => void;
|
||||
onFilterTagSlugsChange: (slugs: string[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
activeFilterCount,
|
||||
filterPublished,
|
||||
filterCourseType,
|
||||
filterTagSlugs,
|
||||
availableTags,
|
||||
courseTypeOptions,
|
||||
onClearAll,
|
||||
onFilterPublishedChange,
|
||||
onFilterCourseTypeChange,
|
||||
onFilterTagSlugsChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted focus-visible:ui:ring-primary/40 relative inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-sm shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={$t('courses.tag_filters.filter')}
|
||||
>
|
||||
<FilterIcon class="size-3.5 shrink-0" />
|
||||
<span class="hidden text-xs sm:inline">{$t('courses.tag_filters.filter')}</span>
|
||||
{#if activeFilterCount > 0}
|
||||
<span
|
||||
class="ui:bg-foreground ui:text-background inline-flex min-w-[18px] items-center justify-center rounded-full px-1 py-0.5 text-[10px] leading-none font-semibold"
|
||||
>
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
class="ui:bg-popover ui:text-popover-foreground ui:border-border w-72 border p-0 shadow-lg"
|
||||
>
|
||||
<div class="ui:border-border flex items-center justify-between border-b px-4 py-3">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ui:hover:text-foreground text-xs disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={activeFilterCount === 0}
|
||||
onclick={onClearAll}
|
||||
>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<!-- Status -->
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
|
||||
{$t('widgets.filter.status')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each [['all', $t('widgets.filter.all')], ['published', $t('widgets.status.published')], ['unpublished', $t('widgets.filter.unpublished')]] as [val, label] (val)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors {filterPublished === val
|
||||
? 'ui:border-foreground ui:bg-foreground ui:text-background'
|
||||
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
|
||||
onclick={() => onFilterPublishedChange(val as FilterPublished)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Course type -->
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
|
||||
{$t('widgets.filter.course_type')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors {filterCourseType === 'all'
|
||||
? 'ui:border-foreground ui:bg-foreground ui:text-background'
|
||||
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
|
||||
onclick={() => onFilterCourseTypeChange('all')}
|
||||
>
|
||||
{$t('widgets.filter.all')}
|
||||
</button>
|
||||
{#each courseTypeOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors {filterCourseType === opt.value
|
||||
? 'ui:border-foreground ui:bg-foreground ui:text-background'
|
||||
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
|
||||
onclick={() => onFilterCourseTypeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if availableTags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
|
||||
{$t('courses.tag_filters.tags')}
|
||||
</p>
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each availableTags as tag (tag.id)}
|
||||
<label class="ui:hover:bg-muted flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1.5">
|
||||
<Checkbox
|
||||
checked={filterTagSlugs.includes(tag.slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
onFilterTagSlugsChange(
|
||||
checked ? [...filterTagSlugs, tag.slug] : filterTagSlugs.filter((s) => s !== tag.slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span class="inline-block h-2 w-2 shrink-0 rounded-full" style="background-color: {tag.color}"></span>
|
||||
<span class="text-xs">{tag.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { WidgetDetail } from '../utils/types';
|
||||
import type { WidgetConfig } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
draftLayoutType: WidgetDetail['widget']['layoutType'];
|
||||
draftConfig: WidgetConfig;
|
||||
featuredCourseOptions: { id: string; title: string }[];
|
||||
tagOptions: WidgetDetail['availableTags'];
|
||||
onToggleCategoryTag: (tagId: string, checked: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
draftLayoutType,
|
||||
draftConfig = $bindable(),
|
||||
featuredCourseOptions,
|
||||
tagOptions,
|
||||
onToggleCategoryTag
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if draftLayoutType === 'card_grid'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.columns')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.layoutOptions.cardGrid.columns}>
|
||||
<Select.Trigger>{draftConfig.layoutOptions.cardGrid.columns}</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="2">2</Select.Item>
|
||||
<Select.Item value="3">3</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.max_count')}
|
||||
bind:value={draftConfig.layoutOptions.cardGrid.maxCount}
|
||||
/>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.cardGrid.showRating} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_rating')}</Field.Label>
|
||||
</Field.Field>
|
||||
{:else if draftLayoutType === 'tag_filter'}
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.max_count')}
|
||||
bind:value={draftConfig.layoutOptions.tagFilter.maxCount}
|
||||
/>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.tag_source')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.layoutOptions.tagFilter.tagSource}>
|
||||
<Select.Trigger>
|
||||
{draftConfig.layoutOptions.tagFilter.tagSource === 'auto'
|
||||
? $t('widgets.layoutOptions.tag_source_auto')
|
||||
: $t('widgets.layoutOptions.tag_source_manual')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="auto">{$t('widgets.layoutOptions.tag_source_auto')}</Select.Item>
|
||||
<Select.Item value="manual">{$t('widgets.layoutOptions.tag_source_manual')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.tagFilter.showAllOption} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_all_option')}</Field.Label>
|
||||
</Field.Field>
|
||||
{:else if draftLayoutType === 'carousel'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.visible_cards')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.layoutOptions.carousel.visibleCards}>
|
||||
<Select.Trigger>{draftConfig.layoutOptions.carousel.visibleCards}</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="2">2</Select.Item>
|
||||
<Select.Item value="3">3</Select.Item>
|
||||
<Select.Item value="4">4</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.max_count')}
|
||||
bind:value={draftConfig.layoutOptions.carousel.maxCount}
|
||||
/>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.carousel.autoPlay} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.auto_play')}</Field.Label>
|
||||
</Field.Field>
|
||||
{#if draftConfig.layoutOptions.carousel.autoPlay}
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.auto_play_interval')}
|
||||
bind:value={draftConfig.layoutOptions.carousel.autoPlayIntervalMs}
|
||||
/>
|
||||
{/if}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.carousel.showDots} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_dots')}</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.carousel.showArrows} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_arrows')}</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.carousel.loop} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.loop')}</Field.Label>
|
||||
</Field.Field>
|
||||
{:else if draftLayoutType === 'primary_course'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.featured_course')}</Field.Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={draftConfig.layoutOptions.primaryCourse.featuredCourseId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
draftConfig.layoutOptions.primaryCourse.featuredCourseId = value || null;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger>
|
||||
{featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.primaryCourse.featuredCourseId)?.title ??
|
||||
$t('widgets.layoutOptions.none')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each featuredCourseOptions as course (course.id)}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<InputField
|
||||
label={$t('widgets.layoutOptions.eyebrow_label')}
|
||||
bind:value={draftConfig.layoutOptions.primaryCourse.eyebrowLabel}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('widgets.layoutOptions.cta_label')}
|
||||
bind:value={draftConfig.layoutOptions.primaryCourse.ctaLabel}
|
||||
/>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.secondary_max_count')}
|
||||
bind:value={draftConfig.layoutOptions.primaryCourse.secondaryMaxCount}
|
||||
/>
|
||||
{:else if draftLayoutType === 'compact_list'}
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.max_count')}
|
||||
bind:value={draftConfig.layoutOptions.compactList.maxCount}
|
||||
/>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.density')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.layoutOptions.compactList.density}>
|
||||
<Select.Trigger>
|
||||
{draftConfig.layoutOptions.compactList.density === 'comfortable'
|
||||
? $t('widgets.layoutOptions.density_comfortable')
|
||||
: $t('widgets.layoutOptions.density_compact')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="comfortable">{$t('widgets.layoutOptions.density_comfortable')}</Select.Item>
|
||||
<Select.Item value="compact">{$t('widgets.layoutOptions.density_compact')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.compactList.showThumbnail} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_thumbnail')}</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.compactList.showTags} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_tags')}</Field.Label>
|
||||
</Field.Field>
|
||||
{:else if draftLayoutType === 'editorial_spotlight'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.main_course')}</Field.Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={draftConfig.layoutOptions.editorialSpotlight.mainCourseId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
draftConfig.layoutOptions.editorialSpotlight.mainCourseId = value || null;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger>
|
||||
{featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.editorialSpotlight.mainCourseId)?.title ??
|
||||
$t('widgets.layoutOptions.none')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each featuredCourseOptions as course (course.id)}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.secondary_max_count')}
|
||||
bind:value={draftConfig.layoutOptions.editorialSpotlight.secondaryMaxCount}
|
||||
/>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.title_style')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.layoutOptions.editorialSpotlight.titleStyle}>
|
||||
<Select.Trigger>
|
||||
{draftConfig.layoutOptions.editorialSpotlight.titleStyle === 'serif'
|
||||
? $t('widgets.layoutOptions.title_style_serif')
|
||||
: $t('widgets.layoutOptions.title_style_sans')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="serif">{$t('widgets.layoutOptions.title_style_serif')}</Select.Item>
|
||||
<Select.Item value="sans">{$t('widgets.layoutOptions.title_style_sans')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
{:else if draftLayoutType === 'category_shelf'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.layoutOptions.category_tags')}</Field.Label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each tagOptions as tag (tag.id)}
|
||||
{@const checked = draftConfig.layoutOptions.categoryShelf.categoryTagIds.includes(tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
checked
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-900 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200'
|
||||
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'
|
||||
}`}
|
||||
onclick={() => onToggleCategoryTag(tag.id, !checked)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.layoutOptions.categoryShelf.showAllTab} />
|
||||
<Field.Label>{$t('widgets.layoutOptions.show_all_tab')}</Field.Label>
|
||||
</Field.Field>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.layoutOptions.max_per_category')}
|
||||
bind:value={draftConfig.layoutOptions.categoryShelf.maxPerCategory}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { widgetApi } from '../api/widget.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let { id, name }: Props = $props();
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let draftName = $state(name);
|
||||
|
||||
async function handleRename() {
|
||||
await widgetApi.updateWidget(id, { name: draftName });
|
||||
renameOpen = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await widgetApi.deleteWidget(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-4 right-4 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">
|
||||
<DropdownMenu.Item
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
draftName = name;
|
||||
renameOpen = true;
|
||||
}}
|
||||
>
|
||||
{$t('widgets.actions.rename')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
{$t('widgets.actions.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Dialog.Root bind:open={renameOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('widgets.actions.rename')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<InputField label={$t('widgets.form.name')} bind:value={draftName} />
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (renameOpen = false)}>{$t('app.cancel')}</Button>
|
||||
<Button onclick={handleRename}>{$t('app.save')}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
|
||||
interface Props {
|
||||
draftName: string;
|
||||
status: string;
|
||||
isPaidPlan: boolean;
|
||||
isDirty: boolean;
|
||||
layoutValidationError: string | null;
|
||||
editingName: boolean;
|
||||
onSave: () => void;
|
||||
onPublish: () => void;
|
||||
onDiscard: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameFinish: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
draftName = $bindable(),
|
||||
status,
|
||||
isPaidPlan,
|
||||
isDirty,
|
||||
layoutValidationError,
|
||||
editingName,
|
||||
onSave,
|
||||
onPublish,
|
||||
onDiscard,
|
||||
onRenameStart,
|
||||
onRenameFinish
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="ui:border-border ui:bg-background ui:text-foreground flex shrink-0 items-center justify-between gap-3 border-b px-3 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href={resolve(`${$currentOrgPath}/widgets`, {})}
|
||||
class="ui:size-8 shrink-0"
|
||||
aria-label={$t('widgets.actions.back')}
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
{#if editingName}
|
||||
<div class="flex max-w-md items-center gap-2">
|
||||
<InputField
|
||||
label={$t('widgets.form.name')}
|
||||
bind:value={draftName}
|
||||
className="min-w-[12rem]"
|
||||
labelClassName="sr-only"
|
||||
autoFocus
|
||||
/>
|
||||
<Button variant="secondary" size="sm" type="button" onclick={onRenameFinish}>
|
||||
{$t('widgets.editor.finish_editing_name')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<h1 class="truncate text-sm tracking-tight sm:text-base">{draftName}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
onclick={onRenameStart}
|
||||
class="shrink-0"
|
||||
aria-label={$t('widgets.editor.rename_widget')}
|
||||
>
|
||||
<PencilIcon class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="secondary" class="text-[10px] uppercase">
|
||||
{$t(`widgets.status.${status.toLowerCase()}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
{isPaidPlan ? $t('widgets.plan.paid') : $t('widgets.plan.free')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
{#if isDirty}
|
||||
<Button variant="ghost" size="sm" type="button" onclick={onDiscard} class="hidden sm:inline-flex">
|
||||
{$t('widgets.editor.discard_short')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!isDirty || Boolean(layoutValidationError)}
|
||||
onclick={onSave}
|
||||
>
|
||||
{$t('widgets.editor.save_changes')}
|
||||
</Button>
|
||||
<Button size="sm" type="button" disabled={Boolean(layoutValidationError)} onclick={onPublish}>
|
||||
{$t('widgets.actions.publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { isValidIframeMessage } from '../utils/preview-protocol';
|
||||
import type { WidgetPreviewParentMessage } from '../utils/preview-protocol';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let {
|
||||
payload = null,
|
||||
maxWidth = undefined,
|
||||
onSynced
|
||||
}: {
|
||||
payload: TWidgetPayload | null;
|
||||
maxWidth?: number;
|
||||
onSynced?: () => void;
|
||||
} = $props();
|
||||
|
||||
let iframeElement = $state<HTMLIFrameElement | null>(null);
|
||||
let iframeReady = $state(false);
|
||||
let iframeError = $state('');
|
||||
let iframeHeight = $state(400);
|
||||
let readyRetryCount = $state(0);
|
||||
|
||||
const MAX_READY_RETRIES = 3;
|
||||
const READY_TIMEOUT_MS = 5000;
|
||||
const RETRY_BACKOFF = [1000, 2000, 4000];
|
||||
|
||||
let readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingPayload: TWidgetPayload | null = null;
|
||||
|
||||
/** postMessage uses the structured clone algorithm; Svelte proxies / non-plain objects must be plain JSON first. */
|
||||
function clonePayloadForPostMessage(widgetPayload: TWidgetPayload): TWidgetPayload {
|
||||
return JSON.parse(JSON.stringify(widgetPayload)) as TWidgetPayload;
|
||||
}
|
||||
|
||||
function postToIframe(message: WidgetPreviewParentMessage) {
|
||||
if (!iframeElement?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOrigin = window.location.origin;
|
||||
iframeElement.contentWindow.postMessage(message, targetOrigin);
|
||||
}
|
||||
|
||||
function sendPayload(widgetPayload: TWidgetPayload) {
|
||||
if (!iframeReady) {
|
||||
pendingPayload = widgetPayload;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
postToIframe({
|
||||
type: 'WIDGET_PREVIEW_RENDER',
|
||||
payload: clonePayloadForPostMessage(widgetPayload),
|
||||
requestId
|
||||
});
|
||||
}
|
||||
|
||||
function handleIframeMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidIframeMessage(event.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_READY') {
|
||||
iframeReady = true;
|
||||
iframeError = '';
|
||||
readyRetryCount = 0;
|
||||
|
||||
if (readyTimeoutId) {
|
||||
clearTimeout(readyTimeoutId);
|
||||
readyTimeoutId = null;
|
||||
}
|
||||
|
||||
if (pendingPayload) {
|
||||
sendPayload(pendingPayload);
|
||||
pendingPayload = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_RENDERED') {
|
||||
onSynced?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_ERROR') {
|
||||
console.error('[WidgetPreview]', message.error);
|
||||
iframeError = message.error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'WIDGET_PREVIEW_RESIZE') {
|
||||
iframeHeight = Math.max(200, message.height + 16);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForReady() {
|
||||
if (iframeReady || readyRetryCount >= MAX_READY_RETRIES) {
|
||||
if (!iframeReady) {
|
||||
iframeError = t.get('widgets.preview.load_failed');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffMs = RETRY_BACKOFF[readyRetryCount] ?? READY_TIMEOUT_MS;
|
||||
readyTimeoutId = setTimeout(() => {
|
||||
if (iframeReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
readyRetryCount += 1;
|
||||
postToIframe({ type: 'WIDGET_PREVIEW_PING' });
|
||||
waitForReady();
|
||||
}, backoffMs);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (payload) {
|
||||
sendPayload(payload);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('message', handleIframeMessage);
|
||||
waitForReady();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('message', handleIframeMessage);
|
||||
|
||||
if (readyTimeoutId) {
|
||||
clearTimeout(readyTimeoutId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if iframeError}
|
||||
<div class="ui:text-muted-foreground flex items-center justify-center rounded-2xl border border-dashed p-8 text-sm">
|
||||
<p>{iframeError}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl transition-all" style={maxWidth ? `max-width:${maxWidth}px` : ''}>
|
||||
<iframe
|
||||
bind:this={iframeElement}
|
||||
src="/widget-preview"
|
||||
title={$t('widgets.preview.heading')}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
class="w-full border-0"
|
||||
style="height:{iframeHeight}px"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetLayoutType } from '@cio/utils/validation/widget';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
import { widgetLayoutThumbnailSrc } from '../utils/layout-thumbnail';
|
||||
|
||||
interface Props {
|
||||
layoutType: TWidgetLayoutType;
|
||||
label?: string;
|
||||
/** Smaller frame for dense lists (e.g. org widgets index). */
|
||||
compact?: boolean;
|
||||
/** `contain` shows the full image (letterboxed); `cover` fills the frame (may crop). */
|
||||
objectFit?: 'cover' | 'contain';
|
||||
/** Strong primary border (e.g. layout picker selected state). */
|
||||
selected?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { layoutType, label, compact = false, objectFit = 'cover', class: className = '' }: Props = $props();
|
||||
|
||||
let imgFailed = $state(false);
|
||||
|
||||
const accent: Record<TWidgetLayoutType, string> = {
|
||||
card_grid: 'from-sky-500/15 via-cyan-500/8',
|
||||
tag_filter: 'from-orange-500/15 via-amber-500/8',
|
||||
carousel: 'from-fuchsia-500/15 via-pink-500/8',
|
||||
primary_course: 'from-emerald-500/15 via-lime-500/8',
|
||||
compact_list: 'from-violet-500/15 via-purple-500/8',
|
||||
editorial_spotlight: 'from-rose-500/15 via-red-500/8',
|
||||
category_shelf: 'from-indigo-500/15 via-blue-500/8'
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
void layoutType;
|
||||
imgFailed = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:bg-background/95 relative w-full overflow-hidden rounded-md border',
|
||||
compact ? 'aspect-5/3 max-h-28' : 'aspect-4/3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
'pointer-events-none absolute inset-x-0 -top-10 h-32 bg-gradient-to-br to-transparent opacity-70 blur-3xl',
|
||||
accent[layoutType]
|
||||
)}
|
||||
></div>
|
||||
|
||||
{#if !imgFailed}
|
||||
<img
|
||||
src={widgetLayoutThumbnailSrc(layoutType)}
|
||||
alt={label ?? layoutType}
|
||||
class={cn(
|
||||
'absolute inset-0 h-full w-full transition-transform',
|
||||
objectFit === 'contain' ? 'object-contain object-center' : 'object-cover'
|
||||
)}
|
||||
width={compact ? 480 : 720}
|
||||
height={compact ? 288 : 540}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => (imgFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="relative flex h-full w-full items-center justify-center px-3 text-center">
|
||||
<span class="ui:text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase">
|
||||
{label ?? layoutType.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import { CourseWidget, widgetStyles } from '@cio/ui/custom/widget-layouts';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { payload = null }: { payload: TWidgetPayload | null } = $props();
|
||||
|
||||
let styleEl: HTMLStyleElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.setAttribute('data-cio-widget-preview', '');
|
||||
styleEl.textContent = widgetStyles;
|
||||
document.head.appendChild(styleEl);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (styleEl?.parentNode) {
|
||||
styleEl.parentNode.removeChild(styleEl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !payload}
|
||||
<div class="ui:text-muted-foreground rounded-2xl border border-dashed p-8 text-sm">
|
||||
{$t('widgets.preview.empty')}
|
||||
</div>
|
||||
{:else}
|
||||
<CourseWidget {payload} />
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api/widget.svelte';
|
||||
export * from './pages';
|
||||
export * from './utils/layout-thumbnail';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as WidgetEditorPage } from './widget-editor.svelte';
|
||||
export { default as WidgetsPage } from './widgets.svelte';
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import type { WidgetDetail } from '../utils/types';
|
||||
import { widgetEditorStore } from '../store/widget-editor.store.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import WidgetIframePreview from '../components/widget-iframe-preview.svelte';
|
||||
import WidgetEditorHeader from '../components/widget-editor-header.svelte';
|
||||
import SelectCoursesPanel from '../panels/select-courses-panel.svelte';
|
||||
import LayoutPanel from '../panels/layout-panel.svelte';
|
||||
import DesignPanel from '../panels/design-panel.svelte';
|
||||
import EmbedPanel from '../panels/embed-panel.svelte';
|
||||
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
|
||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette';
|
||||
import Share2Icon from '@lucide/svelte/icons/share-2';
|
||||
|
||||
const store = widgetEditorStore;
|
||||
|
||||
let { detail: initialDetail }: { detail: WidgetDetail } = $props();
|
||||
|
||||
$effect(() => store.syncFromInitial(initialDetail));
|
||||
|
||||
$effect(() => {
|
||||
if (!store.detail || !$currentOrg.id) return;
|
||||
|
||||
return store.startPreviewEffect();
|
||||
});
|
||||
|
||||
function navButtonVariant(id: typeof store.activePanel): 'default' | 'ghost' {
|
||||
return store.activePanel === id ? 'default' : 'ghost';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if store.detail}
|
||||
<div class="ui:bg-background ui:text-foreground flex h-dvh flex-col">
|
||||
<WidgetEditorHeader
|
||||
bind:draftName={store.draftName}
|
||||
status={store.detail.widget.status}
|
||||
isPaidPlan={store.detail.planGatedFields.isPaidPlan}
|
||||
isDirty={store.isDirty}
|
||||
layoutValidationError={store.layoutValidationError}
|
||||
editingName={store.editingName}
|
||||
onSave={() => store.saveWidget()}
|
||||
onPublish={() => store.publishWidget()}
|
||||
onDiscard={() => store.resetDraft()}
|
||||
onRenameStart={() => (store.editingName = true)}
|
||||
onRenameFinish={() => (store.editingName = false)}
|
||||
/>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Icon rail -->
|
||||
<nav
|
||||
class="ui:border-border ui:bg-secondary flex w-14 shrink-0 flex-col items-center gap-1.5 border-r py-3"
|
||||
aria-label={$t('widgets.heading')}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navButtonVariant('select-courses')}
|
||||
tooltip={$t('widgets.tabs.select_courses')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('widgets.tabs.select_courses')}
|
||||
aria-current={store.activePanel === 'select-courses' ? 'page' : undefined}
|
||||
onclick={() => (store.activePanel = 'select-courses')}
|
||||
>
|
||||
<GraduationCapIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navButtonVariant('layout')}
|
||||
tooltip={$t('widgets.tabs.layout')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('widgets.tabs.layout')}
|
||||
aria-current={store.activePanel === 'layout' ? 'page' : undefined}
|
||||
onclick={() => (store.activePanel = 'layout')}
|
||||
>
|
||||
<LayoutGridIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navButtonVariant('design')}
|
||||
tooltip={$t('widgets.tabs.design')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('widgets.tabs.design')}
|
||||
aria-current={store.activePanel === 'design' ? 'page' : undefined}
|
||||
onclick={() => (store.activePanel = 'design')}
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navButtonVariant('embed')}
|
||||
tooltip={$t('widgets.tabs.embed')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('widgets.tabs.embed')}
|
||||
aria-current={store.activePanel === 'embed' ? 'page' : undefined}
|
||||
onclick={() => (store.activePanel = 'embed')}
|
||||
>
|
||||
<Share2Icon class="size-4" />
|
||||
</IconButton>
|
||||
</nav>
|
||||
|
||||
<!-- Settings panel -->
|
||||
<aside class="ui:border-border ui:bg-card flex w-[min(100%,380px)] shrink-0 flex-col border-r">
|
||||
<div class="ui:border-border border-b px-5 py-4">
|
||||
{#if store.activePanel === 'select-courses'}
|
||||
<h2 class="text-sm font-semibold">{$t('widgets.tabs.select_courses')}</h2>
|
||||
{:else if store.activePanel === 'layout'}
|
||||
<h2 class="text-sm font-semibold">{$t('widgets.editor.pick_layout')}</h2>
|
||||
{:else if store.activePanel === 'design'}
|
||||
<h2 class="text-sm font-semibold">{$t('widgets.editor.pick_design')}</h2>
|
||||
{:else}
|
||||
<h2 class="text-sm font-semibold">{$t('widgets.editor.embed_share')}</h2>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('widgets.editor.panel_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{#if store.activePanel === 'select-courses'}
|
||||
<SelectCoursesPanel
|
||||
detail={store.detail}
|
||||
bind:draftSelectionMode={store.draftSelectionMode}
|
||||
bind:selectedCourseIds={store.selectedCourseIds}
|
||||
bind:courseSearch={store.courseSearch}
|
||||
bind:filterPublished={store.filterPublished}
|
||||
bind:filterCourseType={store.filterCourseType}
|
||||
bind:filterTagSlugs={store.filterTagSlugs}
|
||||
filteredCourses={store.filteredCourses}
|
||||
activeFilterCount={store.activeFilterCount}
|
||||
onContinueToLayout={() => (store.activePanel = 'layout')}
|
||||
onClearFilters={() => store.clearFilters()}
|
||||
/>
|
||||
{:else if store.activePanel === 'layout'}
|
||||
<LayoutPanel
|
||||
bind:draftLayoutType={store.draftLayoutType}
|
||||
bind:draftConfig={store.draftConfig}
|
||||
featuredCourseOptions={store.featuredCourseOptions}
|
||||
tagOptions={store.tagOptions}
|
||||
layoutValidationError={store.layoutValidationError}
|
||||
onToggleCategoryTag={(tagId, checked) => store.toggleCategoryTag(tagId, checked)}
|
||||
/>
|
||||
{:else if store.activePanel === 'design'}
|
||||
<DesignPanel
|
||||
bind:draftConfig={store.draftConfig}
|
||||
availableThemes={store.detail.planGatedFields.availableThemes}
|
||||
canUseCustomColors={store.detail.planGatedFields.canUseCustomColors}
|
||||
canUseCustomCss={store.detail.planGatedFields.canUseCustomCss}
|
||||
isBrandingForced={store.detail.planGatedFields.isBrandingForced}
|
||||
/>
|
||||
{:else}
|
||||
<EmbedPanel
|
||||
detail={store.detail}
|
||||
onRollback={(versionId) => store.rollback(versionId)}
|
||||
onDelete={() => store.deleteWidget()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Preview canvas -->
|
||||
<section
|
||||
class="relative flex min-h-0 min-w-0 flex-1 flex-col bg-zinc-100 bg-[radial-gradient(circle,#d4d4d8_1px,transparent_1px)] bg-size-[18px_18px] dark:bg-zinc-950 dark:bg-[radial-gradient(circle,rgba(113,113,122,0.4)_1px,transparent_1px)]"
|
||||
aria-label={$t('widgets.preview.heading')}
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 sm:p-6 lg:p-8">
|
||||
<div
|
||||
class="ui:border-border ui:bg-card/90 w-full max-w-5xl rounded-xl border p-4 shadow-sm backdrop-blur sm:p-8 dark:shadow-black/20"
|
||||
>
|
||||
<WidgetIframePreview payload={store.previewPayload} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import pluralize from 'pluralize';
|
||||
import { widgetApi } from '../api/widget.svelte';
|
||||
import type { WidgetListItem } from '../utils/types';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import WidgetLayoutThumbnail from '../components/widget-layout-thumbnail.svelte';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
|
||||
import WidgetCardDropdown from '../components/widget-card-dropdown.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
initialWidgets?: WidgetListItem[];
|
||||
onCreate: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { initialWidgets = [] as WidgetListItem[], onCreate }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
widgetApi.widgets = initialWidgets;
|
||||
});
|
||||
|
||||
function getStatusDotClass(status: string) {
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
|
||||
if (normalizedStatus === 'published') {
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'draft') {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
return 'ui:bg-muted-foreground/60';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if widgetApi.widgets.length === 0}
|
||||
<Empty
|
||||
title={$t('widgets.empty.heading')}
|
||||
description={$t('widgets.empty.description')}
|
||||
icon={PanelsTopLeftIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={onCreate}>{$t('widgets.actions.create')}</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each widgetApi.widgets as widget (widget.id)}
|
||||
{@const widgetHref = resolve(`/widgets/${widget.id}`, {})}
|
||||
|
||||
<Item.Root variant="outline" class="group ui:bg-card relative overflow-hidden rounded-2xl border p-0!">
|
||||
<WidgetCardDropdown id={widget.id} name={widget.name} />
|
||||
{#snippet child({ props })}
|
||||
<a href={widgetHref} {...props} class={cn('block h-full w-full', props.class as string)}>
|
||||
<div class="relative flex h-full w-full flex-col p-3.5">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 items-start gap-2.5">
|
||||
<div
|
||||
class="ui:bg-primary/10 ui:text-primary flex size-8 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<PanelsTopLeftIcon class="size-4" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Item.Title class="ui:w-full text-sm! leading-tight font-semibold">
|
||||
<span class="line-clamp-2">{widget.name}</span>
|
||||
</Item.Title>
|
||||
<Item.Description class="mt-0.5 line-clamp-1! text-xs">
|
||||
{$t(`widgets.layout.${widget.layoutType}`)}
|
||||
</Item.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class={cn('mt-1 block size-2 shrink-0 rounded-full', getStatusDotClass(widget.status))}
|
||||
aria-label={widget.status}
|
||||
title={widget.status}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<WidgetLayoutThumbnail
|
||||
class="mt-3"
|
||||
compact
|
||||
layoutType={widget.layoutType}
|
||||
label={$t(`widgets.layout.${widget.layoutType}`)}
|
||||
/>
|
||||
|
||||
<div class="ui:text-muted-foreground mt-3 flex items-center justify-between gap-3 text-xs">
|
||||
<span class="truncate">{$t(`widgets.selection.${widget.selectionMode}`)}</span>
|
||||
<span class="shrink-0">
|
||||
{pluralize($t('tags_admin.table.course'), widget.courseCount ?? 0, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ui:border-border/60 mt-3 flex items-center justify-end border-t pt-2.5">
|
||||
<Button variant="secondary" size="sm" href={widgetHref} class="h-8 gap-1 rounded-full px-3 text-xs">
|
||||
{$t('widgets.actions.edit')}
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { WidgetDetail } from '../utils/types';
|
||||
import type { WidgetConfig } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
draftConfig: WidgetConfig;
|
||||
availableThemes: WidgetDetail['planGatedFields']['availableThemes'];
|
||||
canUseCustomColors: boolean;
|
||||
canUseCustomCss: boolean;
|
||||
isBrandingForced: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
draftConfig = $bindable(),
|
||||
availableThemes,
|
||||
canUseCustomColors,
|
||||
canUseCustomCss,
|
||||
isBrandingForced
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('widgets.editor.design')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('widgets.form.theme_preset')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={draftConfig.themePreset}>
|
||||
<Select.Trigger>{draftConfig.themePreset}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availableThemes as theme (theme)}
|
||||
<Select.Item value={theme}>{theme}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
<InputField label={$t('widgets.form.primary_color')} bind:value={draftConfig.colors.primaryColor} />
|
||||
<InputField
|
||||
label={$t('widgets.form.background_color')}
|
||||
bind:value={draftConfig.colors.backgroundColor}
|
||||
isDisabled={!canUseCustomColors}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('widgets.form.text_color')}
|
||||
bind:value={draftConfig.colors.textColor}
|
||||
isDisabled={!canUseCustomColors}
|
||||
/>
|
||||
<InputField
|
||||
label={$t('widgets.form.border_color')}
|
||||
bind:value={draftConfig.colors.borderColor}
|
||||
isDisabled={!canUseCustomColors}
|
||||
/>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.form.border_radius')}
|
||||
bind:value={draftConfig.content.borderRadius}
|
||||
/>
|
||||
<InputField label={$t('widgets.form.font_family')} bind:value={draftConfig.typography.fontFamily} />
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('widgets.form.font_scale')}
|
||||
bind:value={draftConfig.typography.fontSizeScale}
|
||||
/>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={draftConfig.branding.showPoweredBy} disabled={isBrandingForced} />
|
||||
<Field.Label>{$t('widgets.form.show_powered_by')}</Field.Label>
|
||||
</Field.Field>
|
||||
<TextareaField
|
||||
label={$t('widgets.form.custom_css')}
|
||||
bind:value={draftConfig.advanced.customCss}
|
||||
rows={6}
|
||||
disabled={!canUseCustomCss}
|
||||
helperMessage={!canUseCustomCss ? $t('widgets.form.custom_css_locked') : ''}
|
||||
/>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user