chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
export * from './exercise';
|
||||
export * from './exercise';
|
||||
export * from './exercise-section';
|
||||
export * from './lms';
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './organization';
|
||||
export * from './query-errors';
|
||||
export * from './auth';
|
||||
export * from './attendance';
|
||||
export * from './audience';
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -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>
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user