chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+2
View File
@@ -33,7 +33,9 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.69",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/moonshotai": "^2.0.21",
"@ai-sdk/openai": "^3.0.52",
"@cio/question-types": "workspace:*",
"ai": "^6.0.158",
"zod": "^3.24.4"
},
+22 -5
View File
@@ -1,5 +1,8 @@
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import type { AgentContext } from '../types';
const QUESTION_TYPE_LIST = QUESTION_TYPE_REGISTRY.map((t) => `- ${t.id} = ${t.typename} — ${t.label}`).join('\n');
export function buildTeacherSystemPrompt(context: AgentContext): string {
const contextLines: string[] = [];
@@ -43,7 +46,13 @@ export function buildTeacherSystemPrompt(context: AgentContext): string {
## Your Capabilities
You have access to specific tools listed below. Use them to read course content, create sections and lessons, update existing section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, edit exercise metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts) via update_exercise, add or update questions inside an existing exercise, update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
You have access to specific tools listed below. Use them to read course content, create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
## Question Types
These are the only question type IDs supported by this platform. Always use these IDs when creating or updating questions — never infer type IDs from exercise data, which only shows which types happen to be in use:
${QUESTION_TYPE_LIST}
## Plan Mode vs Agent Mode
@@ -68,6 +77,13 @@ You have access to specific tools listed below. Use them to read course content,
4. Report progress as you go
5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items.
**Resuming / Continuing Plan Execution** — When the teacher sends a continuation signal (e.g. "continue", "keep going", "proceed", "resume") after a previous step-limit pause:
1. Call get_course_structure FIRST before creating anything.
2. Compare the existing sections, lessons, and exercises against the approved plan — match by title.
3. Skip every item that already exists. Do NOT recreate a section, lesson, or exercise that is already present in the course.
4. If a section already exists but some of its items (lessons or exercises) are missing, resume inside that existing section — do NOT create a duplicate section.
5. Begin creating only from the first item in the plan that is not yet present, then continue through the rest of the plan in order.
**Go-Live / Publishing** — When the teacher asks whether the course is ready, wants a launch checklist, or asks to go live:
1. Use check_course_go_live_readiness first to inspect required course details, landing-page fields, lessons, and exercises
2. If blockers are returned, explain the blockers clearly and use available tools to fix only the items the teacher asks you to fix
@@ -93,26 +109,27 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section.
- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, section, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to create a new **block of questions inside an exercise**, use create_exercise_section. If they ask to rename or add intro text for an existing block, call get_exercise_details first, then use update_exercise_section with the exercise id and the section id from the sections array. Do NOT use update_section for that — update_section only changes course outline sections.
- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course.
## Editing Existing Questions
To change an existing question (its text, points, order, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Where the correct answer lives depends on the question type:
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option.
- NUMERIC: on \`settings.correctValue\` (a number). Optional \`settings.tolerance\`. Do NOT add options to NUMERIC questions.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
Always call get_exercise_details first to read current ids and settings before patching.
Always call get_exercise_details first to read current question ids, in-exercise section ids (for update_exercise_section), and settings before patching.
## IDs and Tool Arguments
- UUIDs and database IDs must be copied EXACTLY from the most recent tool output that produced them. Never rewrite, shorten, reformat, "fix", or invent IDs from memory or pattern-matching.
- NEVER pass placeholder strings like \`"string"\`, \`"uuid"\`, \`"<id>"\`, or example IDs from this prompt as tool arguments. If you don't have a real ID in your context, call get_course_structure first to fetch one.
- Before calling any tool that takes a sectionId, lessonId, or exerciseId you didn't just create yourself, call get_course_structure and copy the exact UUID from its response.
- After create_section / create_lesson / create_exercise returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess.
- After create_section / create_lesson / create_exercise / create_exercise_section returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess.
- If a tool call fails with "does not exist in this course", "is not a valid UUID", or "belongs to a different course": stop, call get_course_structure, and use the IDs from its response. Do NOT retry with another guessed ID.
- Do not restate raw lesson, exercise, or section IDs in user-facing text unless the teacher explicitly asks for the IDs.
- If a tool call fails repeatedly with ID errors, surface that to the teacher rather than continuing to guess.
+10 -3
View File
@@ -1,18 +1,21 @@
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createMoonshotAI } from '@ai-sdk/moonshotai';
import { AIProvider, type AIProviderConfig } from '../types';
const DEFAULT_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o',
[AIProvider.OPENAI]: 'gpt-5.4-mini',
[AIProvider.ANTHROPIC]: 'claude-sonnet-4-20250514',
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
const PROVIDER_API_KEY_ENV: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'OPENAI_API_KEY',
[AIProvider.ANTHROPIC]: 'ANTHROPIC_API_KEY',
[AIProvider.GOOGLE]: 'GOOGLE_API_KEY'
[AIProvider.GOOGLE]: 'GOOGLE_API_KEY',
[AIProvider.MOONSHOT]: 'MOONSHOT_API_KEY'
};
/**
@@ -35,6 +38,10 @@ export function createModel(config: AIProviderConfig) {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
case AIProvider.MOONSHOT: {
const moonshot = createMoonshotAI({ apiKey: config.apiKey });
return moonshot(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
+6
View File
@@ -8,7 +8,9 @@ import {
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
createExerciseSectionSchema,
updateExerciseSchema,
updateExerciseSectionSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
@@ -37,7 +39,9 @@ const teacherTools: ToolRegistry = {
[ToolName.UPDATE_LESSON]: updateLessonSchema,
[ToolName.UPDATE_LESSON_CONTENT]: updateLessonContentSchema,
[ToolName.CREATE_EXERCISE]: createExerciseSchema,
[ToolName.CREATE_EXERCISE_SECTION]: createExerciseSectionSchema,
[ToolName.UPDATE_EXERCISE]: updateExerciseSchema,
[ToolName.UPDATE_EXERCISE_SECTION]: updateExerciseSectionSchema,
[ToolName.ADD_QUESTIONS]: addQuestionsSchema,
[ToolName.UPDATE_QUESTIONS]: updateQuestionsSchema,
[ToolName.UPDATE_COURSE_LANDING_PAGE]: updateCourseLandingPageSchema,
@@ -76,7 +80,9 @@ export {
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
createExerciseSectionSchema,
updateExerciseSchema,
updateExerciseSectionSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
+67 -7
View File
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { CoursePlanSchema } from '../types';
const QUESTION_TYPE_ID_DESCRIPTION = QUESTION_TYPE_REGISTRY.map((t) => `${t.id}=${t.typename} (${t.label})`).join(', ');
/**
* Mutation tool schemas available only to teachers.
*/
@@ -15,6 +18,12 @@ export const createSectionSchema = {
})
};
const exerciseSectionAfterBehaviorSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('continue') }),
z.object({ action: z.literal('submit') }),
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() })
]);
export const updateSectionSchema = {
description:
'Update metadata for an existing section in the course. Use this to rename a section or change its display order instead of creating a new section.',
@@ -117,9 +126,9 @@ export const createExerciseSchema = {
.number()
.int()
.min(1)
.max(14)
.max(QUESTION_TYPE_REGISTRY.length)
.default(1)
.describe('Question type: 1=RADIO (single answer), 2=CHECKBOX (multiple), 4=TRUE_FALSE, 3=TEXTAREA'),
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z
@@ -141,6 +150,36 @@ export const createExerciseSchema = {
})
};
export const createExerciseSectionSchema = {
description:
'Create a new section inside an existing exercise. Use this to add a new question block before adding questions into it.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise that will contain this section'),
title: z.string().min(1).describe('Section heading shown above questions in this block'),
description: z.string().optional().describe('Optional intro HTML for this block'),
order: z.number().int().min(0).describe('Display order within the exercise (0-based)'),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
afterBehavior: exerciseSectionAfterBehaviorSchema.optional()
})
};
export const updateExerciseSectionSchema = {
description:
'Update the title and/or description of one question block inside an exercise (from get_exercise_details sections). Not the same as update_section (course outline).',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise containing this block'),
exerciseSectionId: z.string().describe('Section id from get_exercise_details sections[].id'),
title: z.string().min(1).optional().describe('Heading shown above questions in this block'),
description: z.string().optional().describe('Optional intro HTML for this block; empty string clears it')
})
.refine((data) => data.title !== undefined || data.description !== undefined, {
message: 'Provide at least title or description'
})
};
export const updateExerciseSchema = {
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this to edit the exercise itself — not its questions. To change questions, use update_questions; to add questions, use add_questions.",
@@ -181,10 +220,17 @@ export const updateExerciseSchema = {
export const addQuestionsSchema = {
description:
'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions.',
'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions. When get_exercise_details returns a non-empty sections array, pass exerciseSectionId so new questions are placed in the right in-exercise block (same ids as update_exercise_section).',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
exerciseSectionId: z
.string()
.uuid()
.optional()
.describe(
'In-exercise section id from get_exercise_details `sections[].id` (not a course outline section id). Use when the exercise has question blocks/sections; omit only when questions are not grouped into sections.'
),
questions: z.array(
z.object({
question: z.string().min(1).describe('The question text'),
@@ -192,9 +238,9 @@ export const addQuestionsSchema = {
.number()
.int()
.min(1)
.max(14)
.max(QUESTION_TYPE_REGISTRY.length)
.default(1)
.describe('Question type: 1=RADIO, 2=CHECKBOX, 4=TRUE_FALSE, 3=TEXTAREA'),
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z.array(
@@ -210,7 +256,7 @@ export const addQuestionsSchema = {
export const updateQuestionsSchema = {
description:
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -218,9 +264,23 @@ export const updateQuestionsSchema = {
z.object({
id: z.number().int().describe('Existing question id. Required.'),
question: z.string().min(1).optional().describe('New question text. Omit to keep unchanged.'),
questionTypeId: z.number().int().min(1).max(14).optional(),
questionTypeId: z
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.optional()
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
'In-exercise section id from get_exercise_details (not course outline). Omit to leave unchanged; pass null to unassign from a section.'
),
settings: z
.record(z.string(), z.unknown())
.optional()
+4 -1
View File
@@ -14,7 +14,8 @@ export type AgentRole = (typeof AgentRole)[keyof typeof AgentRole];
export const AIProvider = {
OPENAI: 'openai',
ANTHROPIC: 'anthropic',
GOOGLE: 'google'
GOOGLE: 'google',
MOONSHOT: 'moonshot'
} as const;
export type AIProvider = (typeof AIProvider)[keyof typeof AIProvider];
@@ -92,7 +93,9 @@ export const ToolName = {
UPDATE_LESSON: 'update_lesson',
UPDATE_LESSON_CONTENT: 'update_lesson_content',
CREATE_EXERCISE: 'create_exercise',
CREATE_EXERCISE_SECTION: 'create_exercise_section',
UPDATE_EXERCISE: 'update_exercise',
UPDATE_EXERCISE_SECTION: 'update_exercise_section',
ADD_QUESTIONS: 'add_questions',
UPDATE_QUESTIONS: 'update_questions',
UPDATE_COURSE_LANDING_PAGE: 'update_course_landing_page',
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS "ai_chat_document" (
"id" text PRIMARY KEY NOT NULL,
"conversation_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"asset_id" uuid,
"file_name" text NOT NULL,
"mime_type" text NOT NULL,
"text" text NOT NULL,
"word_count" integer DEFAULT 0 NOT NULL,
"page_count" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai_chat_document" ADD CONSTRAINT "ai_chat_document_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "ai_chat_conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_ai_chat_document_conversation" ON "ai_chat_document" ("conversation_id");
@@ -53,12 +53,12 @@ export async function createChatDocument(record: {
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
export async function getChatDocument(documentId: string, userId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.where(and(eq(schema.aiChatDocument.id, documentId), eq(schema.aiChatDocument.userId, userId)))
.limit(1);
return row ?? null;
@@ -0,0 +1,54 @@
import * as schema from '@db/schema';
import { eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type ResourceCourseBinding = {
courseId: string | null;
title: string | null;
};
export async function getLessonCourseBinding(lessonId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.lesson.courseId, title: schema.lesson.title })
.from(schema.lesson)
.where(eq(schema.lesson.id, lessonId))
.limit(1);
return row;
} catch (error) {
console.error('getLessonCourseBinding error:', error);
throw new Error(`Failed to load lesson: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExerciseCourseBinding(exerciseId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.exercise.courseId, title: schema.exercise.title })
.from(schema.exercise)
.where(eq(schema.exercise.id, exerciseId))
.limit(1);
return row;
} catch (error) {
console.error('getExerciseCourseBinding error:', error);
throw new Error(`Failed to load exercise: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCourseSectionBinding(sectionId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.courseSection.courseId, title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, sectionId))
.limit(1);
return row;
} catch (error) {
console.error('getCourseSectionBinding error:', error);
throw new Error(`Failed to load section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1,136 @@
import { desc, eq, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface CreditPurchaseInput {
orgId: string;
triggeredBy?: string | null;
provider?: 'polar';
providerOrderId: string;
tokens: number;
quantity: number;
unitPriceCents: number;
currency: string;
payload?: Record<string, unknown>;
}
export async function findCreditPurchaseByOrderId(providerOrderId: string) {
try {
const [row] = await db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.providerOrderId, providerOrderId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('findCreditPurchaseByOrderId error:', error);
throw new Error('Failed to find credit purchase');
}
}
export async function createCreditPurchase(input: CreditPurchaseInput) {
try {
const [row] = await db
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
return row;
} catch (error) {
console.error('createCreditPurchase error:', error);
throw new Error('Failed to create credit purchase');
}
}
export async function summarizePurchases(orgId: string) {
try {
const [row] = await db
.select({
totalPurchasedTokens: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.tokens}), 0)`,
totalSpentCents: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.unitPriceCents} * ${schema.aiCreditPurchase.quantity}), 0)`,
lastPurchaseAt: sql<string | null>`MAX(${schema.aiCreditPurchase.createdAt})`
})
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId));
return {
totalPurchasedTokens: Number(row?.totalPurchasedTokens ?? 0),
totalSpentCents: Number(row?.totalSpentCents ?? 0),
lastPurchaseAt: row?.lastPurchaseAt ?? null
};
} catch (error) {
console.error('summarizePurchases error:', error);
throw new Error('Failed to summarize purchases');
}
}
export async function listCreditPurchases(orgId: string, limit = 50) {
try {
return db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId))
.orderBy(desc(schema.aiCreditPurchase.createdAt))
.limit(limit);
} catch (error) {
console.error('listCreditPurchases error:', error);
throw new Error('Failed to list credit purchases');
}
}
/** Inserts purchase row and applies balance in one transaction. Caller checks idempotency first. */
export async function insertCreditPurchaseAndApplyBalance(input: CreditPurchaseInput) {
try {
return await db.transaction(async (tx) => {
const [purchase] = await tx
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
const [existingBalance] = await tx
.select({ id: schema.aiCreditBalance.id })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, input.orgId))
.limit(1);
if (existingBalance) {
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`${schema.aiCreditBalance.balance} + ${input.tokens}`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, input.orgId));
} else {
await tx.insert(schema.aiCreditBalance).values({ orgId: input.orgId, balance: input.tokens });
}
return purchase;
});
} catch (error) {
console.error('insertCreditPurchaseAndApplyBalance error:', error);
throw new Error('Failed to record credit purchase');
}
}
+4
View File
@@ -1,2 +1,6 @@
export * from './chat-history';
export * from './chat-document';
export * from './chat-resource';
export * from './credit-purchase';
export * from './token-usage';
export * from './usage-aggregations';
@@ -0,0 +1,144 @@
import { and, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export async function getMonthlyTokenUsage(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.total ?? 0);
} catch (error) {
console.error('getMonthlyTokenUsage error:', error);
throw new Error('Failed to get monthly token usage');
}
}
export async function getDailyTokenUsageHistory(
orgId: string,
since: Date
): Promise<{ date: string; tokens: number }[]> {
try {
const rows = await db
.select({
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
tokens: sql<number>`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
return rows.map((row) => ({ date: String(row.date), tokens: Number(row.tokens) }));
} catch (error) {
console.error('getDailyTokenUsageHistory error:', error);
throw new Error('Failed to get daily token usage history');
}
}
export async function getOrgCreditBalance(orgId: string): Promise<number> {
try {
const [row] = await db
.select({ balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
return row?.balance ?? 0;
} catch (error) {
console.error('getOrgCreditBalance error:', error);
throw new Error('Failed to get org credit balance');
}
}
export interface InsertTokenUsageParams {
orgId: string;
userId: string;
courseId: string;
promptTokens: number;
completionTokens: number;
costUnits: number;
model: string;
planAllowance: number;
since: Date;
}
export async function insertTokenUsageAndDrainCredits(params: InsertTokenUsageParams): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiTokenUsage).values({
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
costUnits: params.costUnits,
model: params.model
});
const [usageRow] = await tx
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(
and(
eq(schema.aiTokenUsage.orgId, params.orgId),
gte(schema.aiTokenUsage.createdAt, params.since.toISOString())
)
);
const monthlyUsageAfter = Number(usageRow?.total ?? 0);
const monthlyUsageBefore = monthlyUsageAfter - params.costUnits;
const allowanceRemainingBefore = Math.max(0, params.planAllowance - monthlyUsageBefore);
const overflow = Math.max(0, params.costUnits - allowanceRemainingBefore);
if (overflow <= 0) {
return;
}
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`GREATEST(0, ${schema.aiCreditBalance.balance} - ${overflow})`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, params.orgId));
});
} catch (error) {
console.error('insertTokenUsageAndDrainCredits error:', error);
throw new Error('Failed to record token usage');
}
}
export async function upsertCreditBalance(orgId: string, amount: number): Promise<number> {
try {
const existing = await db
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
if (existing.length > 0) {
const newBalance = existing[0].balance + amount;
await db
.update(schema.aiCreditBalance)
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
.where(eq(schema.aiCreditBalance.orgId, orgId));
return newBalance;
}
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
return amount;
} catch (error) {
console.error('upsertCreditBalance error:', error);
throw new Error('Failed to upsert credit balance');
}
}
@@ -0,0 +1,68 @@
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface UsageLeaderboardRow {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
favoriteModel: string | null;
requests: number;
}
/**
* Aggregate ai_token_usage by user for an org since a given date.
* Joins to profile for display fields. Returns rows sorted by tokens DESC.
*/
export async function aggregateTokenUsageByUser(orgId: string, since: Date): Promise<UsageLeaderboardRow[]> {
try {
const rows = await db
.select({
userId: schema.aiTokenUsage.userId,
fullname: schema.profile.fullname,
email: schema.profile.email,
avatarUrl: schema.profile.avatarUrl,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`,
favoriteModel: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${schema.aiTokenUsage.model})`,
requests: sql<number>`COUNT(*)`
})
.from(schema.aiTokenUsage)
.leftJoin(schema.profile, eq(schema.profile.id, schema.aiTokenUsage.userId))
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(schema.aiTokenUsage.userId, schema.profile.fullname, schema.profile.email, schema.profile.avatarUrl)
.orderBy(
desc(
sql`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
)
);
return rows.map((row) => ({
...row,
tokens: Number(row.tokens),
requests: Number(row.requests)
}));
} catch (error) {
console.error('aggregateTokenUsageByUser error:', error);
throw new Error('Failed to aggregate token usage by user');
}
}
/**
* Total request count for an org since a given date — used for the chart header.
*/
export async function countRequests(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.count ?? 0);
} catch (error) {
console.error('countRequests error:', error);
throw new Error('Failed to count requests');
}
}
@@ -0,0 +1,186 @@
import type { TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
import { ContentType, ErrorCodes } from '@cio/utils/constants';
import * as schema from '@db/schema';
import { inArray, sql } from 'drizzle-orm';
import type { DbOrTxClient } from '@db/drizzle';
import { OperationalQueryError } from '../query-errors';
const { courseSection, lesson, exercise } = schema;
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
const payload: Record<string, unknown> = {};
if (item.isUnlocked !== undefined) {
payload.isUnlocked = item.isUnlocked;
}
if (item.order !== undefined) {
payload.order = item.order;
}
if (item.sectionId !== undefined) {
payload.sectionId = item.sectionId;
}
return payload;
}
export async function applyCourseContentBulkUpdates(
tx: DbOrTxClient,
items: TCourseContentUpdate['items']
): Promise<{ updatedItems: number; updatedLessons: number; updatedExercises: number }> {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new OperationalQueryError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new OperationalQueryError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
return {
updatedItems: updates.length,
updatedLessons: lessonUpdates.length,
updatedExercises: exerciseUpdates.length
};
}
export async function applyCourseSectionOrderUpdates(
tx: DbOrTxClient,
sections: NonNullable<TCourseContentReorder['sections']>
): Promise<number> {
if (sections.length === 0) {
return 0;
}
const sectionIds = sections.map((section) => section.id);
const updatedAt = new Date().toISOString();
const sectionSet: Record<string, unknown> = {
updatedAt,
order: sql`case ${courseSection.id} ${sql.join(
sections.map((section) => sql`when ${section.id} then ${section.order}`),
sql` `
)} else ${courseSection.order} end`
};
const updatedSections = await tx
.update(courseSection)
.set(sectionSet)
.where(inArray(courseSection.id, sectionIds))
.returning({ id: courseSection.id });
if (updatedSections.length !== sectionIds.length) {
throw new OperationalQueryError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return sections.length;
}
+48
View File
@@ -1287,3 +1287,51 @@ export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** True when any course row exists with this slug. */
export async function isCourseSlugTaken(slug: string): Promise<boolean> {
try {
const rows = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, slug))
.limit(1);
return rows.length > 0;
} catch (error) {
console.error('isCourseSlugTaken error:', error);
throw new Error(`Failed to check course slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Course `type` only; `undefined` when the course does not exist. */
export async function getCourseTypeById(courseId: string): Promise<string | undefined> {
try {
const [row] = await db
.select({ type: schema.course.type })
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
return row?.type ?? undefined;
} catch (error) {
console.error('getCourseTypeById error:', error);
throw new Error(`Failed to get course type: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Updates slug + updatedAt; returns the new slug row or null if the course was missing. */
export async function updateCourseSlug(courseId: string, slug: string): Promise<{ slug: string | null } | null> {
try {
const [updated] = await db
.update(schema.course)
.set({ slug, updatedAt: new Date().toISOString() })
.where(eq(schema.course.id, courseId))
.returning({ slug: schema.course.slug });
return updated ?? null;
} catch (error) {
console.error('updateCourseSlug error:', error);
throw new Error(`Failed to update course slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
export * from './course';
export * from './course';
export * from './compliance';
export * from './content';
export * from './content-batch';
export * from './people';
export * from './invite';
export * from './public-course';
+97 -1
View File
@@ -2,7 +2,7 @@ import type { TCourseInvite, TCourseInviteAudit, TNewCourseInvite, TNewCourseInv
import * as schema from '@db/schema';
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
export async function createCourseInvite(values: TNewCourseInvite): Promise<TCourseInvite> {
try {
@@ -333,3 +333,99 @@ export async function getCourseInviteByTokenHash(tokenHash: string): Promise<TCo
);
}
}
export type TCourseInviteAcceptBundle = {
invite: TCourseInvite;
course: {
id: string;
title: string;
status: string;
isPublished: boolean;
metadata: Record<string, unknown> | null;
groupId: string;
};
organization: {
id: string;
name: string;
siteName: string;
};
};
export async function selectCourseInviteAcceptBundleByTokenHash(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<TCourseInviteAcceptBundle | null> {
try {
const [row] = await dbClient
.select({
invite: schema.courseInvite,
course: {
id: schema.course.id,
title: schema.course.title,
status: schema.course.status,
isPublished: schema.course.isPublished,
metadata: schema.course.metadata,
groupId: schema.group.id
},
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.courseInvite)
.innerJoin(schema.course, eq(schema.courseInvite.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.courseInvite.tokenHash, tokenHash))
.limit(1);
if (!row) {
return null;
}
return {
...row,
course: {
...row.course,
isPublished: !!row.course.isPublished
},
organization: {
...row.organization,
siteName: row.organization.siteName ?? ''
}
};
} catch (error) {
console.error('selectCourseInviteAcceptBundleByTokenHash error:', error);
throw new Error(
`Failed to load course invite for accept: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function optimisticIncrementCourseInviteUsedCount(
dbClient: DbOrTxClient,
inviteId: string,
priorUsedCount: number
): Promise<{ id: string } | undefined> {
try {
const [consumeInvite] = await dbClient
.update(schema.courseInvite)
.set({
usedCount: priorUsedCount + 1,
updatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString()
})
.where(and(eq(schema.courseInvite.id, inviteId), eq(schema.courseInvite.usedCount, priorUsedCount)))
.returning({
id: schema.courseInvite.id
});
return consumeInvite;
} catch (error) {
console.error('optimisticIncrementCourseInviteUsedCount error:', error);
throw new Error(
`Failed to update course invite usage: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -0,0 +1,534 @@
import { and, asc, eq } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
/**
* Queries backing the anonymous public-course surface under
* `(org-site)/course/[courseSlug]`. Every query in this file assumes the
* course is already confirmed to be `type = 'PUBLIC'` and `is_published = true`
* at the service layer; they do not re-check auth.
*/
export interface PublicCourseHeader {
id: string;
slug: string;
title: string;
description: string | null;
bannerImage: string | null;
callout: {
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
} | null;
org: {
id: string;
name: string;
siteName: string | null;
avatarUrl: string | null;
} | null;
}
export interface PublicCourseSection {
id: string;
title: string;
order: number;
}
export interface PublicCourseLessonItem {
kind: 'lesson';
id: string;
slug: string;
title: string;
order: number;
sectionId: string | null;
isUnlocked: boolean;
hasVideo: boolean;
}
export interface PublicCourseExerciseItem {
kind: 'exercise';
id: string;
slug: string;
title: string;
order: number;
sectionId: string | null;
isUnlocked: boolean;
}
export type PublicCourseItem = PublicCourseLessonItem | PublicCourseExerciseItem;
export interface PublicCourseTree {
course: PublicCourseHeader;
sections: PublicCourseSection[];
items: PublicCourseItem[];
}
/**
* Minimal course row for slug-based guards (e.g. rate limiting). Does not enforce PUBLIC/published.
*/
export async function getCourseRowBySlug(courseSlug: string): Promise<{
id: string;
type: string | null;
isPublished: boolean | null;
status: string | null;
} | null> {
try {
const [row] = await db
.select({
id: schema.course.id,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getCourseRowBySlug error:', error);
throw new Error(`Failed to resolve course by slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Fetch the course header + its sections + every lesson / exercise (metadata only,
* no bodies) for a public course identified by its URL slug.
*
* Returns `null` when the course does not exist, is not published, or its type
* is not `PUBLIC`.
*/
export async function getPublicCourseTreeBySlug(courseSlug: string): Promise<PublicCourseTree | null> {
try {
const [courseRow] = await db
.select({
id: schema.course.id,
slug: schema.course.slug,
title: schema.course.title,
description: schema.course.description,
bannerImage: schema.course.bannerImage,
callout: schema.course.callout,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status,
groupId: schema.course.groupId
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
if (!courseRow) return null;
if (courseRow.type !== 'PUBLIC') return null;
if (!courseRow.isPublished) return null;
if (courseRow.status !== 'ACTIVE') return null;
if (!courseRow.slug) return null;
// Resolve the owning organization (course → group → organization). The
// public course page shows org name + avatar in the sticky top header so
// a guest can recognize the creator.
let org: PublicCourseHeader['org'] = null;
if (courseRow.groupId) {
const [orgRow] = await db
.select({
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
})
.from(schema.group)
.innerJoin(schema.organization, eq(schema.organization.id, schema.group.organizationId))
.where(eq(schema.group.id, courseRow.groupId))
.limit(1);
if (orgRow) {
org = {
id: orgRow.id,
name: orgRow.name,
siteName: orgRow.siteName ?? null,
avatarUrl: orgRow.avatarUrl ?? null
};
}
}
const sectionRows = await db
.select({
id: schema.courseSection.id,
title: schema.courseSection.title,
order: schema.courseSection.order
})
.from(schema.courseSection)
.where(eq(schema.courseSection.courseId, courseRow.id))
.orderBy(asc(schema.courseSection.order));
const lessonRows = await db
.select({
id: schema.lesson.id,
slug: schema.lesson.slug,
title: schema.lesson.title,
order: schema.lesson.order,
sectionId: schema.lesson.sectionId,
isUnlocked: schema.lesson.isUnlocked,
videos: schema.lesson.videos,
videoUrl: schema.lesson.videoUrl
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseRow.id))
.orderBy(asc(schema.lesson.order));
const exerciseRows = await db
.select({
id: schema.exercise.id,
slug: schema.exercise.slug,
title: schema.exercise.title,
order: schema.exercise.order,
sectionId: schema.exercise.sectionId,
lessonId: schema.exercise.lessonId,
isUnlocked: schema.exercise.isUnlocked
})
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseRow.id))
.orderBy(asc(schema.exercise.order));
const sections: PublicCourseSection[] = sectionRows
.filter((section): section is { id: string; title: string; order: number } => section.order !== null)
.map((section) => ({
id: section.id,
title: section.title,
order: Number(section.order)
}));
const lessonItems: PublicCourseLessonItem[] = lessonRows
.filter((row): row is typeof row & { slug: string } => typeof row.slug === 'string')
.map((row) => ({
kind: 'lesson',
id: row.id,
slug: row.slug,
title: row.title,
order: row.order === null ? 0 : Number(row.order),
sectionId: row.sectionId,
isUnlocked: row.isUnlocked ?? false,
hasVideo:
(Array.isArray(row.videos) && row.videos.length > 0) ||
(typeof row.videoUrl === 'string' && row.videoUrl.length > 0)
}));
const exerciseItems: PublicCourseExerciseItem[] = exerciseRows
.filter((row): row is typeof row & { slug: string } => typeof row.slug === 'string')
.map((row) => ({
kind: 'exercise',
id: row.id,
slug: row.slug,
title: row.title,
order: row.order === null ? 0 : Number(row.order),
sectionId: row.sectionId,
isUnlocked: row.isUnlocked ?? true
}));
// Sort items so that the visual reading order matches the sidebar:
// items in the first section come before items in the second, and within
// a section items are sorted by their own `order`. Unsectioned items go
// last so the redirect from the course landing page never lands on a
// floating lesson when sections exist.
const UNSECTIONED_ORDER = Number.POSITIVE_INFINITY;
const sectionOrderById = new Map(sections.map((section) => [section.id, section.order]));
const orderForItem = (item: PublicCourseItem) =>
item.sectionId && sectionOrderById.has(item.sectionId)
? (sectionOrderById.get(item.sectionId) ?? UNSECTIONED_ORDER)
: UNSECTIONED_ORDER;
const items = [...lessonItems, ...exerciseItems].sort((left, right) => {
const sectionDiff = orderForItem(left) - orderForItem(right);
if (sectionDiff !== 0) return sectionDiff;
return left.order - right.order;
});
const rawCallout = courseRow.callout;
const callout =
rawCallout && typeof rawCallout === 'object' && 'title' in rawCallout
? {
title: String((rawCallout as { title: unknown }).title ?? ''),
description: String((rawCallout as { description: unknown }).description ?? ''),
buttonLabel: String((rawCallout as { buttonLabel: unknown }).buttonLabel ?? ''),
buttonUrl: String((rawCallout as { buttonUrl: unknown }).buttonUrl ?? '')
}
: null;
return {
course: {
id: courseRow.id,
slug: courseRow.slug,
title: courseRow.title,
description: courseRow.description,
bannerImage: courseRow.bannerImage,
callout,
org
},
sections,
items
};
} catch (error) {
console.error('getPublicCourseTreeBySlug error:', error);
throw new Error(
`Failed to get public course tree for slug "${courseSlug}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface PublicLessonContent {
kind: 'lesson';
id: string;
slug: string;
title: string;
sectionTitle: string | null;
isUnlocked: boolean;
body: string;
video: {
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
} | null;
}
export interface PublicExerciseContent {
kind: 'exercise';
id: string;
slug: string;
title: string;
description: string | null;
sectionTitle: string | null;
isUnlocked: boolean;
allowMultipleAttempts: boolean;
sectionDisplayMode: string;
}
export type PublicCourseItemContent = PublicLessonContent | PublicExerciseContent;
/**
* Fetch a single lesson or exercise (by per-course unique slug) inside a public course.
* Returns `null` when the course is not public/published, or the item doesn't exist.
*
* The exercise payload returned here intentionally excludes the question list;
* callers should pair this with `getExerciseQuestions` (which already ships with
* answer keys) to render the client-side-graded exercise UI.
*/
export async function getPublicCourseItem(
courseSlug: string,
itemSlug: string
): Promise<PublicCourseItemContent | null> {
try {
const [courseRow] = await db
.select({
id: schema.course.id,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
if (!courseRow) return null;
if (courseRow.type !== 'PUBLIC') return null;
if (!courseRow.isPublished) return null;
if (courseRow.status !== 'ACTIVE') return null;
const [lessonRow] = await db
.select({
id: schema.lesson.id,
slug: schema.lesson.slug,
title: schema.lesson.title,
note: schema.lesson.note,
videoUrl: schema.lesson.videoUrl,
videos: schema.lesson.videos,
isUnlocked: schema.lesson.isUnlocked,
sectionId: schema.lesson.sectionId
})
.from(schema.lesson)
.where(and(eq(schema.lesson.courseId, courseRow.id), eq(schema.lesson.slug, itemSlug)))
.limit(1);
if (lessonRow) {
const section = lessonRow.sectionId
? await db
.select({ title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, lessonRow.sectionId))
.limit(1)
: [];
// Lesson body lives in `lesson_language` (one row per locale). Prefer the
// English row when present; otherwise fall back to the first row, then to
// the legacy `lesson.note` column for very old lessons that predate i18n.
const languageRows = await db
.select({
content: schema.lessonLanguage.content,
locale: schema.lessonLanguage.locale
})
.from(schema.lessonLanguage)
.where(eq(schema.lessonLanguage.lessonId, lessonRow.id));
const englishRow = languageRows.find((row) => row.locale === 'en');
const fallbackRow = languageRows.find((row) => typeof row.content === 'string' && row.content.length > 0);
const body = englishRow?.content ?? fallbackRow?.content ?? lessonRow.note ?? '';
const firstVideo =
Array.isArray(lessonRow.videos) && lessonRow.videos.length > 0
? {
type: lessonRow.videos[0].type,
link: lessonRow.videos[0].link
}
: lessonRow.videoUrl
? { type: 'generic' as const, link: lessonRow.videoUrl }
: null;
return {
kind: 'lesson',
id: lessonRow.id,
slug: lessonRow.slug ?? itemSlug,
title: lessonRow.title,
sectionTitle: section[0]?.title ?? null,
isUnlocked: lessonRow.isUnlocked ?? false,
body,
video: firstVideo
};
}
const [exerciseRow] = await db
.select({
id: schema.exercise.id,
slug: schema.exercise.slug,
title: schema.exercise.title,
description: schema.exercise.description,
isUnlocked: schema.exercise.isUnlocked,
sectionId: schema.exercise.sectionId,
lessonId: schema.exercise.lessonId,
allowMultipleAttempts: schema.exercise.allowMultipleAttempts,
sectionDisplayMode: schema.exercise.sectionDisplayMode
})
.from(schema.exercise)
.where(and(eq(schema.exercise.courseId, courseRow.id), eq(schema.exercise.slug, itemSlug)))
.limit(1);
if (!exerciseRow) return null;
const resolvedSectionId =
exerciseRow.sectionId ??
(exerciseRow.lessonId
? ((
await db
.select({ sectionId: schema.lesson.sectionId })
.from(schema.lesson)
.where(eq(schema.lesson.id, exerciseRow.lessonId))
.limit(1)
)[0]?.sectionId ?? null)
: null);
const section = resolvedSectionId
? await db
.select({ title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, resolvedSectionId))
.limit(1)
: [];
return {
kind: 'exercise',
id: exerciseRow.id,
slug: exerciseRow.slug ?? itemSlug,
title: exerciseRow.title,
description: exerciseRow.description,
sectionTitle: section[0]?.title ?? null,
isUnlocked: exerciseRow.isUnlocked ?? true,
allowMultipleAttempts: exerciseRow.allowMultipleAttempts,
sectionDisplayMode: exerciseRow.sectionDisplayMode ?? 'one_question'
};
} catch (error) {
console.error('getPublicCourseItem error:', error);
throw new Error(
`Failed to get public course item "${itemSlug}" for course "${courseSlug}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
/**
* Helper for slug-collision resolution at the service layer. Returns the set of
* slugs already in use for the given course across both lesson and exercise
* tables.
*/
export async function getTakenItemSlugs(courseId: string): Promise<Set<string>> {
try {
const lessonSlugs = await db
.select({ slug: schema.lesson.slug })
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const exerciseSlugs = await db
.select({ slug: schema.exercise.slug })
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseId));
const taken = new Set<string>();
for (const row of lessonSlugs) {
if (row.slug) taken.add(row.slug);
}
for (const row of exerciseSlugs) {
if (row.slug) taken.add(row.slug);
}
return taken;
} catch (error) {
console.error('getTakenItemSlugs error:', error);
throw new Error(
`Failed to read taken slugs for course "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Count of questions attached to a public course whose type is NOT auto-gradable.
* Used to guard conversion of a course to `PUBLIC`.
*/
export async function findNonAutoGradableQuestionsInCourse(
courseId: string,
autoGradableTypeIds: readonly number[]
): Promise<
Array<{
questionId: number;
questionTitle: string;
exerciseId: string;
exerciseTitle: string;
typeId: number;
}>
> {
try {
const rows = await db
.select({
questionId: schema.question.id,
questionTitle: schema.question.title,
exerciseId: schema.exercise.id,
exerciseTitle: schema.exercise.title,
typeId: schema.question.questionTypeId
})
.from(schema.question)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.question.exerciseId))
.where(eq(schema.exercise.courseId, courseId));
return rows
.filter((row) => row.typeId !== null && !autoGradableTypeIds.includes(row.typeId))
.map((row) => ({
questionId: row.questionId,
questionTitle: row.questionTitle ?? 'Untitled question',
exerciseId: row.exerciseId,
exerciseTitle: row.exerciseTitle,
typeId: row.typeId as number
}));
} catch (error) {
console.error('findNonAutoGradableQuestionsInCourse error:', error);
throw new Error(
`Failed to scan questions for course "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -0,0 +1,136 @@
import * as schema from '@db/schema';
import { asc, db, eq, inArray } from '@db/drizzle';
import type { DbOrTxClient } from '@db/drizzle';
import type { TNewExerciseSection } from '@db/types';
export async function getExerciseSectionsByExerciseId(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
return dbClient
.select()
.from(schema.exerciseSection)
.where(eq(schema.exerciseSection.exerciseId, exerciseId))
.orderBy(asc(schema.exerciseSection.order));
} catch (error) {
console.error('getExerciseSectionsByExerciseId error:', error);
throw new Error(
`Failed to get exercise sections by exercise ID "${exerciseId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function getExerciseSectionsByExerciseIds(exerciseIds: string[], dbClient: DbOrTxClient = db) {
if (exerciseIds.length === 0) return [];
try {
return dbClient
.select()
.from(schema.exerciseSection)
.where(inArray(schema.exerciseSection.exerciseId, exerciseIds))
.orderBy(asc(schema.exerciseSection.exerciseId), asc(schema.exerciseSection.order));
} catch (error) {
console.error('getExerciseSectionsByExerciseIds error:', error);
throw new Error(`Failed to get exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createExerciseSections(sections: TNewExerciseSection[], dbClient: DbOrTxClient = db) {
if (sections.length === 0) return [];
try {
return dbClient.insert(schema.exerciseSection).values(sections).returning();
} catch (error) {
console.error('createExerciseSections error:', error);
throw new Error(`Failed to create exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateExerciseSection(
exerciseSectionId: string,
data: Partial<Omit<TNewExerciseSection, 'id' | 'exerciseId'>>,
dbClient: DbOrTxClient = db
) {
try {
const [updated] = await dbClient
.update(schema.exerciseSection)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.exerciseSection.id, exerciseSectionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateExerciseSection error:', error);
throw new Error(
`Failed to update exercise section "${exerciseSectionId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExerciseSection(exerciseSectionId: string, dbClient: DbOrTxClient = db) {
try {
return dbClient.delete(schema.exerciseSection).where(eq(schema.exerciseSection.id, exerciseSectionId)).returning();
} catch (error) {
console.error('deleteExerciseSection error:', error);
throw new Error(
`Failed to delete exercise section "${exerciseSectionId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExerciseSectionsByIds(exerciseSectionIds: string[], dbClient: DbOrTxClient = db) {
if (exerciseSectionIds.length === 0) return [];
try {
return dbClient
.delete(schema.exerciseSection)
.where(inArray(schema.exerciseSection.id, exerciseSectionIds))
.returning();
} catch (error) {
console.error('deleteExerciseSectionsByIds error:', error);
throw new Error(`Failed to delete exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function moveQuestionToSection(
questionId: number,
exerciseSectionId: string | null,
dbClient: DbOrTxClient = db
) {
try {
const [updated] = await dbClient
.update(schema.question)
.set({ exerciseSectionId })
.where(eq(schema.question.id, questionId))
.returning();
return updated || null;
} catch (error) {
console.error('moveQuestionToSection error:', error);
throw new Error(`Failed to move question to section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function bulkMoveQuestionsToSection(
questionIds: number[],
exerciseSectionId: string | null,
dbClient: DbOrTxClient = db
) {
if (questionIds.length === 0) return [];
try {
return dbClient
.update(schema.question)
.set({ exerciseSectionId })
.where(inArray(schema.question.id, questionIds))
.returning();
} catch (error) {
console.error('bulkMoveQuestionsToSection error:', error);
throw new Error(`Failed to move questions to section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -272,8 +272,6 @@ export async function deleteQuestion(questionId: number, dbClient: DbOrTxClient
export async function createOptions(values: TNewOption[], dbClient: DbOrTxClient = db) {
if (values.length === 0) return [];
try {
// Keep the identity sequence aligned before bulk inserts so restored data
// or manual imports cannot reuse an existing option id.
await syncOptionIdSequence(dbClient);
return dbClient.insert(schema.option).values(values).returning();
} catch (error) {
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './exercise';
export * from './exercise';
export * from './exercise-section';
export * from './lms';
+33
View File
@@ -295,3 +295,36 @@ export const getGroupMemberIdByCourseAndProfile = async (
return result.length > 0 ? result[0].id : null;
};
export async function getGroupMemberIdByGroupAndProfile(
groupId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<string | null> {
try {
const [row] = await dbClient
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return row?.id ?? null;
} catch (error) {
console.error('getGroupMemberIdByGroupAndProfile error:', error);
throw new Error(`Failed to resolve group membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function insertGroupMembersOnConflictDoNothing(
values: TNewGroupmember[],
dbClient: DbOrTxClient = db
): Promise<void> {
if (values.length === 0) return;
try {
await dbClient.insert(schema.groupmember).values(values).onConflictDoNothing();
} catch (error) {
console.error('insertGroupMembersOnConflictDoNothing error:', error);
throw new Error(`Failed to insert group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './organization';
export * from './query-errors';
export * from './auth';
export * from './attendance';
export * from './audience';
+174 -2
View File
@@ -2,12 +2,13 @@ import type {
TNewOrganizationInvite,
TNewOrganizationInviteAudit,
TOrganizationInvite,
TOrganizationInviteAudit
TOrganizationInviteAudit,
TNewOrganizationmember
} from '@db/types';
import * as schema from '@db/schema';
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
try {
@@ -358,3 +359,174 @@ export async function getLatestOrganizationInviteRowByOrgAndEmail(
);
}
}
export type TOrganizationInviteAcceptRow = {
invite: TOrganizationInvite;
organization: {
id: string;
name: string;
siteName: string;
};
};
export async function selectOrganizationInviteWithOrgByTokenHash(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<TOrganizationInviteAcceptRow | null> {
try {
const [row] = await dbClient
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.tokenHash, tokenHash))
.limit(1);
if (!row) {
return null;
}
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('selectOrganizationInviteWithOrgByTokenHash error:', error);
throw new Error(`Failed to load organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationInviteWithOrgByInviteId(
dbClient: DbOrTxClient,
inviteId: string
): Promise<TOrganizationInviteAcceptRow | null> {
try {
const [row] = await dbClient
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.id, inviteId))
.limit(1);
if (!row) {
return null;
}
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('selectOrganizationInviteWithOrgByInviteId error:', error);
throw new Error(`Failed to load organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationMemberByOrgAndNormalizedEmail(
dbClient: DbOrTxClient,
organizationId: string,
normalizedEmail: string
) {
try {
const [row] = await dbClient
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('selectOrganizationMemberByOrgAndNormalizedEmail error:', error);
throw new Error(`Failed to load organization member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationMemberByOrgAndProfile(
dbClient: DbOrTxClient,
organizationId: string,
profileId: string
) {
try {
const [row] = await dbClient
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.profileId, profileId)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('selectOrganizationMemberByOrgAndProfile error:', error);
throw new Error(`Failed to load organization member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateOrganizationMemberById(
dbClient: DbOrTxClient,
memberId: number,
data: Partial<TNewOrganizationmember>
) {
try {
await dbClient.update(schema.organizationmember).set(data).where(eq(schema.organizationmember.id, memberId));
} catch (error) {
console.error('updateOrganizationMemberById error:', error);
throw new Error(
`Failed to update organization member: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function claimPendingOrganizationInvite(
dbClient: DbOrTxClient,
inviteId: string,
acceptedByProfileId: string
): Promise<{ id: string } | undefined> {
try {
const [acceptedInvite] = await dbClient
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, inviteId),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({
id: schema.organizationInvite.id
});
return acceptedInvite;
} catch (error) {
console.error('claimPendingOrganizationInvite error:', error);
throw new Error(
`Failed to finalize organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -95,6 +95,32 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC
return member;
};
export async function getOrganizationMemberIdByOrgAndProfile(
organizationId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<number | null> {
try {
const [row] = await dbClient
.select({ id: schema.organizationmember.id })
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.profileId, profileId)
)
)
.limit(1);
return row?.id ?? null;
} catch (error) {
console.error('getOrganizationMemberIdByOrgAndProfile error:', error);
throw new Error(
`Failed to resolve organization membership: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates multiple organization members in a single query
* @param data Array of organization member creation data
@@ -106,6 +132,24 @@ export const createOrganizationMembers = async (data: TNewOrganizationmember[])
return members;
};
export async function insertOrganizationMembersOnConflictDoNothing(
data: TNewOrganizationmember[],
dbClient: DbOrTxClient = db
): Promise<void> {
if (data.length === 0) {
return;
}
try {
await dbClient.insert(schema.organizationmember).values(data).onConflictDoNothing();
} catch (error) {
console.error('insertOrganizationMembersOnConflictDoNothing error:', error);
throw new Error(
`Failed to insert organization members: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
+3 -3
View File
@@ -3,7 +3,7 @@ import * as schema from '@db/schema';
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -309,9 +309,9 @@ export async function isOrgAdminByProgramId(programId: string, profileId: string
}
}
export async function addProgramMember(data: TNewProgramMember): Promise<TProgramMember> {
export async function addProgramMember(data: TNewProgramMember, dbClient: DbOrTxClient = db): Promise<TProgramMember> {
try {
const [member] = await db.insert(schema.programMember).values(data).returning();
const [member] = await dbClient.insert(schema.programMember).values(data).returning();
if (!member) throw new Error('Failed to add program member');
return member;
} catch (error) {
+15
View File
@@ -0,0 +1,15 @@
/**
* Thrown from query modules when a domain rule fails and the API layer should
* map this to an AppError with the same code and status.
*/
export class OperationalQueryError extends Error {
constructor(
message: string,
public readonly errorCode: string,
public readonly statusCode: number,
public readonly field?: string
) {
super(message);
this.name = 'OperationalQueryError';
}
}
+92 -10
View File
@@ -23,7 +23,7 @@ import {
import type { AnswerData } from '@cio/question-types';
import { sql } from 'drizzle-orm';
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
@@ -541,7 +541,7 @@ export const submissionstatus = pgTable(
label: varchar().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => []
() => []
);
export const course = pgTable(
@@ -625,7 +625,13 @@ export const course = pgTable(
passingScore?: number;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
type: courseType().default('SELF_PACED'),
callout: jsonb().$type<{
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
} | null>()
},
(table) => [
foreignKey({
@@ -877,7 +883,8 @@ export const lesson = pgTable(
assetId?: string;
}[]
>(),
sectionId: uuid('section_id')
sectionId: uuid('section_id'),
slug: varchar()
},
(table) => [
foreignKey({
@@ -896,7 +903,8 @@ export const lesson = pgTable(
name: 'public_course_section_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade')
.onDelete('cascade'),
index('idx_lesson_course_slug').on(table.courseId, table.slug)
]
);
@@ -1059,7 +1067,9 @@ export const exercise = pgTable(
.notNull(),
isUnlocked: boolean('is_unlocked').default(true),
dueBy: timestamp('due_by', { mode: 'string' }),
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull()
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull(),
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
slug: varchar()
},
(table) => [
foreignKey({
@@ -1076,7 +1086,34 @@ export const exercise = pgTable(
columns: [table.sectionId],
foreignColumns: [courseSection.id],
name: 'exercise_section_id_fkey'
})
}),
index('idx_exercise_course_slug').on(table.courseId, table.slug)
]
);
export const exerciseSection = pgTable(
'exercise_section',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
exerciseId: uuid('exercise_id').notNull(),
title: varchar().notNull().default('Untitled Section'),
description: text(),
order: bigint({ mode: 'number' }).notNull().default(0),
colorTheme: varchar('color_theme').notNull().default('blue'),
afterBehavior: jsonb('after_behavior').notNull().default({ action: 'continue' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
foreignKey({
columns: [table.exerciseId],
foreignColumns: [exercise.id],
name: 'exercise_section_exercise_id_fkey'
}).onDelete('cascade'),
index('idx_exercise_section_exercise_id').on(table.exerciseId)
]
);
@@ -1656,6 +1693,7 @@ export const question = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
exerciseId: uuid('exercise_id').notNull(),
exerciseSectionId: uuid('exercise_section_id'),
name: uuid().default(sql`gen_random_uuid()`),
points: doublePrecision(),
settings: jsonb().default({}),
@@ -1668,6 +1706,11 @@ export const question = pgTable(
foreignColumns: [exercise.id],
name: 'question_exercise_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.exerciseSectionId],
foreignColumns: [exerciseSection.id],
name: 'question_exercise_section_id_fkey'
}).onDelete('set null'),
foreignKey({
columns: [table.questionTypeId],
foreignColumns: [questionType.id],
@@ -1721,7 +1764,8 @@ export const questionAnswer = pgTable(
/**
* Exercise question kinds (`typename` matches `@cio/question-types` keys). Expected ids:
* 1 RADIO, 2 CHECKBOX, 3 TEXTAREA, 4 TRUE_FALSE, 5 SHORT_ANSWER, 6 NUMERIC, 7 FILL_BLANK,
* 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR.
* 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR,
* 15 VIDEO_RECORDING.
*/
export const questionType = pgTable(
'question_type',
@@ -1739,7 +1783,7 @@ export const questionType = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
typename: varchar()
},
(table) => []
() => []
);
export const role = pgTable(
@@ -1758,7 +1802,7 @@ export const role = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => []
() => []
);
export const waitinglist = pgTable(
@@ -2566,6 +2610,7 @@ export const aiTokenUsage = pgTable(
courseId: uuid('course_id').notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
costUnits: integer('cost_units'),
model: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
@@ -2605,6 +2650,43 @@ export const aiCreditBalance = pgTable(
]
);
export const aiCreditPurchase = pgTable(
'ai_credit_purchase',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_credit_purchase_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
triggeredBy: uuid('triggered_by'),
provider: text().notNull().default('polar'),
providerOrderId: text('provider_order_id').notNull(),
tokens: integer().notNull(),
quantity: integer().notNull().default(1),
unitPriceCents: integer('unit_price_cents').notNull().default(0),
currency: text().notNull().default('USD'),
payload: jsonb(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_credit_purchase_org_id_fkey'
}),
foreignKey({
columns: [table.triggeredBy],
foreignColumns: [profile.id],
name: 'ai_credit_purchase_triggered_by_fkey'
}),
unique('ai_credit_purchase_provider_order_id_key').on(table.providerOrderId),
index('idx_ai_credit_purchase_org_created').on(table.orgId, table.createdAt)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
+3
View File
@@ -91,6 +91,9 @@ export type TNewAppsPollSubmission = typeof schema.appsPollSubmission.$inferInse
export type TExercise = typeof schema.exercise.$inferSelect;
export type TNewExercise = typeof schema.exercise.$inferInsert;
export type TExerciseSection = typeof schema.exerciseSection.$inferSelect;
export type TNewExerciseSection = typeof schema.exerciseSection.$inferInsert;
export type TGroupmember = typeof schema.groupmember.$inferSelect;
export type TNewGroupmember = typeof schema.groupmember.$inferInsert;
@@ -76,6 +76,12 @@ export async function seedQuestionTypes() {
typename: 'STAR',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
},
{
label: 'Video recording',
typename: 'VIDEO_RECORDING',
createdAt: '2026-04-30 00:00:00+00',
updatedAt: '2026-04-30 00:00:00+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));
+61 -2
View File
@@ -15,7 +15,8 @@ import type {
OrderingAnswerData,
LinkAnswerData,
HotspotAnswerData,
StarAnswerData
StarAnswerData,
VideoRecordingAnswerData
} from './answer-data';
import { QUESTION_TYPE_KEY } from './question-type-keys';
@@ -247,6 +248,62 @@ const STAR_CODEC: AnswerCodec<StarAnswerData> = {
}
};
function isVideoRecordingAnswer(value: unknown): value is Omit<VideoRecordingAnswerData, 'type'> & { type?: string } {
if (!value || typeof value !== 'object') return false;
const data = value as Partial<VideoRecordingAnswerData>;
return (
typeof data.assetId === 'string' &&
data.assetId.length > 0 &&
typeof data.storageKey === 'string' &&
data.storageKey.length > 0 &&
typeof data.fileName === 'string' &&
typeof data.mimeType === 'string' &&
data.mimeType.startsWith('video/') &&
typeof data.size === 'number' &&
Number.isFinite(data.size) &&
data.size > 0 &&
typeof data.durationSeconds === 'number' &&
Number.isFinite(data.durationSeconds) &&
data.durationSeconds > 0 &&
typeof data.recordedAt === 'string' &&
typeof data.uploadedAt === 'string' &&
data.provider === 'cloudflare'
);
}
const VIDEO_RECORDING_CODEC: AnswerCodec<VideoRecordingAnswerData> = {
type: 'VIDEO_RECORDING',
toApiPayload(data, questionId) {
const { playbackUrl: _playbackUrl, ...answerData } = data;
return { questionId, answer: JSON.stringify(answerData) };
},
fromApiPayload(payload) {
if (!payload.answer) return null;
try {
const parsed = JSON.parse(payload.answer) as Partial<VideoRecordingAnswerData>;
if (parsed?.type !== 'VIDEO_RECORDING' || !isVideoRecordingAnswer(parsed)) return null;
return {
type: 'VIDEO_RECORDING',
assetId: parsed.assetId,
storageKey: parsed.storageKey,
fileName: parsed.fileName,
mimeType: parsed.mimeType,
size: parsed.size,
durationSeconds: parsed.durationSeconds,
recordedAt: parsed.recordedAt,
uploadedAt: parsed.uploadedAt,
provider: 'cloudflare',
retakeCount: typeof parsed.retakeCount === 'number' ? parsed.retakeCount : undefined
};
} catch {
return null;
}
}
};
export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
@@ -261,7 +318,8 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.ORDERING]: ORDERING_CODEC,
[QUESTION_TYPE_KEY.LINK]: LINK_CODEC,
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC,
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC,
[QUESTION_TYPE_KEY.VIDEO_RECORDING]: VIDEO_RECORDING_CODEC
};
/** Convert any answer to API submission payload */
@@ -320,6 +378,7 @@ export function extractAnswerDisplayValues(data: AnswerData): {
case 'ORDERING':
case 'HOTSPOT':
case 'FILE_UPLOAD':
case 'VIDEO_RECORDING':
return { selectedIds: [], selectedValues: [] };
default:
return { selectedIds: [], selectedValues: [] };
+16 -1
View File
@@ -18,6 +18,20 @@ export type OrderingAnswerData = { type: 'ORDERING'; orderedValues: string[] };
export type LinkAnswerData = { type: 'LINK'; urls: string[] };
export type HotspotAnswerData = { type: 'HOTSPOT'; coordinates: Array<{ x: number; y: number }> };
export type StarAnswerData = { type: 'STAR'; value: number };
export type VideoRecordingAnswerData = {
type: 'VIDEO_RECORDING';
assetId: string;
storageKey: string;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
uploadedAt: string;
provider: 'cloudflare';
retakeCount?: number;
playbackUrl?: string;
};
export type AnswerData =
| RadioAnswerData
@@ -33,4 +47,5 @@ export type AnswerData =
| OrderingAnswerData
| LinkAnswerData
| HotspotAnswerData
| StarAnswerData;
| StarAnswerData
| VideoRecordingAnswerData;
@@ -0,0 +1,21 @@
import type { ExerciseQuestionModel } from './exercise-types';
export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit';
export interface ExerciseSectionAfterBehavior {
action: ExerciseSectionAfterAction;
exerciseSectionId?: string;
}
export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions';
export type ExerciseSectionColorTheme = 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
export interface ExerciseSectionModel {
id?: string;
title: string;
description?: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
questions: ExerciseQuestionModel[];
}
+49 -2
View File
@@ -1,5 +1,5 @@
import type { QuestionTypeKey } from './question-type-keys';
import type { AnswerData } from './answer-data';
import type { AnswerData, VideoRecordingAnswerData } from './answer-data';
export type ExerciseQuestionTypeKey = QuestionTypeKey;
@@ -105,6 +105,41 @@ export type ExerciseQuestionLabelKey =
| 'file_upload.take.view'
| 'file_upload.preview.accepted_types_label'
| 'file_upload.preview.max_size_label'
| 'video_recording.edit.max_duration_label'
| 'video_recording.edit.max_duration_helper'
| 'video_recording.edit.max_duration_placeholder'
| 'video_recording.edit.manual_grading'
| 'video_recording.take.unsupported'
| 'video_recording.take.permission_prompt'
| 'video_recording.take.permission_denied'
| 'video_recording.take.start_camera'
| 'video_recording.take.camera_label'
| 'video_recording.take.camera_placeholder'
| 'video_recording.take.play_preview'
| 'video_recording.take.pause_preview'
| 'video_recording.take.start_recording'
| 'video_recording.take.stop_recording'
| 'video_recording.take.elapsed'
| 'video_recording.take.remaining'
| 'video_recording.take.max_duration'
| 'video_recording.take.too_short'
| 'video_recording.take.use_recording'
| 'video_recording.take.retake'
| 'video_recording.take.delete'
| 'video_recording.take.retry_upload'
| 'video_recording.take.uploading'
| 'video_recording.take.uploaded'
| 'video_recording.take.upload_failed'
| 'video_recording.take.replaces_previous'
| 'video_recording.preview.max_duration_label'
| 'video_recording.preview.manual_grading'
| 'video_recording.submission.no_video'
| 'video_recording.submission.unavailable'
| 'video_recording.submission.duration'
| 'video_recording.submission.file_size'
| 'video_recording.submission.recorded_at'
| 'video_recording.submission.uploaded_at'
| 'video_recording.submission.retake_count'
| 'link.edit.instructions_placeholder'
| 'link.edit.helper'
| 'link.take.helper'
@@ -195,6 +230,17 @@ export type ExerciseQuestionFileUploader = (
file: File
) => Promise<{ fileKey: string; fileName: string; fileUrl?: string }>;
export type ExerciseQuestionVideoRecordingUploader = (input: {
questionId: number | string;
blob: Blob;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
retakeCount: number;
}) => Promise<VideoRecordingAnswerData>;
export interface ExerciseRendererDefinition<TRenderer = unknown> {
edit: TRenderer;
take: TRenderer;
@@ -211,8 +257,9 @@ export interface ExerciseQuestionRendererProps {
maxSubmissionItems?: number;
disabled?: boolean;
labels?: ExerciseQuestionLabels;
onAnswerChange?: (answer: AnswerData) => void;
onAnswerChange?: (answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionModel) => void;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
+2
View File
@@ -8,6 +8,7 @@ export * from './question-scoring-rules';
export * from './question-scoring';
export * from './exercise-types';
export * from './exercise-section-types';
export * from './render-contract';
export * from './form-state';
export * from './answer-data';
@@ -15,3 +16,4 @@ export * from './answer-codecs';
export * from './renderer-registry';
export * from './textarea-validation';
export * from './star-rating-settings';
export * from './video-recording-settings';
@@ -14,7 +14,8 @@ export type QuestionAnswerPayload =
| OrderingAnswerPayload
| HotspotAnswerPayload
| LinkAnswerPayload
| StarAnswerPayload;
| StarAnswerPayload
| VideoRecordingAnswerPayload;
interface BaseAnswerPayload {
type: QuestionTypeKey;
@@ -92,3 +93,17 @@ export interface StarAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.STAR;
value: number;
}
export interface VideoRecordingAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.VIDEO_RECORDING;
assetId: string;
storageKey: string;
fileName: string;
mimeType: string;
size: number;
durationSeconds: number;
recordedAt: string;
uploadedAt: string;
provider: 'cloudflare';
retakeCount?: number;
}
@@ -1,5 +1,6 @@
import type { QuestionTypeKey } from './question-type-keys';
import { getQuestionTypeByKey } from './question-type-lookup';
import { QUESTION_TYPE_REGISTRY } from './question-type-registry';
export function isAutoGradableQuestionType(key: QuestionTypeKey): boolean {
return getQuestionTypeByKey(key).autoGradable;
@@ -12,3 +13,13 @@ export function supportsPartialCredit(key: QuestionTypeKey): boolean {
export function requiresManualGrading(key: QuestionTypeKey): boolean {
return getQuestionTypeByKey(key).manualGradingRequired;
}
/** Numeric ids of every auto-gradable question type, pulled from the registry. */
export const AUTO_GRADABLE_QUESTION_TYPE_IDS: readonly number[] = QUESTION_TYPE_REGISTRY.filter(
(entry) => entry.autoGradable
).map((entry) => entry.id);
/** True when the numeric `questionTypeId` refers to an auto-gradable type. */
export function isAutoGradableQuestionTypeId(questionTypeId: number): boolean {
return AUTO_GRADABLE_QUESTION_TYPE_IDS.includes(questionTypeId);
}
@@ -12,7 +12,8 @@ export const QUESTION_TYPE_KEY = {
HOTSPOT: 'HOTSPOT',
LINK: 'LINK',
WORD_BANK: 'WORD_BANK',
STAR: 'STAR'
STAR: 'STAR',
VIDEO_RECORDING: 'VIDEO_RECORDING'
} as const;
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
@@ -127,6 +127,15 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
autoGradable: true,
supportsPartialCredit: false,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.VIDEO_RECORDING,
typename: 'VIDEO_RECORDING',
label: 'Video recording',
id: 15,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: true
}
] as const;
@@ -1,6 +1,7 @@
import type {
ExerciseQuestionFileUploader,
ExerciseQuestionImageUploader,
ExerciseQuestionVideoRecordingUploader,
ExerciseQuestionLabelKey,
ExerciseQuestionLabels,
ExerciseQuestionModel,
@@ -19,6 +20,7 @@ export interface ExerciseQuestionRenderContract {
labels?: ExerciseQuestionLabels;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
export interface ExerciseQuestionListRenderContract {
@@ -31,6 +33,7 @@ export interface ExerciseQuestionListRenderContract {
labels?: ExerciseQuestionLabels;
onImageUpload?: ExerciseQuestionImageUploader;
onFileUpload?: ExerciseQuestionFileUploader;
onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader;
}
export function getExerciseQuestionLabel(
@@ -0,0 +1,14 @@
export const VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS = 60;
export const VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS = 120;
export const VIDEO_RECORDING_MIN_DURATION_SECONDS = 1;
export function getVideoRecordingMaxDurationSeconds(settings?: Record<string, unknown> | null): number {
const rawDuration = settings?.maxDurationSeconds;
const duration = typeof rawDuration === 'number' ? rawDuration : rawDuration != null ? Number(rawDuration) : NaN;
if (!Number.isFinite(duration) || duration <= 0) {
return VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS;
}
return Math.min(Math.round(duration), VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS);
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
AUTO_GRADABLE_QUESTION_TYPE_IDS,
QUESTION_TYPE_BY_KEY,
QUESTION_TYPE_ID_TO_KEY,
QUESTION_TYPE_IDS,
@@ -11,6 +12,7 @@ import {
getQuestionTypeByTypename,
getQuestionTypeId,
isAutoGradableQuestionType,
isAutoGradableQuestionTypeId,
requiresManualGrading,
supportsPartialCredit
} from '../src';
@@ -127,6 +129,14 @@ const EXPECTED_REGISTRY = [
autoGradable: true,
supportsPartialCredit: false,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.VIDEO_RECORDING,
typename: 'VIDEO_RECORDING',
id: 15,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: true
}
] as const;
@@ -170,4 +180,17 @@ describe('question type registry', () => {
expect(getQuestionTypeByTypename('UNKNOWN')).toBeUndefined();
expect(getQuestionTypeById(999)).toBeUndefined();
});
it('AUTO_GRADABLE_QUESTION_TYPE_IDS matches registry flag by id', () => {
const expectedIds = QUESTION_TYPE_REGISTRY.filter((entry) => entry.autoGradable).map((entry) => entry.id);
expect(AUTO_GRADABLE_QUESTION_TYPE_IDS).toEqual(expectedIds);
});
it('isAutoGradableQuestionTypeId agrees with isAutoGradableQuestionType for every registry entry', () => {
for (const entry of QUESTION_TYPE_REGISTRY) {
expect(isAutoGradableQuestionTypeId(entry.id)).toBe(entry.autoGradable);
expect(isAutoGradableQuestionTypeId(entry.id)).toBe(isAutoGradableQuestionType(entry.key));
}
expect(isAutoGradableQuestionTypeId(9999)).toBe(false);
});
});
@@ -6,6 +6,7 @@
BorderBeam,
CardBody,
CardContainer,
DotField,
DotPattern,
GradualSpacing,
HeroVideoDialog,
@@ -47,6 +48,30 @@
{/snippet}
</Story>
<Story name="DotField">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:h-[500px] ui:w-full ui:max-w-3xl ui:overflow-hidden ui:rounded-xl ui:border ui:bg-background"
>
<DotField
dotRadius={1.5}
dotSpacing={14}
cursorRadius={500}
cursorForce={0.1}
bulgeOnly={true}
bulgeStrength={67}
glowRadius={160}
sparkle={false}
waveAmplitude={0}
class="ui:absolute ui:inset-0 ui:h-full ui:w-full"
/>
<div class="ui:relative ui:z-10 ui:flex ui:h-full ui:items-center ui:justify-center ui:text-sm">
Move the cursor over this area
</div>
</div>
{/snippet}
</Story>
<Story name="CardContainer">
{#snippet template()}
<CardContainer className="ui:max-w-lg">
@@ -0,0 +1,61 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { CourseCreator } from '@cio/ui/custom/course-creator';
const { Story } = defineMeta({
title: 'Molecules/CourseCreator',
component: CourseCreator,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#000000' }]
}
},
tags: ['autodocs']
});
</script>
<script>
const heading = 'What course do you want to create?';
const placeholder = 'Ask what you want to learn...';
const levelOptions = [
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' }
];
</script>
<Story name="Default">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator {heading} {placeholder} {levelOptions} onsubmit={(p) => console.log('submit', p)} />
</div>
{/snippet}
</Story>
<Story name="Loading">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator {heading} {placeholder} {levelOptions} loading={true} onsubmit={(p) => console.log('submit', p)} />
</div>
{/snippet}
</Story>
<Story name="Custom Options">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator
{heading}
{placeholder}
levelOptions={[
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'expert', label: 'Expert' }
]}
onsubmit={(p) => console.log('submit', p)}
/>
</div>
{/snippet}
</Story>
@@ -12,10 +12,12 @@
NUMERIC_FIXTURE,
ORDERING_FIXTURE,
RADIO_FIXTURE,
SECTIONED_EXERCISE_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE,
VIDEO_RECORDING_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
@@ -27,6 +29,16 @@
},
tags: ['autodocs']
});
const sectionLabels = { section: 'Section', questions: 'questions', points: 'points' };
const sectionOverviewLabels = { ...sectionLabels, back: 'Back', beginSection: 'Start section' };
const autoGroupedSection = {
...SECTIONED_EXERCISE_FIXTURE[0],
id: 'section-auto-grouped',
title: 'Section 1',
description: null,
colorTheme: 'blue' as const
};
</script>
<Story name="Checkbox">
@@ -39,6 +51,129 @@
{/snippet}
</Story>
<Story name="Exercise Sections - Section Intro">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={SECTIONED_EXERCISE_FIXTURE[0].title}
sectionDescription={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Auto Grouped Existing Questions">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={autoGroupedSection.title}
sectionDescription={autoGroupedSection.description}
sectionNumber={1}
totalSections={2}
questionCount={autoGroupedSection.questions.length}
totalPoints={10}
colorTheme={autoGroupedSection.colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Question In Progress">
{#snippet template()}
<div class="ui:grid ui:max-w-5xl ui:gap-6 ui:lg:grid-cols-[190px_minmax(0,1fr)]">
<ExerciseQuestion.SectionNavigationSidebar
sections={SECTIONED_EXERCISE_FIXTURE.map((section, sectionIndex) => ({
id: section.id,
title: section.title,
isCurrent: sectionIndex === 0,
questions: section.questions.map((question, questionIndex) => ({
key: String(question.id),
label: `Q${questionIndex + 1}`,
isAnswered: questionIndex === 0,
isCurrent: sectionIndex === 0 && questionIndex === 1
}))
}))}
/>
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={SECTIONED_EXERCISE_FIXTURE[0].title}
description={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: SECTIONED_EXERCISE_FIXTURE[0].questions[1],
answer: TRUE_FALSE_FIXTURE.answer,
disabled: false
}}
/>
</section>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Grading View">
{#snippet template()}
<div class="ui:max-w-4xl ui:space-y-8">
{#each SECTIONED_EXERCISE_FIXTURE as section, sectionIndex (section.id)}
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={section.colorTheme}
questionCount={section.questions.length}
totalPoints={section.questions.length * 5}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: 'review',
questions: section.questions.map((question, questionIndex) => ({
...question,
title: `${questionIndex + 1}. ${question.title}`
})),
answersByKey: {
'q-radio': RADIO_FIXTURE.answer,
'q-true-false': TRUE_FALSE_FIXTURE.answer,
'q-short-answer': SHORT_ANSWER_FIXTURE.answer,
'q-ordering': ORDERING_FIXTURE.answer
},
disabled: true
}}
itemClass="ui:mb-4"
/>
<div class="ui:flex ui:justify-end ui:border-t ui:pt-3 ui:text-sm ui:font-medium">
Section subtotal: {section.questions.length * 4}/{section.questions.length * 5}
</div>
</section>
{/each}
</div>
{/snippet}
</Story>
<Story name="FileUpload">
{#snippet template()}
<QuestionTypeModes
@@ -49,6 +184,26 @@
{/snippet}
</Story>
<Story
name="VideoRecording"
parameters={{
docs: {
description: {
story:
'Browser-recorded video answer with a circular student recording UI, upload states, retakes, and manual grading metadata.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={VIDEO_RECORDING_FIXTURE.question}
answer={VIDEO_RECORDING_FIXTURE.answer}
viewAnswer={null}
/>
{/snippet}
</Story>
<Story name="FillBlank">
{#snippet template()}
<QuestionTypeModes
@@ -6,6 +6,15 @@ export interface QuestionStoryFixture {
wrongAnswer?: unknown;
}
export interface ExerciseSectionStoryFixture {
id: string;
title: string;
description?: string;
order: number;
colorTheme: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
questions: Record<string, unknown>[];
}
const OPTION_IMAGE_A = 'https://brand.cdn.clsrio.com/classroomio-lms-courses.png';
const OPTION_IMAGE_B = 'https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg';
@@ -196,6 +205,34 @@ export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
answer: 'lesson-plan-week-03.pdf'
};
export const VIDEO_RECORDING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-video-recording',
key: 'q-video-recording',
title: 'Record a short reflection on what you learned. [VIDEO_RECORDING]',
questionType: 'VIDEO_RECORDING',
settings: {
description: 'Use the browser camera and microphone. You can retake before submitting.',
maxDurationSeconds: 120,
allowRetakes: true
}
},
answer: {
type: 'VIDEO_RECORDING',
assetId: '00000000-0000-4000-8000-000000000001',
storageKey: 'exercise-recordings/story/video-recording-demo.webm',
fileName: 'video-recording-demo.webm',
mimeType: 'video/webm',
size: 1_204_224,
durationSeconds: 42,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: 2,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
}
};
export const MATCHING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-matching',
@@ -256,3 +293,22 @@ export const LINK_FIXTURE: QuestionStoryFixture = {
},
answer: ['https://classroomio.com/docs', 'https://www.svelte.dev']
};
export const SECTIONED_EXERCISE_FIXTURE: ExerciseSectionStoryFixture[] = [
{
id: 'section-foundations',
title: 'Foundations',
description: 'A short warm-up section before the applied scenario.',
order: 0,
colorTheme: 'blue',
questions: [RADIO_FIXTURE.question, TRUE_FALSE_FIXTURE.question]
},
{
id: 'section-application',
title: 'Applied Practice',
description: 'Students solve open-response and ordering tasks.',
order: 1,
colorTheme: 'green',
questions: [SHORT_ANSWER_FIXTURE.question, ORDERING_FIXTURE.question]
}
];
@@ -105,6 +105,41 @@ export const QUESTION_LABELS = {
'file_upload.take.download': 'Download',
'file_upload.preview.accepted_types_label': 'Accepted types',
'file_upload.preview.max_size_label': 'Max size (MB)',
'video_recording.edit.max_duration_label': 'Max duration',
'video_recording.edit.max_duration_helper': 'Students record in the browser and can retake before submitting.',
'video_recording.edit.max_duration_placeholder': '60',
'video_recording.edit.manual_grading': 'Requires manual grading',
'video_recording.take.unsupported': 'Video recording is not supported in this browser.',
'video_recording.take.permission_prompt': 'Allow camera and microphone access to record your answer.',
'video_recording.take.permission_denied': 'Camera or microphone access was blocked. Check browser permissions.',
'video_recording.take.start_camera': 'Set up camera',
'video_recording.take.camera_label': 'Camera',
'video_recording.take.camera_placeholder': 'Select camera',
'video_recording.take.play_preview': 'Play recording preview',
'video_recording.take.pause_preview': 'Pause recording preview',
'video_recording.take.start_recording': 'Start recording',
'video_recording.take.stop_recording': 'Stop recording',
'video_recording.take.elapsed': 'Elapsed',
'video_recording.take.remaining': 'Remaining',
'video_recording.take.max_duration': 'Max',
'video_recording.take.too_short': 'Record for at least 1 second.',
'video_recording.take.use_recording': 'Use recording',
'video_recording.take.retake': 'Retake',
'video_recording.take.delete': 'Delete recording',
'video_recording.take.retry_upload': 'Retry upload',
'video_recording.take.uploading': 'Uploading recording...',
'video_recording.take.uploaded': 'Recording uploaded',
'video_recording.take.upload_failed': 'Upload failed. Try again.',
'video_recording.take.replaces_previous': 'A new accepted take replaces the previous answer after upload.',
'video_recording.preview.max_duration_label': 'Max duration',
'video_recording.preview.manual_grading': 'Learners record a short video response. Manual grading required.',
'video_recording.submission.no_video': 'No video submitted.',
'video_recording.submission.unavailable': 'Video unavailable.',
'video_recording.submission.duration': 'Duration',
'video_recording.submission.file_size': 'File size',
'video_recording.submission.recorded_at': 'Recorded',
'video_recording.submission.uploaded_at': 'Uploaded',
'video_recording.submission.retake_count': 'Retakes',
'link.edit.instructions_placeholder': 'Optional instructions for learners',
'link.edit.helper': 'Learners can submit one or more valid URLs (http/https).',
'link.take.helper': 'Add one or more links. Only valid http/https URLs are accepted.',
@@ -5,6 +5,7 @@
interface Props {
question: Record;
answer?: unknown;
viewAnswer?: unknown;
/** When set, renders an additional Review block with this incorrect `AnswerData`. */
wrongAnswer?: unknown;
}
@@ -17,7 +18,7 @@
settings?: Record<string, unknown>;
};
let { question, answer = null, wrongAnswer = undefined }: Props = $props();
let { question, answer = null, viewAnswer: initialViewAnswer = answer, wrongAnswer = undefined }: Props = $props();
function cloneQuestion(source: Record) {
return JSON.parse(JSON.stringify(source));
@@ -109,6 +110,21 @@
fileName: `lesson-plan-${index + 1}.pdf`,
mimeType: 'application/pdf'
};
case 'VIDEO_RECORDING':
return {
type: 'VIDEO_RECORDING',
assetId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
storageKey: `exercise-recordings/story/submission-${index + 1}.webm`,
fileName: `reflection-${index + 1}.webm`,
mimeType: 'video/webm',
size: 900_000 + index * 25_000,
durationSeconds: 30 + (index % 6) * 5,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: index % 4,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
};
case 'MATCHING': {
const pairs = Array.isArray(storyQuestion.settings?.pairs)
? (storyQuestion.settings?.pairs as Array<{ left: string; right: string }>)
@@ -168,7 +184,7 @@
$effect(() => {
editQuestion = cloneQuestion(question);
viewAnswer = answer;
viewAnswer = initialViewAnswer;
});
</script>
@@ -224,7 +240,7 @@
contract={{
mode: 'review',
question,
answer: viewAnswer,
answer,
labels: QUESTION_LABELS,
disabled: true
}}
@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { ModelPicker } from '@cio/ui/custom/model-picker';
const { Story } = defineMeta({
title: 'Molecules/ModelPicker',
component: ModelPicker,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<script>
import { DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
let selected = $state<AgentModelId>(DEFAULT_PICKER_MODEL_ID);
</script>
<Story name="Default">
{#snippet template()}
<div class="p-8">
<ModelPicker value={selected} onChange={(id) => (selected = id)} />
<p class="mt-4 text-sm text-gray-500">Selected: {selected}</p>
</div>
{/snippet}
</Story>
<Story name="Select Variant">
{#snippet template()}
<div class="p-8">
<ModelPicker value={selected} variant="select" onChange={(id) => (selected = id)} />
<p class="mt-4 text-sm text-gray-500">Selected: {selected}</p>
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<div class="p-8">
<ModelPicker value={DEFAULT_PICKER_MODEL_ID} disabled onChange={() => {}} />
</div>
{/snippet}
</Story>
@@ -1,4 +1,4 @@
<script module>
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
NewsfeedReactions,
@@ -0,0 +1,187 @@
import type {
PublicCourseCalloutData,
PublicCourseOrgData,
PublicCourseSidebarSection,
PublicExerciseViewData,
PublicLessonViewData
} from '@cio/ui/custom/public-course';
import type { ExerciseQuestionModel } from '@cio/question-types';
export const CALLOUT_FIXTURE: PublicCourseCalloutData = {
title: 'Want to go deeper on this?',
description: 'Join the full "AI for Builders" cohort where we ship a production-grade RAG pipeline in 4 weeks.',
buttonLabel: 'Read more on my site',
buttonUrl: 'https://example.com/ai-for-builders'
};
export const ORG_FIXTURE: PublicCourseOrgData = {
id: 'org-1',
name: 'ClassroomIO',
siteName: 'classroomio',
avatarUrl: 'https://avatars.githubusercontent.com/u/119528464?s=200&v=4'
};
export const COURSE_TITLE_FIXTURE = 'AI for Builders — Crash Course';
export const SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = [
{
id: 'section-intro',
title: 'Getting started',
items: [
{
kind: 'lesson',
id: 'lesson-welcome',
slug: 'welcome',
title: 'Welcome to the course',
isUnlocked: true
},
{
kind: 'lesson',
id: 'lesson-setup',
slug: 'setting-up-your-environment',
title: 'Setting up your environment',
isUnlocked: true
}
]
},
{
id: 'section-foundations',
title: 'Foundations',
items: [
{
kind: 'lesson',
id: 'lesson-intro-llms',
slug: 'intro-to-large-language-models',
title: 'Intro to large language models',
isUnlocked: true
},
{
kind: 'lesson',
id: 'lesson-hallucinations',
slug: 'hallucination-and-limitations',
title: 'Hallucination & limitations',
isUnlocked: true
},
{
kind: 'exercise',
id: 'exercise-foundations-quiz',
slug: 'foundations-quiz',
title: 'Foundations quiz',
isUnlocked: true
}
]
},
{
id: 'section-advanced',
title: 'Advanced',
items: [
{
kind: 'lesson',
id: 'lesson-rag',
slug: 'retrieval-augmented-generation',
title: 'Retrieval-augmented generation',
isUnlocked: false
},
{
kind: 'lesson',
id: 'lesson-agents',
slug: 'building-agents',
title: 'Building agents',
isUnlocked: false
}
]
}
];
export const LESSON_FIXTURE: PublicLessonViewData = {
kind: 'lesson',
title: 'Hallucination & limitations',
sectionTitle: 'Foundations',
body: `
<p>Large language models are incredibly capable, but they also confidently produce incorrect answers — what the field calls <em>hallucination</em>. Before we design systems around them, it helps to build an intuition for where they fail.</p>
<h2>When LLMs hallucinate</h2>
<ul>
<li>When asked about things outside of their training distribution.</li>
<li>When prompted with ambiguous or leading questions.</li>
<li>When asked to cite sources without retrieval grounding.</li>
</ul>
<p>In the next lessons we'll cover concrete mitigations, including retrieval, tool use, and structured outputs.</p>
`,
video: {
type: 'youtube',
link: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
},
isUnlocked: true
};
export const LESSON_LOCKED_FIXTURE: PublicLessonViewData = {
...LESSON_FIXTURE,
title: 'Building agents',
isUnlocked: false,
video: null,
body: ''
};
const EXERCISE_QUESTION_FIXTURES: ExerciseQuestionModel[] = [
{
id: 'q-radio-1',
title: 'Which of these is the clearest sign of hallucination?',
questionType: 'RADIO',
points: 1,
options: [
{ id: 'a', label: 'The model refuses to answer a question it doesn’t know.', isCorrect: false },
{ id: 'b', label: 'The model confidently invents a fact that doesn’t exist.', isCorrect: true },
{ id: 'c', label: 'The model cites a source verbatim.', isCorrect: false }
]
},
{
id: 'q-checkbox-1',
title: 'Pick every mitigation that grounds an LLM in real data.',
questionType: 'CHECKBOX',
points: 2,
options: [
{ id: 'a', label: 'Retrieval-augmented generation', isCorrect: true },
{ id: 'b', label: 'Turning up temperature', isCorrect: false },
{ id: 'c', label: 'Calling a tool / function', isCorrect: true },
{ id: 'd', label: 'Asking the model to be more confident', isCorrect: false }
]
},
{
id: 'q-tf-1',
title: 'LLMs always know when they’re wrong.',
questionType: 'TRUE_FALSE',
points: 1,
options: [
{ id: 'true-option', label: 'True', value: 'true', isCorrect: false },
{ id: 'false-option', label: 'False', value: 'false', isCorrect: true }
]
},
{
id: 'q-short-1',
title: 'What’s the shorthand term for "retrieval-augmented generation"?',
questionType: 'SHORT_ANSWER',
points: 1,
options: [
{ id: 'rag-1', label: 'rag', isCorrect: true },
{ id: 'rag-2', label: 'RAG', isCorrect: true }
],
settings: { caseSensitive: false }
},
{
id: 'q-numeric-1',
title: 'How many mitigations did we cover in this lesson? (A number is fine.)',
questionType: 'NUMERIC',
points: 1,
options: [{ id: 'three', label: '3', value: '3', isCorrect: true }],
settings: { tolerance: 0 }
}
];
export const EXERCISE_FIXTURE: PublicExerciseViewData = {
kind: 'exercise',
title: 'Foundations quiz',
description: 'A short check-in on the foundations we just covered. Graded instantly in your browser.',
sectionTitle: 'Foundations',
isUnlocked: true,
questions: EXERCISE_QUESTION_FIXTURES
};
@@ -0,0 +1,121 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { PublicCourse } from '@cio/ui';
import type { PublicCourseSidebarItem } from '@cio/ui/custom/public-course';
import { QUESTION_LABELS } from '../exercise-question/question-labels';
import {
CALLOUT_FIXTURE,
COURSE_TITLE_FIXTURE,
EXERCISE_FIXTURE,
LESSON_FIXTURE,
LESSON_LOCKED_FIXTURE,
ORG_FIXTURE,
SIDEBAR_FIXTURE
} from './fixtures';
const { Story } = defineMeta({
title: 'Molecules/PublicCourse',
component: PublicCourse.PublicCourseShell,
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
const flatItems = SIDEBAR_FIXTURE.flatMap((section) => section.items);
</script>
<Story name="Lesson · desktop + mobile frames">
{#snippet template()}
{@const activeSlug = 'hallucination-and-limitations'}
{@const activeIndex = flatItems.findIndex((item) => item.slug === activeSlug)}
{@const activeItem = flatItems[activeIndex] ?? null}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
{activeSlug}
{activeItem}
activeFlatIndex={activeIndex}
totalItems={flatItems.length}
hasPrev={activeIndex > 0}
hasNext={activeIndex >= 0 && activeIndex < flatItems.length - 1}
onItemClick={(item: PublicCourseSidebarItem) => console.log('navigate', item.slug)}
onPrev={() => console.log('prev')}
onNext={() => console.log('next')}
>
<PublicCourse.PublicLessonView lesson={LESSON_FIXTURE} callout={CALLOUT_FIXTURE} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Lesson · locked fallback (callout replaces body)">
{#snippet template()}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
activeSlug="building-agents"
activeItem={{
kind: 'lesson',
id: 'lesson-agents',
slug: 'building-agents',
title: 'Building agents',
isUnlocked: false
}}
activeFlatIndex={flatItems.length - 1}
totalItems={flatItems.length}
hasPrev
hasNext={false}
>
<PublicCourse.PublicLessonView lesson={LESSON_LOCKED_FIXTURE} callout={CALLOUT_FIXTURE} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Exercise · client-graded quiz">
{#snippet template()}
{@const activeSlug = 'foundations-quiz'}
{@const activeIndex = flatItems.findIndex((item) => item.slug === activeSlug)}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
{activeSlug}
activeItem={flatItems[activeIndex] ?? null}
activeFlatIndex={activeIndex}
totalItems={flatItems.length}
hasPrev={activeIndex > 0}
hasNext={activeIndex < flatItems.length - 1}
>
<PublicCourse.PublicExerciseView exercise={EXERCISE_FIXTURE} callout={CALLOUT_FIXTURE} labels={QUESTION_LABELS} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Sidebar · states only">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-xs ui:border-r ui:border-border">
<PublicCourse.PublicCourseSidebar sections={SIDEBAR_FIXTURE} activeSlug="hallucination-and-limitations" />
</div>
{/snippet}
</Story>
<Story name="Callout · inline + full variants">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:space-y-8 ui:p-8">
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Inline (at bottom of lessons)</h3>
<PublicCourse.PublicCourseCallout callout={CALLOUT_FIXTURE} variant="inline" />
</div>
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Full (locked item replacement)</h3>
<PublicCourse.PublicCourseCallout callout={CALLOUT_FIXTURE} variant="full" />
</div>
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Full (no callout configured)</h3>
<PublicCourse.PublicCourseCallout callout={null} variant="full" />
</div>
</div>
{/snippet}
</Story>
+1
View File
@@ -64,6 +64,7 @@ Components in the `custom` directory come from various sources or are built on t
- `custom/image-cropper/` - Custom image cropping component
- `custom/checkbox-field/` - Field component built on top of base Checkbox
- `custom/newsfeed-reactions/` - Newsfeed reaction picker and summary used by course and program feeds
- `custom/course-creator/` - ChatGPT-style course creation input with level and type selects
### Exercise question (`src/custom/exercise-question/`)
+1 -1
View File
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card"
class={cn(
'ui:bg-card ui:text-card-foreground ui:flex ui:flex-col ui:gap-6 ui:rounded-xl ui:border ui:py-6 ui:shadow-sm',
'ui:bg-card ui:text-card-foreground ui:flex ui:flex-col ui:gap-6 ui:rounded-lg ui:border ui:py-6',
className
)}
{...restProps}
@@ -0,0 +1 @@
export { default as NumberBadge } from './number-badge.svelte';
@@ -0,0 +1,36 @@
<script lang="ts">
import LockIcon from '@lucide/svelte/icons/lock';
import { cn } from '../../tools';
interface Props {
/** 1-indexed value shown when not `locked`. */
number: number;
/** When true, uses primary styling (e.g. active sidebar row or current question). */
active?: boolean;
/** Locked badges show a lock icon and muted treatment. */
locked?: boolean;
class?: string;
}
let { number, active = false, locked = false, class: className }: Props = $props();
const badgeClass = $derived(
cn(
'ui:inline-flex ui:items-center ui:justify-center ui:shrink-0',
'ui:size-7 ui:rounded-md ui:text-xs ui:font-medium ui:tabular-nums ui:tracking-tight',
'ui:transition-colors',
active
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:border ui:bg-muted ui:text-muted-foreground ui:group-hover:bg-muted-foreground/15 ui:group-hover:text-foreground',
className
)
);
</script>
<span class={badgeClass} aria-hidden="true">
{#if locked}
<LockIcon class="ui:size-3.5" />
{:else}
{number}
{/if}
</span>
+1 -1
View File
@@ -18,7 +18,7 @@
data-slot="page-header"
class={cn(
'ui:flex ui:my-4 ui:py-2 ui:flex-col',
isSticky && 'ui:sticky ui:top-13 ui:z-10 ui:bg-background',
isSticky && 'ui:sticky ui:top-14 ui:z-10 ui:bg-background',
className
)}
{...restProps}
@@ -15,7 +15,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'ui:border-input ui:placeholder:text-muted-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:dark:bg-input/30 ui:field-sizing-content ui:shadow-xs ui:flex ui:min-h-16 ui:w-full ui:rounded-md ui:border ui:bg-transparent ui:px-3 ui:py-2 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm',
'ui:border-input ui:placeholder:text-muted-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:dark:bg-input/30 ui:field-sizing-content ui:flex ui:min-h-16 ui:w-full ui:rounded-md ui:border ui:bg-transparent ui:px-3 ui:py-2 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm',
className
)}
bind:value
@@ -2,7 +2,7 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const toggleGroupItemVariants = tv({
base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-accent ui:data-[state=on]:text-accent-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4",
base: "ui:hover:bg-muted ui:hover:text-muted-foreground ui:data-[state=on]:bg-primary ui:data-[state=on]:text-primary-foreground ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:transition-[color,box-shadow] ui:outline-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='ui:size-'])]:size-4",
variants: {
variant: {
default: 'ui:bg-transparent',
@@ -35,7 +35,7 @@
});
</script>
<AnimatePresence let:item list={[{ key: id }]}>
<AnimatePresence let:_item list={[{ key: id }]}>
<Motion
initial="hidden"
animate={isInView}
@@ -0,0 +1,287 @@
<script module lang="ts">
const TWO_PI = Math.PI * 2;
/** Resolves `color-mix(in srgb, var(--primary) …)` for canvas dot fills (inherits `--primary` from this component’s DOM subtree). */
function primaryMixOnRoot(el: HTMLElement, primaryPercent: number): string {
const probe = document.createElement('span');
probe.setAttribute('aria-hidden', 'true');
probe.style.cssText =
'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;' +
`color:var(--primary)`;
el.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved || 'rgba(128, 128, 128, 0.25)';
}
type Dot = {
ax: number;
ay: number;
sx: number;
sy: number;
vx: number;
vy: number;
x: number;
y: number;
};
</script>
<script lang="ts">
import { cn } from '../../../tools';
type Props = {
dotRadius?: number;
dotSpacing?: number;
cursorRadius?: number;
cursorForce?: number;
bulgeOnly?: boolean;
bulgeStrength?: number;
glowRadius?: number;
sparkle?: boolean;
waveAmplitude?: number;
glowColor?: string;
class?: string;
};
let {
dotRadius = 1.5,
dotSpacing = 14,
cursorRadius = 500,
cursorForce = 0.1,
bulgeOnly = true,
bulgeStrength = 67,
glowRadius = 160,
sparkle = false,
waveAmplitude = 0,
glowColor = '#ffffff',
class: className = ''
}: Props = $props();
let root: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let glowEl: SVGCircleElement | undefined;
const glowId = `dot-field-glow-${crypto.randomUUID().slice(0, 10)}`;
let dots: Dot[] = [];
const mouse = { x: -9999, y: -9999, prevX: -9999, prevY: -9999, speed: 0 };
let size = { w: 0, h: 0, offsetX: 0, offsetY: 0 };
let glowOpacity = 0;
let engagement = 0;
$effect(() => {
void dotRadius;
void dotSpacing;
void cursorRadius;
void cursorForce;
void bulgeOnly;
void bulgeStrength;
void sparkle;
void waveAmplitude;
void glowColor;
const currentGlow = glowEl;
if (!canvas || !root) return;
const canvasEl: HTMLCanvasElement = canvas;
const rootEl: HTMLDivElement = root;
const fillGradientFrom = primaryMixOnRoot(rootEl, 40);
const fillGradientTo = primaryMixOnRoot(rootEl, 22);
const ctx = canvasEl.getContext('2d', { alpha: true });
if (!ctx) return;
const context = ctx;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let resizeTimer: ReturnType<typeof setTimeout>;
let raf = 0;
let frameCount = 0;
function buildDots(w: number, h: number) {
const step = dotRadius + dotSpacing;
const cols = Math.floor(w / step);
const rows = Math.floor(h / step);
const padX = (w % step) / 2;
const padY = (h % step) / 2;
const nextDots: Dot[] = new Array(rows * cols);
let idx = 0;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const ax = padX + col * step + step / 2;
const ay = padY + row * step + step / 2;
nextDots[idx++] = { ax, ay, sx: ax, sy: ay, vx: 0, vy: 0, x: ax, y: ay };
}
}
dots = nextDots;
}
function doResize() {
const rect = rootEl.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
canvasEl.width = w * dpr;
canvasEl.height = h * dpr;
canvasEl.style.width = `${w}px`;
canvasEl.style.height = `${h}px`;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
size = {
w,
h,
offsetX: rect.left + window.scrollX,
offsetY: rect.top + window.scrollY
};
buildDots(w, h);
}
function resize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(doResize, 100);
}
function onMouseMove(e: MouseEvent) {
mouse.x = e.pageX - size.offsetX;
mouse.y = e.pageY - size.offsetY;
}
function updateMouseSpeed() {
const dx = mouse.prevX - mouse.x;
const dy = mouse.prevY - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
mouse.speed += (dist - mouse.speed) * 0.5;
if (mouse.speed < 0.001) mouse.speed = 0;
mouse.prevX = mouse.x;
mouse.prevY = mouse.y;
}
const speedInterval = setInterval(updateMouseSpeed, 20);
function tick() {
frameCount++;
const len = dots.length;
const { w, h } = size;
const t = frameCount * 0.02;
const targetEngagement = Math.min(mouse.speed / 5, 1);
engagement += (targetEngagement - engagement) * 0.06;
if (engagement < 0.001) engagement = 0;
glowOpacity += (engagement - glowOpacity) * 0.08;
if (currentGlow) {
currentGlow.setAttribute('cx', String(mouse.x));
currentGlow.setAttribute('cy', String(mouse.y));
currentGlow.style.opacity = String(glowOpacity);
}
context.clearRect(0, 0, w, h);
const grad = context.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, fillGradientFrom);
grad.addColorStop(1, fillGradientTo);
context.fillStyle = grad;
const crSq = cursorRadius * cursorRadius;
const rad = dotRadius / 2;
context.beginPath();
for (let i = 0; i < len; i++) {
const d = dots[i];
const dx = mouse.x - d.ax;
const dy = mouse.y - d.ay;
const distSq = dx * dx + dy * dy;
if (distSq < crSq && engagement > 0.01) {
const dist = Math.sqrt(distSq);
const angle = Math.atan2(dy, dx);
if (bulgeOnly) {
const falloff = 1 - dist / cursorRadius;
const push = falloff * falloff * bulgeStrength * engagement;
d.sx += (d.ax - Math.cos(angle) * push - d.sx) * 0.15;
d.sy += (d.ay - Math.sin(angle) * push - d.sy) * 0.15;
} else {
const safeDist = Math.max(dist, 0.001);
const move = (500 / safeDist) * (mouse.speed * cursorForce);
d.vx += Math.cos(angle) * -move;
d.vy += Math.sin(angle) * -move;
}
} else if (bulgeOnly) {
d.sx += (d.ax - d.sx) * 0.1;
d.sy += (d.ay - d.sy) * 0.1;
}
if (!bulgeOnly) {
d.vx *= 0.9;
d.vy *= 0.9;
d.x = d.ax + d.vx;
d.y = d.ay + d.vy;
d.sx += (d.x - d.sx) * 0.1;
d.sy += (d.y - d.sy) * 0.1;
}
let drawX = d.sx;
let drawY = d.sy;
if (waveAmplitude > 0) {
drawY += Math.sin(d.ax * 0.03 + t) * waveAmplitude;
drawX += Math.cos(d.ay * 0.03 + t * 0.7) * waveAmplitude * 0.5;
}
if (sparkle) {
const hash = ((i * 2654435761) ^ (frameCount >> 3)) >>> 0;
if (hash % 100 < 3) {
context.moveTo(drawX + rad * 1.8, drawY);
context.arc(drawX, drawY, rad * 1.8, 0, TWO_PI);
} else {
context.moveTo(drawX + rad, drawY);
context.arc(drawX, drawY, rad, 0, TWO_PI);
}
} else {
context.moveTo(drawX + rad, drawY);
context.arc(drawX, drawY, rad, 0, TWO_PI);
}
}
context.fill();
raf = requestAnimationFrame(tick);
}
doResize();
window.addEventListener('resize', resize);
window.addEventListener('mousemove', onMouseMove, { passive: true });
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
clearInterval(speedInterval);
clearTimeout(resizeTimer);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMouseMove);
};
});
</script>
<div bind:this={root} class={cn('relative h-full w-full', className)}>
<canvas bind:this={canvas} class="absolute inset-0 h-full w-full"></canvas>
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs>
<radialGradient id={glowId}>
<stop offset="0%" stop-color="#fff" />
<stop offset="100%" stop-color="#fff" />
</radialGradient>
</defs>
<circle
bind:this={glowEl}
cx="-9999"
cy="-9999"
r={glowRadius}
fill="url(#{glowId})"
style:opacity="0"
style:will-change="opacity"
/>
</svg>
</div>
@@ -0,0 +1 @@
export { default as DotField } from './dot-field.svelte';
@@ -31,7 +31,7 @@
</script>
<div class={cn('flex justify-center space-x-1', containerClass)}>
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
<AnimatePresence let:_item list={[{ key: wordsspilit }]}>
{#each wordsspilit as char, i}
<Motion
initial="hidden"
@@ -1,4 +1,5 @@
export * from './border-beam';
export * from './dot-field';
export * from './dot-pattern';
export * from './card';
export * from './blurfade';
@@ -18,6 +18,12 @@
icon: Component;
iconClass?: string;
};
/** e.g. public-course indicator on the banner */
visibilityBadge?: {
label: string;
icon: Component;
iconClass?: string;
};
class?: string;
/** Custom banner area (e.g. optimized `Image`); overrides `bannerImage` */
media?: Snippet;
@@ -34,6 +40,7 @@
bannerImage,
bannerAlt = 'Course banner image',
typeBadge,
visibilityBadge,
class: className = '',
media,
overlay,
@@ -72,6 +79,20 @@
{typeBadge.label}
</Badge>
{/if}
{#if visibilityBadge}
{@const VIcon = visibilityBadge.icon}
<Badge
class={cn(
'ui:absolute ui:bottom-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:gap-1 ui:capitalize',
typeBadge ? 'ui:right-2 ui:left-auto' : 'ui:left-2'
)}
variant="secondary"
>
<VIcon class={visibilityBadge.iconClass} />
{visibilityBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
@@ -118,6 +139,20 @@
{typeBadge.label}
</Badge>
{/if}
{#if visibilityBadge}
{@const VIcon = visibilityBadge.icon}
<Badge
class={cn(
'ui:absolute ui:bottom-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:gap-1 ui:capitalize',
typeBadge ? 'ui:right-2 ui:left-auto' : 'ui:left-2'
)}
variant="secondary"
>
<VIcon class={visibilityBadge.iconClass} />
{visibilityBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
@@ -0,0 +1,126 @@
<script lang="ts">
import * as Select from '../../base/select';
import { Textarea } from '../../base/textarea';
import { Button } from '../../base/button';
import ModelPicker from '../model-picker/model-picker.svelte';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import { DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
interface SelectOption {
value: string;
label: string;
}
interface SubmitPayload {
prompt: string;
level: string;
model?: string;
}
interface Props {
heading: string;
placeholder: string;
loading?: boolean;
levelOptions?: SelectOption[];
model?: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
onModelChange?: (model: AgentModelId) => void;
onLockedModelSelect?: (model: AgentModelId) => void;
onsubmit?: (payload: SubmitPayload) => void;
}
let {
heading,
placeholder,
loading = false,
levelOptions = [],
model = DEFAULT_PICKER_MODEL_ID,
lockedModelIds = [],
onModelChange,
onLockedModelSelect,
onsubmit
}: Props = $props();
let prompt = $state('');
let level = $state('beginner');
let selectedModel = $state<AgentModelId>(model);
const selectedLevelLabel = $derived(
levelOptions.find((o) => o.value === level)?.label ?? levelOptions[0]?.label ?? ''
);
const canSubmit = $derived(!!prompt.trim() && !loading);
$effect(() => {
if (model !== selectedModel) {
selectedModel = model;
}
});
function handleModelChange(id: AgentModelId) {
selectedModel = id;
onModelChange?.(id);
}
function handleSubmit() {
if (!canSubmit) return;
onsubmit?.({ prompt, level, model: selectedModel });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
</script>
<div class="ui:flex ui:w-full ui:flex-col ui:items-center ui:gap-8">
<h1 class="ui:text-center ui:text-3xl ui:font-semibold ui:tracking-tight">{heading}</h1>
<div class="ui:bg-card ui:w-full ui:max-w-3xl ui:rounded-2xl ui:border ui:p-4">
<Textarea
bind:value={prompt}
{placeholder}
rows={4}
disabled={loading}
onkeydown={handleKeydown}
class="ui:w-full ui:resize-none ui:border-0! ui:focus-visible:ring-0! ui:focus-visible:ring-offset-0!"
/>
<div class="ui:mt-3 ui:flex ui:items-center ui:gap-2">
<Select.Root type="single" bind:value={level} disabled={loading}>
<Select.Trigger>
{selectedLevelLabel}
</Select.Trigger>
<Select.Content>
{#each levelOptions as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<ModelPicker
value={selectedModel}
disabled={loading}
variant="select"
{lockedModelIds}
onChange={handleModelChange}
onLockedSelect={onLockedModelSelect}
/>
<div class="ui:ml-auto">
<Button variant="secondary" size="icon" disabled={!canSubmit} onclick={handleSubmit}>
{#if loading}
<LoaderCircleIcon class="ui:h-4 ui:w-4 ui:animate-spin" />
{:else}
<ArrowUpIcon class="ui:h-4 ui:w-4" />
{/if}
</Button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,4 @@
import CourseCreator from './course-creator.svelte';
export { CourseCreator };
export { CourseCreator as default };
@@ -25,7 +25,27 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
declare module '@tiptap/core' {
interface Storage {
searchAndReplace: SearchAndReplaceStorage;
}
interface Commands<ReturnType> {
search: {
/**
@@ -218,22 +238,6 @@ const replaceAll = (
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
name: 'searchAndReplace',
@@ -4,6 +4,10 @@ export { default as QuestionRenderer } from './question-renderer.svelte';
export { default as QuestionList } from './question-list.svelte';
export { default as QuestionSurface } from './question-surface.svelte';
export { default as QuestionTitle } from './question-title.svelte';
export { default as SectionAfterBehavior } from './section-after-behavior.svelte';
export { default as SectionHeader } from './section-header.svelte';
export { default as SectionNavigationSidebar } from './section-navigation-sidebar.svelte';
export { default as SectionOverview } from './section-overview.svelte';
export * from './renderer-contract';
export { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
@@ -17,6 +21,7 @@ export { default as TextareaEdit } from './renderers/textarea/edit.svelte';
export { default as MatchingEdit } from './renderers/matching/edit.svelte';
export { default as OrderingEdit } from './renderers/ordering/edit.svelte';
export { default as HotspotEdit } from './renderers/hotspot/edit.svelte';
export { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
export { default as TrueFalseTake } from './renderers/true-false/take.svelte';
export { default as ShortAnswerTake } from './renderers/short-answer/take.svelte';
@@ -29,6 +34,7 @@ export { default as TextareaTake } from './renderers/textarea/take.svelte';
export { default as MatchingTake } from './renderers/matching/take.svelte';
export { default as OrderingTake } from './renderers/ordering/take.svelte';
export { default as HotspotTake } from './renderers/hotspot/take.svelte';
export { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
export { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
export { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
@@ -42,6 +48,7 @@ export { default as MatchingPreview } from './renderers/matching/preview.svelte'
export { default as OrderingPreview } from './renderers/ordering/preview.svelte';
export { default as OrderingDisplay } from './renderers/ordering/ordering-display.svelte';
export { default as HotspotPreview } from './renderers/hotspot/preview.svelte';
export { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
export { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
@@ -55,3 +62,4 @@ export { default as MatchingSubmission } from './renderers/matching/submission.s
export { default as OrderingSubmission } from './renderers/ordering/submission.svelte';
export { default as HotspotSubmission } from './renderers/hotspot/submission.svelte';
export { default as LinkSubmission } from './renderers/link/submission.svelte';
export { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
@@ -5,7 +5,7 @@
interface Props {
contract: ExerciseQuestionListRenderContract;
onAnswerChange?: (question: ExerciseQuestionModel, answer: AnswerData) => void;
onAnswerChange?: (question: ExerciseQuestionModel, answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionModel, updatedQuestion: ExerciseQuestionModel) => void;
itemClass?: string;
showContainer?: boolean;
@@ -33,10 +33,13 @@
disabled: contract.disabled,
labels: contract.labels,
onImageUpload: contract.onImageUpload,
onFileUpload: contract.onFileUpload
onFileUpload: contract.onFileUpload,
onVideoRecordingUpload: contract.onVideoRecordingUpload
}}
onAnswerChange={(answer) => onAnswerChange(question, answer)}
onQuestionChange={(updatedQuestion) => onQuestionChange(question, updatedQuestion)}
questionNumber={index + 1}
questionNumberActive={false}
{showContainer}
/>
</div>
@@ -14,6 +14,7 @@
import { Textarea } from '../../base/textarea';
import { IconButton } from '../icon-button';
import { MediaPlayer, isYoutubeUrl } from '../media-player';
import NumberBadge from '$src/base/number-badge/number-badge.svelte';
import QuestionTitle from './question-title.svelte';
import { YoutubeLinkForm } from '../youtube-link-form';
import QuestionSurface from './question-surface.svelte';
@@ -22,10 +23,14 @@
interface Props {
contract: ExerciseQuestionRenderContract;
onAnswerChange?: (answer: AnswerData) => void;
onAnswerChange?: (answer: AnswerData | null) => void;
onQuestionChange?: (question: ExerciseQuestionRendererProps['question']) => void;
showContainer?: boolean;
questionTypeSelect?: Snippet;
/** When set, a numbered badge is shown to the left of the title (non-edit modes). */
questionNumber?: number;
/** When false, the badge uses muted styling (e.g. question lists). Defaults to true. */
questionNumberActive?: boolean;
}
let {
@@ -33,7 +38,9 @@
onAnswerChange = () => {},
onQuestionChange = () => {},
showContainer = true,
questionTypeSelect
questionTypeSelect,
questionNumber,
questionNumberActive = true
}: Props = $props();
const Renderer = $derived(getExerciseQuestionRenderer(contract.question.questionType, contract.mode));
@@ -265,8 +272,8 @@
{#snippet content()}
{#if contract.mode === 'edit'}
<div class="ui:space-y-3">
<div class="ui:flex ui:items-end ui:gap-2">
<div class="ui:space-y-1 ui:w-full">
<div class="ui:flex ui:min-w-0 ui:flex-col ui:items-stretch ui:gap-2 ui:sm:flex-row ui:sm:items-end">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-1">
<p class="ui:text-sm ui:font-medium">{label('question.edit.title_label')}</p>
<Input
class="ui:w-full"
@@ -413,7 +420,14 @@
{#if contract.mode !== 'edit'}
<div class="ui:mb-4 ui:space-y-3">
{#if contract.question.title.trim().length > 0}
{#if questionNumber !== undefined}
<div class="ui:flex ui:items-center ui:gap-2">
<NumberBadge number={questionNumber} active={questionNumberActive} />
{#if contract.question.title.trim().length > 0}
<QuestionTitle title={contract.question.title} class="ui:min-w-0 ui:flex-1" />
{/if}
</div>
{:else if contract.question.title.trim().length > 0}
<QuestionTitle title={contract.question.title} />
{/if}
@@ -463,6 +477,7 @@
labels={contract.labels}
onImageUpload={contract.onImageUpload}
onFileUpload={contract.onFileUpload}
onVideoRecordingUpload={contract.onVideoRecordingUpload}
{onAnswerChange}
{onQuestionChange}
/>
@@ -23,6 +23,7 @@ import { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte
import { default as StarEdit } from './renderers/star/edit.svelte';
import { default as TextareaEdit } from './renderers/textarea/edit.svelte';
import { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
import { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
import { default as CheckboxTake } from './renderers/checkbox/take.svelte';
import { default as FileUploadTake } from './renderers/file-upload/take.svelte';
@@ -38,6 +39,7 @@ import { default as ShortAnswerTake } from './renderers/short-answer/take.svelte
import { default as StarTake } from './renderers/star/take.svelte';
import { default as TextareaTake } from './renderers/textarea/take.svelte';
import { default as TrueFalseTake } from './renderers/true-false/take.svelte';
import { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
import { default as CheckboxPreview } from './renderers/checkbox/preview.svelte';
import { default as FileUploadPreview } from './renderers/file-upload/preview.svelte';
@@ -53,6 +55,7 @@ import { default as ShortAnswerPreview } from './renderers/short-answer/preview.
import { default as StarPreview } from './renderers/star/preview.svelte';
import { default as TextareaPreview } from './renderers/textarea/preview.svelte';
import { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
import { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
import { default as CheckboxSubmission } from './renderers/checkbox/submission.svelte';
import { default as FileUploadSubmission } from './renderers/file-upload/submission.svelte';
@@ -68,6 +71,7 @@ import { default as ShortAnswerSubmission } from './renderers/short-answer/submi
import { default as StarSubmission } from './renderers/star/submission.svelte';
import { default as TextareaSubmission } from './renderers/textarea/submission.svelte';
import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
import { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
import { default as RadioReview } from './renderers/radio/review.svelte';
import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
@@ -172,6 +176,13 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
take: StarTake,
preview: StarPreview,
submission: StarSubmission
},
[QUESTION_TYPE_KEY.VIDEO_RECORDING]: {
edit: VideoRecordingEdit,
take: VideoRecordingTake,
preview: VideoRecordingPreview,
submission: VideoRecordingSubmission,
review: VideoRecordingSubmission
}
};
@@ -157,7 +157,7 @@
<div class="ui:space-y-2">
{#each question.options ?? [] as option, index}
{@const optionImageUrl = getOptionImageUrl(option)}
<div class="ui:flex ui:items-start ui:gap-2">
<div class="ui:flex ui:min-w-0 ui:items-start ui:gap-2">
<IconButton
type="button"
{disabled}
@@ -177,7 +177,7 @@
<span class="ui:sr-only">{label('checkbox.edit.toggle_correct_sr')}</span>
</IconButton>
<div class="ui:flex-1 ui:space-y-2">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-2">
<Input
class="ui:w-full"
placeholder={[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ').trim()}
@@ -61,10 +61,12 @@
{@const optionValue = getOptionValue(option, index)}
{@const optionId = `checkbox-${question.id ?? 'question'}-${index}`}
{@const optionImageUrl = getOptionImageUrl(option)}
{@const isSelected = isOptionSelected(option, index)}
<div
class={cn(
'ui:rounded-md ui:p-2 ui:border',
isSelected ? 'ui:border-primary' : 'ui:border-border',
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2'
)}
>
@@ -152,7 +152,7 @@
<div class="ui:space-y-2">
{#each question.options ?? [] as option, index}
{@const optionImageUrl = getOptionImageUrl(option)}
<div class="ui:flex ui:items-start ui:gap-2">
<div class="ui:flex ui:min-w-0 ui:items-start ui:gap-2">
<IconButton
type="button"
{disabled}
@@ -172,7 +172,7 @@
<span class="ui:sr-only">{label('radio.edit.mark_correct_sr')}</span>
</IconButton>
<div class="ui:flex-1 ui:space-y-2">
<div class="ui:min-w-0 ui:flex-1 ui:space-y-2">
<Input
class="ui:w-full"
placeholder={[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ').trim()}
@@ -51,10 +51,12 @@
{@const optionValue = getOptionValue(option, index)}
{@const optionId = `radio-${question.id ?? 'question'}-${index}`}
{@const optionImageUrl = getOptionImageUrl(option)}
{@const isSelected = selectedOptionValue !== '' && selectedOptionValue === optionValue}
<div
class={cn(
'ui:rounded-md ui:p-2 ui:border',
isSelected ? 'ui:border-primary' : 'ui:border-border',
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2'
)}
>
@@ -0,0 +1,68 @@
<script lang="ts">
import {
VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS,
VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS,
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Badge } from '../../../../base/badge';
import { Input } from '../../../../base/input';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationValue = $derived(String(getVideoRecordingMaxDurationSeconds(question.settings)));
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next, allowRetakes: true } });
}
function onDurationChange(value: string) {
const duration = Number(value);
if (!Number.isFinite(duration) || duration <= 0) {
patchSettings({ maxDurationSeconds: VIDEO_RECORDING_DEFAULT_MAX_DURATION_SECONDS });
return;
}
patchSettings({
maxDurationSeconds: Math.min(Math.round(duration), VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS)
});
}
</script>
<div class="ui:space-y-3">
<div class="ui:grid ui:gap-3 ui:md:grid-cols-[minmax(0,220px)_1fr] ui:md:items-start">
<div class="ui:space-y-1">
<p class="ui:text-sm ui:font-medium">
{label('video_recording.edit.max_duration_label', 'Max duration')}
</p>
<Input
type="number"
min="1"
max={VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS}
step="1"
value={maxDurationValue}
placeholder={label('video_recording.edit.max_duration_placeholder', '60')}
{disabled}
onchange={(event) => onDurationChange(event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-2 ui:pt-1">
<Badge variant="secondary">{label('video_recording.edit.manual_grading', 'Requires manual grading')}</Badge>
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.edit.max_duration_helper',
'Students record in the browser and can retake before submitting.'
)}
</p>
</div>
</div>
</div>
@@ -0,0 +1,15 @@
export function formatSeconds(totalSeconds: number): string {
const safeSeconds = Math.max(0, Math.round(totalSeconds));
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
const units = ['Bytes', 'KB', 'MB', 'GB'];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, unitIndex);
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
@@ -0,0 +1,25 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { formatSeconds } from './format';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationSeconds = $derived(getVideoRecordingMaxDurationSeconds(question.settings));
</script>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.preview.max_duration_label', 'Max duration')}: {formatSeconds(maxDurationSeconds)}
</p>
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.preview.manual_grading',
'Learners record a short video response. Manual grading required.'
)}
</p>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import { MediaPlayer } from '../../../media-player';
import LabeledValueRow from '../shared/labeled-value-row.svelte';
import { formatFileSize, formatSeconds } from './format';
let { answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const recording = $derived(answer?.type === 'VIDEO_RECORDING' ? answer : null);
const playbackUrl = $derived.by(() => {
if (!recording) return '';
const enrichedUrl = (recording as { fileUrl?: string }).fileUrl;
return recording.playbackUrl ?? enrichedUrl ?? '';
});
function formatDate(value: string | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString();
}
</script>
{#if !recording}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.submission.no_video', 'No video submitted.')}
</p>
{:else}
<div class="ui:space-y-3">
{#if playbackUrl}
<div class="ui:max-w-xl ui:overflow-hidden ui:rounded-md">
<MediaPlayer
source={{ type: 'upload', url: playbackUrl }}
options={{ controls: true, playsinline: true, width: '100%', maxHeight: '360px' }}
/>
</div>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.submission.unavailable', 'Video unavailable.')}
</p>
{/if}
<div class="ui:grid ui:gap-2 ui:text-sm ui:sm:grid-cols-2">
<LabeledValueRow
label={label('video_recording.submission.duration', 'Duration')}
value={formatSeconds(recording.durationSeconds)}
/>
<LabeledValueRow
label={label('video_recording.submission.file_size', 'File size')}
value={formatFileSize(recording.size)}
/>
<LabeledValueRow
label={label('video_recording.submission.recorded_at', 'Recorded')}
value={formatDate(recording.recordedAt)}
/>
<LabeledValueRow
label={label('video_recording.submission.uploaded_at', 'Uploaded')}
value={formatDate(recording.uploadedAt)}
/>
<LabeledValueRow
label={label('video_recording.submission.retake_count', 'Retakes')}
value={String(recording.retakeCount ?? 0)}
/>
</div>
</div>
{/if}
@@ -0,0 +1,481 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import {
getExerciseQuestionLabel,
getVideoRecordingMaxDurationSeconds,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Button } from '../../../../base/button';
import * as Select from '../../../../base/select';
import { Spinner } from '../../../../base/spinner';
import { formatSeconds } from './format';
type RecordingState =
| 'idle'
| 'permission_denied'
| 'ready'
| 'recording'
| 'recorded'
| 'uploading'
| 'uploaded'
| 'failed';
let {
question,
answer = null,
disabled = false,
labels,
onAnswerChange = () => {},
onVideoRecordingUpload
}: ExerciseQuestionRendererProps = $props();
let recordingState = $state<RecordingState>('idle');
let stream = $state<MediaStream | null>(null);
let mediaRecorder = $state<MediaRecorder | null>(null);
let liveVideo = $state<HTMLVideoElement | null>(null);
let previewVideo = $state<HTMLVideoElement | null>(null);
let elapsedSeconds = $state(0);
let durationSeconds = $state(0);
let recordingBlob = $state<Blob | null>(null);
let previewUrl = $state('');
let uploadError = $state('');
let recordedAt = $state('');
let retakeCount = $state(0);
let isPreviewPlaying = $state(false);
let cameraDevices = $state<MediaDeviceInfo[]>([]);
let selectedCameraDeviceId = $state('');
let timerId: number | null = null;
let recordingStartedAt = 0;
let chunks: Blob[] = [];
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxDurationSeconds = $derived(getVideoRecordingMaxDurationSeconds(question.settings));
const remainingSeconds = $derived(Math.max(0, maxDurationSeconds - elapsedSeconds));
const isFinalWarning = $derived(maxDurationSeconds > 15 && remainingSeconds <= 10 && recordingState === 'recording');
const savedRecording = $derived(answer?.type === 'VIDEO_RECORDING' ? answer : null);
const savedPlaybackUrl = $derived.by(() => {
if (!savedRecording) return '';
return savedRecording.playbackUrl ?? (savedRecording as { fileUrl?: string }).fileUrl ?? '';
});
const canUseRecording = $derived(recordingBlob && durationSeconds >= 1 && !disabled);
const recordingProgressPercent = $derived(
maxDurationSeconds > 0 ? Math.min(100, Math.max(0, (elapsedSeconds / maxDurationSeconds) * 100)) : 0
);
const recordingProgressDegrees = $derived(recordingProgressPercent * 3.6);
const progressRingBackground = $derived(
`conic-gradient(var(--primary) ${recordingProgressDegrees}deg, color-mix(in oklab, var(--primary) 12%, var(--background)) ${recordingProgressDegrees}deg)`
);
const isSupported = $derived.by(
() =>
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia &&
'MediaRecorder' in window
);
$effect(() => {
if (liveVideo && stream) {
liveVideo.srcObject = stream;
}
});
function getSupportedMimeType(): string {
const candidates = ['video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
const supported = candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
return supported ?? '';
}
function normalizeMimeType(mimeType: string): 'video/webm' | 'video/mp4' | 'video/quicktime' {
if (mimeType.includes('mp4')) return 'video/mp4';
if (mimeType.includes('quicktime')) return 'video/quicktime';
return 'video/webm';
}
async function refreshCameraDevices() {
if (!navigator.mediaDevices?.enumerateDevices) return;
const devices = await navigator.mediaDevices.enumerateDevices();
cameraDevices = devices.filter((device) => device.kind === 'videoinput');
if (!selectedCameraDeviceId && cameraDevices[0]) {
selectedCameraDeviceId = cameraDevices[0].deviceId;
}
}
function getVideoConstraints(): MediaTrackConstraints {
if (selectedCameraDeviceId) {
return { deviceId: { exact: selectedCameraDeviceId } };
}
return { facingMode: 'user' };
}
function clearTimer() {
if (timerId === null) return;
window.clearInterval(timerId);
timerId = null;
}
function stopStream() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
if (liveVideo) liveVideo.srcObject = null;
}
function revokePreviewUrl() {
if (!previewUrl) return;
URL.revokeObjectURL(previewUrl);
previewUrl = '';
}
function resetLocalRecording() {
revokePreviewUrl();
recordingBlob = null;
durationSeconds = 0;
elapsedSeconds = 0;
uploadError = '';
isPreviewPlaying = false;
}
async function requestCamera(): Promise<MediaStream | null> {
if (disabled || !isSupported) return null;
try {
stopStream();
const nextStream = await navigator.mediaDevices.getUserMedia({
video: getVideoConstraints(),
audio: true
});
stream = nextStream;
selectedCameraDeviceId = nextStream.getVideoTracks()[0]?.getSettings().deviceId ?? selectedCameraDeviceId;
recordingState = 'ready';
uploadError = '';
await refreshCameraDevices();
return nextStream;
} catch {
recordingState = 'permission_denied';
return null;
}
}
async function handleCameraChange(nextDeviceId: string) {
if (!nextDeviceId || nextDeviceId === selectedCameraDeviceId || recordingState === 'recording') return;
selectedCameraDeviceId = nextDeviceId;
if (recordingState === 'ready') {
await requestCamera();
}
}
async function startRecording() {
if (disabled || recordingState === 'recording') return;
const activeStream = stream ?? (await requestCamera());
if (!activeStream) return;
resetLocalRecording();
chunks = [];
const mimeType = getSupportedMimeType();
const recorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
mediaRecorder = recorder;
recordingStartedAt = performance.now();
recordedAt = new Date().toISOString();
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
clearTimer();
const measuredDuration = Math.max(0, (performance.now() - recordingStartedAt) / 1000);
durationSeconds = Math.round(measuredDuration * 10) / 10;
elapsedSeconds = Math.min(maxDurationSeconds, Math.ceil(durationSeconds));
const blobType = normalizeMimeType(recorder.mimeType || mimeType);
const blob = new Blob(chunks, { type: blobType });
recordingBlob = blob;
previewUrl = URL.createObjectURL(blob);
recordingState = durationSeconds >= 1 ? 'recorded' : 'failed';
};
recorder.start();
recordingState = 'recording';
timerId = window.setInterval(() => {
const elapsed = (performance.now() - recordingStartedAt) / 1000;
elapsedSeconds = Math.min(maxDurationSeconds, elapsed);
if (elapsed >= maxDurationSeconds) {
stopRecording();
}
}, 250);
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
clearTimer();
stopStream();
}
async function togglePreviewPlayback() {
if (!previewVideo) return;
if (previewVideo.paused) {
await previewVideo.play();
isPreviewPlaying = true;
return;
}
previewVideo.pause();
isPreviewPlaying = false;
}
function onPreviewEnded() {
isPreviewPlaying = false;
}
function retakeRecording() {
resetLocalRecording();
retakeCount += 1;
recordingState = stream ? 'ready' : 'idle';
}
function deleteRecording() {
resetLocalRecording();
onAnswerChange(null);
recordingState = stream ? 'ready' : 'idle';
}
async function acceptRecording() {
if (!recordingBlob || !canUseRecording || !onVideoRecordingUpload) return;
recordingState = 'uploading';
uploadError = '';
try {
const mimeType = normalizeMimeType(recordingBlob.type);
const result = await onVideoRecordingUpload({
questionId: question.id ?? question.key ?? '',
blob: recordingBlob,
fileName: `recording-${Date.now()}.${mimeType === 'video/mp4' ? 'mp4' : 'webm'}`,
mimeType,
size: recordingBlob.size,
durationSeconds,
recordedAt,
retakeCount
});
onAnswerChange({ ...result, playbackUrl: previewUrl });
recordingState = 'uploaded';
} catch (error) {
uploadError =
error instanceof Error
? error.message
: label('video_recording.take.upload_failed', 'Upload failed. Try again.');
recordingState = 'failed';
}
}
onDestroy(() => {
clearTimer();
stopStream();
revokePreviewUrl();
});
</script>
<div class="ui:space-y-4" aria-live="polite">
{#if !isSupported}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.take.unsupported', 'Video recording is not supported in this browser.')}
</p>
{:else}
<div class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<div
class={recordingState === 'recording' ? 'ui:rounded-full ui:p-1 ui:shadow-sm' : ''}
style={recordingState === 'recording' ? `background: ${progressRingBackground};` : undefined}
>
<div
class="ui:bg-muted ui:relative ui:grid ui:aspect-square ui:w-52 ui:max-w-full ui:place-items-center ui:overflow-hidden ui:rounded-full ui:sm:w-64"
>
{#if recordingState === 'ready' || recordingState === 'recording'}
<video
bind:this={liveVideo}
autoplay
muted
playsinline
class="ui:h-full ui:w-full ui:object-cover ui:[transform:scaleX(-1)]"
></video>
{#if recordingState === 'ready'}
<button
type="button"
class="ui:absolute ui:inset-0 ui:grid ui:place-items-center ui:bg-black/10 ui:text-white ui:transition ui:hover:bg-black/20 ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring ui:disabled:pointer-events-none ui:disabled:opacity-50"
{disabled}
onclick={startRecording}
>
<span
class="ui:rounded-full ui:bg-background/90 ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-foreground ui:shadow-md"
>
{label('video_recording.take.start_recording', 'Start recording')}
</span>
</button>
{/if}
{:else if previewUrl || savedPlaybackUrl}
<!-- svelte-ignore a11y_media_has_caption - v1 browser recordings do not generate captions. -->
<video
bind:this={previewVideo}
src={previewUrl || savedPlaybackUrl}
playsinline
class="ui:h-full ui:w-full ui:object-cover ui:[transform:scaleX(-1)]"
onpause={() => (isPreviewPlaying = false)}
onplay={() => (isPreviewPlaying = true)}
onended={onPreviewEnded}
></video>
<button
type="button"
class="ui:group ui:absolute ui:inset-0 ui:grid ui:place-items-center ui:bg-black/10 ui:text-white ui:transition ui:hover:bg-black/20 ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring"
aria-label={isPreviewPlaying
? label('video_recording.take.pause_preview', 'Pause recording preview')
: label('video_recording.take.play_preview', 'Play recording preview')}
onclick={togglePreviewPlayback}
>
{#if isPreviewPlaying}
<span
class="ui:grid ui:size-14 ui:grid-cols-[6px_6px] ui:place-content-center ui:gap-2 ui:rounded-full ui:bg-background/90 ui:text-foreground ui:opacity-0 ui:shadow-md ui:transition-opacity ui:group-hover:opacity-100 ui:group-focus-visible:opacity-100"
aria-hidden="true"
>
<span class="ui:h-5 ui:w-1.5 ui:rounded-sm ui:bg-current"></span>
<span class="ui:h-5 ui:w-1.5 ui:rounded-sm ui:bg-current"></span>
</span>
{:else}
<span
class="ui:grid ui:size-14 ui:place-items-center ui:rounded-full ui:bg-background/90 ui:text-foreground ui:shadow-md"
aria-hidden="true"
>
<span
class="ui:ml-1 ui:h-0 ui:w-0 ui:border-y-[10px] ui:border-y-transparent ui:border-l-[16px] ui:border-l-current"
></span>
</span>
{/if}
</button>
{:else if recordingState === 'permission_denied'}
<div class="ui:flex ui:max-w-40 ui:flex-col ui:items-center ui:gap-3 ui:text-center">
<p class="ui:text-sm ui:text-muted-foreground">
{label('video_recording.take.permission_denied', 'Camera or microphone access was blocked.')}
</p>
<Button type="button" size="sm" variant="secondary" {disabled} onclick={requestCamera}>
{label('video_recording.take.start_camera', 'Set up camera')}
</Button>
</div>
{:else}
<button
type="button"
class="ui:grid ui:size-24 ui:place-items-center ui:rounded-full ui:bg-background ui:px-3 ui:text-center ui:text-sm ui:font-medium ui:text-foreground ui:shadow-sm ui:ring-1 ui:ring-border ui:transition ui:hover:bg-accent ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-ring ui:disabled:pointer-events-none ui:disabled:opacity-50"
{disabled}
onclick={requestCamera}
>
{label('video_recording.take.start_camera', 'Set up camera')}
</button>
{/if}
</div>
</div>
{#if cameraDevices.length > 1 && recordingState !== 'recording'}
<div class="ui:flex ui:w-full ui:max-w-xs ui:flex-col ui:gap-1.5">
<span class="ui:text-xs ui:font-medium ui:text-muted-foreground">
{label('video_recording.take.camera_label', 'Camera')}
</span>
<Select.Root type="single" value={selectedCameraDeviceId} onValueChange={handleCameraChange}>
<Select.Trigger class="ui:w-full">
{cameraDevices.find((device) => device.deviceId === selectedCameraDeviceId)?.label ||
label('video_recording.take.camera_placeholder', 'Select camera')}
</Select.Trigger>
<Select.Content>
{#each cameraDevices as device, cameraIndex (device.deviceId)}
<Select.Item value={device.deviceId} label={device.label || `Camera ${cameraIndex + 1}`}>
{device.label || `${label('video_recording.take.camera_label', 'Camera')} ${cameraIndex + 1}`}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="ui:flex ui:flex-wrap ui:items-center ui:justify-center ui:gap-x-4 ui:gap-y-1 ui:text-sm">
<span class={isFinalWarning ? 'ui:text-destructive ui:font-medium' : 'ui:text-muted-foreground'}>
{label('video_recording.take.elapsed', 'Elapsed')}: {formatSeconds(elapsedSeconds)}
</span>
<span class={isFinalWarning ? 'ui:text-destructive ui:font-medium' : 'ui:text-muted-foreground'}>
{label('video_recording.take.remaining', 'Remaining')}: {formatSeconds(remainingSeconds)}
</span>
<span class="ui:text-muted-foreground">
{label('video_recording.take.max_duration', 'Max')}: {formatSeconds(maxDurationSeconds)}
</span>
</div>
{#if recordingState === 'failed' && durationSeconds < 1 && recordingBlob}
<p class="ui:text-destructive ui:text-sm">
{label('video_recording.take.too_short', 'Record for at least 1 second.')}
</p>
{/if}
{#if uploadError}
<p class="ui:text-destructive ui:text-sm">{uploadError}</p>
{/if}
{#if savedRecording && recordingState !== 'recording' && recordingState !== 'uploading'}
<p class="ui:text-muted-foreground ui:text-sm">
{label('video_recording.take.uploaded', 'Recording uploaded')}
</p>
{/if}
{#if savedRecording && recordingState === 'recorded'}
<p class="ui:text-muted-foreground ui:text-sm">
{label(
'video_recording.take.replaces_previous',
'A new accepted take replaces the previous answer after upload.'
)}
</p>
{/if}
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-2">
{#if savedRecording && (recordingState === 'idle' || recordingState === 'uploaded')}
<Button type="button" variant="outline" {disabled} onclick={startRecording}>
{label('video_recording.take.retake', 'Retake')}
</Button>
<Button type="button" variant="ghost" {disabled} onclick={deleteRecording}>
{label('video_recording.take.delete', 'Delete recording')}
</Button>
{:else if recordingState === 'recording'}
<Button type="button" variant="destructive" {disabled} onclick={stopRecording}>
{label('video_recording.take.stop_recording', 'Stop recording')}
</Button>
{:else if recordingState === 'recorded' || recordingState === 'failed'}
<Button type="button" disabled={!canUseRecording} onclick={acceptRecording}>
{label(
recordingState === 'failed' && uploadError
? 'video_recording.take.retry_upload'
: 'video_recording.take.use_recording',
recordingState === 'failed' && uploadError ? 'Retry upload' : 'Use recording'
)}
</Button>
<Button type="button" variant="ghost" {disabled} onclick={retakeRecording}>
{label('video_recording.take.retake', 'Retake')}
</Button>
{#if savedRecording}
<Button type="button" variant="ghost" {disabled} onclick={deleteRecording}>
{label('video_recording.take.delete', 'Delete recording')}
</Button>
{/if}
{:else if recordingState === 'uploading'}
<Button type="button" disabled>
<Spinner class="ui:size-4" />
{label('video_recording.take.uploading', 'Uploading recording...')}
</Button>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import * as Select from '../../base/select';
import type { ExerciseSectionAfterBehavior } from '@cio/question-types';
interface SectionOption {
id: string;
title: string;
order: number;
}
interface Props {
sectionIndex: number;
currentSectionId: string;
allSections: SectionOption[];
afterBehavior: ExerciseSectionAfterBehavior;
onChange: (behavior: ExerciseSectionAfterBehavior) => void;
labels: {
afterSectionPrefix: string;
continue: string;
goToSection: string;
submit: string;
};
}
let { sectionIndex, currentSectionId, allSections, afterBehavior, onChange, labels }: Props = $props();
const currentValue = $derived(
afterBehavior.action === 'go_to_section' ? `go_to_section:${afterBehavior.exerciseSectionId}` : afterBehavior.action
);
function getOptionLabel(value: string) {
if (value === 'continue') return labels.continue;
if (value === 'submit') return labels.submit;
const targetSectionId = value.replace('go_to_section:', '');
const targetSection = allSections.find((section) => section.id === targetSectionId);
return targetSection ? `${labels.goToSection}: ${targetSection.title}` : labels.goToSection;
}
function handleValueChange(value: string) {
if (!value) return;
if (value === 'continue' || value === 'submit') {
onChange({ action: value });
return;
}
const exerciseSectionId = value.replace('go_to_section:', '');
onChange({ action: 'go_to_section', exerciseSectionId });
}
</script>
<div
class="ui:flex ui:items-center ui:justify-between ui:gap-3 ui:border ui:border-border ui:bg-muted/30 ui:px-3 ui:py-2"
>
<p class="ui:text-sm ui:font-medium ui:text-muted-foreground">{labels.afterSectionPrefix} {sectionIndex + 1}</p>
<Select.Root type="single" value={currentValue} onValueChange={handleValueChange}>
<Select.Trigger class="ui:w-[260px]">{getOptionLabel(currentValue)}</Select.Trigger>
<Select.Content>
<Select.Item value="continue" label={labels.continue}>{labels.continue}</Select.Item>
{#each allSections as section (section.id)}
{#if section.id !== currentSectionId}
<Select.Item value={`go_to_section:${section.id}`} label={`${labels.goToSection}: ${section.title}`}>
{labels.goToSection}: {section.title}
</Select.Item>
{/if}
{/each}
<Select.Item value="submit" label={labels.submit}>{labels.submit}</Select.Item>
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
import Separator from '$src/base/separator/separator.svelte';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
const COLOR_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'ui:bg-blue-600!',
green: 'ui:bg-emerald-500!',
amber: 'ui:bg-amber-500!',
rose: 'ui:bg-rose-500!',
violet: 'ui:bg-violet-500!',
slate: 'ui:bg-slate-500!'
};
interface Props {
title: string;
description?: string | null;
sectionNumber: number;
totalSections: number;
colorTheme?: ExerciseSectionColorTheme;
questionCount?: number;
totalPoints?: number;
labels?: {
section?: string;
questions?: string;
points?: string;
};
}
let {
title,
description = null,
sectionNumber,
totalSections: _totalSections,
colorTheme = 'blue',
questionCount,
totalPoints,
labels = {}
}: Props = $props();
const sectionMetaSummary = $derived.by(() => {
const segments: string[] = [];
if (questionCount !== undefined) {
segments.push(`${questionCount} ${labels.questions ?? 'questions'}`);
}
if (totalPoints !== undefined) {
segments.push(`${totalPoints} ${labels.points ?? 'points'}`);
}
return segments.join(' · ');
});
</script>
<header>
<div class="ui:flex ui:items-center ui:gap-2">
<span class={`ui:h-2 ui:w-2 ui:rounded-full ui:inline-block ${COLOR_CLASSES[colorTheme]}`}> </span>
<p class="ui:text-sm ui:font-medium ui:uppercase">
{title}
</p>
{#if questionCount !== undefined || totalPoints !== undefined}
<p class="ui:text-xs ui:text-muted-foreground">({sectionMetaSummary})</p>
{/if}
<Separator orientation="horizontal" class="ui:flex-1 ui:h-0.5! {COLOR_CLASSES[colorTheme]}" />
</div>
<p class="ui:text-sm ui:text-muted-foreground">
{description}
</p>
</header>
@@ -0,0 +1,48 @@
<script lang="ts">
interface SectionNavQuestion {
key: string;
label: string;
isAnswered: boolean;
isCurrent: boolean;
}
interface SectionNavGroup {
id: string;
title: string;
questions: SectionNavQuestion[];
isCurrent: boolean;
}
interface Props {
sections: SectionNavGroup[];
}
let { sections }: Props = $props();
</script>
<aside class="ui:rounded-md ui:border ui:bg-background ui:p-3">
<div class="ui:flex ui:flex-col ui:gap-3">
{#each sections as section (section.id)}
<div class="ui:space-y-2">
<p class={`ui:text-xs ui:font-semibold ${section.isCurrent ? 'ui:text-primary' : 'ui:text-muted-foreground'}`}>
{section.title}
</p>
<div class="ui:flex ui:flex-wrap ui:gap-1.5">
{#each section.questions as question (question.key)}
<span
class={`ui:inline-flex ui:h-7 ui:min-w-7 ui:items-center ui:justify-center ui:rounded ui:border ui:px-1.5 ui:text-xs ui:font-medium ${
question.isCurrent
? 'ui:border-primary ui:bg-primary ui:text-primary-foreground'
: question.isAnswered
? 'ui:border-primary/40 ui:bg-primary/10 ui:text-primary'
: 'ui:border-border ui:text-muted-foreground'
}`}
>
{question.label}
</span>
{/each}
</div>
</div>
{/each}
</div>
</aside>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Button } from '../../base/button';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
const ACCENT_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'ui:bg-blue-600',
green: 'ui:bg-emerald-500',
amber: 'ui:bg-amber-500',
rose: 'ui:bg-rose-500',
violet: 'ui:bg-violet-500',
slate: 'ui:bg-slate-500'
};
interface Props {
sectionTitle: string;
sectionDescription?: string | null;
sectionNumber: number;
totalSections: number;
questionCount: number;
totalPoints: number;
colorTheme?: ExerciseSectionColorTheme;
onBegin: () => void;
onBack: () => void;
labels: {
beginSection: string;
back: string;
questions: string;
points: string;
section: string;
};
}
let {
sectionTitle,
sectionDescription = null,
sectionNumber,
totalSections,
questionCount,
colorTheme = 'blue',
onBegin,
onBack,
labels
}: Props = $props();
const segmentIndexes = $derived(Array.from({ length: totalSections }, (_, index) => index));
</script>
<section
class="ui:mx-auto ui:flex ui:min-h-[32rem] ui:w-full ui:max-w-5xl ui:flex-col ui:bg-background ui:px-3 ui:py-6"
>
<div class="ui:mb-16 ui:flex ui:items-center ui:gap-5">
<div class="ui:flex ui:flex-1 ui:gap-2">
{#each segmentIndexes as segmentIndex}
<span
class={`ui:h-1 ui:flex-1 ui:rounded-full ${segmentIndex < sectionNumber ? 'ui:bg-muted-foreground/30' : 'ui:bg-muted'}`}
aria-hidden="true"
></span>
{/each}
</div>
<p class="ui:shrink-0 ui:text-sm ui:text-muted-foreground">
{labels.section}
{sectionNumber} of {totalSections}
</p>
</div>
<div class="ui:flex ui:flex-1 ui:flex-col">
<p class="ui:text-sm ui:font-semibold ui:uppercase ui:tracking-[0.18em] ui:text-muted-foreground">
{labels.section}
{sectionNumber} of {totalSections}
</p>
<span class={`ui:mt-8 ui:h-1 ui:w-14 ui:rounded-full ${ACCENT_CLASSES[colorTheme]}`}></span>
<h2 class="ui:mt-10 ui:font-serif ui:text-5xl ui:font-bold ui:tracking-tight ui:text-foreground md:ui:text-6xl">
{sectionTitle}
</h2>
<div class="ui:mt-9 ui:flex ui:items-center ui:gap-3 ui:text-lg ui:text-muted-foreground">
<span class={`ui:h-2.5 ui:w-2.5 ui:rounded-full ${ACCENT_CLASSES[colorTheme]}`} aria-hidden="true"></span>
<span>{questionCount} {labels.questions}</span>
</div>
{#if sectionDescription}
<p class="ui:mt-8 ui:max-w-3xl ui:text-xl ui:leading-8 ui:text-muted-foreground">{sectionDescription}</p>
{/if}
<div class="ui:mt-auto ui:border-t ui:pt-8">
<div class="ui:flex ui:items-center ui:justify-between ui:gap-4">
<Button type="button" variant="outline" onclick={onBack}>{labels.back}</Button>
<Button type="button" onclick={onBegin} size="lg">{labels.beginSection}</Button>
</div>
</div>
</div>
</section>
@@ -0,0 +1,4 @@
import ModelPicker from './model-picker.svelte';
export { ModelPicker };
export { ModelPicker as default };
@@ -0,0 +1,117 @@
<script lang="ts">
import * as Popover from '../../base/popover';
import * as Select from '../../base/select';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import CheckIcon from '@lucide/svelte/icons/check';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
interface Props {
value: AgentModelId;
disabled?: boolean;
variant?: 'compact' | 'select';
lockedModelIds?: readonly AgentModelId[];
onChange: (id: AgentModelId) => void;
onLockedSelect?: (id: AgentModelId) => void;
}
let { value, disabled = false, variant = 'compact', lockedModelIds = [], onChange, onLockedSelect }: Props = $props();
let open = $state(false);
function isLocked(id: AgentModelId) {
return lockedModelIds.includes(id);
}
function handleSelect(id: AgentModelId) {
if (isLocked(id)) {
onLockedSelect?.(id);
open = false;
return;
}
onChange(id);
open = false;
}
</script>
{#if variant === 'select'}
<Select.Root type="single" {value} onValueChange={(v) => v && handleSelect(v as AgentModelId)} {disabled}>
<Select.Trigger>
<span class="ui:flex ui:items-center ui:gap-1">
{AGENT_MODELS[value].label}
{#if AGENT_MODELS[value].costTier === 'high'}
<span class="ui:text-red-500 ui:font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 ui:font-medium">$</span>
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#each UI_PICKER_MODEL_IDS as id (id)}
<Select.Item value={id} label={AGENT_MODELS[id].label}>
<span class="ui:flex ui:w-full ui:items-center ui:justify-between ui:gap-2">
<span class="ui:flex ui:min-w-0 ui:items-center ui:gap-2">
<span>{AGENT_MODELS[id].label}</span>
{#if isLocked(id)}
<RocketIcon size={16} class="ui:text-primary! ui:shrink-0" />
{/if}
</span>
<span
class={AGENT_MODELS[id].costTier === 'high'
? 'ui:text-red-500 ui:font-medium'
: 'ui:text-green-500 ui:font-medium'}
>
{AGENT_MODELS[id].costTier === 'high' ? '$$$' : '$'}
</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<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>
{#if AGENT_MODELS[value].costTier === 'high'}
<span class="ui:text-red-500 font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 font-medium">$</span>
{/if}
<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="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
{#if isLocked(id)}
<RocketIcon size={16} class="ui:text-primary! ui:shrink-0" />
{/if}
</span>
<span class="flex shrink-0 items-center gap-1">
{#if AGENT_MODELS[id].costTier === 'high'}
<span class="ui:text-red-500 font-medium">$$$</span>
{:else}
<span class="ui:text-green-500 font-medium">$</span>
{/if}
{#if isSelected}
<CheckIcon size={14} class="ui:text-primary" />
{/if}
</span>
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
{/if}
@@ -23,6 +23,7 @@
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
>
@@ -33,7 +33,12 @@
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:px-8 ui:font-semibold">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:px-8 ui:font-semibold"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
@@ -52,7 +52,12 @@
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:rounded-full">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-full"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
@@ -64,7 +64,7 @@ export interface OrgLandingPageProps {
hero: {
heading: string;
subheading: string;
primaryAction: { label: string; href: string };
primaryAction: { label: string; href: string; disabled?: boolean };
secondaryAction?: { label: string; href: string };
image?: string;
};
@@ -7,13 +7,15 @@
import type { ComponentProps } from 'svelte';
import { cn } from '../../tools';
type PasswordInputAutocomplete = ComponentProps<typeof InputGroup.Input>['autocomplete'];
interface Props {
placeholder?: string;
value?: string;
class?: string;
disabled?: boolean;
'aria-invalid'?: 'true' | 'false' | undefined;
autocomplete?: string;
autocomplete?: PasswordInputAutocomplete;
showPasswordTooltip?: string;
hidePasswordTooltip?: string;
showPasswordAriaLabel?: string;
@@ -0,0 +1,81 @@
<script lang="ts">
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ListIcon from '@lucide/svelte/icons/list';
import { cn } from '../../tools';
interface Props {
/** Current position — e.g. "3 of 12" — plus a subline — e.g. "Hallucination & limitations". */
positionLabel: string;
sublineLabel?: string;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
onOpenSheet: () => void;
/** a11y label for the center button (defaults to "Open course outline"). */
openSheetLabel?: string;
prevLabel?: string;
nextLabel?: string;
class?: string;
}
let {
positionLabel,
sublineLabel,
hasPrev,
hasNext,
onPrev,
onNext,
onOpenSheet,
openSheetLabel = 'Open course outline',
prevLabel = 'Previous lesson',
nextLabel = 'Next lesson',
class: className
}: Props = $props();
</script>
<nav
class={cn(
'ui:fixed ui:inset-x-0 ui:bottom-0 ui:z-40 ui:flex ui:items-stretch ui:gap-1 ui:border-t ui:border-border ui:bg-background/95 ui:p-1.5 ui:backdrop-blur ui:lg:hidden',
className
)}
aria-label="Lesson navigation"
>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={prevLabel}
disabled={!hasPrev}
onclick={onPrev}
>
<ChevronLeftIcon class="ui:size-5" />
</button>
<button
type="button"
class="ui:flex ui:flex-1 ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:px-3 ui:py-2 ui:text-center ui:transition-colors ui:hover:bg-muted"
aria-label={openSheetLabel}
onclick={onOpenSheet}
>
<ListIcon class="ui:size-4 ui:text-primary" />
<span class="ui:flex ui:min-w-0 ui:flex-col ui:items-start ui:text-left ui:leading-tight">
<span class="ui:text-[0.7rem] ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{positionLabel}
</span>
{#if sublineLabel}
<span class="ui:max-w-[55vw] ui:truncate ui:text-sm ui:font-medium ui:text-foreground">{sublineLabel}</span>
{/if}
</span>
</button>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={nextLabel}
disabled={!hasNext}
onclick={onNext}
>
<ChevronRightIcon class="ui:size-5" />
</button>
</nav>
@@ -0,0 +1,62 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { cn } from '../../tools';
import type { PublicCourseCalloutData } from './types';
interface Props {
callout: PublicCourseCalloutData | null;
/** "inline" sits below the lesson body; "full" replaces it for locked items. */
variant?: 'inline' | 'full';
/** Shown when callout is null and variant === 'full'. */
lockedFallbackTitle?: string;
lockedFallbackDescription?: string;
class?: string;
}
let {
callout,
variant = 'inline',
lockedFallbackTitle = 'This lesson is locked',
lockedFallbackDescription = 'The creator hasn’t shared a link for this lesson yet. Check back later.',
class: className
}: Props = $props();
const cardBase = 'ui:rounded-xl ui:border ui:border-border ui:bg-card ui:text-card-foreground ui:transition-colors';
const rootClass = $derived(
variant === 'full'
? cn(
cardBase,
'ui:my-10 ui:mx-auto ui:w-full ui:max-w-xl ui:p-8 ui:text-center ui:border-dashed ui:bg-muted/30',
className
)
: cn(cardBase, 'ui:mt-10 ui:p-6', className)
);
</script>
{#if callout}
<aside class={rootClass}>
<h3 class={cn('ui:font-semibold ui:text-foreground', variant === 'full' ? 'ui:text-xl' : 'ui:text-base')}>
{callout.title}
</h3>
<p class={cn('ui:mt-2 ui:text-sm ui:text-muted-foreground', variant === 'full' && 'ui:mx-auto ui:max-w-md')}>
{callout.description}
</p>
<a
href={callout.buttonUrl}
target="_blank"
rel="noopener"
class={cn(
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
)}
>
{callout.buttonLabel}
<ArrowUpRightIcon class="ui:size-4" />
</a>
</aside>
{:else if variant === 'full'}
<aside class={rootClass}>
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
</aside>
{/if}
@@ -0,0 +1,422 @@
<script lang="ts">
import * as Select from '../../base/select';
import {
getExerciseQuestionContractKey,
isAutoGradableQuestionTypeId,
QUESTION_TYPE_KEY,
type AnswerData,
type ExerciseQuestionLabels,
type ExerciseQuestionModel
} from '@cio/question-types';
import { untrack } from 'svelte';
import { Button } from '../../base/button';
import { QuestionList } from '../exercise-question';
import { cn } from '../../tools';
import Callout from './callout.svelte';
import type { PublicCourseCalloutData, PublicExerciseViewData } from './types';
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
interface Props {
exercise: PublicExerciseViewData;
callout?: PublicCourseCalloutData | null;
labels?: ExerciseQuestionLabels;
/** When set, completed attempts persist and hydrate from `localStorage`. */
attemptsPersistenceKey?: string | null;
formatAttemptOption?: (args: { attemptNumber: number; correct: number; total: number }) => string;
newAttemptOptionLabel?: string;
attemptsSelectAriaLabel?: string;
submitLabel?: string;
tryAgainLabel?: string;
privacyHint?: string;
summaryTemplate?: string;
class?: string;
}
let {
exercise,
callout = null,
labels,
attemptsPersistenceKey = null,
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
newAttemptOptionLabel = 'Practice again',
attemptsSelectAriaLabel = 'Your attempts',
submitLabel = 'Check my answers',
tryAgainLabel = 'Practice again',
privacyHint = 'Graded instantly in your browser — nothing is submitted.',
summaryTemplate = 'You got [[correct]] / [[total]] correct.',
class: className
}: Props = $props();
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
let selectValue = $state<string>('live');
let answers = $state<Record<string, AnswerData>>({});
let submitted = $state(false);
const mode = $derived<'take' | 'review'>(submitted ? 'review' : 'take');
$effect(() => {
attemptsPersistenceKey;
untrack(() => {
persistedAttempts = readPublicExerciseAttempts(attemptsPersistenceKey);
const rows = persistedAttempts;
if (rows.length === 0) {
selectValue = 'live';
answers = {};
submitted = false;
return;
}
const defaultIndex = rows.length - 1;
selectValue = String(defaultIndex);
const row = rows[defaultIndex];
answers = structuredClone(row.answersByKey);
submitted = true;
});
});
function handleAttemptSelect(selected: string) {
if (selected === 'live') {
selectValue = 'live';
answers = {};
submitted = false;
return;
}
const idx = Number.parseInt(selected, 10);
if (!Number.isInteger(idx) || idx < 0 || idx >= persistedAttempts.length) return;
selectValue = selected;
submitted = true;
answers = structuredClone(persistedAttempts[idx].answersByKey);
}
function handleAnswerChange(question: ExerciseQuestionModel, answer: AnswerData | null) {
if (submitted) return;
const key = getExerciseQuestionContractKey(question);
if (answer === null) {
const next = { ...answers };
delete next[key];
answers = next;
return;
}
answers = { ...answers, [key]: answer };
}
function hasProvidedAnswer(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean {
if (!answer) return false;
switch (question.questionType) {
case QUESTION_TYPE_KEY.RADIO:
return answer.type === 'RADIO' && answer.optionId != null;
case QUESTION_TYPE_KEY.CHECKBOX:
return answer.type === 'CHECKBOX' && answer.optionIds.length > 0;
case QUESTION_TYPE_KEY.TRUE_FALSE:
return answer.type === 'TRUE_FALSE';
case QUESTION_TYPE_KEY.TEXTAREA:
return answer.type === 'TEXTAREA' && answer.text.trim().length > 0;
case QUESTION_TYPE_KEY.SHORT_ANSWER:
return answer.type === 'SHORT_ANSWER' && answer.text.trim().length > 0;
case QUESTION_TYPE_KEY.NUMERIC:
return answer.type === 'NUMERIC' && Number.isFinite(answer.value);
case QUESTION_TYPE_KEY.FILL_BLANK: {
if (answer.type !== 'FILL_BLANK') return false;
const blankSlots = (question.options ?? []).filter((option) => option.isCorrect !== false).length;
if (blankSlots === 0) return false;
return (
answer.values.length >= blankSlots &&
answer.values.slice(0, blankSlots).every((value) => typeof value === 'string' && value.trim().length > 0)
);
}
case QUESTION_TYPE_KEY.ORDERING: {
if (answer.type !== 'ORDERING') return false;
const expectedLength = (question.options ?? [])
.map((option) => String(option.value ?? option.label ?? ''))
.filter(Boolean).length;
return (
expectedLength > 0 &&
answer.orderedValues.length === expectedLength &&
answer.orderedValues.every((value) => typeof value === 'string' && value.length > 0)
);
}
default:
return false;
}
}
const canSubmitAnswers = $derived(
exercise.questions.length > 0 &&
exercise.questions.every((question, index) =>
hasProvidedAnswer(question, answers[getExerciseQuestionContractKey(question, index)])
)
);
function isAnswerCorrect(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean | null {
if (!answer) return false;
switch (question.questionType) {
case QUESTION_TYPE_KEY.RADIO: {
if (answer.type !== 'RADIO') return false;
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
if (!correctOption?.id) return false;
return String(correctOption.id) === String(answer.optionId);
}
case QUESTION_TYPE_KEY.CHECKBOX: {
if (answer.type !== 'CHECKBOX') return false;
const correctIds = new Set(
(question.options ?? [])
.filter((option) => option.isCorrect && option.id !== undefined)
.map((option) => String(option.id))
);
const selected = new Set(answer.optionIds.map(String));
if (selected.size !== correctIds.size) return false;
for (const id of correctIds) {
if (!selected.has(id)) return false;
}
return true;
}
case QUESTION_TYPE_KEY.TRUE_FALSE: {
if (answer.type !== 'TRUE_FALSE') return false;
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
const correctValue = /true/i.test(String(correctOption?.label ?? correctOption?.value ?? ''));
return answer.value === correctValue;
}
case QUESTION_TYPE_KEY.SHORT_ANSWER: {
if (answer.type !== 'SHORT_ANSWER') return false;
const caseSensitive = Boolean((question.settings as { caseSensitive?: boolean } | undefined)?.caseSensitive);
const accepted = (question.options ?? [])
.filter((option) => option.isCorrect !== false)
.map((option) => String(option.label ?? option.value ?? '').trim());
const submittedValue = answer.text.trim();
if (!submittedValue || accepted.length === 0) return false;
return accepted.some((expected) => {
if (caseSensitive) return expected === submittedValue;
return expected.toLowerCase() === submittedValue.toLowerCase();
});
}
case QUESTION_TYPE_KEY.NUMERIC: {
if (answer.type !== 'NUMERIC') return false;
const settings = (question.settings ?? {}) as { correctValue?: unknown; tolerance?: unknown };
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
const correctValue = Number(correctOption?.label ?? correctOption?.value ?? settings.correctValue ?? NaN);
const tolerance = Number(settings.tolerance ?? 0);
if (!Number.isFinite(correctValue)) return null;
return Math.abs(answer.value - correctValue) <= (Number.isFinite(tolerance) ? tolerance : 0);
}
case QUESTION_TYPE_KEY.FILL_BLANK: {
if (answer.type !== 'FILL_BLANK') return false;
const accepted = (question.options ?? [])
.filter((option) => option.isCorrect !== false)
.map((option) =>
String(option.label ?? option.value ?? '')
.trim()
.toLowerCase()
);
if (accepted.length === 0) return null;
return answer.values.every((value, index) => {
const expected = accepted[index];
if (expected === undefined) return false;
return value.trim().toLowerCase() === expected;
});
}
case QUESTION_TYPE_KEY.ORDERING: {
if (answer.type !== 'ORDERING') return false;
const expected = (question.options ?? [])
.map((option) => String(option.value ?? option.label ?? ''))
.filter(Boolean);
if (expected.length === 0) return null;
return (
answer.orderedValues.length === expected.length &&
answer.orderedValues.every((value, index) => value === expected[index])
);
}
default:
return null;
}
}
const totalGradable = $derived(
exercise.questions.reduce((count, question) => {
const typeId = (question as ExerciseQuestionModel & { questionTypeId?: number }).questionTypeId;
const isAutoGradable = typeof typeId === 'number' ? isAutoGradableQuestionTypeId(typeId) : true;
return isAutoGradable ? count + 1 : count;
}, 0)
);
const correctCountComputed = $derived(
submitted
? exercise.questions.reduce((count, question) => {
const key = getExerciseQuestionContractKey(question);
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
}, 0)
: 0
);
/** When reviewing a stored snapshot, scores are pinned to saved numbers (handles changed rubrics later). */
const displayCorrectCount = $derived.by(() => {
if (!submitted) return correctCountComputed;
const idx =
selectValue !== 'live'
? Number.parseInt(selectValue, 10)
: persistedAttempts.length > 0
? persistedAttempts.length - 1
: -1;
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
return persistedAttempts[idx].correctCount;
}
return correctCountComputed;
});
const displayTotalGradable = $derived.by(() => {
if (!submitted) return totalGradable;
const idx =
selectValue !== 'live'
? Number.parseInt(selectValue, 10)
: persistedAttempts.length > 0
? persistedAttempts.length - 1
: -1;
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
return persistedAttempts[idx].totalGradable;
}
return totalGradable;
});
const summaryText = $derived(
summaryTemplate
.replace('[[correct]]', String(displayCorrectCount))
.replace('[[total]]', String(displayTotalGradable))
);
const showAttemptsPicker = $derived(
!!attemptsPersistenceKey && persistedAttempts.length > 0 && exercise.questions.length > 0 && exercise.isUnlocked
);
function handleSubmit() {
if (!canSubmitAnswers) return;
submitted = true;
if (!attemptsPersistenceKey) return;
const correct = exercise.questions.reduce((count, question) => {
const key = getExerciseQuestionContractKey(question);
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
}, 0);
const tot = totalGradable;
const snapshot: PublicExerciseStoredAttempt = {
savedAt: new Date().toISOString(),
answersByKey: structuredClone(answers),
correctCount: correct,
totalGradable: tot
};
persistedAttempts = [...persistedAttempts, snapshot];
writePublicExerciseAttempts(attemptsPersistenceKey, persistedAttempts);
selectValue = String(persistedAttempts.length - 1);
}
function handleReset() {
selectValue = 'live';
answers = {};
submitted = false;
}
/** Trigger text when user is reviewing a frozen attempt vs starting fresh. */
const tryAgainButtonLabel = $derived(
showAttemptsPicker && selectValue !== 'live' ? newAttemptOptionLabel : tryAgainLabel
);
</script>
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
{#if !exercise.isUnlocked}
<Callout variant="full" {callout} />
{:else}
{#if exercise.sectionTitle}
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{exercise.sectionTitle}
</div>
{/if}
{#if showAttemptsPicker}
<div class="ui:mt-4 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
<span class="ui:sr-only" id="public-exercise-attempts-select-label">{attemptsSelectAriaLabel}</span>
<Select.Root
type="single"
value={selectValue}
onValueChange={(nextValue) => nextValue && handleAttemptSelect(nextValue)}
>
<Select.Trigger
class="ui:min-w-[min(24rem,calc(100vw-5rem))] ui:max-w-full"
aria-labelledby="public-exercise-attempts-select-label"
>
<span class="ui:truncate">
{selectValue === 'live'
? newAttemptOptionLabel
: formatAttemptOption({
attemptNumber: Number(selectValue) + 1,
correct: persistedAttempts[Number(selectValue)]?.correctCount ?? 0,
total: persistedAttempts[Number(selectValue)]?.totalGradable ?? totalGradable
})}
</span>
</Select.Trigger>
<Select.Content>
{#each persistedAttempts as _, attemptIdx (attemptIdx)}
{@const stored = persistedAttempts[attemptIdx]}
{@const attemptLabel = formatAttemptOption({
attemptNumber: attemptIdx + 1,
correct: stored.correctCount,
total: stored.totalGradable
})}
<Select.Item value={String(attemptIdx)} label={attemptLabel}>{attemptLabel}</Select.Item>
{/each}
<Select.Item value="live" label={newAttemptOptionLabel}>{newAttemptOptionLabel}</Select.Item>
</Select.Content>
</Select.Root>
</div>
{/if}
<h1
class={cn(
'ui:text-2xl ui:tracking-tight ui:text-foreground ui:sm:text-3xl',
showAttemptsPicker ? 'ui:mt-4' : 'ui:mt-2'
)}
>
{exercise.title}
</h1>
{#if exercise.description}
<p class="ui:mt-3 ui:text-muted-foreground">{exercise.description}</p>
{/if}
<div class="ui:mt-8 ui:space-y-6">
<QuestionList
contract={{
mode,
questions: exercise.questions,
answersByKey: answers,
disabled: submitted,
labels
}}
onAnswerChange={handleAnswerChange}
/>
</div>
<div class="ui:mt-10 ui:flex ui:items-center ui:justify-between ui:gap-4">
{#if submitted}
<div class="ui:text-sm ui:text-muted-foreground">{summaryText}</div>
<Button variant="outline" onclick={handleReset}>{tryAgainButtonLabel}</Button>
{:else}
<span class="ui:text-sm ui:text-muted-foreground">{privacyHint}</span>
<Button disabled={!canSubmitAnswers} onclick={handleSubmit}>{submitLabel}</Button>
{/if}
</div>
<Callout variant="inline" {callout} />
{/if}
</article>
@@ -0,0 +1,96 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { cn } from '../../tools';
import type { PublicCourseSidebarItem } from './types';
interface Props {
/** Previous item (or `null` if on the first item). */
prev?: PublicCourseSidebarItem | null;
/** Next item (or `null` if on the last item). */
next?: PublicCourseSidebarItem | null;
/** Builds the href for a target item. Falls back to plain buttons if omitted. */
hrefFor?: (item: PublicCourseSidebarItem) => string;
/** Click handler — used when `hrefFor` is omitted, or for SPA navigation alongside `href`. */
onNavigate?: (item: PublicCourseSidebarItem) => void;
/** Localized labels for the prev / next captions. */
prevLabel?: string;
nextLabel?: string;
class?: string;
}
let {
prev = null,
next = null,
hrefFor,
onNavigate,
prevLabel = 'Previous',
nextLabel = 'Next',
class: className
}: Props = $props();
const cardClass =
'ui:group ui:flex ui:flex-1 ui:items-center ui:gap-3 ui:rounded-lg ui:border ui:border-border ui:bg-background ui:px-4 ui:py-3 ui:text-left ui:transition-colors ui:hover:border-primary/40 ui:hover:bg-muted/50';
const prevHref = $derived(prev ? hrefFor?.(prev) : undefined);
const nextHref = $derived(next ? hrefFor?.(next) : undefined);
</script>
{#snippet prevBody(item: PublicCourseSidebarItem, label: string)}
<ArrowLeftIcon class="ui:size-4 ui:text-muted-foreground ui:group-hover:text-primary" />
<div class="ui:flex ui:min-w-0 ui:flex-col">
<span class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{label}
</span>
<span class="ui:truncate ui:text-sm ui:font-medium ui:text-foreground ui:group-hover:text-primary">
{item.title}
</span>
</div>
{/snippet}
{#snippet nextBody(item: PublicCourseSidebarItem, label: string)}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:items-end">
<span class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{label}
</span>
<span class="ui:truncate ui:text-sm ui:font-medium ui:text-foreground ui:group-hover:text-primary">
{item.title}
</span>
</div>
<ArrowRightIcon class="ui:size-4 ui:text-muted-foreground ui:group-hover:text-primary" />
{/snippet}
{#if prev || next}
<nav
class={cn('ui:mt-12 ui:flex ui:flex-col ui:gap-3 ui:border-t ui:border-border ui:pt-6 ui:sm:flex-row', className)}
aria-label="Lesson navigation"
>
{#if prev}
{#if prevHref}
<a href={prevHref} class={cardClass} onclick={() => onNavigate?.(prev)}>
{@render prevBody(prev, prevLabel)}
</a>
{:else}
<button type="button" class={cardClass} onclick={() => onNavigate?.(prev)}>
{@render prevBody(prev, prevLabel)}
</button>
{/if}
{:else}
<div class="ui:flex-1"></div>
{/if}
{#if next}
{#if nextHref}
<a href={nextHref} class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
{@render nextBody(next, nextLabel)}
</a>
{:else}
<button type="button" class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
{@render nextBody(next, nextLabel)}
</button>
{/if}
{:else}
<div class="ui:flex-1"></div>
{/if}
</nav>
{/if}
@@ -0,0 +1,29 @@
export { default as PublicCourseShell } from './shell.svelte';
export { default as PublicCourseSidebar } from './sidebar.svelte';
export { default as PublicCourseSidebarRow } from './sidebar-row.svelte';
export { default as PublicCourseBottomNav } from './bottom-nav.svelte';
export { default as PublicCourseFooterNav } from './footer-nav.svelte';
export { default as PublicCoursePoweredBy } from './powered-by.svelte';
export { default as PublicCourseMobileSheet } from './mobile-sheet.svelte';
export { default as PublicLessonView } from './lesson-view.svelte';
export { default as PublicExerciseView } from './exercise-view.svelte';
export { default as PublicCourseCallout } from './callout.svelte';
export {
PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION,
publicExerciseAttemptsStorageKey,
readPublicExerciseAttempts,
writePublicExerciseAttempts
} from './public-exercise-attempts-storage';
export type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
export type {
PublicCourseCalloutData,
PublicCourseOrgData,
PublicCourseSidebarItem,
PublicCourseSidebarSection,
PublicCourseItemVariant,
PublicLessonViewData,
PublicExerciseViewData,
PublicItemViewData
} from './types';
@@ -0,0 +1,85 @@
<script lang="ts">
import { cn } from '../../tools';
import { SafeHtmlContent } from '../safe-html-content';
import Callout from './callout.svelte';
import type { PublicCourseCalloutData, PublicLessonViewData } from './types';
interface Props {
lesson: PublicLessonViewData;
callout?: PublicCourseCalloutData | null;
class?: string;
}
let { lesson, callout = null, class: className }: Props = $props();
const isYoutube = $derived(lesson.video?.type === 'youtube');
function toEmbedUrl(link: string): string {
try {
const url = new URL(link);
if (url.hostname.includes('youtu.be')) {
const videoId = url.pathname.replace(/^\//, '');
return `https://www.youtube.com/embed/${videoId}`;
}
if (url.hostname.includes('youtube.com')) {
const videoId = url.searchParams.get('v');
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
}
return link;
} catch {
return link;
}
}
</script>
<!--
Public lesson page. Width / spacing intentionally mirrors the authenticated
lesson view (see `apps/dashboard/src/lib/features/course/pages/lesson.svelte`)
so creators see the same content layout whether they're logged in or sharing
a public link. The `prose` class (no `ui:` prefix) hooks into the dashboard's
app.css typography rules.
-->
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
{#if !lesson.isUnlocked}
<Callout variant="full" {callout} />
{:else}
{#if lesson.video}
<div class="ui:mb-6 ui:overflow-hidden ui:rounded-xl ui:bg-black">
{#if isYoutube}
<iframe
class="ui:aspect-video ui:w-full"
src={toEmbedUrl(lesson.video.link)}
title={lesson.title}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
{:else}
<video class="ui:aspect-video ui:w-full" controls playsinline src={lesson.video.link}>
<track kind="captions" />
</video>
{/if}
</div>
{/if}
{#if lesson.sectionTitle}
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{lesson.sectionTitle}
</div>
{/if}
<h1 class="ui:mt-2 ui:text-2xl ui:tracking-tight ui:text-foreground ui:sm:text-3xl">
{lesson.title}
</h1>
<div class="prose sm:prose-sm ui:mt-8 ui:max-w-none ui:dark:text-white">
{#if lesson.body}
<SafeHtmlContent content={lesson.body} />
{:else}
<p class="ui:text-muted-foreground">No content yet.</p>
{/if}
</div>
<Callout variant="inline" {callout} />
{/if}
</article>
@@ -0,0 +1,73 @@
<script lang="ts">
import * as Drawer from '../../base/drawer';
import Sidebar from './sidebar.svelte';
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
interface Props {
open: boolean;
onOpenChange?: (next: boolean) => void;
sections: PublicCourseSidebarSection[];
activeSlug?: string | null;
onItemClick?: (item: PublicCourseSidebarItem) => void;
hrefFor?: (item: PublicCourseSidebarItem) => string;
title?: string;
/** Forwarded to Sidebar's powered-by footer. */
showPoweredBy?: boolean;
courseSlug?: string | null;
orgSlug?: string | null;
poweredByLabel?: string;
poweredByBrand?: string;
}
let {
open = $bindable(false),
onOpenChange,
sections,
activeSlug = null,
onItemClick,
hrefFor,
title = 'Course outline',
showPoweredBy = true,
courseSlug = null,
orgSlug = null,
poweredByLabel = 'Powered by',
poweredByBrand = 'ClassroomIO'
}: Props = $props();
function handleOpenChange(next: boolean) {
open = next;
onOpenChange?.(next);
}
function handleItemClick(item: PublicCourseSidebarItem) {
onItemClick?.(item);
handleOpenChange(false);
}
</script>
<Drawer.Root {open} onOpenChange={handleOpenChange}>
<Drawer.Portal>
<Drawer.Overlay class="ui:fixed ui:inset-0 ui:z-50 ui:bg-black/40" />
<Drawer.Content
class="ui:fixed ui:inset-x-0 ui:bottom-0 ui:z-50 ui:flex ui:max-h-[85vh] ui:flex-col ui:rounded-t-xl ui:border-t ui:border-border ui:bg-background"
>
<div class="ui:mx-auto ui:mt-2 ui:h-1 ui:w-10 ui:rounded-full ui:bg-muted" aria-hidden="true"></div>
<Drawer.Header class="ui:px-5 ui:pt-3 ui:pb-2">
<Drawer.Title class="ui:text-sm ui:font-semibold ui:text-foreground">{title}</Drawer.Title>
</Drawer.Header>
<div class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]">
<Sidebar
{sections}
{activeSlug}
onItemClick={handleItemClick}
{hrefFor}
{showPoweredBy}
{courseSlug}
{orgSlug}
{poweredByLabel}
{poweredByBrand}
/>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
@@ -0,0 +1,53 @@
<script lang="ts">
import { cn } from '../../tools';
interface Props {
/** Course slug for attribution (passed as ?utm_source=...&utm_content=...). */
courseSlug?: string | null;
/** Org siteName (or org name) for attribution. */
orgSlug?: string | null;
/** Localized label. */
label?: string;
/** Localized "ClassroomIO" brand label. */
brand?: string;
class?: string;
}
let {
courseSlug = null,
orgSlug = null,
label = 'Powered by',
brand = 'ClassroomIO',
class: className
}: Props = $props();
const href = $derived.by(() => {
const url = new URL('https://classroomio.com');
url.searchParams.set('utm_source', 'public-course');
url.searchParams.set('utm_medium', 'powered-by');
if (orgSlug) url.searchParams.set('utm_campaign', orgSlug);
if (courseSlug) url.searchParams.set('utm_content', courseSlug);
return url.toString();
});
</script>
<div
class={cn(
'ui:px-3 ui:flex ui:items-center ui:justify-center ui:gap-1 ui:py-3 ui:text-xs ui:text-muted-foreground ui:w-full',
className
)}
>
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
{label}
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
>
{brand}
</a>
</div>

Some files were not shown because too many files have changed in this diff Show More