feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ const globalDatabase = globalThis as typeof globalThis & {
function createDatabaseClient() {
return postgres(connectionString, {
max: process.env.NODE_ENV === 'production' ? 10 : 1,
max: Number.parseInt(process.env.DATABASE_POOL_MAX ?? '10', 10) || 5,
idle_timeout: 20,
connect_timeout: 10,
max_lifetime: 60 * 30
+227 -51
View File
@@ -11,7 +11,7 @@ import {
TNewCourseSection,
TProfile
} from '@db/types';
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
@@ -23,6 +23,7 @@ import { getCourseContentItems, type CourseContentItemRow } from './content';
*/
export interface TBaseCourse extends TCourse {
lessonCount: number;
exerciseCount: number;
}
/**
@@ -40,7 +41,6 @@ export interface TAdminCourse extends TBaseCourse {
*/
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exerciseCount: number;
exercisesCompleted: number;
}
@@ -49,7 +49,11 @@ export interface TStudentCourse extends TBaseCourse {
* @param siteName Organization site name
* @returns Array of published courses with lesson counts
*/
export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<TBaseCourse[]> => {
export const getPublishedCoursesBySiteName = async (
siteName: string,
courseIds?: string[],
limit?: number
): Promise<TBaseCourse[]> => {
try {
if (courseIds && courseIds.length === 0) {
return [];
@@ -65,22 +69,46 @@ export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?
conditions.push(inArray(schema.course.id, courseIds));
}
const result = await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(and(...conditions))
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt));
const exerciseCountSql = sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count');
const result = limit
? await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count'),
exerciseCount: exerciseCountSql
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(and(...conditions))
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt))
.limit(limit)
: await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count'),
exerciseCount: exerciseCountSql
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(and(...conditions))
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount)
lessonCount: Number(row.lessonCount),
exerciseCount: Number(row.exerciseCount)
}));
} catch (error) {
console.error('getCoursesBySiteName error:', error);
@@ -90,6 +118,40 @@ export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?
}
};
export const countPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<number> => {
try {
if (courseIds && courseIds.length === 0) {
return 0;
}
const conditions = [
eq(schema.organization.siteName, siteName),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true)
];
if (courseIds && courseIds.length > 0) {
conditions.push(inArray(schema.course.id, courseIds));
}
const [result] = await db
.select({
total: count(schema.course.id)
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(and(...conditions));
return Number(result?.total ?? 0);
} catch (error) {
console.error('countPublishedCoursesBySiteName error:', error);
throw new Error(
`Failed to count courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Gets courses by organization orgId
* @param orgId Organization orgId
@@ -354,18 +416,18 @@ export async function getCourseWithRelations(
}
}
export async function createCourse(newCourse: TNewCourse) {
export async function createCourse(newCourse: TNewCourse, dbClient: DbOrTxClient = db) {
try {
return await db.insert(schema.course).values(newCourse).returning();
return await dbClient.insert(schema.course).values(newCourse).returning();
} catch (error) {
console.error('createCourse error:', error);
throw new Error(`Failed to create course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createCourseNewsfeed(newsfeed: TNewCourseNewsfeed) {
export async function createCourseNewsfeed(newsfeed: TNewCourseNewsfeed, dbClient: DbOrTxClient = db) {
try {
return await db.insert(schema.courseNewsfeed).values(newsfeed).returning();
return await dbClient.insert(schema.courseNewsfeed).values(newsfeed).returning();
} catch (error) {
console.error('createCourseNewsfeed error:', error);
throw new Error(`Failed to create course newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -392,6 +454,18 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
}
}
/**
* Bumps updatedAt on a course without changing any other fields.
* Call this whenever course content (lessons, exercises) changes.
*/
export async function touchCourseUpdatedAt(courseId: string) {
try {
await db.update(schema.course).set({ updatedAt: new Date().toISOString() }).where(eq(schema.course.id, courseId));
} catch (error) {
console.error('touchCourseUpdatedAt error:', error);
}
}
/**
* Soft deletes a course by setting status to 'DELETED'
* @param courseId Course ID
@@ -563,6 +637,20 @@ interface GetOrgCoursesOptions {
profileId?: string;
/** Optional course IDs filter */
courseIds?: string[];
/** Optional search query against the course title */
search?: string;
/** Page number (1-indexed) */
page?: number;
/** Page size */
limit?: number;
}
export interface GetOrgCoursesResult {
items: TAdminCourse[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
@@ -572,47 +660,101 @@ interface GetOrgCoursesOptions {
* @param options.profileId Optional profile ID to filter by membership
* @returns Array of courses with admin-level data
*/
export const getOrgCourses = async ({ orgId, profileId, courseIds }: GetOrgCoursesOptions): Promise<TAdminCourse[]> => {
export const getOrgCourses = async ({
orgId,
profileId,
courseIds,
search,
page = 1,
limit = 20
}: GetOrgCoursesOptions): Promise<GetOrgCoursesResult> => {
try {
if (courseIds && courseIds.length === 0) {
return [];
return {
items: [],
total: 0,
page,
limit,
totalPages: 0
};
}
const conditions = [eq(schema.group.organizationId, orgId), eq(schema.course.status, 'ACTIVE')];
if (courseIds && courseIds.length > 0) {
conditions.push(inArray(schema.course.id, courseIds));
}
if (search?.trim()) {
conditions.push(ilike(schema.course.title, `%${search.trim()}%`));
}
const totalQuery = profileId
? db
.select({ count: count(schema.course.id) })
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.group.id), eq(schema.groupmember.profileId, profileId))
)
.where(and(...conditions))
: db
.select({ count: count(schema.course.id) })
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(and(...conditions));
const [countRow] = await totalQuery;
const total = Number(countRow?.count ?? 0);
const baseQuery = db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('lesson_count'),
studentCount: sql<number>`(SELECT COUNT(*)::bigint
FROM ${schema.groupmember} as gm
WHERE gm.group_id = ${schema.course.groupId}
WHERE ${eq(sql`gm.group_id`, schema.course.groupId)}
AND gm.role_id = ${ROLE.STUDENT}
)`.as('total_students')
)`.as('total_students'),
exerciseCount: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId));
// Build conditions
const conditions = [eq(schema.group.organizationId, orgId), eq(schema.course.status, 'ACTIVE')];
if (courseIds && courseIds.length > 0) {
conditions.push(inArray(schema.course.id, courseIds));
}
// If profileId provided, join groupmember and filter by membership
const query = profileId
const itemsQuery = profileId
? baseQuery
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.where(and(...conditions, eq(schema.groupmember.profileId, profileId)))
.innerJoin(
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.group.id), eq(schema.groupmember.profileId, profileId))
)
.where(and(...conditions))
: baseQuery.where(and(...conditions));
const result = await query.groupBy(schema.course.id).orderBy(desc(schema.course.createdAt));
const result = await itemsQuery
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt))
.limit(limit)
.offset((page - 1) * limit);
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
totalStudents: Number(row.studentCount)
}));
return {
items: result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
totalStudents: Number(row.studentCount),
exerciseCount: Number(row.exerciseCount)
})),
total,
page,
limit,
totalPages: total === 0 ? 0 : Math.ceil(total / limit)
};
} catch (error) {
console.error('getOrgCourses error:', error);
throw new Error(`Failed to get org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -638,6 +780,15 @@ export const getEnrolledCourses = async ({
profileId
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
try {
// Single query covering both access paths:
// 1. Direct enrollment: student has a groupmember row for the course's group
// 2. Program membership: student is a program_member of a program that contains the course
//
// Profile filters are pushed into each LEFT JOIN's ON clause so the database
// can filter early. The WHERE then requires at least one path to have matched.
// GROUP BY course.id deduplicates courses accessible via both paths simultaneously.
// exercisesCompleted uses a profileId-based subquery so it doesn't depend on
// the outer groupmember join (which may be NULL for program-only access).
const result = await db
.select({
course: schema.course,
@@ -646,7 +797,7 @@ export const getEnrolledCourses = async ({
SELECT COUNT(*)::bigint
FROM ${schema.lessonCompletion} as lc
JOIN ${schema.lesson} as l ON l.id = lc.lesson_id
WHERE l.course_id = ${schema.course.id}
WHERE ${eq(sql`l.course_id`, schema.course.id)}
AND lc.is_complete = true
AND lc.profile_id = ${sql.raw(`'${profileId}'::uuid`)}
)`.as('progress_rate'),
@@ -654,31 +805,49 @@ export const getEnrolledCourses = async ({
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ex.course_id = ${schema.course.id}
OR el.course_id = ${schema.course.id}
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count'),
exercisesCompleted: sql<number>`(
SELECT COUNT(DISTINCT s.exercise_id)::bigint
FROM ${schema.submission} as s
JOIN ${schema.exercise} as ex ON ex.id = s.exercise_id
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE (ex.course_id = ${schema.course.id} OR el.course_id = ${schema.course.id})
AND s.submitted_by = ${schema.groupmember.id}
JOIN ${schema.groupmember} as gm ON gm.id = s.submitted_by
WHERE ${and(
or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)),
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
)}
)`.as('exercises_completed')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.leftJoin(
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
and(
eq(schema.programCourse.programId, schema.program.id),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE')
)
)
.leftJoin(
schema.programMember,
and(eq(schema.program.id, schema.programMember.programId), eq(schema.programMember.profileId, profileId))
)
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
eq(schema.groupmember.profileId, profileId)
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id))
)
)
.groupBy(schema.course.id, schema.groupmember.id)
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
@@ -711,7 +880,13 @@ export const getExploreCourses = async ({ orgId, profileId }: GetExploreCoursesO
const result = await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons')
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
exerciseCount: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -733,7 +908,8 @@ export const getExploreCourses = async ({ orgId, profileId }: GetExploreCoursesO
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount)
lessonCount: Number(row.lessonCount),
exerciseCount: Number(row.exerciseCount)
}));
} catch (error) {
console.error('getExploreCourses error:', error);
+58 -2
View File
@@ -7,9 +7,9 @@ import {
eq,
group,
groupmember,
isNotNull,
lesson,
lessonCompletion,
organization,
profile,
sql
} from '@db/drizzle';
@@ -24,7 +24,11 @@ export function getCourseStats(orgId: string) {
completedLessons: sql<number>`
COUNT(DISTINCT ${lessonCompletion.id})
FILTER (WHERE ${lessonCompletion.isComplete} = true)
`.as('completed_lessons')
`.as('completed_lessons'),
certificatesEarned: sql<number>`
COUNT(DISTINCT ${groupmember.id})
FILTER (WHERE ${groupmember.certificateEarnedAt} IS NOT NULL)
`.as('certificates_earned')
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
@@ -49,6 +53,12 @@ export function getCourseStats(orgId: string) {
WHEN ${subquery.totalStudents} * ${subquery.totalLessons} = 0 THEN 0
ELSE ROUND((${subquery.completedLessons}::numeric / (${subquery.totalStudents} * ${subquery.totalLessons})) * 100)::integer
END
`,
certificationPercentage: sql<number>`
CASE
WHEN ${subquery.totalStudents} = 0 THEN 0
ELSE ROUND((${subquery.certificatesEarned}::numeric / ${subquery.totalStudents}) * 100)::integer
END
`
})
.from(subquery)
@@ -76,6 +86,52 @@ export function getRecentEnrollments(orgId: string) {
.limit(5);
}
/** Count of student enrollments that have earned a certificate (per org, active courses). */
export function getTotalCertificatesIssued(orgId: string) {
return db
.select({
count: sql<number>`count(${groupmember.id})::int`
})
.from(groupmember)
.innerJoin(group, eq(groupmember.groupId, group.id))
.innerJoin(course, eq(course.groupId, group.id))
.where(
and(
eq(group.organizationId, orgId),
eq(course.status, 'ACTIVE'),
eq(groupmember.roleId, 3),
isNotNull(groupmember.certificateEarnedAt)
)
);
}
/** Most recent certificate earners for the org dashboard. */
export function getRecentCertifications(orgId: string) {
return db
.select({
profileId: profile.id,
avatarUrl: profile.avatarUrl,
fullname: profile.fullname,
courseId: course.id,
courseTitle: course.title,
earnedAt: groupmember.certificateEarnedAt
})
.from(groupmember)
.innerJoin(group, eq(groupmember.groupId, group.id))
.innerJoin(course, eq(course.groupId, group.id))
.innerJoin(profile, eq(profile.id, groupmember.profileId))
.where(
and(
eq(group.organizationId, orgId),
eq(course.status, 'ACTIVE'),
eq(groupmember.roleId, 3),
isNotNull(groupmember.certificateEarnedAt)
)
)
.orderBy(desc(groupmember.certificateEarnedAt))
.limit(5);
}
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
+3 -2
View File
@@ -1,5 +1,5 @@
export interface OrganisationAnalytics {
revenue: number;
totalCertificates: number;
numberOfCourses: number;
totalStudents: number;
topCourses: {
@@ -7,8 +7,9 @@ export interface OrganisationAnalytics {
title: string;
enrollments: number;
completion: number;
certification: number;
}[];
enrollments: {
recentCertifications: {
id: string;
avatarUrl: string;
name: string;
@@ -210,6 +210,9 @@ export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient
export async function createQuestions(values: TNewQuestion[], 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 question id.
await syncQuestionIdSequence(dbClient);
return dbClient.insert(schema.question).values(values).returning();
} catch (error) {
const err = error as Error & { code?: string; cause?: unknown };
@@ -268,6 +271,9 @@ 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) {
console.error('createOptions error:', error);
@@ -524,3 +530,19 @@ export async function syncOptionIdSequence(dbClient: DbOrTxClient = db): Promise
`
);
}
/**
* Syncs the question table's identity sequence to the current max(id).
* Call before inserting questions to avoid duplicate key on question_pkey when the sequence is behind.
*/
export async function syncQuestionIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
await dbClient.execute(
sql`
SELECT setval(
pg_get_serial_sequence('question', 'id'),
COALESCE((SELECT MAX(id) FROM question), 1),
EXISTS(SELECT 1 FROM question)
)
`
);
}
+59 -6
View File
@@ -1,23 +1,23 @@
import * as schema from '@db/schema';
import { TNewGroup, TNewGroupmember } from '@db/types';
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
import { and, asc, eq, inArray, isNotNull, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
export async function createGroup(values: TNewGroup) {
export async function createGroup(values: TNewGroup, dbClient: DbOrTxClient = db) {
try {
return db.insert(schema.group).values(values).returning();
return dbClient.insert(schema.group).values(values).returning();
} catch (error) {
console.error('createGroup error:', error);
throw new Error(`Failed to create group: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function addGroupMember(values: TNewGroupmember) {
export async function addGroupMember(values: TNewGroupmember, dbClient: DbOrTxClient = db) {
try {
return db.insert(schema.groupmember).values(values).returning();
return dbClient.insert(schema.groupmember).values(values).returning();
} catch (error) {
console.error('addGroupMember error:', error);
throw new Error(`Failed to add group member: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -223,6 +223,59 @@ export const isCourseTeamMemberOrOrgAdmin = async (courseId: string, profileId:
return result.length > 0;
};
/**
* Resolves program-based access to a course.
* Returns the course group/org context plus the matched program member role.
*/
export async function getCourseProgramAccess(
courseId: string,
profileId: string
): Promise<{
courseId: string;
courseGroupId: string;
organizationId: string | null;
profileEmail: string | null;
roleId: number;
} | null> {
try {
const result = await db
.select({
courseId: schema.course.id,
courseGroupId: schema.group.id,
organizationId: schema.group.organizationId,
profileEmail: schema.profile.email,
roleId: schema.programMember.roleId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.innerJoin(
schema.program,
and(eq(schema.program.id, schema.programCourse.programId), eq(schema.program.status, 'ACTIVE'))
)
.innerJoin(
schema.programMember,
and(eq(schema.programMember.programId, schema.program.id), eq(schema.programMember.profileId, profileId))
)
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
.where(eq(schema.course.id, courseId))
.orderBy(asc(schema.programMember.roleId))
.limit(1);
if (result.length === 0) {
return null;
}
return {
...result[0],
roleId: Number(result[0].roleId)
};
} catch (error) {
console.error('getCourseProgramAccess error:', error);
throw new Error(`Failed to get course program access: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets the group member ID for a user in a course
* @param courseId Course ID
+1
View File
@@ -12,3 +12,4 @@ export * from './newsfeed';
export * from './submission';
export * from './tag';
export * from './course-import';
export * from './program';
@@ -44,6 +44,15 @@ export async function updateLessonLanguage(
return language;
}
export async function deleteLessonLanguage(lessonId: string, locale: TLocale): Promise<TLessonLanguage | null> {
const [language] = await db
.delete(schema.lessonLanguage)
.where(and(eq(schema.lessonLanguage.lessonId, lessonId), eq(schema.lessonLanguage.locale, locale)))
.returning();
return language || null;
}
export async function upsertLessonLanguage(data: TNewLessonLanguage): Promise<TLessonLanguage> {
// Check if exists
const existing = await getLessonLanguageByLessonIdAndLocale(data.lessonId!, data.locale || 'en');
+1 -1
View File
@@ -482,7 +482,7 @@ function getCommentCount() {
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.courseNewsfeedComment}
WHERE ${schema.courseNewsfeedComment.courseNewsfeedId} = ${schema.courseNewsfeed.id}),
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}),
0
)
`.as('commentCount');
@@ -272,6 +272,59 @@ export async function getLatestOrgInvitesByEmails(
}
}
/**
* Returns the latest active (non-revoked, non-accepted, non-expired) org invite for a given org+email.
* Used to surface a pending invite to a logged-in student on the LMS dashboard.
*/
export async function getActivePendingOrgInviteForEmail(
organizationId: string,
email: string
): Promise<TOrganizationInviteTokenData | null> {
const normalized = email.toLowerCase().trim();
if (!normalized) {
return null;
}
try {
const [row] = await db
.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(
and(
eq(schema.organizationInvite.organizationId, organizationId),
eq(schema.organizationInvite.email, normalized),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt),
gt(schema.organizationInvite.expiresAt, sql`NOW()`)
)
)
.orderBy(desc(schema.organizationInvite.createdAt))
.limit(1);
if (!row) {
return null;
}
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('getActivePendingOrgInviteForEmail error:', error);
throw new Error(
`Failed to get active pending org invite: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Latest organization_invite row for an org + email (by created_at), for metadata when resending.
*/
@@ -268,6 +268,67 @@ export const deleteOrganizationAudienceMember = async (orgId: string, memberId:
return deleted || null;
};
export const getOrganizationAudienceMember = async (orgId: string, memberId: number) => {
const [row] = await db
.select({
memberId: schema.organizationmember.id,
profileId: schema.organizationmember.profileId,
fullname: schema.profile.fullname,
email: sql<string>`coalesce(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
avatarUrl: schema.profile.avatarUrl,
profileCreatedAt: schema.profile.createdAt,
memberCreatedAt: schema.organizationmember.createdAt
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.id, memberId),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.limit(1);
if (!row) {
return null;
}
const email = row.email?.trim() ?? '';
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
return {
id: row.memberId,
profileId: row.profileId ?? null,
name,
email,
avatarUrl: row.avatarUrl || '',
createdAt
};
};
export const updateOrganizationAudienceMember = async (
orgId: string,
memberId: number,
data: Partial<Pick<TNewOrganizationmember, 'email' | 'verified'>>
) => {
const [updated] = await db
.update(schema.organizationmember)
.set(data)
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.id, memberId),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.returning();
return updated || null;
};
/**
* Checks if a user is an admin of an organization
* @param orgId Organization ID
+1
View File
@@ -0,0 +1 @@
export * from './program';
+699
View File
@@ -0,0 +1,699 @@
import * as schema from '@db/schema';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
// ─── Types ───────────────────────────────────────────────────────────────────
export type TProgram = typeof schema.program.$inferSelect;
export type TNewProgram = typeof schema.program.$inferInsert;
export type TProgramMember = typeof schema.programMember.$inferSelect;
export type TNewProgramMember = typeof schema.programMember.$inferInsert;
export type TProgramCourse = typeof schema.programCourse.$inferSelect;
export type TProgramNewsfeed = typeof schema.programNewsfeed.$inferSelect;
export type TNewProgramNewsfeed = typeof schema.programNewsfeed.$inferInsert;
export type TProgramNewsfeedComment = typeof schema.programNewsfeedComment.$inferSelect;
export type TNewProgramNewsfeedComment = typeof schema.programNewsfeedComment.$inferInsert;
// ─── Program CRUD ────────────────────────────────────────────────────────────
export async function createProgram(data: TNewProgram): Promise<TProgram> {
try {
const [program] = await db.insert(schema.program).values(data).returning();
if (!program) throw new Error('Failed to create program');
return program;
} catch (error) {
console.error('createProgram error:', error);
throw new Error(`Failed to create program: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Inserts the program and adds `creatorProfileId` as a program team member (tutor) in one transaction. */
export async function createProgramWithCreatorMembership(
programData: TNewProgram,
creatorProfileId: string
): Promise<TProgram> {
try {
return await db.transaction(async (tx) => {
const [program] = await tx.insert(schema.program).values(programData).returning();
if (!program) {
throw new Error('Failed to create program');
}
await tx.insert(schema.programMember).values({
programId: program.id,
profileId: creatorProfileId,
roleId: ROLE.TUTOR
});
return program;
});
} catch (error) {
console.error('createProgramWithCreatorMembership error:', error);
throw new Error(`Failed to create program: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getProgramById(programId: string): Promise<TProgram | null> {
try {
const [program] = await db.select().from(schema.program).where(eq(schema.program.id, programId)).limit(1);
return program || null;
} catch (error) {
console.error('getProgramById error:', error);
throw new Error(
`Failed to get program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getProgramsByOrg(
organizationId: string,
programIds?: string[]
): Promise<Array<TProgram & { courseCount: number; studentCount: number }>> {
try {
const whereCondition =
programIds && programIds.length > 0
? and(eq(schema.program.organizationId, organizationId), inArray(schema.program.id, programIds))
: eq(schema.program.organizationId, organizationId);
const result = await db
.select({
program: schema.program,
courseCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.programCourse}
WHERE ${eq(schema.programCourse.programId, schema.program.id)}),
0
)
`.as('courseCount'),
studentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.programMember}
WHERE ${and(
eq(schema.programMember.programId, schema.program.id),
eq(schema.programMember.roleId, ROLE.STUDENT)
)}),
0
)
`.as('studentCount')
})
.from(schema.program)
.where(whereCondition)
.orderBy(desc(schema.program.createdAt));
return result.map((row) => ({
...row.program,
courseCount: Number(row.courseCount || 0),
studentCount: Number(row.studentCount || 0)
}));
} catch (error) {
console.error('getProgramsByOrg error:', error);
throw new Error(
`Failed to get programs for org "${organizationId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExistingProgramMembers(
pairs: Array<{ programId: string; profileId: string }>
): Promise<Set<string>> {
if (pairs.length === 0) {
return new Set();
}
try {
const programIds = [...new Set(pairs.map((pair) => pair.programId))];
const profileIds = [...new Set(pairs.map((pair) => pair.profileId))];
const rows = await db
.select({ programId: schema.programMember.programId, profileId: schema.programMember.profileId })
.from(schema.programMember)
.where(
and(inArray(schema.programMember.programId, programIds), inArray(schema.programMember.profileId, profileIds))
);
return new Set(
rows
.filter((row): row is { programId: string; profileId: string } => Boolean(row.programId && row.profileId))
.map((row) => `${row.programId}:${row.profileId}`)
);
} catch (error) {
console.error('getExistingProgramMembers error:', error);
throw new Error(
`Failed to check existing program members: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateProgram(programId: string, data: Partial<TNewProgram>): Promise<TProgram | null> {
try {
const [updated] = await db
.update(schema.program)
.set({ ...data, updatedAt: sql`now()` })
.where(eq(schema.program.id, programId))
.returning();
return updated || null;
} catch (error) {
console.error('updateProgram error:', error);
throw new Error(
`Failed to update program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteProgram(programId: string): Promise<TProgram | null> {
try {
const [deleted] = await db.delete(schema.program).where(eq(schema.program.id, programId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteProgram error:', error);
throw new Error(
`Failed to delete program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Membership ──────────────────────────────────────────────────────
export async function getProgramMemberByProfileId(
programId: string,
profileId: string
): Promise<TProgramMember | null> {
try {
const [member] = await db
.select()
.from(schema.programMember)
.where(and(eq(schema.programMember.programId, programId), eq(schema.programMember.profileId, profileId)))
.limit(1);
return member || null;
} catch (error) {
console.error('getProgramMemberByProfileId error:', error);
throw new Error(`Failed to get program member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function isProgramMember(programId: string, profileId: string): Promise<boolean> {
try {
const member = await getProgramMemberByProfileId(programId, profileId);
return member !== null;
} catch (error) {
console.error('isProgramMember error:', error);
throw new Error(`Failed to check program membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getProgramMemberRole(programId: string, profileId: string): Promise<number | null> {
try {
const member = await getProgramMemberByProfileId(programId, profileId);
return member?.roleId || null;
} catch (error) {
console.error('getProgramMemberRole error:', error);
throw new Error(`Failed to get program member role: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function isOrgAdminByProgramId(programId: string, profileId: string): Promise<boolean> {
try {
const result = await db
.select({ orgMemberId: schema.organizationmember.id })
.from(schema.program)
.innerJoin(
schema.organizationmember,
and(
eq(schema.organizationmember.organizationId, schema.program.organizationId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.where(eq(schema.program.id, programId))
.limit(1);
return result.length > 0;
} catch (error) {
console.error('isOrgAdminByProgramId error:', error);
throw new Error(`Failed to check org admin status: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function addProgramMember(data: TNewProgramMember): Promise<TProgramMember> {
try {
const [member] = await db.insert(schema.programMember).values(data).returning();
if (!member) throw new Error('Failed to add program member');
return member;
} catch (error) {
console.error('addProgramMember error:', error);
throw new Error(`Failed to add program member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function removeProgramMember(memberId: string): Promise<TProgramMember | null> {
try {
const [deleted] = await db.delete(schema.programMember).where(eq(schema.programMember.id, memberId)).returning();
return deleted || null;
} catch (error) {
console.error('removeProgramMember error:', error);
throw new Error(
`Failed to remove program member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateProgramMember(
memberId: string,
data: Partial<TNewProgramMember>
): Promise<TProgramMember | null> {
try {
const [updated] = await db
.update(schema.programMember)
.set(data)
.where(eq(schema.programMember.id, memberId))
.returning();
return updated || null;
} catch (error) {
console.error('updateProgramMember error:', error);
throw new Error(
`Failed to update program member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getProgramMembers(programId: string): Promise<
Array<
TProgramMember & {
profile: {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
} | null;
}
>
> {
try {
const result = await db
.select({
member: schema.programMember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.programMember)
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
.where(eq(schema.programMember.programId, programId))
.orderBy(asc(schema.programMember.createdAt));
return result.map((row) => ({
...row.member,
profile: row.profile?.id ? row.profile : null
}));
} catch (error) {
console.error('getProgramMembers error:', error);
throw new Error(
`Failed to get program members for "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getEnrolledProgramsByProfile(
profileId: string
): Promise<Array<TProgram & { roleId: number; courseCount: number; studentCount: number }>> {
try {
const result = await db
.select({
program: schema.program,
roleId: schema.programMember.roleId,
courseCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.programCourse}
INNER JOIN ${schema.course}
ON ${schema.programCourse.courseId} = ${schema.course.id}
WHERE ${schema.programCourse.programId} = ${schema.program.id}
AND (
${schema.programMember.roleId} <> ${ROLE.STUDENT}
OR ${schema.course.isPublished} = true
)),
0
)
`.as('courseCount'),
studentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.programMember} AS student_member
WHERE student_member.program_id = ${schema.program.id}
AND student_member.role_id = ${ROLE.STUDENT}),
0
)
`.as('studentCount')
})
.from(schema.programMember)
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
.where(eq(schema.programMember.profileId, profileId))
.orderBy(desc(schema.program.createdAt));
return result.map((row) => ({
...row.program,
roleId: row.roleId,
courseCount: Number(row.courseCount || 0),
studentCount: Number(row.studentCount || 0)
}));
} catch (error) {
console.error('getEnrolledProgramsByProfile error:', error);
throw new Error(
`Failed to get enrolled programs for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Courses ─────────────────────────────────────────────────────────
export async function addCourseToProgram(programId: string, courseId: string): Promise<TProgramCourse> {
try {
const [row] = await db.insert(schema.programCourse).values({ programId, courseId }).returning();
if (!row) throw new Error('Failed to add course to program');
return row;
} catch (error) {
console.error('addCourseToProgram error:', error);
throw new Error(`Failed to add course to program: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function removeCourseFromProgram(programId: string, courseId: string): Promise<TProgramCourse | null> {
try {
const [deleted] = await db
.delete(schema.programCourse)
.where(and(eq(schema.programCourse.programId, programId), eq(schema.programCourse.courseId, courseId)))
.returning();
return deleted || null;
} catch (error) {
console.error('removeCourseFromProgram error:', error);
throw new Error(
`Failed to remove course from program: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCoursesByProgram(
programId: string,
onlyPublished = false
): Promise<
Array<
TProgramCourse & {
course: {
id: string;
title: string | null;
description: string | null;
coverImage: string | null;
slug: string | null;
status: string | null;
};
}
>
> {
try {
const whereCondition = onlyPublished
? and(eq(schema.programCourse.programId, programId), eq(schema.course.isPublished, true))
: eq(schema.programCourse.programId, programId);
const result = await db
.select({
programCourse: schema.programCourse,
course: {
id: schema.course.id,
title: schema.course.title,
description: schema.course.description,
coverImage: schema.course.logo,
slug: schema.course.slug,
status: schema.course.status,
isPublished: schema.course.isPublished
}
})
.from(schema.programCourse)
.innerJoin(schema.course, eq(schema.programCourse.courseId, schema.course.id))
.where(whereCondition)
.orderBy(asc(schema.programCourse.addedAt));
return result.map((row) => ({ ...row.programCourse, course: row.course }));
} catch (error) {
console.error('getCoursesByProgram error:', error);
throw new Error(
`Failed to get courses for program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function isProgramCourse(programId: string, courseId: string): Promise<boolean> {
try {
const [row] = await db
.select({ id: schema.programCourse.id })
.from(schema.programCourse)
.where(and(eq(schema.programCourse.programId, programId), eq(schema.programCourse.courseId, courseId)))
.limit(1);
return !!row;
} catch (error) {
console.error('isProgramCourse error:', error);
throw new Error(`Failed to check program course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// ─── Program Newsfeed ────────────────────────────────────────────────────────
export async function getProgramNewsfeed(
programId: string,
options: { cursor?: string; limit: number }
): Promise<{
items: Array<
TProgramNewsfeed & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
commentCount: number;
}
>;
totalCount: number;
hasMore: boolean;
nextCursor: string | null;
}> {
try {
const { cursor, limit } = options;
const whereConditions = [eq(schema.programNewsfeed.programId, programId)];
if (cursor) {
whereConditions.push(sql`${schema.programNewsfeed.createdAt} < ${cursor}`);
}
const totalCountResult = await db
.select({ count: sql<number>`count(*)`.as('count') })
.from(schema.programNewsfeed)
.where(eq(schema.programNewsfeed.programId, programId));
const totalCount = Number(totalCountResult[0]?.count || 0);
const feeds = await db
.select({
feed: schema.programNewsfeed,
profile: schema.profile,
commentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.programNewsfeedComment}
WHERE ${eq(schema.programNewsfeedComment.programNewsfeedId, schema.programNewsfeed.id)}),
0
)
`.as('commentCount')
})
.from(schema.programNewsfeed)
.leftJoin(schema.programMember, eq(schema.programNewsfeed.authorId, schema.programMember.id))
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.programNewsfeed.createdAt))
.limit(limit + 1);
const hasMore = feeds.length > limit;
const items = feeds.slice(0, limit);
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].feed.createdAt : null;
return {
items: items.map((row) => ({
...row.feed,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null,
commentCount: Number(row.commentCount || 0)
})),
totalCount,
hasMore,
nextCursor
};
} catch (error) {
console.error('getProgramNewsfeed error:', error);
throw new Error(
`Failed to get program newsfeed "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getProgramNewsfeedById(feedId: string): Promise<TProgramNewsfeed | null> {
try {
const [feed] = await db.select().from(schema.programNewsfeed).where(eq(schema.programNewsfeed.id, feedId)).limit(1);
return feed || null;
} catch (error) {
console.error('getProgramNewsfeedById error:', error);
throw new Error(
`Failed to get program newsfeed item "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createProgramNewsfeed(data: TNewProgramNewsfeed): Promise<TProgramNewsfeed> {
try {
const [feed] = await db.insert(schema.programNewsfeed).values(data).returning();
if (!feed) throw new Error('Failed to create program newsfeed');
return feed;
} catch (error) {
console.error('createProgramNewsfeed error:', error);
throw new Error(`Failed to create program newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateProgramNewsfeed(
feedId: string,
data: Partial<TNewProgramNewsfeed>
): Promise<TProgramNewsfeed | null> {
try {
const [updated] = await db
.update(schema.programNewsfeed)
.set(data)
.where(eq(schema.programNewsfeed.id, feedId))
.returning();
return updated || null;
} catch (error) {
console.error('updateProgramNewsfeed error:', error);
throw new Error(
`Failed to update program newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateProgramNewsfeedReaction(
feedId: string,
reaction: { clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }
): Promise<TProgramNewsfeed | null> {
try {
const [updated] = await db
.update(schema.programNewsfeed)
.set({ reaction })
.where(eq(schema.programNewsfeed.id, feedId))
.returning();
return updated || null;
} catch (error) {
console.error('updateProgramNewsfeedReaction error:', error);
throw new Error(
`Failed to update program newsfeed reaction "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteProgramNewsfeed(feedId: string): Promise<TProgramNewsfeed | null> {
try {
const [deleted] = await db.delete(schema.programNewsfeed).where(eq(schema.programNewsfeed.id, feedId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteProgramNewsfeed error:', error);
throw new Error(
`Failed to delete program newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
export async function getProgramNewsfeedComments(feedId: string): Promise<
Array<
TProgramNewsfeedComment & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
}
>
> {
try {
const result = await db
.select({
comment: schema.programNewsfeedComment,
profile: schema.profile
})
.from(schema.programNewsfeedComment)
.leftJoin(schema.programMember, eq(schema.programNewsfeedComment.authorId, schema.programMember.id))
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
.where(eq(schema.programNewsfeedComment.programNewsfeedId, feedId))
.orderBy(asc(schema.programNewsfeedComment.createdAt));
return result.map((row) => ({
...row.comment,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null
}));
} catch (error) {
console.error('getProgramNewsfeedComments error:', error);
throw new Error(
`Failed to get program newsfeed comments for "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createProgramNewsfeedComment(data: TNewProgramNewsfeedComment): Promise<TProgramNewsfeedComment> {
try {
const [comment] = await db.insert(schema.programNewsfeedComment).values(data).returning();
if (!comment) throw new Error('Failed to create program newsfeed comment');
return comment;
} catch (error) {
console.error('createProgramNewsfeedComment error:', error);
throw new Error(
`Failed to create program newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteProgramNewsfeedComment(commentId: number): Promise<TProgramNewsfeedComment | null> {
try {
const [deleted] = await db
.delete(schema.programNewsfeedComment)
.where(eq(schema.programNewsfeedComment.id, commentId))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteProgramNewsfeedComment error:', error);
throw new Error(
`Failed to delete program newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getProgramNewsfeedCommentById(commentId: number): Promise<TProgramNewsfeedComment | null> {
try {
const [comment] = await db
.select()
.from(schema.programNewsfeedComment)
.where(eq(schema.programNewsfeedComment.id, commentId))
.limit(1);
return comment || null;
} catch (error) {
console.error('getProgramNewsfeedCommentById error:', error);
throw new Error(
`Failed to get program newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+160 -1
View File
@@ -701,7 +701,7 @@ export const lesson = pgTable(
isUnlocked: boolean('is_unlocked').default(false),
videos: jsonb().default([]).$type<
{
type: 'youtube' | 'generic' | 'upload';
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
key?: string;
assetId?: string;
@@ -1569,6 +1569,11 @@ 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.
*/
export const questionType = pgTable(
'question_type',
{
@@ -2114,3 +2119,157 @@ export const courseImportDraft = pgTable(
.where(sql`${table.idempotencyKey} IS NOT NULL`)
]
);
// ─── Programs ────────────────────────────────────────────────────────────────
export const programStatus = pgEnum('PROGRAM_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
export const program = pgTable(
'program',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
organizationId: uuid('organization_id').notNull(),
name: varchar().notNull(),
description: text(),
coverImage: text('cover_image'),
status: programStatus().default('ACTIVE').notNull(),
createdByProfileId: uuid('created_by_profile_id')
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'program_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'program_created_by_profile_id_fkey'
}),
index('idx_program_organization_id').on(table.organizationId)
]
);
export const programCourse = pgTable(
'program_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
programId: uuid('program_id').notNull(),
courseId: uuid('course_id').notNull(),
addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.programId],
foreignColumns: [program.id],
name: 'program_course_program_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'program_course_course_id_fkey'
}).onDelete('cascade'),
unique('program_course_program_id_course_id_unique').on(table.programId, table.courseId),
index('idx_program_course_program_id').on(table.programId)
]
);
export const programMember = pgTable(
'program_member',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
programId: uuid('program_id').notNull(),
profileId: uuid('profile_id'),
roleId: integer('role_id').notNull(),
email: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.programId],
foreignColumns: [program.id],
name: 'program_member_program_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'program_member_profile_id_fkey'
}),
foreignKey({
columns: [table.roleId],
foreignColumns: [role.id],
name: 'program_member_role_id_fkey'
}),
unique('program_member_program_id_profile_id_unique').on(table.programId, table.profileId),
index('idx_program_member_program_id').on(table.programId),
index('idx_program_member_profile_id').on(table.profileId)
]
);
export const programNewsfeed = pgTable(
'program_newsfeed',
{
id: uuid().defaultRandom().primaryKey().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
authorId: uuid('author_id'),
programId: uuid('program_id'),
content: text(),
reaction: jsonb()
.default({ clap: [], smile: [], thumbsup: [], thumbsdown: [] })
.$type<{ clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }>(),
isPinned: boolean('is_pinned').default(false).notNull()
},
(table) => [
foreignKey({
columns: [table.authorId],
foreignColumns: [programMember.id],
name: 'program_newsfeed_author_id_fkey'
}),
foreignKey({
columns: [table.programId],
foreignColumns: [program.id],
name: 'program_newsfeed_program_id_fkey'
}).onDelete('cascade'),
index('idx_program_newsfeed_program_id').on(table.programId)
]
);
export const programNewsfeedComment = pgTable(
'program_newsfeed_comment',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'program_newsfeed_comment_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
authorId: uuid('author_id'),
content: text(),
programNewsfeedId: uuid('program_newsfeed_id')
},
(table) => [
foreignKey({
columns: [table.authorId],
foreignColumns: [programMember.id],
name: 'program_newsfeed_comment_author_id_fkey'
}),
foreignKey({
columns: [table.programNewsfeedId],
foreignColumns: [programNewsfeed.id],
name: 'program_newsfeed_comment_program_newsfeed_id_fkey'
}).onDelete('cascade')
]
);
@@ -24,7 +24,7 @@ type LessonDocument = {
};
type AssetKind = 'video' | 'document' | 'image' | 'audio' | 'other';
type AssetProvider = 'upload' | 'youtube' | 'generic' | 'external_url';
type AssetProvider = 'upload' | 'youtube' | 'generic' | 'external_url' | 'google_drive';
type SlotType = 'lesson_video' | 'lesson_document';
type MediaSummaryKey =
@@ -64,6 +64,18 @@ export async function seedQuestionTypes() {
typename: 'LINK',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
},
{
label: 'Word bank',
typename: 'WORD_BANK',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
},
{
label: 'Star rating',
typename: 'STAR',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));
+3
View File
@@ -20,6 +20,7 @@ export * from './teacher-student-buy-request';
export * from './student-prove-payment';
export * from './teacher-course-welcome';
export * from './student-org-invite';
export * from './student-program-welcome';
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
@@ -32,6 +33,7 @@ import type { studentCourseCompletionEmail } from './student-course-completion';
import type { studentCourseWelcomeEmail } from './student-course-welcome';
import type { studentProvePaymentEmail } from './student-prove-payment';
import type { studentOrgInviteEmail } from './student-org-invite';
import type { studentProgramWelcomeEmail } from './student-program-welcome';
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
@@ -60,4 +62,5 @@ export type EmailSchemas = {
studentProvePayment: typeof studentProvePaymentEmail.template.schema;
teacherCourseWelcome: typeof teacherCourseWelcomeEmail.template.schema;
studentOrgInvite: typeof studentOrgInviteEmail.template.schema;
studentProgramWelcome: typeof studentProgramWelcomeEmail.template.schema;
};
@@ -0,0 +1,26 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const studentProgramWelcomeEmail = defineEmail({
id: 'studentProgramWelcome',
subject: 'You have access to a program — sign in to get started',
schema: z.object({
orgName: z.string().min(1),
programName: z.string().min(1),
loginUrl: z.string().min(1)
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>You now have access to <strong>${fields.programName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the program and get started.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
}
});
+2 -2
View File
@@ -177,11 +177,11 @@ export const getDefaultTemplate = (content: string): string =>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
<a style="text-decoration: none" href="https://classroomio.com/tos"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
href="https://classroomio.com/privacy" target="_blank">Privacy
Policy</a>
</p>
</div>
+1
View File
@@ -10,6 +10,7 @@ export const EMAIL_IDS = [
'studentCourseCompletion',
'studentCourseWelcome',
'studentOrgInvite',
'studentProgramWelcome',
'studentProvePayment',
'teacherCourseWelcome',
'teacherStudentBuyRequest',
+1 -1
View File
@@ -91,7 +91,7 @@ export class ClassroomIoApiClient {
});
}
async listOrganizationCourses(query: TGetOrganizationCoursesQuery = {}) {
async listOrganizationCourses(query: Partial<TGetOrganizationCoursesQuery> = {}) {
const searchParams = new URLSearchParams();
if (query.tags) searchParams.set('tags', query.tags);
+1 -1
View File
@@ -98,7 +98,7 @@ const publishCourseDraftToExistingCourseShape =
const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRawShapeCompat;
const SUPPORTED_QUESTION_TYPES_GUIDE =
'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links). Use the numeric ids in exercise payloads.';
'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links), WORD_BANK=13 (Word bank). Use the numeric ids in exercise payloads.';
const LESSON_HTML_GUIDE =
'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.';
@@ -9,7 +9,7 @@ This guide walks through adding a new question type to ClassroomIO. Question typ
A question type flows through several layers:
1. **`@cio/question-types`** — Keys, metadata, answer serialization (codecs)
2. **`@cio/db`** — Database seed/migration for the `question_type` table
2. **`@cio/db`** — Document the type in `schema.ts` and add a seed row for `question_type` (no migration file for reference data)
3. **`@cio/ui`** — Edit, take, and preview renderers
4. **`apps/dashboard`** — Translation keys, question type picker
5. **`apps/api`** — Uses the above via `@cio/question-types`; no code changes usually needed
@@ -151,9 +151,17 @@ export type ExerciseQuestionLabelKey =
---
## Step 6: Database — seed and migration
## Step 6: Database — `schema.ts` and seed
### 6a. Seed (for new environments)
**Do not add a migration file** (nothing new under `packages/db/src/migrations/`) for a new question type. Reference data like `question_type` rows is not versioned via Drizzle migrations in this repo. Updating **`schema.ts`** (documentation only, unless you change table structure) and the **seed** is enough.
### 6a. Update `schema.ts` documentation
**File:** `packages/db/src/schema.ts`
On the `questionType` table, extend the JSDoc comment that lists expected ids and typenames (e.g. `1 RADIO`, `2 CHECKBOX`, …) so it includes your new type and id. That keeps the database package aligned with `QUESTION_TYPE_REGISTRY` for anyone reading the schema.
### 6b. Seed (for new environments)
**File:** `packages/db/src/utils/seed/questionType.ts`
@@ -168,17 +176,11 @@ Add a seed entry so new databases get the type:
}
```
The `id` is auto-generated. Use the next sequential id (e.g. 13) in `QUESTION_TYPE_REGISTRY` so it matches after seed runs.
The `id` is auto-generated. Use the next sequential id in `QUESTION_TYPE_REGISTRY` so it matches the order rows get when seed runs (or document the expected id in both places).
### 6b. Migration (for existing databases)
### 6c. Existing / production databases
For existing production databases, add a migration that inserts the new question type:
```sql
INSERT INTO question_type (label, typename) VALUES ('My New Type', 'MY_NEW_TYPE');
```
If you need a specific `id` to match the registry, you may need to use `SELECT setval(...)` or an `ON CONFLICT`-style approach depending on your migration strategy.
For databases that already exist and will not re-run seed, insert the `question_type` row **outside** the migration folder (e.g. a one-off SQL run in staging/production, or your ops playbook). Do **not** commit a migration whose only purpose is `INSERT INTO question_type`.
---
@@ -303,7 +305,8 @@ In `constants.ts`, you can:
- [ ] `AnswerCodec` (`toApiPayload`, `fromApiPayload`) in `answer-codecs.ts` and registration in `ANSWER_CODECS`
- [ ] Optional: payload in `question-answer-payload.ts`
- [ ] Optional: label keys in `exercise-types.ts`
- [ ] Seed in `questionType.ts` and migration for existing DBs
- [ ] `schema.ts` comment on `questionType` updated with the new id/typename
- [ ] Seed entry in `questionType.ts` (no new migration file for `INSERT INTO question_type`)
- [ ] Three renderers (edit, take, preview) in `@cio/ui`
- [ ] Registration in `EXERCISE_QUESTION_RENDERER_CONTRACT`
- [ ] Translation keys in dashboard `constants.ts` and `en.json`
@@ -328,4 +331,4 @@ In `constants.ts`, you can:
| HOTSPOT | 11 | Auto-gradable, partial credit |
| LINK | 12 | Manual grading |
Use the next available id (13) for your new type, and ensure the DB `question_type` row has a matching id.
Use the next available id for your new type, keep `QUESTION_TYPE_REGISTRY` and the `questionType` comment in `schema.ts` in sync, and ensure environments that need the row have it inserted (seed or one-off—**not** a committed migration for reference data).
+47 -2
View File
@@ -9,14 +9,17 @@ import type {
ShortAnswerData,
NumericAnswerData,
FillBlankAnswerData,
WordBankAnswerData,
FileUploadAnswerData,
MatchingAnswerData,
OrderingAnswerData,
LinkAnswerData,
HotspotAnswerData
HotspotAnswerData,
StarAnswerData
} from './answer-data';
import { QUESTION_TYPE_KEY } from './question-type-keys';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
export type ApiPayload = { questionId: number; optionId?: number; answer?: string };
@@ -129,6 +132,23 @@ const FILL_BLANK_CODEC: AnswerCodec<FillBlankAnswerData> = {
}
};
const WORD_BANK_CODEC: AnswerCodec<WordBankAnswerData> = {
type: 'WORD_BANK',
toApiPayload(data, questionId) {
return { questionId, answer: JSON.stringify({ type: 'WORD_BANK', filledBlanks: data.filledBlanks }) };
},
fromApiPayload(payload) {
if (!payload.answer) return null;
try {
const parsed = JSON.parse(payload.answer) as { type?: string; filledBlanks?: string[] };
if (parsed?.type !== 'WORD_BANK' || !Array.isArray(parsed.filledBlanks)) return null;
return { type: 'WORD_BANK', filledBlanks: parsed.filledBlanks.map((v) => String(v ?? '')) };
} catch {
return null;
}
}
};
const FILE_UPLOAD_CODEC: AnswerCodec<FileUploadAnswerData> = {
type: 'FILE_UPLOAD',
toApiPayload(data, questionId) {
@@ -208,6 +228,25 @@ const HOTSPOT_CODEC: AnswerCodec<HotspotAnswerData> = {
}
};
const STAR_CODEC: AnswerCodec<StarAnswerData> = {
type: 'STAR',
toApiPayload(data, questionId) {
return { questionId, answer: JSON.stringify({ type: 'STAR', value: data.value }) };
},
fromApiPayload(payload, question) {
if (!payload.answer) return null;
try {
const parsed = JSON.parse(payload.answer) as { type?: string; value?: unknown };
if (parsed?.type !== 'STAR') return null;
const maxStars = getStarRatingMaxFromSettings(question.settings);
if (!isValidStarRatingValue(parsed.value, maxStars)) return null;
return { type: 'STAR', value: parsed.value };
} catch {
return null;
}
}
};
export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
@@ -216,11 +255,13 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.SHORT_ANSWER]: SHORT_ANSWER_CODEC,
[QUESTION_TYPE_KEY.NUMERIC]: NUMERIC_CODEC,
[QUESTION_TYPE_KEY.FILL_BLANK]: FILL_BLANK_CODEC,
[QUESTION_TYPE_KEY.WORD_BANK]: WORD_BANK_CODEC,
[QUESTION_TYPE_KEY.FILE_UPLOAD]: FILE_UPLOAD_CODEC,
[QUESTION_TYPE_KEY.MATCHING]: MATCHING_CODEC,
[QUESTION_TYPE_KEY.ORDERING]: ORDERING_CODEC,
[QUESTION_TYPE_KEY.LINK]: LINK_CODEC,
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC,
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC
};
/** Convert any answer to API submission payload */
@@ -267,8 +308,12 @@ export function extractAnswerDisplayValues(data: AnswerData): {
return { selectedIds: [], selectedValues: [], text: data.text };
case 'NUMERIC':
return { selectedIds: [], selectedValues: [], text: String(data.value) };
case 'STAR':
return { selectedIds: [], selectedValues: [data.value], text: String(data.value) };
case 'FILL_BLANK':
return { selectedIds: [], selectedValues: [], text: data.values.join(', ') };
case 'WORD_BANK':
return { selectedIds: [], selectedValues: [], text: data.filledBlanks.filter(Boolean).join(', ') };
case 'LINK':
return { selectedIds: [], selectedValues: data.urls, text: data.urls.join(', ') };
case 'MATCHING':
+5 -1
View File
@@ -5,6 +5,7 @@ export type TextareaAnswerData = { type: 'TEXTAREA'; text: string };
export type ShortAnswerData = { type: 'SHORT_ANSWER'; text: string };
export type NumericAnswerData = { type: 'NUMERIC'; value: number };
export type FillBlankAnswerData = { type: 'FILL_BLANK'; values: string[] };
export type WordBankAnswerData = { type: 'WORD_BANK'; filledBlanks: string[] };
export type FileUploadAnswerData = {
type: 'FILE_UPLOAD';
fileKey: string;
@@ -16,6 +17,7 @@ export type MatchingAnswerData = { type: 'MATCHING'; pairs: Array<{ left: string
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 AnswerData =
| RadioAnswerData
@@ -25,8 +27,10 @@ export type AnswerData =
| ShortAnswerData
| NumericAnswerData
| FillBlankAnswerData
| WordBankAnswerData
| FileUploadAnswerData
| MatchingAnswerData
| OrderingAnswerData
| LinkAnswerData
| HotspotAnswerData;
| HotspotAnswerData
| StarAnswerData;
@@ -66,10 +66,35 @@ export type ExerciseQuestionLabelKey =
| 'numeric.review.your_answer_label'
| 'numeric.review.in_range'
| 'numeric.review.out_of_range'
| 'star.edit.correct_value_label'
| 'star.edit.correct_value_info'
| 'star.edit.max_stars_label'
| 'star.edit.max_stars_info'
| 'star.edit.correct_value_placeholder'
| 'star.edit.max_stars_placeholder'
| 'star.take.helper'
| 'star.take.star_sr'
| 'star.preview.correct_label'
| 'fill_blank.edit.accepted_answers_placeholder'
| 'fill_blank.take.placeholder'
| 'fill_blank.preview.accepted_answers_label'
| 'fill_blank.review.your_answer_label'
| 'word_bank.edit.template_label'
| 'word_bank.edit.template_placeholder'
| 'word_bank.edit.template_helper'
| 'word_bank.edit.no_blanks_warning'
| 'word_bank.edit.correct_answers_heading'
| 'word_bank.edit.blank_label'
| 'word_bank.edit.correct_placeholder'
| 'word_bank.edit.distractors_heading'
| 'word_bank.edit.distractor_placeholder'
| 'word_bank.edit.add_distractor'
| 'word_bank.take.helper'
| 'word_bank.take.bank_heading'
| 'word_bank.take.clear_blank_sr'
| 'word_bank.preview.filled_label'
| 'word_bank.review.your_answer_label'
| 'word_bank.review.correct_label'
| 'file_upload.edit.accepted_types_placeholder'
| 'file_upload.edit.max_size_placeholder'
| 'file_upload.take.selected_file_label'
+1
View File
@@ -14,3 +14,4 @@ export * from './answer-data';
export * from './answer-codecs';
export * from './renderer-registry';
export * from './textarea-validation';
export * from './star-rating-settings';
@@ -8,11 +8,13 @@ export type QuestionAnswerPayload =
| ShortAnswerPayload
| NumericAnswerPayload
| FillBlankAnswerPayload
| WordBankAnswerPayload
| FileUploadAnswerPayload
| MatchingAnswerPayload
| OrderingAnswerPayload
| HotspotAnswerPayload
| LinkAnswerPayload;
| LinkAnswerPayload
| StarAnswerPayload;
interface BaseAnswerPayload {
type: QuestionTypeKey;
@@ -53,6 +55,11 @@ export interface FillBlankAnswerPayload extends BaseAnswerPayload {
answers: string[];
}
export interface WordBankAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.WORD_BANK;
filledBlanks: string[];
}
export interface FileUploadAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.FILE_UPLOAD;
fileId: string;
@@ -80,3 +87,8 @@ export interface LinkAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.LINK;
links: string[];
}
export interface StarAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.STAR;
value: number;
}
@@ -4,6 +4,7 @@ import { QUESTION_TYPE_KEY } from './question-type-keys';
import { getQuestionTypeById } from './question-type-lookup';
import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry';
import { supportsPartialCredit } from './question-type-capabilities';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
export type DbQuestionForScoring = {
id: number;
@@ -129,6 +130,20 @@ function scoreTrueFalse(
return answer.value === correctIsTrue ? maxPoints : 0;
}
function scoreStar(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'STAR' }>,
maxPoints: number
): number {
const maxStars = getStarRatingMaxFromSettings(question.settings);
const rawCorrect = question.settings?.correctValue;
const correct = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
if (!isValidStarRatingValue(correct, maxStars)) return 0;
if (!isValidStarRatingValue(answer.value, maxStars)) return 0;
return answer.value === correct ? maxPoints : 0;
}
function scoreNumeric(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'NUMERIC' }>,
@@ -169,6 +184,31 @@ function scoreFillBlank(
return (correct / expected.length) * maxPoints;
}
function scoreWordBank(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'WORD_BANK' }>,
maxPoints: number,
partial: boolean
): number {
const rawCorrect = question.settings?.correctAnswers;
const expected = Array.isArray(rawCorrect)
? (rawCorrect as unknown[]).map((s) => String(s).trim().toLowerCase()).filter(Boolean)
: [];
if (expected.length === 0) return 0;
const studentVals = (answer.filledBlanks ?? []).map((v) => String(v).trim().toLowerCase());
let correct = 0;
for (let i = 0; i < expected.length; i++) {
if (studentVals[i] === expected[i]) correct++;
}
if (!partial) {
return correct === expected.length && studentVals.length === expected.length ? maxPoints : 0;
}
return (correct / expected.length) * maxPoints;
}
function pairKey(left: string, right: string): string {
return `${normalizeText(left)}::${normalizeText(right)}`;
}
@@ -298,9 +338,15 @@ export function scoreAnswerForQuestion(question: ExerciseQuestionModel, answer:
case 'NUMERIC':
if (key !== QUESTION_TYPE_KEY.NUMERIC) return 0;
return scoreNumeric(question, answer, maxPoints);
case 'STAR':
if (key !== QUESTION_TYPE_KEY.STAR) return 0;
return scoreStar(question, answer, maxPoints);
case 'FILL_BLANK':
if (key !== QUESTION_TYPE_KEY.FILL_BLANK) return 0;
return clampPoints(scoreFillBlank(question, answer, maxPoints, partial), maxPoints);
case 'WORD_BANK':
if (key !== QUESTION_TYPE_KEY.WORD_BANK) return 0;
return clampPoints(scoreWordBank(question, answer, maxPoints, partial), maxPoints);
case 'MATCHING':
if (key !== QUESTION_TYPE_KEY.MATCHING) return 0;
return clampPoints(scoreMatching(question, answer, maxPoints, partial), maxPoints);
@@ -10,7 +10,9 @@ export const QUESTION_TYPE_KEY = {
MATCHING: 'MATCHING',
ORDERING: 'ORDERING',
HOTSPOT: 'HOTSPOT',
LINK: 'LINK'
LINK: 'LINK',
WORD_BANK: 'WORD_BANK',
STAR: 'STAR'
} as const;
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
@@ -109,6 +109,24 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: true
},
{
key: QUESTION_TYPE_KEY.WORD_BANK,
typename: 'WORD_BANK',
label: 'Word bank',
id: 13,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.STAR,
typename: 'STAR',
label: 'Star rating',
id: 14,
autoGradable: true,
supportsPartialCredit: false,
manualGradingRequired: false
}
] as const;
@@ -0,0 +1,17 @@
import type { ExerciseQuestionModel } from './exercise-types';
const STAR_RATING_DEFAULT_MAX = 5;
const STAR_RATING_ABSOLUTE_MAX = 10;
/** Max star count for STAR questions (from `question.settings.maxStars`, clamped). */
export function getStarRatingMaxFromSettings(settings: ExerciseQuestionModel['settings']): number {
const raw = settings?.maxStars;
const parsed = typeof raw === 'number' ? raw : raw != null ? Number(raw) : NaN;
if (!Number.isFinite(parsed) || parsed < 1) return STAR_RATING_DEFAULT_MAX;
return Math.min(STAR_RATING_ABSOLUTE_MAX, Math.max(1, Math.floor(parsed)));
}
export function isValidStarRatingValue(value: unknown, maxStars: number): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= maxStars;
}
@@ -111,6 +111,22 @@ const EXPECTED_REGISTRY = [
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: true
},
{
key: QUESTION_TYPE_KEY.WORD_BANK,
typename: 'WORD_BANK',
id: 13,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.STAR,
typename: 'STAR',
id: 14,
autoGradable: true,
supportsPartialCredit: false,
manualGradingRequired: false
}
] as const;
+3 -1
View File
@@ -13,7 +13,9 @@
"storybook:publish": "pnpm build-storybook && pnpm storybook:upload"
},
"dependencies": {
"@cio/ui": "workspace:*"
"@cio/ui": "workspace:*",
"svelte-inview": "^4.0.4",
"svelte-motion": "^0.12.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.388.0",
@@ -8,7 +8,7 @@
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import PlusIcon from '@lucide/svelte/icons/plus';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
@@ -362,15 +362,19 @@
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href="#/" target="_blank" rel="noopener noreferrer" {...props}>
<Item.Content>
<Item.Title>External resource</Item.Title>
<Item.Description>Opens in a new tab with security attributes.</Item.Description>
</Item.Content>
<Item.Actions>
<ExternalLinkIcon class="size-4" />
</Item.Actions>
</a>
<HoverableItem>
{#snippet children(isHovered)}
<a href="#/" target="_blank" rel="noopener noreferrer" {...props}>
<Item.Content>
<Item.Title>External resource</Item.Title>
<Item.Description>Opens in a new tab with security attributes.</Item.Description>
</Item.Content>
<Item.Actions>
<ExternalLinkIcon {isHovered} size={16} ariaHidden={true} />
</Item.Actions>
</a>
{/snippet}
</HoverableItem>
{/snippet}
</Item.Root>
</div>
+13
View File
@@ -2,4 +2,17 @@
@import 'tailwindcss';
/* Unprefixed animation utilities for packages/ui/custom/animation.
@cio/ui/output.css is built with prefix(ui), so animate-* must be declared for Storybook's scan. */
@theme inline {
--animate-magicslide: magicslide var(--speed) ease-in-out infinite alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
--animate-meteor: meteor 5s linear infinite;
}
@import '@cio/ui/output.css';
@import '../../ui/src/custom/animation/marketing-layers.css';
@source '../../ui/src/custom/animation/**/*.svelte';
@source inline('animate-magicslide animate-spin-around animate-meteor');
@@ -0,0 +1,176 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
BlurFade,
BlurIn,
BorderBeam,
CardBody,
CardContainer,
DotPattern,
GradualSpacing,
HeroVideoDialog,
MagicCard,
Meteors,
ShimmerButton,
ShinnyText,
Sparkle,
SquareCardBody,
SquareCardIcon
} from '@cio/ui/custom/animation';
const { Story } = defineMeta({
title: 'Molecules/Animation',
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
</script>
<Story name="BorderBeam">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:flex ui:h-48 ui:w-80 ui:items-center ui:justify-center ui:rounded-xl ui:border ui:bg-muted"
>
<BorderBeam />
<span class="ui:relative ui:z-10 ui:text-sm ui:font-medium">Border beam</span>
</div>
{/snippet}
</Story>
<Story name="DotPattern">
{#snippet template()}
<div class="ui:relative ui:mx-auto ui:h-48 ui:w-80 ui:overflow-hidden ui:rounded-xl ui:border ui:bg-background">
<DotPattern />
<div class="ui:relative ui:z-10 ui:flex ui:h-full ui:items-center ui:justify-center ui:text-sm">Dot pattern</div>
</div>
{/snippet}
</Story>
<Story name="CardContainer">
{#snippet template()}
<CardContainer className="ui:max-w-lg">
{#snippet children()}
<CardBody>
{#snippet children()}
<p class="ui:text-center ui:text-sm">Card container with body</p>
{/snippet}
</CardBody>
{/snippet}
</CardContainer>
{/snippet}
</Story>
<Story name="BlurFade">
{#snippet template()}
<div class="ui:p-8">
<BlurFade>
{#snippet children()}
<p class="ui:text-lg">Scroll into view (blur fade)</p>
{/snippet}
</BlurFade>
</div>
{/snippet}
</Story>
<Story name="BlurIn">
{#snippet template()}
<div class="ui:p-8 ui:text-center">
<BlurIn>
{#snippet children()}
Blur in heading
{/snippet}
</BlurIn>
</div>
{/snippet}
</Story>
<Story name="Meteors">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:min-h-64 ui:w-full ui:max-w-xl ui:overflow-visible ui:rounded-xl ui:border ui:bg-slate-950 ui:p-4"
>
<Meteors number={12} />
<p class="ui:relative ui:z-10 ui:p-6 ui:text-center ui:text-sm ui:text-slate-300">
Meteors (absolute layer behind this copy)
</p>
</div>
{/snippet}
</Story>
<Story name="ShimmerButton">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-neutral-950 ui:p-8">
<ShimmerButton href="https://example.com" target="_blank" rel="noreferrer">
{#snippet children()}
Shimmer CTA
{/snippet}
</ShimmerButton>
</div>
{/snippet}
</Story>
<Story name="Sparkle">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-background ui:p-12">
<Sparkle text="Sparkle text" sparklesCount={6} />
</div>
{/snippet}
</Story>
<Story name="HeroVideoDialog">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:p-8">
<HeroVideoDialog
thumbnailSrc="https://images.unsplash.com/photo-1492691527719-9d1e07e534b4?w=1200&q=80"
videoSrc="https://www.youtube.com/embed/dQw4w9WgXcQ"
thumbnailAlt="Sample"
/>
</div>
{/snippet}
</Story>
<Story name="ShinnyText">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-background ui:p-12">
<ShinnyText shimmerWidth={120}>
{#snippet children()}
Shiny marketing copy
{/snippet}
</ShinnyText>
</div>
{/snippet}
</Story>
<Story name="MagicCard">
{#snippet template()}
<div class="ui:mx-auto ui:h-40 ui:max-w-sm ui:p-4">
<MagicCard>
{#snippet children()}
<p class="ui:text-center ui:text-sm">Hover for gradient</p>
{/snippet}
</MagicCard>
</div>
{/snippet}
</Story>
<Story name="GradualSpacing">
{#snippet template()}
<div class="ui:bg-background ui:p-12">
<GradualSpacing words="Hello" />
</div>
{/snippet}
</Story>
<Story name="SquareCard">
{#snippet template()}
<div class="ui:relative ui:mx-auto ui:w-64 ui:rounded-lg ui:border ui:p-6">
<SquareCardIcon class="ui:right-2 ui:top-2" />
<SquareCardBody>
{#snippet children()}
<p class="ui:text-sm">Square card body with icon</p>
{/snippet}
</SquareCardBody>
</div>
{/snippet}
</Story>
@@ -13,8 +13,10 @@
ORDERING_FIXTURE,
RADIO_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE
TRUE_FALSE_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
const { Story } = defineMeta({
@@ -57,6 +59,16 @@
{/snippet}
</Story>
<Story name="WordBank">
{#snippet template()}
<QuestionTypeModes
question={WORD_BANK_FIXTURE.question}
answer={WORD_BANK_FIXTURE.answer}
wrongAnswer={WORD_BANK_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story
name="Hotspot"
parameters={{
@@ -127,6 +139,25 @@
{/snippet}
</Story>
<Story
name="Star"
parameters={{
docs: {
description: {
story: '**Premium in the app** – Gated behind a paid plan in the exercise editor.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={STAR_FIXTURE.question}
answer={STAR_FIXTURE.answer}
wrongAnswer={STAR_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story name="Ordering">
{#snippet template()}
<QuestionTypeModes
@@ -131,6 +131,18 @@ export const NUMERIC_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'NUMERIC', value: 42 }
};
export const STAR_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-star',
key: 'q-star',
title: 'How clear were the lesson objectives? [STAR]',
questionType: 'STAR',
settings: { correctValue: 4, maxStars: 5 }
},
answer: { type: 'STAR', value: 4 },
wrongAnswer: { type: 'STAR', value: 2 }
};
export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-fill-blank',
@@ -143,6 +155,29 @@ export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'FILL_BLANK', values: ['SOAP'] }
};
export const WORD_BANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-word-bank',
key: 'q-word-bank',
title: 'Match each term with the sentence. [WORD_BANK]',
questionType: 'WORD_BANK',
settings: {
template:
'Measurements to describe our data are called ___. The ___ shows values and how often they occur. Outliers appear when data is skewed or has ___.',
correctAnswers: ['summary statistics', 'distribution', 'outliers'],
distractors: ['categorical variables', 'linear relationships', 'numeric variables']
}
},
answer: {
type: 'WORD_BANK',
filledBlanks: ['summary statistics', 'distribution', 'outliers']
},
wrongAnswer: {
type: 'WORD_BANK',
filledBlanks: ['numeric variables', 'distribution', 'linear relationships']
}
};
export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-file-upload',
@@ -66,10 +66,35 @@ export const QUESTION_LABELS = {
'numeric.review.your_answer_label': 'Submitted:',
'numeric.review.in_range': 'Within tolerance',
'numeric.review.out_of_range': 'Outside tolerance',
'star.edit.correct_value_label': 'Correct star value',
'star.edit.correct_value_info': 'Expected star count for full credit.',
'star.edit.max_stars_label': 'Maximum stars',
'star.edit.max_stars_info': 'Stars shown to learners (1–10).',
'star.edit.correct_value_placeholder': 'e.g. 3',
'star.edit.max_stars_placeholder': 'e.g. 5',
'star.take.helper': 'Select your rating.',
'star.take.star_sr': 'Star {n} of {max}',
'star.preview.correct_label': 'Correct answer',
'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)',
'fill_blank.take.placeholder': 'Enter answers separated by commas',
'fill_blank.preview.accepted_answers_label': 'Accepted answers',
'fill_blank.review.your_answer_label': 'Submitted:',
'word_bank.edit.template_label': 'Sentence template',
'word_bank.edit.template_placeholder': 'Use ___ for each blank...',
'word_bank.edit.template_helper': 'Use three underscores (___) for each blank.',
'word_bank.edit.no_blanks_warning': 'Add at least one ___ blank marker in your template.',
'word_bank.edit.correct_answers_heading': 'Correct answer for each blank',
'word_bank.edit.blank_label': 'Blank',
'word_bank.edit.correct_placeholder': 'Correct term or phrase',
'word_bank.edit.distractors_heading': 'Extra words (distractors)',
'word_bank.edit.distractor_placeholder': 'Wrong option',
'word_bank.edit.add_distractor': 'Add distractor',
'word_bank.take.helper': 'Click or drag words into blanks.',
'word_bank.take.bank_heading': 'Word bank',
'word_bank.take.clear_blank_sr': 'Remove word from blank',
'word_bank.preview.filled_label': 'Expected answers in context',
'word_bank.review.your_answer_label': 'Your answers',
'word_bank.review.correct_label': 'Correct answers',
'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats',
'file_upload.edit.max_size_placeholder': 'Max size (MB)',
'file_upload.take.selected_file_label': 'Selected file',
@@ -63,11 +63,45 @@
return { type: 'SHORT_ANSWER', text: 'Use deterministic fixtures' };
case 'NUMERIC':
return { type: 'NUMERIC', value: 88 + (index % 6) };
case 'STAR': {
const rawMax = storyQuestion.settings?.maxStars;
const maxStars =
typeof rawMax === 'number' && Number.isFinite(rawMax) ? Math.min(10, Math.max(1, Math.floor(rawMax))) : 5;
const rawCorrect = storyQuestion.settings?.correctValue;
const correct =
typeof rawCorrect === 'number' && Number.isFinite(rawCorrect)
? Math.min(maxStars, Math.max(1, Math.floor(rawCorrect)))
: Math.min(3, maxStars);
if (index % 4 === 0) return { type: 'STAR', value: correct };
return { type: 'STAR', value: (index % maxStars) + 1 };
}
case 'FILL_BLANK':
return {
type: 'FILL_BLANK',
values: index % 2 === 0 ? ['API'] : ['endpoint']
};
case 'WORD_BANK': {
const correct = Array.isArray(storyQuestion.settings?.correctAnswers)
? (storyQuestion.settings.correctAnswers as string[]).map((s) => String(s ?? ''))
: ['alpha', 'beta'];
const blankCount = Math.max(1, correct.length);
if (index % 4 === 0) {
return { type: 'WORD_BANK', filledBlanks: correct.slice(0, blankCount) };
}
if (index % 4 === 1) {
const wrongFirst = ['wrong term', ...correct.slice(1)].slice(0, blankCount);
return { type: 'WORD_BANK', filledBlanks: wrongFirst };
}
if (index % 4 === 2 && blankCount > 1) {
const swapped = [...correct.slice(0, blankCount)];
[swapped[0], swapped[1]] = [swapped[1], swapped[0]];
return { type: 'WORD_BANK', filledBlanks: swapped };
}
return {
type: 'WORD_BANK',
filledBlanks: Array.from({ length: blankCount }, () => '')
};
}
case 'FILE_UPLOAD':
return {
type: 'FILE_UPLOAD',
@@ -0,0 +1,112 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
NewsfeedReactions,
type NewsfeedReactionCounts,
type NewsfeedReactionType
} from '@cio/ui/custom/newsfeed-reactions';
const { Story } = defineMeta({
title: 'Molecules/NewsfeedReactions',
component: NewsfeedReactions,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<script lang="ts">
let summaryReactionCounts = $state<NewsfeedReactionCounts>({
clap: 12,
smile: 4,
thumbsup: 8,
thumbsdown: 1
});
let summarySelectedReaction = $state<NewsfeedReactionType | null>('smile');
let openReactionCounts = $state<NewsfeedReactionCounts>({
clap: 6,
smile: 3,
thumbsup: 5,
thumbsdown: 2
});
let openSelectedReaction = $state<NewsfeedReactionType | null>('clap');
let openPopover = $state(true);
let interactiveReactionCounts = $state<NewsfeedReactionCounts>({
clap: 2,
smile: 7,
thumbsup: 1
});
let interactiveSelectedReaction = $state<NewsfeedReactionType | null>('smile');
let interactivePopover = $state(false);
function updateCounts(
reactionCounts: NewsfeedReactionCounts,
nextReactionType: NewsfeedReactionType,
currentReactionType: NewsfeedReactionType | null
) {
const nextCounts: NewsfeedReactionCounts = {
clap: reactionCounts.clap ?? 0,
smile: reactionCounts.smile ?? 0,
thumbsup: reactionCounts.thumbsup ?? 0,
thumbsdown: reactionCounts.thumbsdown ?? 0
};
if (currentReactionType === nextReactionType) {
nextCounts[nextReactionType] = Math.max(0, (nextCounts[nextReactionType] ?? 0) - 1);
return nextCounts;
}
if (currentReactionType) {
nextCounts[currentReactionType] = Math.max(0, (nextCounts[currentReactionType] ?? 0) - 1);
}
nextCounts[nextReactionType] = (nextCounts[nextReactionType] ?? 0) + 1;
return nextCounts;
}
</script>
<Story name="Summary">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions reactionCounts={summaryReactionCounts} selectedReactionType={summarySelectedReaction} />
</div>
{/snippet}
</Story>
<Story name="Popover Open">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions
bind:open={openPopover}
reactionCounts={openReactionCounts}
selectedReactionType={openSelectedReaction}
/>
</div>
{/snippet}
</Story>
<Story name="Interactive">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions
bind:open={interactivePopover}
reactionCounts={interactiveReactionCounts}
selectedReactionType={interactiveSelectedReaction}
onReactionToggle={(reactionType) => {
interactiveReactionCounts = updateCounts(
interactiveReactionCounts,
reactionType,
interactiveSelectedReaction
);
interactiveSelectedReaction = interactiveSelectedReaction === reactionType ? null : reactionType;
}}
/>
<p class="ui:mt-3 ui:text-sm ui:text-muted-foreground">
Click the trigger, pick a reaction, then click the active reaction again to clear it.
</p>
</div>
{/snippet}
</Story>
@@ -0,0 +1,27 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { QuestionTypePicker } from '@cio/ui';
const { Story } = defineMeta({
title: 'Molecules/QuestionTypePicker',
component: QuestionTypePicker,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Marketing-style question type picker with a left nav and live take-mode previews. Also published as a CDN embed (`apps/embeds`).'
}
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<div class="ui:max-w-5xl">
<QuestionTypePicker />
</div>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
/** Controls surfaced in Storybook for `ResourceListRow.Root`. */
export const FIELDS = ['variant', 'size', 'align', 'density', 'class'] as string[];
@@ -0,0 +1,449 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import * as Item from '@cio/ui/base/item';
import * as Avatar from '@cio/ui/base/avatar';
import * as Checkbox from '@cio/ui/base/checkbox';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import ClockIcon from '@lucide/svelte/icons/clock';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GitBranchIcon from '@lucide/svelte/icons/git-branch';
import GitCommitHorizontalIcon from '@lucide/svelte/icons/git-commit-horizontal';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import UserIcon from '@lucide/svelte/icons/user';
import { FIELDS } from './fields';
/** Storybook-only sample data for the courses-style list row. */
const sampleCourses = [
{
title: 'Introduction to Python',
type: 'Self-paced',
lastUpdated: 'Last updated Mar 28, 2026',
tags: ['Beginner', 'Programming'],
lessons: 24,
exercises: 12,
students: 128,
published: true
},
{
title: 'Advanced TypeScript',
type: 'Live',
lastUpdated: 'Last updated Mar 26, 2026',
tags: ['TypeScript', 'Advanced'],
lessons: 18,
exercises: 9,
students: 42,
published: true
},
{
title: 'Draft: Data visualization',
type: 'Self-paced',
lastUpdated: 'Last updated Mar 20, 2026',
tags: [],
lessons: 6,
exercises: 2,
students: 0,
published: false
}
];
const { Story } = defineMeta({
title: 'Molecules/ResourceListRow',
component: ResourceListRow.Root,
args: {
variant: 'default',
size: 'sm',
align: 'start',
density: 'default',
class: ''
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'outline', 'muted', 'muted-border'],
description: 'Passed through to `Item.Root` (border / background).'
},
size: {
control: 'select',
options: ['default', 'sm'],
description: 'Item padding and gap (`Item` size variant).'
},
align: {
control: 'select',
options: ['center', 'start'],
description: 'Vertical alignment of row sections (e.g. start for multi-line cells).'
},
density: {
control: 'select',
options: ['default', 'toolbar'],
description: '`toolbar` tightens padding for a “select all” style header; otherwise use default.'
},
class: {
control: 'text',
description: 'Extra classes merged onto the row (Tailwind / `ui:`).'
},
child: { control: false },
children: { control: false },
ref: { control: false }
},
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
{@const studentAvatarSrc = [
'https://github.com/shadcn.png',
'https://github.com/maxleiter.png',
'https://github.com/evilrabbit.png'
]}
<ResourceListRow.Group class="w-3xl">
{#each sampleCourses as course (course.title)}
<ResourceListRow.Root variant="default" align="start" class="ui:cursor-pointer ui:py-3">
<div
class="ui:grid ui:w-full ui:items-start ui:gap-3"
style="grid-template-columns: 2fr 5.5rem 2fr 4.5rem 6rem 2.5rem"
>
<!-- Title -->
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<p class="ui:line-clamp-1 ui:font-semibold ui:text-sm">{course.title}</p>
<p class="ui:text-muted-foreground ui:mt-0.5 ui:text-sm">{course.type}</p>
<p class="ui:text-muted-foreground ui:mt-0.5 ui:text-xs">{course.lastUpdated}</p>
</div>
<!-- Published -->
<div>
{#if course.published}
<Badge variant="success" class="ui:whitespace-nowrap">Published</Badge>
{:else}
<Badge variant="secondary" class="ui:whitespace-nowrap">Draft</Badge>
{/if}
</div>
<!-- Tags -->
<div class="ui:flex ui:min-w-0 ui:flex-wrap ui:items-center ui:gap-1">
{#if course.tags.length === 0}
<span class="ui:text-muted-foreground ui:text-xs">—</span>
{:else}
{#each course.tags as tag (tag)}
<Badge variant="outline" class="ui:max-w-[140px] ui:truncate">{tag}</Badge>
{/each}
{/if}
</div>
<!-- Lessons / exercises -->
<div class="ui:flex ui:flex-col ui:gap-1 ui:tabular-nums">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm" aria-label="{course.lessons} lessons">
<BookOpenIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" aria-hidden="true" />
<span class="ui:font-medium">{course.lessons}</span>
</p>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm" aria-label="{course.exercises} exercises">
<ListChecksIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" aria-hidden="true" />
<span class="ui:font-medium">{course.exercises}</span>
</p>
</div>
<!-- Students -->
<div class="flex items-center gap-1">
{#if course.students > 0}
<div class="flex -space-x-2">
{#each studentAvatarSrc as src, index (src)}
{#if index < Math.min(2, course.students)}
<Avatar.Root class="ui:size-6 ui:border-2 ui:border-background">
<Avatar.Image {src} alt="" />
<Avatar.Fallback>?</Avatar.Fallback>
</Avatar.Root>
{/if}
{/each}
{#if course.students > 2}
<Avatar.Root class="ui:size-6 ui:border-2 ui:border-background">
<Avatar.Fallback class="ui:text-[10px]">+{course.students - 2}</Avatar.Fallback>
</Avatar.Root>
{/if}
</div>
{:else}
<Avatar.Root class="ui:size-6">
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<p class="ui:text-xs">0</p>
{/if}
</div>
<!-- Actions -->
<div class="ui:flex ui:justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="ui:size-8"
aria-label="Course actions"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item>Open</DropdownMenu.Item>
<DropdownMenu.Item>Clone</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</ResourceListRow.Root>
{/each}
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="DomainStyle">
{#snippet template()}
<ResourceListRow.Group class="w-3xl!">
<ResourceListRow.Root density="toolbar">
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select all domains" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-semibold">Select all</span>
</ResourceListRow.Main>
<Item.Actions>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="ui:size-8" aria-label="Bulk actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item>Export</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select fastlearner.io" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">fastlearner.io</Item.Title>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-amber-800 dark:ui:text-amber-200">
<ClockIcon class="ui:size-3.5 ui:shrink-0" />
Expires 16 Jan 2027
</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">Jan 16</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select macusaone.com" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">macusaone.com</Item.Title>
<p class="ui:text-muted-foreground ui:text-sm">Third Party</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">11/20/25</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>ML</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="WithoutSelection">
{#snippet template()}
<ResourceListRow.Group class="w-3xl!">
<ResourceListRow.Root>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">fastlearner.io</Item.Title>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-amber-800 dark:ui:text-amber-200">
<ClockIcon class="ui:size-3.5 ui:shrink-0" />
Expires 16 Jan 2027
</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">Jan 16</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">macusaone.com</Item.Title>
<p class="ui:text-muted-foreground ui:text-sm">Third Party</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">11/20/25</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>ML</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="Density">
{#snippet template()}
<div class="ui:text-muted-foreground ui:mb-3 ui:w-full ui:max-w-md ui:text-xs">
Use rows inside <code>ResourceListGroup</code>. Optional <code>density=&quot;toolbar&quot;</code> shortens the row
for a “select all” header.
</div>
<ResourceListRow.Group class="max-w-md">
<ResourceListRow.Root density="default">
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-medium">density=&quot;default&quot;</span>
</ResourceListRow.Main>
</ResourceListRow.Root>
<ResourceListRow.Root density="toolbar">
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-medium">density=&quot;toolbar&quot;</span>
</ResourceListRow.Main>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="DeploymentStyle">
{#snippet template()}
<ResourceListRow.Group class="w-5xl">
<ResourceListRow.Root align="start" class="ui:py-3">
<ResourceListRow.Main class="ui:max-w-[140px]">
<span class="ui:font-mono ui:text-sm ui:font-semibold">2CSQFANmQ</span>
<button
type="button"
class="ui:text-primary ui:w-fit ui:text-left ui:text-xs ui:underline ui:underline-offset-2"
>
Preview
</button>
</ResourceListRow.Main>
<div class="ui:flex ui:min-w-0 ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:bg-destructive ui:size-2 ui:shrink-0 ui:rounded-full" aria-hidden="true"></span>
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<span class="ui:text-sm ui:font-medium">Error</span>
<span class="ui:text-muted-foreground ui:text-xs">3s</span>
</div>
</div>
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:flex ui:size-6 ui:items-center ui:justify-center ui:rounded-full ui:bg-blue-600/15">
<span class="ui:size-2 ui:rounded-full ui:bg-blue-600"></span>
</span>
<span class="ui:text-sm">cio-com</span>
</div>
<div class="ui:min-w-0 ui:flex-1 ui:px-2">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm">
<GitBranchIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">release-v2/debug-course</span>
</p>
<p class="ui:text-muted-foreground ui:flex ui:items-center ui:gap-1.5 ui:truncate ui:text-xs">
<GitCommitHorizontalIcon class="ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">b00bf14 fix: move org id into the inv…</span>
</p>
</div>
<ResourceListRow.End class="ui:flex-col ui:items-end ui:gap-1 ui:self-start">
<span class="ui:text-muted-foreground ui:text-xs">Mar 30</span>
<span class="ui:text-muted-foreground ui:text-xs">by Chifez</span>
<Avatar.Root class="ui:size-6">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>C</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
</ResourceListRow.Root>
<ResourceListRow.Root align="start" class="ui:py-3">
<ResourceListRow.Main class="ui:max-w-[140px]">
<span class="ui:font-mono ui:text-sm ui:font-semibold">AiS1LEGci</span>
<button
type="button"
class="ui:text-primary ui:w-fit ui:text-left ui:text-xs ui:underline ui:underline-offset-2"
>
Preview
</button>
</ResourceListRow.Main>
<div class="ui:flex ui:min-w-0 ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:bg-destructive ui:size-2 ui:shrink-0 ui:rounded-full" aria-hidden="true"></span>
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<span class="ui:text-sm ui:font-medium">Error</span>
<span class="ui:text-muted-foreground ui:text-xs">8s</span>
</div>
</div>
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:flex ui:size-6 ui:items-center ui:justify-center ui:rounded-full ui:bg-blue-600/15">
<span class="ui:size-2 ui:rounded-full ui:bg-blue-600"></span>
</span>
<span class="ui:text-sm">cio-docs</span>
</div>
<div class="ui:min-w-0 ui:flex-1 ui:px-2">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm">
<GitBranchIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">feat/release-v2</span>
</p>
<p class="ui:text-muted-foreground ui:flex ui:items-center ui:gap-1.5 ui:truncate ui:text-xs">
<GitCommitHorizontalIcon class="ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">a1b2c3d docs: update API reference</span>
</p>
</div>
<ResourceListRow.End class="ui:flex-col ui:items-end ui:gap-1 ui:self-start">
<span class="ui:text-muted-foreground ui:text-xs">Mar 29</span>
<span class="ui:text-muted-foreground ui:text-xs">by rotimi-best</span>
<Avatar.Root class="ui:size-6">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>R</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
@@ -0,0 +1,133 @@
import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types';
export const mockProps: OrgLandingPageProps = {
orgName: 'Certifi Academy',
logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff',
navItems: [
{ label: 'Programs', href: '#courses' },
{ label: 'About', href: '#about' },
{ label: 'Contact', href: '#contact' },
{ label: 'Sign In', href: '#signin' }
],
hero: {
heading: 'Become a Certified AI Engineer',
subheading: 'Master the skills, earn your certification, and prove your expertise with hands-on training programs.',
primaryAction: { label: 'Get Started', href: '#courses' },
secondaryAction: { label: 'View Programs', href: '#about' },
image: 'https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80'
},
courses: [
{
id: '1',
slug: 'workplace-safety-certification',
title: 'Workplace Safety Certification',
description:
'OSHA-aligned training covering hazard identification, emergency procedures, and workplace safety best practices.',
logo: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
cost: 0,
currency: 'USD',
type: 'SELF_PACED',
lessonCount: 12,
exerciseCount: 6,
totalStudents: 4350,
duration: '3 hours',
tags: [
{ id: 'tag-1', name: 'Required', slug: 'required', color: '#dc2626' },
{ id: 'tag-2', name: 'Safety', slug: 'safety', color: '#16a34a' }
]
},
{
id: '2',
slug: 'data-privacy-gdpr',
title: 'Data Privacy & GDPR Compliance',
description:
'Comprehensive program covering data handling, privacy regulations, GDPR requirements, and breach response protocols.',
logo: 'https://images.unsplash.com/photo-1563013544-824ae1b704d3?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
cost: 49.0,
currency: 'USD',
type: 'SELF_PACED',
lessonCount: 18,
exerciseCount: 10,
totalStudents: 2800,
duration: '5 hours',
tags: [
{ id: 'tag-3', name: 'Compliance', slug: 'compliance', color: '#2563eb' },
{ id: 'tag-4', name: 'Privacy', slug: 'privacy', color: '#7c3aed' }
]
},
{
id: '3',
slug: 'anti-harassment-training',
title: 'Anti-Harassment & DEI Training',
description:
'State-mandated harassment prevention training with modules on inclusive workplace culture and bystander intervention.',
logo: 'https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
cost: 0,
currency: 'USD',
type: 'SELF_PACED',
lessonCount: 8,
exerciseCount: 4,
totalStudents: 6100,
duration: '2 hours',
tags: [
{ id: 'tag-5', name: 'Required', slug: 'required', color: '#dc2626' },
{ id: 'tag-6', name: 'HR', slug: 'hr', color: '#f59e0b' }
]
},
{
id: '4',
slug: 'information-security-certification',
title: 'Information Security Certification',
description:
'Prepare for SOC 2 and ISO 27001 audits with training on access controls, incident response, and security policies.',
logo: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80',
cost: 99.0,
currency: 'USD',
type: 'SELF_PACED',
lessonCount: 24,
exerciseCount: 12,
totalStudents: 1920,
duration: '8 hours',
tags: [
{ id: 'tag-7', name: 'Security', slug: 'security', color: '#0ea5e9' },
{ id: 'tag-8', name: 'Certification', slug: 'certification', color: '#64748b' }
],
metadata: {
showDiscount: true,
discount: 15
}
}
],
embed: {
title: 'How Automated Certification Works',
description:
'See how teams use ClassroomIO to assign programs, track progress, and generate certificates — automatically.',
code: `
<div style="width: 100%; max-width: 720px; margin: 0 auto; border-radius: 24px; border: 1px solid rgba(148,163,184,.35); background: linear-gradient(135deg, rgba(59,130,246,.10), rgba(15,23,42,.04)); padding: 32px; box-sizing: border-box;">
<div style="font-family: sans-serif;">
<p style="margin: 0 0 8px; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: #64748b;">Platform demo</p>
<h4 style="margin: 0 0 12px; font-size: 28px; line-height: 1.1; color: #0f172a;">Automate Your Certification Workflow</h4>
<p style="margin: 0; font-size: 16px; line-height: 1.7; color: #334155;">
Assign programs to teams, set deadlines, and let the platform handle reminders, grading, and certificate generation.
</p>
</div>
</div>
`,
secondaryAction: {
label: 'Request a Demo',
href: '#contact'
}
},
callout: {
heading: 'Ready to simplify compliance?',
description:
'Launch your first certification program in minutes. Automate tracking, reduce risk, and keep your team certified.',
action: { label: 'Start Free', href: '#courses' }
},
footerLinks: [
{ label: 'Terms of Service', href: '#terms' },
{ label: 'Privacy Policy', href: '#privacy' },
{ label: 'Support', href: '#support' }
],
footerText: '© 2026 Certifi Academy. All rights reserved.'
};
@@ -0,0 +1,25 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { MinimalLandingPage, BoldLandingPage, ClassicLandingPage } from '@cio/ui/custom/org-landing-page';
import { mockProps } from './fixtures';
const { Story } = defineMeta({
title: 'Templates/Org Landing Page',
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
</script>
<Story name="Minimal">
<MinimalLandingPage {...mockProps} />
</Story>
<Story name="Bold">
<BoldLandingPage {...mockProps} />
</Story>
<Story name="Classic">
<ClassicLandingPage {...mockProps} />
</Story>
+5
View File
@@ -63,6 +63,7 @@ Components in the `custom` directory come from various sources or are built on t
- `custom/editor/` - Rich text editor based on Edra/Tiptap
- `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
### Exercise question (`src/custom/exercise-question/`)
@@ -74,6 +75,10 @@ Learner and author UIs for exercise questions (take, preview, review, submission
**Types with heavier or distinct UIs** (textarea editor, file upload, matching/hotspot authoring, link lists) intentionally keep separate `take` / `preview` implementations until a second consumer (for example a dedicated `review` mode) justifies extracting more shared fragments.
### Question type picker (`src/custom/question-type-picker/`)
Marketing / demo widget: left-hand list of question types and a live **take**-mode preview using `ExerciseQuestion.QuestionRenderer`. Copy is English-only (no dashboard i18n). Also consumed by the **`@cio/embeds`** app as a CDN bundle (`apps/embeds`).
### Hooks (`src/hooks/`)
Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components.
+4 -1
View File
@@ -78,6 +78,9 @@
"tiptap-extension-auto-joiner": "^0.1.3"
},
"peerDependencies": {
"bits-ui": "^2.14.4"
"bits-ui": "^2.14.4",
"@lucide/svelte": "^0.545.0 || ^0.562.0",
"svelte-inview": "^4.0.4",
"svelte-motion": "^0.12.2"
}
}
@@ -2,12 +2,21 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DialogPrimitive.DescriptionProps = $props();
let {
ref = $bindable(null),
class: className,
child,
children,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('ui:text-muted-foreground ui:text-sm', className)}
{child}
{...restProps}
/>
>
{@render children?.()}
</DialogPrimitive.Description>
@@ -2,12 +2,15 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DialogPrimitive.TitleProps = $props();
let { ref = $bindable(null), class: className, child, children, ...restProps }: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn('ui:text-lg ui:font-semibold ui:leading-none', className)}
{child}
{...restProps}
/>
>
{@render children?.()}
</DialogPrimitive.Title>
@@ -1,7 +1,9 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
let { ref = $bindable(null), child, children, ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {child} {...restProps}>
{@render children?.()}
</DialogPrimitive.Trigger>
+2 -1
View File
@@ -7,7 +7,8 @@
variant: {
default: 'ui:bg-transparent',
outline: 'ui:border-border',
muted: 'ui:bg-muted/50'
muted: 'ui:bg-muted/50',
'muted-border': 'ui:border-border ui:bg-muted/50'
},
size: {
default: 'ui:gap-4 ui:p-4',
+5 -1
View File
@@ -16,7 +16,11 @@
<div
bind:this={ref}
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-white', className)}
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',
className
)}
{...restProps}
>
<div class="ui:flex ui:items-start ui:justify-between ui:gap-4 ui:flex-col ui:md:flex-row">
@@ -0,0 +1,62 @@
<script lang="ts">
import { Motion, AnimatePresence } from 'svelte-motion';
import { inview } from 'svelte-inview';
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
duration?: number;
delay?: number;
yOffset?: number;
inViewMargin?: string;
blur?: string;
once?: boolean;
class?: string;
children?: Snippet;
}
let {
duration = 0.4,
delay = 0,
yOffset = 6,
inViewMargin = '-50px',
blur = '6px',
once = false,
class: className = '',
children
}: Props = $props();
const id = crypto.randomUUID().slice(0, 8);
let isInView = $state<'hidden' | 'visible'>('hidden');
const defaultVariants = $derived({
hidden: { opacity: 0, y: yOffset, filter: `blur(${blur})` },
visible: { opacity: 1, y: 0, filter: `blur(0px)` }
});
</script>
<AnimatePresence let:item list={[{ key: id }]}>
<Motion
initial="hidden"
animate={isInView}
exit="hidden"
variants={defaultVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: 'easeOut'
}}
let:motion
>
<div
use:inview={{ rootMargin: inViewMargin, unobserveOnEnter: once }}
use:motion
oninview_change={({ detail }) => {
isInView = detail.inView ? 'visible' : 'hidden';
}}
class={cn(className)}
>
{@render children?.()}
</div>
</Motion>
</AnimatePresence>
@@ -0,0 +1 @@
export { default as BlurFade } from './blurfade.svelte';
@@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '../../../tools';
import { Motion } from 'svelte-motion';
import type { Snippet } from 'svelte';
interface Props {
variant?: {
hidden: { filter: string; opacity: number };
visible: { filter: string; opacity: number };
};
duration?: number;
class?: string;
children?: Snippet;
}
let {
variant,
duration = 1,
class: className = 'text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-7xl md:leading-20',
children
}: Props = $props();
const defaultVariants = {
hidden: { filter: 'blur(10px)', opacity: 0 },
visible: { filter: 'blur(0px)', opacity: 1 }
};
const combinedVariants = $derived(variant ?? defaultVariants);
</script>
<Motion initial="hidden" animate="visible" transition={{ duration }} variants={combinedVariants} let:motion>
<h1 class={cn(className)} use:motion>
{@render children?.()}
</h1>
</Motion>
@@ -0,0 +1 @@
export { default as BlurIn } from './blurin.svelte';
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '../../../tools';
interface Props {
size?: number;
duration?: number;
anchor?: number;
borderWidth?: number;
colorFrom?: string;
colorTo?: string;
delay?: number;
class?: string;
}
let {
size = 200,
duration = 15,
anchor = 90,
borderWidth = 1.5,
colorFrom = '#ffaa40',
colorTo = '#9c40ff',
delay = 0,
class: className = ''
}: Props = $props();
</script>
<div
style:--border-width={borderWidth}
style:--size={size}
style:--color-from={colorFrom}
style:--color-to={colorTo}
style:--delay="{delay}s"
style:--anchor={anchor}
style:--duration={duration}
class={cn('cio-marketing-border-beam', className)}
></div>
@@ -0,0 +1 @@
export { default as BorderBeam } from './border-beam.svelte';
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children: Snippet;
}
let { children, className }: Props = $props();
</script>
<div class={cn('p-4 text-left', className)}>
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '../../../tools';
import Ellipses from './ellipses.svelte';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children?: Snippet;
}
let { className, children }: Props = $props();
</script>
<div
class={cn(
'relative mx-auto w-full border border-dashed border-zinc-300 px-8 shadow-sm sm:px-6 md:px-8 dark:border-zinc-800 dark:bg-[#09090B]',
className
)}
>
<div class="absolute top-4 left-0 z-0 h-px w-full bg-zinc-400 sm:top-6 md:top-8 dark:bg-zinc-700"></div>
<div class="absolute bottom-4 left-0 z-0 h-px w-full bg-zinc-400 sm:bottom-6 md:bottom-8 dark:bg-zinc-700"></div>
<div class="relative w-full border-x border-zinc-400 dark:border-zinc-700">
<Ellipses />
<div class="relative z-20 mx-auto py-8">
{@render children?.()}
</div>
</div>
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
const sharedClass =
'rounded-full outline outline-8 dark:outline-gray-950 sm:my-6 md:my-8 size-1 my-4 outline-gray-50 bg-green-400';
</script>
<div class="absolute z-0 grid h-full w-full items-center gap-8 lg:grid-cols-2">
<section class="absolute z-0 grid h-full w-full grid-cols-2 place-content-between">
<div class={`${sharedClass} -mx-[2.5px]`}></div>
<div class={`${sharedClass} -mx-[2px] place-self-end`}></div>
<div class={`${sharedClass} -mx-[2.5px]`}></div>
<div class={`${sharedClass} -mx-[2px] place-self-end`}></div>
</section>
</div>
@@ -0,0 +1,2 @@
export { default as CardContainer } from './container.svelte';
export { default as CardBody } from './body.svelte';
@@ -0,0 +1,45 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { SVGAttributes } from 'svelte/elements';
interface Props extends SVGAttributes<SVGSVGElement> {
width?: string | number;
height?: string | number;
x?: number;
y?: number;
cx?: number;
cy?: number;
cr?: number;
fillColor?: string;
class?: string;
}
let {
width = 16,
height = 16,
x = 0,
y = 0,
cx = 1,
cy = 1,
cr = 1,
fillColor = 'rgb(163 163 163 / 0.8)',
class: className = '',
...restProps
}: Props = $props();
const patternId = crypto.randomUUID().toString().slice(0, 10);
</script>
<svg
aria-hidden="true"
class={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
{...restProps}
fill={fillColor}
>
<defs>
<pattern id={patternId} {width} {height} patternUnits="userSpaceOnUse" patternContentUnits="userSpaceOnUse" {x} {y}>
<circle id="pattern-circle" {cx} {cy} r={cr} />
</pattern>
</defs>
<rect width="100%" height="100%" stroke-width={0} fill={`url(#${patternId})`} />
</svg>
@@ -0,0 +1 @@
export { default as DotPattern } from './dot-pattern.svelte';
@@ -0,0 +1,57 @@
<script lang="ts">
import { cn } from '../../../tools';
import { AnimatePresence, Motion } from 'svelte-motion';
interface Props {
class?: string;
/** Applied to the flex row wrapper (e.g. alignment). */
containerClass?: string;
words?: string;
duration?: number;
delayMultiple?: number;
framerProps?: {
hidden: { opacity: number; x: number };
visible: { opacity: number; x: number };
};
}
let {
class: className = 'drop-shadow-sm',
containerClass = '',
words = 'Gradual Spacing',
duration = 0.5,
delayMultiple = 0.04,
framerProps = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 }
}
}: Props = $props();
const wordsspilit = $derived(words.split(''));
</script>
<div class={cn('flex justify-center space-x-1', containerClass)}>
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
{#each wordsspilit as char, i}
<Motion
initial="hidden"
animate="visible"
exit="hidden"
variants={framerProps}
transition={{
duration,
delay: i * delayMultiple
}}
let:motion
>
<span use:motion class={cn(className)}>
{#if char === ' '}
<span>&nbsp;</span>
{:else}
{char}
{/if}
</span>
</Motion>
{/each}
</AnimatePresence>
</div>
@@ -0,0 +1 @@
export { default as GradualSpacing } from './gradual-spacing.svelte';
@@ -0,0 +1,171 @@
<script lang="ts">
import { AnimatePresence, Motion } from 'svelte-motion';
import { Play, X } from '@lucide/svelte';
type AnimationStyle =
| 'from-bottom'
| 'from-center'
| 'from-top'
| 'from-left'
| 'from-right'
| 'fade'
| 'top-in-bottom-out'
| 'left-in-right-out';
interface Props {
animationStyle?: AnimationStyle;
videoSrc: string;
thumbnailSrc: string;
thumbnailAlt?: string;
iconColor?: string;
}
let {
animationStyle = 'from-center',
videoSrc,
thumbnailSrc,
thumbnailAlt = 'Video thumbnail',
iconColor = 'white'
}: Props = $props();
let isVideoOpen = $state(false);
let isCloseHovered = $state(false);
let isPlayHovered = $state(false);
function openVideo() {
isVideoOpen = true;
}
function closeVideo() {
isVideoOpen = false;
}
const animationVariants: Record<
AnimationStyle,
{ initial: Record<string, number>; animate: Record<string, number>; exit: Record<string, number> }
> = {
'from-bottom': {
initial: { y: 100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 100, opacity: 0 }
},
'from-center': {
initial: { scale: 0.5, opacity: 0 },
animate: { scale: 1, opacity: 1 },
exit: { scale: 0.5, opacity: 0 }
},
'from-top': {
initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -100, opacity: 0 }
},
'from-left': {
initial: { x: -100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -100, opacity: 0 }
},
'from-right': {
initial: { x: 100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 100, opacity: 0 }
},
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 }
},
'top-in-bottom-out': {
initial: { y: -100, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 200, opacity: 0 }
},
'left-in-right-out': {
initial: { x: -100, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 200, opacity: 0 }
}
};
const selectedAnimation = $derived(animationVariants[animationStyle]);
</script>
<div class="relative">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="relative cursor-pointer" onclick={openVideo} role="presentation">
<img
src={thumbnailSrc}
alt={thumbnailAlt}
width={1920}
height={1080}
class="w-full rounded-2xl transition-all duration-200"
/>
<div class="absolute inset-0 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex size-24 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-md transition-transform duration-300 ease-out"
onmouseenter={() => (isPlayHovered = true)}
onmouseleave={() => (isPlayHovered = false)}
role="presentation"
>
<div
class="relative flex size-20 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-2xl transition-all duration-300 ease-out"
>
<span
class="inline-flex size-8 items-center justify-center transition-transform duration-300 ease-out"
style:transform={`scale(${isPlayHovered ? 1.1 : 1})`}
style:color={iconColor}
>
<Play strokeWidth={1.7} class="size-full" />
</span>
</div>
</div>
</div>
</div>
<AnimatePresence let:item list={[{ key: isVideoOpen }]}>
{#if item.key}
<Motion initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} let:motion>
<div use:motion class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md">
<Motion
initial={selectedAnimation.initial}
animate={selectedAnimation.animate}
exit={selectedAnimation.exit}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
let:motion
>
<div use:motion class="relative mx-4 aspect-video w-full max-w-4xl md:mx-0">
<Motion let:motion whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<button
type="button"
use:motion
class="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 backdrop-blur-md"
onclick={closeVideo}
onmouseenter={() => (isCloseHovered = true)}
onmouseleave={() => (isCloseHovered = false)}
>
<X class="size-5 text-white" />
</button>
</Motion>
<Motion animate={{ scale: isCloseHovered ? 0.98 : 1 }} transition={{ duration: 0.2 }} let:motion>
<div
use:motion
class="relative isolate z-1 size-full overflow-hidden rounded-2xl border-2 border-white"
>
<!-- svelte-ignore a11y_missing_attribute -->
<iframe
src={videoSrc}
class="size-full rounded-2xl"
allowfullscreen
title="Video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
></iframe>
</div>
</Motion>
</div>
</Motion>
</div>
</Motion>
{/if}
</AnimatePresence>
</div>
@@ -0,0 +1 @@
export { default as HeroVideoDialog } from './hero-video-dialog.svelte';
+13
View File
@@ -0,0 +1,13 @@
export * from './border-beam';
export * from './dot-pattern';
export * from './card';
export * from './blurfade';
export * from './blurin';
export * from './meteors';
export * from './shimmerbutton';
export * from './sparkle';
export * from './hero-video-dialog';
export * from './shinny-text';
export * from './magic-card';
export * from './gradual-spacing';
export * from './square-card';
@@ -0,0 +1 @@
export { default as MagicCard } from './magic-card.svelte';
@@ -0,0 +1,78 @@
<script lang="ts">
import { Motion, useMotionValue, useMotionTemplate } from 'svelte-motion';
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
gradientSize?: number;
gradientColor?: string;
gradientOpacity?: number;
class?: string;
children?: Snippet;
}
let {
gradientSize = 200,
gradientColor = '#262626',
gradientOpacity = 0.8,
class: className = '',
children
}: Props = $props();
const gradSize = useMotionValue(200);
const gradColor = useMotionValue('#262626');
const mouseX = useMotionValue(-200);
const mouseY = useMotionValue(-200);
$effect(() => {
gradSize.set(gradientSize);
gradColor.set(gradientColor);
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
});
function handleMouseMove(e: MouseEvent) {
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
}
function handleMouseLeave() {
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}
const bg = useMotionTemplate`radial-gradient(${gradSize}px circle at ${mouseX}px ${mouseY}px, ${gradColor}, transparent 100%)`;
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
class={cn(
'group relative flex size-full justify-center overflow-hidden rounded-xl border bg-neutral-100 py-4 text-black dark:bg-neutral-900 dark:text-white',
className
)}
>
<div class="relative z-10">
{#if children}
{@render children()}
{:else}
<div class="flex h-full items-center justify-center text-center">
<p class="text-2xl">Magic Card</p>
</div>
{/if}
</div>
<Motion
style={{
background: bg,
opacity: gradientOpacity
}}
let:motion
>
<div
use:motion
class="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
</Motion>
</div>
@@ -0,0 +1,81 @@
/* Shared by @cio/ui index.css and course-app (no Tailwind prefix; no global theme reset). */
@keyframes border-beam {
100% {
offset-distance: 100%;
}
}
@keyframes meteor {
0% {
transform: rotate(215deg) translateX(0);
opacity: 1;
}
70% {
opacity: 1;
}
100% {
transform: rotate(215deg) translateX(-500px);
opacity: 0;
}
}
@keyframes shimmer-marketing {
0%,
90%,
100% {
background-position: calc(-100% - var(--shimmer-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shimmer-width)) 0;
}
}
@keyframes spin-around {
0% {
transform: translateZ(0) rotate(0);
}
15%,
35% {
transform: translateZ(0) rotate(90deg);
}
65%,
85% {
transform: translateZ(0) rotate(270deg);
}
100% {
transform: translateZ(0) rotate(360deg);
}
}
@keyframes magicslide {
to {
transform: translate(calc(100cqw - 100%), 0);
}
}
@layer components {
.cio-marketing-border-beam {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
border: calc(var(--border-width) * 1px) solid transparent;
mask: linear-gradient(transparent, transparent), linear-gradient(white, white);
mask-clip: padding-box, border-box;
mask-composite: intersect;
}
.cio-marketing-border-beam::after {
content: '';
position: absolute;
aspect-ratio: 1;
width: calc(var(--size) * 1px);
animation-delay: var(--delay);
background: linear-gradient(to left, var(--color-from), var(--color-to), transparent);
offset-anchor: calc(var(--anchor) * 1%) 50%;
offset-path: rect(0 auto auto 0 round calc(var(--size) * 1px));
animation: border-beam calc(var(--duration) * 1s) infinite linear;
}
}
@@ -0,0 +1 @@
export { default as Meteors } from './meteors.svelte';
@@ -0,0 +1,48 @@
<script lang="ts">
import { cn } from '../../../tools';
interface Props {
number?: number;
}
let { number = 10 }: Props = $props();
type MeteorStyle = {
top: number;
left: string;
animationDelay: string;
animationDuration: string;
};
let meteorStyles = $state<MeteorStyle[]>([]);
function changeMeteors(num: number) {
meteorStyles = [...Array(num)].map(() => ({
// Keep within typical card heights so parents with overflow-hidden still show meteors
top: Math.floor(Math.random() * 160) + 8,
left: Math.floor(Math.random() * 100) + '%',
animationDelay: Math.random() * 1 + 0.2 + 's',
animationDuration: Math.floor(Math.random() * 8 + 2.9) + 's'
}));
}
$effect(() => {
const count = number;
if (typeof window === 'undefined') return;
changeMeteors(count);
});
</script>
{#each meteorStyles as style, idx}
<span
id="meteor-{idx + 1}"
class={cn(
'ui:animate-meteor ui:pointer-events-none ui:absolute ui:size-[2.4px] ui:origin-center ui:rounded-full ui:bg-slate-500 ui:shadow-[0_0_0_1px_#ffffff10]'
)}
style="top: {style.top}px; left: {style.left}; animation-delay: {style.animationDelay}; animation-duration: {style.animationDuration};"
>
<div
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:-z-10 ui:h-px ui:w-[50px] ui:-translate-y-1/2 ui:bg-linear-to-r ui:from-slate-500 ui:via-blue-600/30 ui:to-transparent"
></div>
</span>
{/each}
@@ -0,0 +1 @@
export { default as ShimmerButton } from './shimmerbutton.svelte';
@@ -0,0 +1,67 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
href: string;
target?: string;
rel?: string;
shimmerColor?: string;
shimmerSize?: string;
shimmerDuration?: string;
borderRadius?: string;
background?: string;
class?: string;
children?: Snippet;
}
let {
href,
target = '',
rel = '',
shimmerColor = '#ffffff',
shimmerSize = '0.05em',
shimmerDuration = '3s',
borderRadius = '100px',
background = 'rgba(0, 0, 0, 1)',
class: className = '',
children
}: Props = $props();
</script>
<a
style:--spread="90deg"
style:--shimmer-color={shimmerColor}
style:--radius={borderRadius}
style:--speed={shimmerDuration}
style:--cut={shimmerSize}
style:--bg={background}
class={cn(
'group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden rounded-(--radius) border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)] dark:text-white',
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px',
className
)}
{href}
{target}
{rel}
data-rewardful
>
<div class={cn('-z-30 blur-[2px]', '@container-[size] absolute inset-0 overflow-visible')}>
<div class="animate-magicslide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
<div
class="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
></div>
</div>
</div>
{@render children?.()}
<div
class={cn(
'absolute inset-0 h-full w-full',
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
'transform-gpu transition-all duration-300 ease-in-out',
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
)}
></div>
<div class={cn('absolute inset-(--cut) -z-20 rounded-(--radius) [background:var(--bg)]')}></div>
</a>
@@ -0,0 +1 @@
export { default as ShinnyText } from './shinny-text.svelte';
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
shimmerWidth?: number;
class?: string;
children?: Snippet;
}
let { shimmerWidth = 100, class: className, children }: Props = $props();
</script>
<p
style:--shimmer-width="{shimmerWidth}px"
class={cn(
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50',
'animate-shimmer bg-size-[var(--shimmer-width)_100%] bg-clip-text bg-position-[0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
'bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
className
)}
>
{@render children?.()}
</p>
@@ -0,0 +1 @@
export { default as Sparkle } from './sparkle-text.svelte';
@@ -0,0 +1,34 @@
<script lang="ts">
import { Motion } from 'svelte-motion';
interface Props {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
}
let { id, x, y, color, delay, scale }: Props = $props();
</script>
<Motion
initial={{ opacity: 0, left: x, top: y }}
animate={{
opacity: [0, 1, 0],
scale: [0, scale, 0],
rotate: [75, 120, 150]
}}
transition={{ duration: 0.8, repeat: Infinity, delay }}
let:motion
>
<div {id} use:motion class="pointer-events-none absolute z-20" style:left={x} style:top={y}>
<svg width="21" height="21" viewBox="0 0 21 21" class="block">
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={color}
/>
</svg>
</div>
</Motion>
@@ -0,0 +1,72 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import SparkleParticle from './sparkle-particle.svelte';
interface TSparkle {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
lifespan: number;
}
interface Props {
text?: string;
colors?: { first: string; second: string };
sparklesCount?: number;
class?: string;
}
let {
text = 'Hello World',
colors = { first: '#9E7AFF', second: '#FE8BBB' },
sparklesCount = 5,
class: className = ''
}: Props = $props();
let sparkles = $state<TSparkle[]>([]);
function generateStar(): TSparkle {
const starX = `${Math.random() * 100}%`;
const starY = `${Math.random() * 100}%`;
const color = Math.random() > 0.5 ? colors.first : colors.second;
const delay = Math.random() * 4;
const scale = Math.random() * 1 + 0.3;
const lifespan = Math.random() * 10 + 5;
const id = `${starX}-${starY}-${Date.now()}`;
return { id, x: starX, y: starY, color, delay, scale, lifespan };
}
function initializeStars() {
sparkles = Array.from({ length: sparklesCount }, generateStar);
}
function updateStars() {
sparkles = sparkles.map((star) => {
if (star.lifespan <= 0) {
return generateStar();
}
return { ...star, lifespan: star.lifespan - 0.1 };
});
}
let interval: ReturnType<typeof setInterval>;
onMount(() => {
initializeStars();
interval = setInterval(updateStars, 100);
});
onDestroy(() => clearInterval(interval));
</script>
<div style:--sparkles-first-color="{colors.first};" style:--sparkles-second-color="{colors.second};" class={className}>
<span class="relative inline-block">
{#each sparkles as item}
<SparkleParticle {...item} />
{/each}
<strong>{text}</strong>
</span>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { Snippet } from 'svelte';
interface Props {
className?: string;
children: Snippet;
}
let { children, className }: Props = $props();
</script>
<div class={cn('text-left', className)}>
{@render children()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '../../../tools';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<SVGElement> {
class?: string;
}
let { class: className = '', ...rest }: Props = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={24}
height={24}
stroke-width="1"
stroke-linecap="round"
stroke="currentColor"
class={cn('absolute text-black dark:text-white', className)}
{...rest}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
@@ -0,0 +1,2 @@
export { default as SquareCardBody } from './body.svelte';
export { default as SquareCardIcon } from './icon.svelte';
@@ -0,0 +1 @@
export const DEFAULT_COURSE_BANNER_IMAGE = '/images/classroomio-course-img-template.jpg';
@@ -0,0 +1,143 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '../../tools';
import { Badge } from '../../base/badge';
import { Separator } from '../../base/separator';
import * as Item from '../../base/item';
import { DEFAULT_COURSE_BANNER_IMAGE } from './constants';
interface Props {
href?: string;
title: string;
description: string;
/** Used when `media` snippet is not provided */
bannerImage?: string;
bannerAlt?: string;
typeBadge?: {
label: string;
icon: Component;
iconClass?: string;
};
class?: string;
/** Custom banner area (e.g. optimized `Image`); overrides `bannerImage` */
media?: Snippet;
/** Optional overlay inside the link (e.g. dropdown), typically absolutely positioned */
overlay?: Snippet;
tags?: Snippet;
footer?: Snippet;
}
let {
href,
title,
description,
bannerImage,
bannerAlt = 'Course banner image',
typeBadge,
class: className = '',
media,
overlay,
tags,
footer
}: Props = $props();
const resolvedBannerImage = $derived(bannerImage?.trim() ? bannerImage : DEFAULT_COURSE_BANNER_IMAGE);
</script>
<Item.Root variant="outline" class={cn('ui:group ui:relative ui:max-w-[320px] ui:p-3!', className)}>
{#snippet child({ props })}
{#if href}
<a {href} {...props} class={cn('ui:block', props.class as string)}>
{@render overlay?.()}
<Item.Header>
<Item.Media variant="image" class="ui:relative ui:h-50! ui:w-full!">
{#if media}
{@render media()}
{:else}
<img
src={resolvedBannerImage}
alt={bannerAlt}
class="ui:w-full ui:h-full ui:rounded-sm ui:object-cover"
/>
{/if}
{#if typeBadge}
{@const Icon = typeBadge.icon}
<Badge
class="ui:absolute ui:bottom-2 ui:left-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:capitalize"
variant="secondary"
>
<Icon class={typeBadge.iconClass} />
{typeBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="ui:flex ui:w-full ui:min-h-14 ui:items-center ui:text-base!">
<span class="ui:line-clamp-2">{title}</span>
</Item.Title>
<Item.Description class="ui:min-h-[63px]">{description}</Item.Description>
{#if tags}
{@render tags()}
{/if}
{#if footer}
<Separator class="ui:my-3" />
{@render footer()}
{/if}
</Item.Content>
</a>
{:else}
<div {...props} class={cn('ui:block', props.class as string)}>
{@render overlay?.()}
<Item.Header>
<Item.Media variant="image" class="ui:relative ui:h-50! ui:w-full!">
{#if media}
{@render media()}
{:else}
<img
src={resolvedBannerImage}
alt={bannerAlt}
class="ui:w-full ui:h-full ui:rounded-sm ui:object-cover"
/>
{/if}
{#if typeBadge}
{@const Icon = typeBadge.icon}
<Badge
class="ui:absolute ui:bottom-2 ui:left-2 ui:z-10 ui:flex ui:items-center ui:rounded-md! ui:capitalize"
variant="secondary"
>
<Icon class={typeBadge.iconClass} />
{typeBadge.label}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<Item.Content>
<Item.Title class="ui:flex ui:w-full ui:min-h-14 ui:items-center ui:text-base!">
<span class="ui:line-clamp-2">{title}</span>
</Item.Title>
<Item.Description class="ui:min-h-[63px]">{description}</Item.Description>
{#if tags}
{@render tags()}
{/if}
{#if footer}
<Separator class="ui:my-3" />
{@render footer()}
{/if}
</Item.Content>
</div>
{/if}
{/snippet}
</Item.Root>
@@ -0,0 +1,2 @@
export { default as CourseCard } from './course-card.svelte';
export { DEFAULT_COURSE_BANNER_IMAGE } from './constants';
@@ -18,7 +18,9 @@ import { default as NumericEdit } from './renderers/numeric/edit.svelte';
import { default as OrderingEdit } from './renderers/ordering/edit.svelte';
import { default as RadioEdit } from './renderers/radio/edit.svelte';
import { default as LinkEdit } from './renderers/link/edit.svelte';
import { default as WordBankEdit } from './renderers/word-bank/edit.svelte';
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';
@@ -31,7 +33,9 @@ import { default as NumericTake } from './renderers/numeric/take.svelte';
import { default as OrderingTake } from './renderers/ordering/take.svelte';
import { default as RadioTake } from './renderers/radio/take.svelte';
import { default as LinkTake } from './renderers/link/take.svelte';
import { default as WordBankTake } from './renderers/word-bank/take.svelte';
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';
@@ -44,7 +48,9 @@ import { default as NumericPreview } from './renderers/numeric/preview.svelte';
import { default as OrderingPreview } from './renderers/ordering/preview.svelte';
import { default as RadioPreview } from './renderers/radio/preview.svelte';
import { default as LinkPreview } from './renderers/link/preview.svelte';
import { default as WordBankPreview } from './renderers/word-bank/preview.svelte';
import { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
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';
@@ -57,7 +63,9 @@ import { default as NumericSubmission } from './renderers/numeric/submission.sve
import { default as OrderingSubmission } from './renderers/ordering/submission.svelte';
import { default as RadioSubmission } from './renderers/radio/submission.svelte';
import { default as LinkSubmission } from './renderers/link/submission.svelte';
import { default as WordBankSubmission } from './renderers/word-bank/submission.svelte';
import { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
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';
@@ -66,6 +74,7 @@ import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
import { default as NumericReview } from './renderers/numeric/review.svelte';
import { default as ShortAnswerReview } from './renderers/short-answer/review.svelte';
import { default as FillBlankReview } from './renderers/fill-blank/review.svelte';
import { default as WordBankReview } from './renderers/word-bank/review.svelte';
import { default as OrderingReview } from './renderers/ordering/review.svelte';
import { default as TrueFalseReview } from './renderers/true-false/review.svelte';
@@ -150,6 +159,19 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
take: LinkTake,
preview: LinkPreview,
submission: LinkSubmission
},
[QUESTION_TYPE_KEY.WORD_BANK]: {
edit: WordBankEdit,
take: WordBankTake,
preview: WordBankPreview,
submission: WordBankSubmission,
review: WordBankReview
},
[QUESTION_TYPE_KEY.STAR]: {
edit: StarEdit,
take: StarTake,
preview: StarPreview,
submission: StarSubmission
}
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { ExternalLinkIcon, HoverableItem } from '../../../moving-icons';
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
@@ -35,15 +35,19 @@
{#each links as link, index}
<div class="ui:bg-background ui:rounded-md ui:border ui:p-2">
{#if isValidLink(link)}
<a
href={link}
target="_blank"
rel="noreferrer"
class="ui:text-primary ui:inline-flex ui:items-center ui:gap-2 ui:text-sm ui:underline"
>
<span>{formatLinkLabel(index)}</span>
<ExternalLinkIcon class="ui:size-3.5" />
</a>
<HoverableItem>
{#snippet children(isHovered)}
<a
href={link}
target="_blank"
rel="noreferrer"
class="ui:text-primary ui:inline-flex ui:items-center ui:gap-2 ui:text-sm ui:underline"
>
<span>{formatLinkLabel(index)}</span>
<ExternalLinkIcon {isHovered} size={14} class="ui:shrink-0" ariaHidden={true} />
</a>
{/snippet}
</HoverableItem>
<p class="ui:text-muted-foreground ui:mt-1 ui:text-xs">{link}</p>
{:else}
<p class="ui:text-sm">{link}</p>
@@ -0,0 +1,101 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import CircleQuestionMarkIcon from '@lucide/svelte/icons/circle-question-mark';
import { Input } from '../../../../base/input';
import { IconButton } from '../../../icon-button';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
}
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
function onCorrectValueChange(raw: string) {
const parsed = Number(raw);
if (raw.trim() === '' || Number.isNaN(parsed)) {
patchSettings({ correctValue: undefined });
return;
}
const clamped = Math.min(maxStars, Math.max(1, Math.floor(parsed)));
patchSettings({ correctValue: clamped });
}
function onMaxStarsChange(raw: string) {
const parsed = Number(raw);
if (raw.trim() === '' || Number.isNaN(parsed)) {
patchSettings({ maxStars: undefined });
return;
}
const nextMax = Math.min(10, Math.max(1, Math.floor(parsed)));
const rawCorrect = question.settings?.correctValue;
const currentCorrect = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
const nextCorrect = Number.isFinite(currentCorrect)
? Math.min(nextMax, Math.max(1, Math.floor(currentCorrect)))
: undefined;
if (nextCorrect !== undefined) {
patchSettings({ maxStars: nextMax, correctValue: nextCorrect });
return;
}
patchSettings({ maxStars: nextMax });
}
</script>
<div class="ui:space-y-3">
<div class="ui:grid ui:gap-3 ui:md:grid-cols-2">
<div class="ui:space-y-1">
<div class="ui:flex ui:items-center ui:gap-1">
<p class="ui:text-sm ui:font-medium">{label('star.edit.correct_value_label')}</p>
<IconButton type="button" class="ui:h-6 ui:w-6" {disabled} tooltip={label('star.edit.correct_value_info')}>
<CircleQuestionMarkIcon class="ui:size-3.5" />
<span class="ui:sr-only">{label('star.edit.correct_value_info')}</span>
</IconButton>
</div>
<Input
type="number"
min="1"
max={maxStars}
placeholder={label('star.edit.correct_value_placeholder')}
value={String((question.settings?.correctValue as number | undefined) ?? '')}
{disabled}
onchange={(event) => onCorrectValueChange(event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-1">
<div class="ui:flex ui:items-center ui:gap-1">
<p class="ui:text-sm ui:font-medium">{label('star.edit.max_stars_label')}</p>
<IconButton type="button" class="ui:h-6 ui:w-6" {disabled} tooltip={label('star.edit.max_stars_info')}>
<CircleQuestionMarkIcon class="ui:size-3.5" />
<span class="ui:sr-only">{label('star.edit.max_stars_info')}</span>
</IconButton>
</div>
<Input
type="number"
min="1"
max="10"
placeholder={label('star.edit.max_stars_placeholder')}
value={String((question.settings?.maxStars as number | undefined) ?? '')}
{disabled}
onchange={(event) => onMaxStarsChange(event.currentTarget.value)}
/>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
isValidStarRatingValue,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import StarRow from './star-row.svelte';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
const correctValue = $derived.by(() => {
const rawCorrect = question.settings?.correctValue;
const parsed = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
if (!isValidStarRatingValue(parsed, maxStars)) return 0;
return parsed;
});
</script>
<div class="ui:space-y-2">
<p class="ui:text-sm ui:font-medium">{label('star.preview.correct_label')}</p>
<StarRow
{maxStars}
value={correctValue}
disabled={true}
starLabel={(starIndex) =>
label('star.take.star_sr', 'Star {n} of {max}')
.replace('{n}', String(starIndex))
.replace('{max}', String(maxStars))}
/>
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import Star from '@lucide/svelte/icons/star';
let {
maxStars,
value,
disabled = false,
starLabel,
onPick
}: {
maxStars: number;
value: number;
disabled?: boolean;
starLabel: (starIndex: number) => string;
onPick?: (starIndex: number) => void;
} = $props();
const starIndices = $derived(Array.from({ length: maxStars }, (_, index) => index + 1));
</script>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1" role="group">
{#each starIndices as starIndex (starIndex)}
{#if onPick && !disabled}
<button
type="button"
class="ui:rounded-md ui:p-1 ui:text-amber-600 ui:transition-colors ui:hover:bg-amber-500/10 focus-visible:ui:ring-ring ui:outline-none focus-visible:ui:ring-2 ui:dark:text-amber-400"
aria-label={starLabel(starIndex)}
aria-pressed={value >= starIndex}
{disabled}
onclick={() => onPick(starIndex)}
>
<Star
class="ui:size-8 {starIndex <= value
? 'ui:fill-amber-400 ui:text-amber-500 ui:dark:fill-amber-400 ui:dark:text-amber-300'
: 'ui:fill-none ui:text-amber-200 ui:dark:text-amber-500/40'}"
/>
</button>
{:else}
<span class="ui:inline-flex ui:p-1" aria-hidden="true">
<Star
class="ui:size-8 {starIndex <= value
? 'ui:fill-amber-400 ui:text-amber-500 ui:dark:fill-amber-400 ui:dark:text-amber-300'
: 'ui:fill-none ui:text-amber-200 ui:dark:text-amber-500/40'}"
/>
</span>
{/if}
{/each}
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import GenericSubmission from '../submission-generic.svelte';
let {
question,
submissions = [],
labels,
maxSubmissionItems
}: Pick<ExerciseQuestionRendererProps, 'question' | 'submissions' | 'labels' | 'maxSubmissionItems'> = $props();
</script>
<GenericSubmission {question} {submissions} {labels} {maxSubmissionItems} />
@@ -0,0 +1,46 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
getStarRatingMaxFromSettings,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import StarRow from './star-row.svelte';
let {
question,
answer = null,
disabled = false,
labels,
onAnswerChange = () => {}
}: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const maxStars = $derived(getStarRatingMaxFromSettings(question.settings));
const selectedValue = $derived.by(() => {
if (answer?.type !== 'STAR') return 0;
const value = answer.value;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maxStars) return 0;
return value;
});
function starLabel(starIndex: number) {
return label('star.take.star_sr', 'Star {n} of {max}')
.replace('{n}', String(starIndex))
.replace('{max}', String(maxStars));
}
</script>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-sm">{label('star.take.helper')}</p>
<StarRow
{maxStars}
value={selectedValue}
{disabled}
{starLabel}
onPick={(starIndex) => onAnswerChange({ type: 'STAR', value: starIndex })}
/>
</div>
@@ -17,6 +17,8 @@
return [answer.text];
case 'NUMERIC':
return [String(answer.value)];
case 'STAR':
return [String(answer.value)];
case 'LINK':
return answer.urls;
case 'FILE_UPLOAD':
@@ -0,0 +1,142 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { Button } from '../../../../base/button';
import { Input } from '../../../../base/input';
import { Textarea } from '../../../../base/textarea';
import { IconButton } from '../../../icon-button';
import { countWordBankBlanks } from './word-bank-utils';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
}
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const distractors = $derived.by(() => {
const raw = question.settings?.distractors;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
function onTemplateInput(value: string) {
const nextBlankCount = countWordBankBlanks(value);
const prev = correctAnswers;
const nextCorrect: string[] = [];
for (let i = 0; i < nextBlankCount; i++) {
nextCorrect.push(prev[i] ?? '');
}
patchSettings({ template: value, correctAnswers: nextCorrect });
}
function setCorrectAnswerAt(index: number, value: string) {
const next = [...correctAnswers];
next[index] = value;
patchSettings({ correctAnswers: next });
}
function addDistractor() {
patchSettings({ distractors: [...distractors, ''] });
}
function setDistractorAt(index: number, value: string) {
const next = [...distractors];
next[index] = value;
patchSettings({ distractors: next });
}
function removeDistractorAt(index: number) {
patchSettings({ distractors: distractors.filter((_, i) => i !== index) });
}
</script>
<div class="ui:space-y-4">
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.template_label')}</p>
<Textarea
class="ui:w-full"
rows={4}
value={template}
placeholder={label('word_bank.edit.template_placeholder')}
{disabled}
onchange={(event) => onTemplateInput(event.currentTarget.value)}
/>
<p class="ui:text-muted-foreground ui:text-xs">
{label('word_bank.edit.template_helper')}
</p>
{#if blankCount === 0 && template.trim().length > 0}
<p class="ui:text-destructive ui:text-xs">{label('word_bank.edit.no_blanks_warning')}</p>
{/if}
</div>
{#if blankCount > 0}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.correct_answers_heading')}</p>
<div class="ui:flex ui:flex-col ui:gap-2">
{#each Array.from({ length: blankCount }, (_, i) => i) as blankIndex (blankIndex)}
<div class="ui:flex ui:flex-col ui:gap-1 sm:ui:flex-row sm:ui:items-center sm:ui:gap-3">
<span class="ui:text-muted-foreground ui:min-w-18 ui:text-sm"
>{label('word_bank.edit.blank_label')} {blankIndex + 1}</span
>
<Input
class="ui:flex-1"
value={correctAnswers[blankIndex] ?? ''}
placeholder={label('word_bank.edit.correct_placeholder')}
{disabled}
onchange={(event) => setCorrectAnswerAt(blankIndex, event.currentTarget.value)}
/>
</div>
{/each}
</div>
</div>
{/if}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.edit.distractors_heading')}</p>
<div class="ui:flex ui:flex-col ui:gap-2">
{#each distractors as _, distractorIndex (distractorIndex)}
<div class="ui:flex ui:items-center ui:gap-2">
<Input
class="ui:flex-1"
value={distractors[distractorIndex] ?? ''}
placeholder={label('word_bank.edit.distractor_placeholder')}
{disabled}
onchange={(event) => setDistractorAt(distractorIndex, event.currentTarget.value)}
/>
<IconButton
type="button"
class="ui:shrink-0 ui:text-muted-foreground"
{disabled}
tooltip={label('common.remove')}
onclick={() => removeDistractorAt(distractorIndex)}
>
<Trash2Icon class="ui:size-4" />
</IconButton>
</div>
{/each}
</div>
<Button type="button" variant="outline" size="sm" {disabled} onclick={addDistractor}>
<PlusIcon class="ui:mr-1 ui:size-4" />
{label('word_bank.edit.add_distractor')}
</Button>
</div>
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import { countWordBankBlanks, parseWordBankTemplate } from './word-bank-utils';
let { question, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
const segments = $derived(parseWordBankTemplate(template));
</script>
<div class="ui:space-y-2">
{#if blankCount === 0}
<p class="ui:text-muted-foreground ui:text-sm">
{label('word_bank.preview.filled_label')}: {label('common.not_set')}
</p>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">{label('word_bank.preview.filled_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
{@const expected = correctAnswers[segment.index]?.trim()}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-primary ui:bg-background ui:px-2 ui:py-0.5"
>
{expected || label('common.not_set')}
</span>
{/if}
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,81 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import LabeledValueRow from '../shared/labeled-value-row.svelte';
import { countWordBankBlanks, parseWordBankTemplate } from './word-bank-utils';
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const template = $derived(String((question.settings?.template as string | undefined) ?? ''));
const correctAnswers = $derived.by(() => {
const raw = question.settings?.correctAnswers;
return Array.isArray(raw) ? (raw as string[]).map((s) => String(s ?? '')) : [];
});
const blankCount = $derived(countWordBankBlanks(template));
const segments = $derived(parseWordBankTemplate(template));
const studentFilled = $derived(answer?.type === 'WORD_BANK' ? answer.filledBlanks : []);
function studentAtBlank(index: number): string {
const v = studentFilled[index];
return v != null && String(v).trim() ? String(v).trim() : label('common.not_set');
}
function correctAtBlank(index: number): string {
const v = correctAnswers[index];
return v != null && String(v).trim() ? String(v).trim() : label('common.not_set');
}
</script>
<div class="ui:space-y-3">
{#if blankCount === 0}
<p class="ui:text-muted-foreground ui:text-sm">{label('common.not_set')}</p>
{:else}
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.review.your_answer_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-muted-foreground/40 ui:px-2 ui:py-0.5"
>
{studentAtBlank(segment.index)}
</span>
{/if}
{/each}
</div>
</div>
<div class="ui:space-y-2">
<p class="ui:text-muted-foreground ui:text-xs">{label('word_bank.review.correct_label')}</p>
<div class="ui:bg-muted/30 ui:rounded-lg ui:border ui:p-4 ui:font-mono ui:text-sm ui:leading-relaxed">
{#each segments as segment, segmentIndex (segmentIndex)}
{#if segment.type === 'text'}
<span>{segment.value}</span>
{:else}
<span
class="ui:inline-flex ui:mx-0.5 ui:min-h-8 ui:min-w-[5rem] ui:items-center ui:justify-center ui:rounded ui:border-2 ui:border-solid ui:border-primary ui:px-2 ui:py-0.5"
>
{correctAtBlank(segment.index)}
</span>
{/if}
{/each}
</div>
</div>
<div class="ui:space-y-1">
{#each Array.from({ length: blankCount }, (_, i) => i) as blankIndex (blankIndex)}
<LabeledValueRow
label={`${label('word_bank.edit.blank_label')} ${blankIndex + 1}`}
value={`${studentAtBlank(blankIndex)} → ${correctAtBlank(blankIndex)}`}
/>
{/each}
</div>
{/if}
</div>

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