feat: add sequential progression and video watch lesson completion
Gate student content by completion order, track per-video watch progress with seek enforcement, and auto-complete lessons at the configured threshold. Show ring progress in the lesson header, keep watch progress on rewatch, and sync progression locks client-side so the next item unlocks after completion.
This commit is contained in:
@@ -52,8 +52,8 @@ export const DEFAULT_CERTIFICATE_DESIGN: CertificateDesign = {
|
||||
accentColor: DEFAULT_ACCENT_COLOR,
|
||||
subtitle: 'Awarded with Highest Distinction',
|
||||
signatories: [
|
||||
{ name: 'Course Facilitator', role: 'Facilitator' },
|
||||
{ name: 'Organization Lead', role: 'Director' }
|
||||
{ name: 'Course Facilitator', role: 'Facilitator', enabled: true },
|
||||
{ name: 'Organization Lead', role: 'Director', enabled: true }
|
||||
],
|
||||
idFormat: 'N° {seq}'
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, type TemplateRenderer } from './shared';
|
||||
import { escapeHtml, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
|
||||
|
||||
export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -38,14 +38,8 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
</div>
|
||||
<div class="stamp">Verified</div>
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="lbl">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="lbl">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -200,6 +194,7 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
z-index: 3;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
|
||||
import { escapeHtml, getYear, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
|
||||
|
||||
export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -20,19 +20,13 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="recipient">${escapeHtml(data.recipientName)}</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="footer">
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryOne.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
<div class="seal">
|
||||
<div class="star">★</div>
|
||||
<div class="yr">${getYear(data.date)}</div>
|
||||
<div class="lbl">${escapeHtml(data.certificateId)}</div>
|
||||
</div>
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -140,6 +134,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
gap: 40px;
|
||||
}
|
||||
.t-classique .sig {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border-top: 1px solid ${accent};
|
||||
padding-top: 6px;
|
||||
@@ -190,6 +185,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
letter-spacing: 0.2em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { escapeHtml, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
renderFooterMetaBlock,
|
||||
renderSignatoryBlock,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -8,7 +14,6 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
|
||||
const body = `
|
||||
<div class="cert t-minimal">
|
||||
<span class="accent-bar" aria-hidden="true"></span>
|
||||
<div class="top">
|
||||
<span>${escapeHtml(data.orgName)}</span>
|
||||
<span>${escapeHtml(data.certificateId)} · ${escapeHtml(data.date)}</span>
|
||||
@@ -23,22 +28,10 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">Issued</div>
|
||||
<div class="v">${escapeHtml(data.date)}</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
<div class="k">Reference</div>
|
||||
<div class="v">${escapeHtml(data.certificateId)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
|
||||
${renderFooterMetaBlock('Reference', data.certificateId, { wrapperClass: 'ref', labelClass: 'k', valueClass: 'v' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -52,14 +45,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.t-minimal .accent-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
background: ${accent};
|
||||
border-left: 12px solid ${accent};
|
||||
}
|
||||
.t-minimal .top {
|
||||
display: flex;
|
||||
@@ -145,11 +131,20 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
.t-minimal .footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #0a0a0a;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.t-minimal .footer .sig-content,
|
||||
.t-minimal .footer .sig-text {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.t-minimal .footer .sig-img-slot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.t-minimal .footer .k {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.25em;
|
||||
@@ -165,6 +160,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
}
|
||||
.t-minimal .footer .ref .k { color: ${accent}; }
|
||||
.t-minimal .footer .ref .v { color: ${accent}; }
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { escapeHtml, getYear, shadeColor, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
getYear,
|
||||
renderSignatoryBlock,
|
||||
shadeColor,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -27,18 +34,12 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="recipient">${escapeHtml(data.recipientName)}</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="footer">
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryOne.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
<div class="medal">
|
||||
<div class="yr">${getYear(data.date)}</div>
|
||||
<div class="lbl">★ AWARD ★</div>
|
||||
</div>
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -158,6 +159,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
gap: 40px;
|
||||
}
|
||||
.t-noir .sig {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border-top: 1px solid ${accent};
|
||||
padding-top: 6px;
|
||||
@@ -206,6 +208,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
getYear,
|
||||
renderFooterMetaBlock,
|
||||
renderSignatoryBlock,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -26,18 +33,9 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="bottom">
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">Issued</div>
|
||||
<div class="v">${escapeHtml(data.date)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
|
||||
</div>
|
||||
</div>
|
||||
<div class="corner-num">${getYear(data.date)}</div>
|
||||
@@ -162,11 +160,20 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 24px;
|
||||
border-top: 2px solid #1a1a1a;
|
||||
padding-top: 18px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.t-poster .bottom .sig-content,
|
||||
.t-poster .bottom .sig-text {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.t-poster .bottom .sig-img-slot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.t-poster .bottom .k {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
@@ -192,6 +199,7 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
z-index: 3;
|
||||
line-height: 0.8;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CertificateDesign, CertificateRenderData } from '../types';
|
||||
import type { CertificateDesign, CertificateRenderData, CertificateSignatory } from '../types';
|
||||
|
||||
export function escapeHtml(input: unknown): string {
|
||||
return String(input ?? '').replace(/[&<>"']/g, (char) => {
|
||||
@@ -53,12 +53,100 @@ export interface TemplateRenderOutput {
|
||||
|
||||
export type TemplateRenderer = (args: TemplateRenderArgs) => TemplateRenderOutput;
|
||||
|
||||
export interface SignatoryBlockOptions {
|
||||
wrapperClass?: string;
|
||||
nameClass: string;
|
||||
roleClass: string;
|
||||
roleFirst?: boolean;
|
||||
}
|
||||
|
||||
export function renderSignatoryBlock(sig: CertificateSignatory, options: SignatoryBlockOptions): string {
|
||||
if (sig.enabled === false) return '';
|
||||
|
||||
const sigImg = sig.signatureUrl ? `<img class="sig-img" src="${escapeHtml(sig.signatureUrl)}" alt="" />` : '';
|
||||
|
||||
const nameEl = `<div class="${options.nameClass}">${escapeHtml(sig.name)}</div>`;
|
||||
const roleEl = `<div class="${options.roleClass}">${escapeHtml(sig.role)}</div>`;
|
||||
const content = options.roleFirst ? `${roleEl}${nameEl}` : `${nameEl}${roleEl}`;
|
||||
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
|
||||
|
||||
return `
|
||||
<div${wrapperClass}>
|
||||
<div class="sig-content">
|
||||
<div class="sig-img-slot">${sigImg}</div>
|
||||
<div class="sig-text">${content}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface FooterMetaBlockOptions {
|
||||
wrapperClass?: string;
|
||||
labelClass: string;
|
||||
valueClass: string;
|
||||
}
|
||||
|
||||
export function renderFooterMetaBlock(label: string, value: string, options: FooterMetaBlockOptions): string {
|
||||
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
|
||||
|
||||
return `
|
||||
<div${wrapperClass}>
|
||||
<div class="sig-content">
|
||||
<div class="sig-img-slot"></div>
|
||||
<div class="sig-text">
|
||||
<div class="${options.labelClass}">${escapeHtml(label)}</div>
|
||||
<div class="${options.valueClass}">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const SIGNATURE_IMAGE_STYLES = `
|
||||
.sig-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.sig-img-slot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sig-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.sig-img {
|
||||
display: block;
|
||||
max-height: 48px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FONTS_LINK_HREF =
|
||||
'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Cinzel:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Bodoni+Moda:ital,wght@0,400;0,700;1,400&family=Archivo+Black&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;700&display=swap';
|
||||
|
||||
export const BASE_STYLES = `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 1100px; height: 780px; background: transparent; }
|
||||
body { -webkit-font-smoothing: antialiased; }
|
||||
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; }
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; box-shadow: inset 0 0 0 3px rgba(0,0,0,0.12); }
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,8 @@ export type CertificateTemplateId = (typeof CERTIFICATE_TEMPLATE_IDS)[number];
|
||||
export interface CertificateSignatory {
|
||||
name: string;
|
||||
role: string;
|
||||
enabled: boolean;
|
||||
signatureUrl?: string;
|
||||
}
|
||||
|
||||
export interface CertificateDesign {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-ex
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { annotateCourseContentWithProgression } from './progression';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
@@ -136,13 +137,31 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
|
||||
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
const progressionMode = course.metadata?.progressionMode ?? 'free';
|
||||
const roleId = profileId
|
||||
? (course.group?.members?.find((member) => member.profileId === profileId)?.roleId ?? null)
|
||||
: null;
|
||||
|
||||
const content = profileId
|
||||
? await annotateCourseContentWithProgression({
|
||||
courseId: course.id,
|
||||
profileId,
|
||||
roleId,
|
||||
progressionMode,
|
||||
contentRows: course.contentItems,
|
||||
isContentGroupingEnabled
|
||||
})
|
||||
: buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
|
||||
const { contentItems, org: courseOrg, ...rest } = course;
|
||||
|
||||
const base = {
|
||||
...rest,
|
||||
content
|
||||
content,
|
||||
metadata: {
|
||||
...course.metadata,
|
||||
progressionMode
|
||||
}
|
||||
};
|
||||
|
||||
if (courseOrg) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import type { CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import {
|
||||
getCompletedLessonIdsForProfile,
|
||||
getExerciseProgressionPolicies,
|
||||
getLessonProgressionPolicies,
|
||||
getPassedExerciseIdsForMember,
|
||||
getSubmittedExerciseIdsForMember,
|
||||
type ExerciseProgressionPolicy,
|
||||
type LessonProgressionPolicy
|
||||
} from '@cio/db/queries/course/progression';
|
||||
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
|
||||
import { buildCourseContent, type CourseContent, type CourseContentItem } from './utils';
|
||||
|
||||
export type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
export type AnnotatedCourseContentItem = CourseContentItem & {
|
||||
accessible?: boolean;
|
||||
lockReason?: ProgressionLockReason | null;
|
||||
completionPolicy?: string | null;
|
||||
videoWatchThreshold?: number | null;
|
||||
passThreshold?: number | null;
|
||||
};
|
||||
|
||||
export type AnnotatedCourseContent = {
|
||||
grouped: boolean;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
order: number | null;
|
||||
items: AnnotatedCourseContentItem[];
|
||||
}>;
|
||||
items: AnnotatedCourseContentItem[];
|
||||
};
|
||||
|
||||
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function flattenNavigableItems(content: CourseContent): CourseContentItem[] {
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
|
||||
return sortNavigableItems(
|
||||
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonBlocksProgression(policy: string | undefined): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
function exerciseBlocksProgression(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isItemProgressComplete(params: {
|
||||
item: CourseContentItem;
|
||||
lessonPolicy?: LessonProgressionPolicy;
|
||||
exercisePolicy?: ExerciseProgressionPolicy;
|
||||
completedLessonIds: Set<string>;
|
||||
submittedExerciseIds: Set<string>;
|
||||
passedExerciseIds: Set<string>;
|
||||
}): boolean {
|
||||
const { item, lessonPolicy, exercisePolicy, completedLessonIds, submittedExerciseIds, passedExerciseIds } = params;
|
||||
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return completedLessonIds.has(item.id);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
const policy = exercisePolicy?.completionPolicy ?? 'submitted';
|
||||
if (policy === 'passed') {
|
||||
return passedExerciseIds.has(item.id);
|
||||
}
|
||||
|
||||
return submittedExerciseIds.has(item.id) || Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function annotateNavigableAccess(params: {
|
||||
navigableItems: CourseContentItem[];
|
||||
lessonPolicyById: Map<string, LessonProgressionPolicy>;
|
||||
exercisePolicyById: Map<string, ExerciseProgressionPolicy>;
|
||||
progressionMode: 'free' | 'sequential';
|
||||
completedLessonIds: Set<string>;
|
||||
submittedExerciseIds: Set<string>;
|
||||
passedExerciseIds: Set<string>;
|
||||
}): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
let priorBlockingComplete = true;
|
||||
|
||||
for (const item of params.navigableItems) {
|
||||
const lessonPolicy = params.lessonPolicyById.get(item.id);
|
||||
const exercisePolicy = params.exercisePolicyById.get(item.id);
|
||||
const teacherLocked = !(item.isUnlocked ?? false);
|
||||
const progressionLocked = params.progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const complete = isItemProgressComplete({
|
||||
item,
|
||||
lessonPolicy,
|
||||
exercisePolicy,
|
||||
completedLessonIds: params.completedLessonIds,
|
||||
submittedExerciseIds: params.submittedExerciseIds,
|
||||
passedExerciseIds: params.passedExerciseIds
|
||||
});
|
||||
|
||||
const blocks =
|
||||
item.type === ContentType.Lesson
|
||||
? lessonBlocksProgression(lessonPolicy?.completionPolicy ?? 'manual')
|
||||
: exerciseBlocksProgression();
|
||||
|
||||
if (blocks && !complete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
}
|
||||
|
||||
function applyAccessToContent(
|
||||
content: CourseContent,
|
||||
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>,
|
||||
lessonPolicyById: Map<string, LessonProgressionPolicy>,
|
||||
exercisePolicyById: Map<string, ExerciseProgressionPolicy>
|
||||
): AnnotatedCourseContent {
|
||||
const annotateItem = (item: CourseContentItem): AnnotatedCourseContentItem => {
|
||||
const access = accessById.get(item.id);
|
||||
const lessonPolicy = lessonPolicyById.get(item.id);
|
||||
const exercisePolicy = exercisePolicyById.get(item.id);
|
||||
|
||||
return {
|
||||
...item,
|
||||
accessible: access?.accessible ?? true,
|
||||
lockReason: access?.lockReason ?? null,
|
||||
completionPolicy: lessonPolicy?.completionPolicy ?? exercisePolicy?.completionPolicy ?? null,
|
||||
videoWatchThreshold: lessonPolicy?.videoWatchThreshold ?? null,
|
||||
passThreshold: exercisePolicy?.passThreshold ?? null
|
||||
};
|
||||
};
|
||||
|
||||
if (!content.grouped) {
|
||||
return {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items: content.items.map(annotateItem)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
sections: content.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map(annotateItem)
|
||||
})),
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
export async function annotateCourseContentWithProgression(params: {
|
||||
courseId: string;
|
||||
profileId: string;
|
||||
roleId: number | null;
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
contentRows: CourseContentItemRow[];
|
||||
isContentGroupingEnabled: boolean;
|
||||
}): Promise<AnnotatedCourseContent> {
|
||||
const content = buildCourseContent(params.contentRows, params.isContentGroupingEnabled);
|
||||
|
||||
if (params.roleId !== ROLE.STUDENT) {
|
||||
const [lessonPolicies, exercisePolicies] = await Promise.all([
|
||||
getLessonProgressionPolicies(params.courseId),
|
||||
getExerciseProgressionPolicies(params.courseId)
|
||||
]);
|
||||
|
||||
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
|
||||
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
|
||||
for (const item of flattenNavigableItems(content)) {
|
||||
accessById.set(item.id, { accessible: true, lockReason: null });
|
||||
}
|
||||
|
||||
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
|
||||
}
|
||||
|
||||
const progressionMode = params.progressionMode ?? 'free';
|
||||
const groupMemberId = await getGroupMemberIdByCourseAndProfile(params.courseId, params.profileId);
|
||||
|
||||
const [lessonPolicies, exercisePolicies, completedLessonIds, submittedExerciseIds] = await Promise.all([
|
||||
getLessonProgressionPolicies(params.courseId),
|
||||
getExerciseProgressionPolicies(params.courseId),
|
||||
getCompletedLessonIdsForProfile(params.courseId, params.profileId),
|
||||
groupMemberId
|
||||
? getSubmittedExerciseIdsForMember(params.courseId, groupMemberId)
|
||||
: Promise.resolve(new Set<string>())
|
||||
]);
|
||||
|
||||
const passedExerciseIds = groupMemberId
|
||||
? await getPassedExerciseIdsForMember(params.courseId, groupMemberId, exercisePolicies)
|
||||
: new Set<string>();
|
||||
|
||||
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
|
||||
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
|
||||
|
||||
const accessById = annotateNavigableAccess({
|
||||
navigableItems: flattenNavigableItems(content),
|
||||
lessonPolicyById,
|
||||
exercisePolicyById,
|
||||
progressionMode,
|
||||
completedLessonIds,
|
||||
submittedExerciseIds,
|
||||
passedExerciseIds
|
||||
});
|
||||
|
||||
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
|
||||
}
|
||||
|
||||
export async function assertStudentCanAccessContent(params: {
|
||||
courseId: string;
|
||||
profileId: string;
|
||||
roleId: number | null;
|
||||
contentId: string;
|
||||
type: ContentType.Lesson | ContentType.Exercise;
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
contentRows: CourseContentItemRow[];
|
||||
isContentGroupingEnabled: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.roleId !== ROLE.STUDENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotated = await annotateCourseContentWithProgression({
|
||||
courseId: params.courseId,
|
||||
profileId: params.profileId,
|
||||
roleId: params.roleId,
|
||||
progressionMode: params.progressionMode,
|
||||
contentRows: params.contentRows,
|
||||
isContentGroupingEnabled: params.isContentGroupingEnabled
|
||||
});
|
||||
|
||||
const items = annotated.grouped ? annotated.sections.flatMap((section) => section.items) : annotated.items;
|
||||
const target = items.find((item) => item.id === params.contentId && item.type === params.type);
|
||||
|
||||
if (!target) {
|
||||
throw new AppError('Content not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (target.accessible === false) {
|
||||
throw new AppError('Content is locked', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export type CourseContentItem = {
|
||||
documentsCount: number | null;
|
||||
questionCount: number | null;
|
||||
dueBy: string | null;
|
||||
accessible?: boolean;
|
||||
lockReason?: 'teacher_locked' | 'progression_blocked' | null;
|
||||
completionPolicy?: string | null;
|
||||
videoWatchThreshold?: number | null;
|
||||
passThreshold?: number | null;
|
||||
};
|
||||
|
||||
export type CourseContentSection = {
|
||||
|
||||
@@ -12,14 +12,19 @@ import {
|
||||
getLessonCommentsByLessonIdPaginated,
|
||||
getLessonCompletion,
|
||||
getLessonVersionHistory,
|
||||
getLessonVideoProgress,
|
||||
getLessonVideoProgressForLesson,
|
||||
getLessonsByCourseId,
|
||||
updateLesson,
|
||||
updateLessonComment,
|
||||
upsertLessonCompletion,
|
||||
upsertLessonVideoProgress,
|
||||
type LessonById
|
||||
} from '@cio/db/queries/lesson';
|
||||
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
|
||||
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
|
||||
/**
|
||||
@@ -372,6 +377,19 @@ export async function getLessonCompletionService(lessonId: string, profileId: st
|
||||
*/
|
||||
export async function upsertLessonCompletionService(lessonId: string, profileId: string, isComplete: boolean) {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.completionPolicy === 'video_watch' && isComplete) {
|
||||
throw new AppError(
|
||||
'This lesson completes automatically when the video watch requirement is met',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const completionData: TNewLessonCompletion = {
|
||||
lessonId,
|
||||
profileId,
|
||||
@@ -380,6 +398,10 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
|
||||
|
||||
return await upsertLessonCompletion(completionData);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson completion',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
@@ -388,6 +410,232 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
|
||||
}
|
||||
}
|
||||
|
||||
const WATCH_PROGRESS_WALL_CLOCK_TOLERANCE = 1.5;
|
||||
|
||||
export type LessonWatchProgressAssetResult = {
|
||||
assetId: string;
|
||||
lastPositionSeconds: number;
|
||||
watchedSeconds: number;
|
||||
furthestSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
watchedPercent: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
export type LessonWatchProgressResult = {
|
||||
lastPositionSeconds: number;
|
||||
watchedSeconds: number;
|
||||
furthestSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
isComplete: boolean;
|
||||
didJustComplete: boolean;
|
||||
watchedPercent: number;
|
||||
videosComplete: number;
|
||||
videosRequired: number;
|
||||
assets: LessonWatchProgressAssetResult[];
|
||||
};
|
||||
|
||||
function getWatchedPercent(watchedSeconds: number, durationSeconds: number | null | undefined): number {
|
||||
if (!durationSeconds || durationSeconds <= 0) return 0;
|
||||
|
||||
return Math.min(100, Math.round((watchedSeconds / durationSeconds) * 100));
|
||||
}
|
||||
|
||||
function buildAggregateWatchProgress(
|
||||
rows: Awaited<ReturnType<typeof getLessonVideoProgressForLesson>>,
|
||||
requiredAssetIds: string[]
|
||||
): LessonWatchProgressResult | null {
|
||||
if (requiredAssetIds.length === 0) return null;
|
||||
|
||||
const rowByAssetId = new Map(rows.map((row) => [row.assetId, row]));
|
||||
const assets: LessonWatchProgressAssetResult[] = requiredAssetIds.map((assetId) => {
|
||||
const row = rowByAssetId.get(assetId);
|
||||
|
||||
const isAssetComplete = row?.isComplete ?? false;
|
||||
|
||||
return {
|
||||
assetId,
|
||||
lastPositionSeconds: row?.lastPositionSeconds ?? 0,
|
||||
watchedSeconds: row?.watchedSeconds ?? 0,
|
||||
furthestSeconds: row?.furthestSeconds ?? 0,
|
||||
durationSeconds: row?.durationSeconds ?? null,
|
||||
watchedPercent: isAssetComplete ? 100 : getWatchedPercent(row?.watchedSeconds ?? 0, row?.durationSeconds),
|
||||
isComplete: isAssetComplete
|
||||
};
|
||||
});
|
||||
|
||||
const videosComplete = assets.filter((asset) => asset.isComplete).length;
|
||||
const videosRequired = requiredAssetIds.length;
|
||||
const isComplete = videosRequired > 0 && videosComplete === videosRequired;
|
||||
const watchedPercent = isComplete
|
||||
? 100
|
||||
: assets.length > 0
|
||||
? Math.min(...assets.map((asset) => asset.watchedPercent))
|
||||
: 0;
|
||||
const primaryAsset = assets[0];
|
||||
|
||||
return {
|
||||
lastPositionSeconds: primaryAsset?.lastPositionSeconds ?? 0,
|
||||
watchedSeconds: primaryAsset?.watchedSeconds ?? 0,
|
||||
furthestSeconds: primaryAsset?.furthestSeconds ?? 0,
|
||||
durationSeconds: primaryAsset?.durationSeconds ?? null,
|
||||
isComplete,
|
||||
didJustComplete: false,
|
||||
watchedPercent,
|
||||
videosComplete,
|
||||
videosRequired,
|
||||
assets
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLessonWatchProgressService(
|
||||
lessonId: string,
|
||||
profileId: string
|
||||
): Promise<LessonWatchProgressResult | null> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson || lesson.completionPolicy !== 'video_watch') return null;
|
||||
|
||||
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
|
||||
if (requiredAssetIds.length === 0) return null;
|
||||
|
||||
const progressRows = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const filteredRows = progressRows.filter((row) => requiredAssetIds.includes(row.assetId));
|
||||
|
||||
return buildAggregateWatchProgress(filteredRows, requiredAssetIds);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get lesson watch progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLessonWatchProgressService(
|
||||
lessonId: string,
|
||||
profileId: string,
|
||||
beat: TUpdateLessonWatchProgress
|
||||
): Promise<LessonWatchProgressResult> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.completionPolicy !== 'video_watch') {
|
||||
throw new AppError('This lesson does not require video watch tracking', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (!beat.assetId) {
|
||||
throw new AppError('Video asset is required for watch progress', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
|
||||
if (!requiredAssetIds.includes(beat.assetId)) {
|
||||
throw new AppError('This video is not configured for watch enforcement', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const existing = await getLessonVideoProgress(lessonId, profileId, beat.assetId);
|
||||
const threshold = lesson.videoWatchThreshold ?? 95;
|
||||
const now = Date.now();
|
||||
|
||||
if (existing?.isComplete) {
|
||||
const aggregate = buildAggregateWatchProgress(
|
||||
await getLessonVideoProgressForLesson(lessonId, profileId),
|
||||
requiredAssetIds
|
||||
);
|
||||
|
||||
return {
|
||||
...(aggregate ?? {
|
||||
lastPositionSeconds: existing.lastPositionSeconds ?? beat.positionSeconds,
|
||||
watchedSeconds: existing.watchedSeconds ?? 0,
|
||||
furthestSeconds: existing.furthestSeconds ?? 0,
|
||||
durationSeconds: existing.durationSeconds ?? beat.durationSeconds,
|
||||
isComplete: true,
|
||||
didJustComplete: false,
|
||||
watchedPercent: 100,
|
||||
videosComplete: requiredAssetIds.length,
|
||||
videosRequired: requiredAssetIds.length,
|
||||
assets: []
|
||||
}),
|
||||
didJustComplete: false
|
||||
};
|
||||
}
|
||||
|
||||
const positionSeconds = Math.floor(beat.positionSeconds);
|
||||
|
||||
if (existing?.updatedAt) {
|
||||
const elapsedSeconds = (now - new Date(existing.updatedAt).getTime()) / 1000;
|
||||
const maxAllowedDelta = Math.max(5, elapsedSeconds * WATCH_PROGRESS_WALL_CLOCK_TOLERANCE);
|
||||
|
||||
if (beat.playedDeltaSeconds > maxAllowedDelta) {
|
||||
throw new AppError('Invalid watch progress update', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const previousFurthest = existing?.furthestSeconds ?? 0;
|
||||
const playedDeltaSeconds = Math.max(0, Math.round(beat.playedDeltaSeconds));
|
||||
const reachedEnd = positionSeconds >= beat.durationSeconds - 1;
|
||||
let watchedSeconds = Math.min(beat.durationSeconds, (existing?.watchedSeconds ?? 0) + playedDeltaSeconds);
|
||||
|
||||
if (reachedEnd) {
|
||||
watchedSeconds = beat.durationSeconds;
|
||||
}
|
||||
|
||||
const furthestSeconds = Math.max(previousFurthest, positionSeconds, reachedEnd ? beat.durationSeconds : 0);
|
||||
const watchPercent = (watchedSeconds / beat.durationSeconds) * 100;
|
||||
const assetComplete = watchPercent >= threshold;
|
||||
|
||||
const rowsBeforeUpdate = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const priorAggregate = buildAggregateWatchProgress(rowsBeforeUpdate, requiredAssetIds);
|
||||
const wasLessonComplete = priorAggregate?.isComplete ?? false;
|
||||
|
||||
await upsertLessonVideoProgress({
|
||||
lessonId,
|
||||
profileId,
|
||||
assetId: beat.assetId,
|
||||
durationSeconds: beat.durationSeconds,
|
||||
watchedSeconds,
|
||||
furthestSeconds,
|
||||
lastPositionSeconds: reachedEnd ? beat.durationSeconds : positionSeconds,
|
||||
isComplete: assetComplete,
|
||||
completedAt: assetComplete ? new Date().toISOString() : null
|
||||
});
|
||||
|
||||
const allRows = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const aggregate = buildAggregateWatchProgress(allRows, requiredAssetIds);
|
||||
const didJustComplete = (aggregate?.isComplete ?? false) && !wasLessonComplete;
|
||||
|
||||
if (didJustComplete) {
|
||||
await upsertLessonCompletion({
|
||||
lessonId,
|
||||
profileId,
|
||||
isComplete: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!aggregate) {
|
||||
throw new AppError('Failed to aggregate lesson watch progress', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
...aggregate,
|
||||
didJustComplete
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson watch progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets lesson version history for a lesson and locale
|
||||
* @param lessonId Lesson ID
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
type LessonVideo = {
|
||||
type: string;
|
||||
assetId?: string | null;
|
||||
watchEnforced?: boolean | null;
|
||||
link?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function isHlsUploadVideo(video: LessonVideo): boolean {
|
||||
if (video.type !== 'upload') return false;
|
||||
|
||||
const metadata = video.metadata;
|
||||
if (metadata && typeof metadata === 'object' && metadata.hls === true) return true;
|
||||
|
||||
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
|
||||
}
|
||||
|
||||
export function isEnforceableLessonVideo(video: LessonVideo): boolean {
|
||||
return video.type === 'upload' && Boolean(video.assetId);
|
||||
}
|
||||
|
||||
export function isEnforceableLessonVideoIncludingHls(video: LessonVideo): boolean {
|
||||
return isEnforceableLessonVideo(video) || isHlsUploadVideo(video);
|
||||
}
|
||||
|
||||
export function resolveWatchEnforcedAssetIds(
|
||||
videos: LessonVideo[] | null | undefined,
|
||||
completionPolicy: string | null | undefined
|
||||
): string[] {
|
||||
const lessonVideos = videos ?? [];
|
||||
const flaggedAssetIds = lessonVideos
|
||||
.filter((video) => video.watchEnforced && video.assetId && isEnforceableLessonVideoIncludingHls(video))
|
||||
.map((video) => video.assetId as string);
|
||||
|
||||
if (flaggedAssetIds.length > 0) {
|
||||
return Array.from(new Set(flaggedAssetIds));
|
||||
}
|
||||
|
||||
if (completionPolicy !== 'video_watch') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
lessonVideos
|
||||
.filter((video) => video.assetId && isEnforceableLessonVideoIncludingHls(video))
|
||||
.map((video) => video.assetId as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -42,13 +42,33 @@ export async function getCourseContentItems(courseId: string, profileId?: string
|
||||
|
||||
const exerciseCompletionSql = profileId
|
||||
? drizzleSql`COALESCE(
|
||||
(
|
||||
SELECT true
|
||||
FROM submission
|
||||
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
|
||||
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN exercise.completion_policy = 'passed' THEN (
|
||||
SELECT true
|
||||
FROM submission
|
||||
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
|
||||
WHERE submission.exercise_id = exercise.id
|
||||
AND groupmember.profile_id = ${profileId}
|
||||
AND submission.grading_state = 'completed'
|
||||
AND (
|
||||
SELECT CASE
|
||||
WHEN COALESCE(SUM(question.points), 0) <= 0 THEN false
|
||||
ELSE ROUND((submission.total::numeric / SUM(question.points)::numeric) * 100)
|
||||
>= COALESCE(exercise.pass_threshold, 100)
|
||||
END
|
||||
FROM question
|
||||
WHERE question.exercise_id = exercise.id
|
||||
)
|
||||
LIMIT 1
|
||||
)
|
||||
ELSE (
|
||||
SELECT true
|
||||
FROM submission
|
||||
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
|
||||
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
|
||||
LIMIT 1
|
||||
)
|
||||
END,
|
||||
false
|
||||
)`
|
||||
: drizzleSql`false`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './course';
|
||||
export * from './compliance';
|
||||
export * from './content';
|
||||
export * from './progression';
|
||||
export * from './content-batch';
|
||||
export * from './people';
|
||||
export * from './invite';
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import { getExerciseTitleAndMaxPoints, getStudentSubmissionsForExercise } from './certification-exercise';
|
||||
|
||||
export type LessonProgressionPolicy = {
|
||||
id: string;
|
||||
completionPolicy: string;
|
||||
videoWatchThreshold: number | null;
|
||||
isUnlocked: boolean | null;
|
||||
};
|
||||
|
||||
export type ExerciseProgressionPolicy = {
|
||||
id: string;
|
||||
completionPolicy: string;
|
||||
passThreshold: number | null;
|
||||
isUnlocked: boolean | null;
|
||||
};
|
||||
|
||||
export async function getLessonProgressionPolicies(courseId: string): Promise<LessonProgressionPolicy[]> {
|
||||
try {
|
||||
return db
|
||||
.select({
|
||||
id: schema.lesson.id,
|
||||
completionPolicy: schema.lesson.completionPolicy,
|
||||
videoWatchThreshold: schema.lesson.videoWatchThreshold,
|
||||
isUnlocked: schema.lesson.isUnlocked
|
||||
})
|
||||
.from(schema.lesson)
|
||||
.where(eq(schema.lesson.courseId, courseId));
|
||||
} catch (error) {
|
||||
console.error('getLessonProgressionPolicies error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionPolicies(courseId: string): Promise<ExerciseProgressionPolicy[]> {
|
||||
try {
|
||||
return db
|
||||
.select({
|
||||
id: schema.exercise.id,
|
||||
completionPolicy: schema.exercise.completionPolicy,
|
||||
passThreshold: schema.exercise.passThreshold,
|
||||
isUnlocked: schema.exercise.isUnlocked
|
||||
})
|
||||
.from(schema.exercise)
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
|
||||
} catch (error) {
|
||||
console.error('getExerciseProgressionPolicies error:', error);
|
||||
throw new Error(
|
||||
`Failed to get exercise progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function percentFromTotal(total: number, maxPoints: number): number {
|
||||
if (maxPoints <= 0) return 0;
|
||||
|
||||
return Math.round((total / maxPoints) * 100);
|
||||
}
|
||||
|
||||
export async function isExercisePassedForMember(
|
||||
exerciseId: string,
|
||||
groupMemberId: string,
|
||||
passThreshold: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { maxPoints } = await getExerciseTitleAndMaxPoints(exerciseId);
|
||||
if (maxPoints <= 0) return false;
|
||||
|
||||
const submissions = await getStudentSubmissionsForExercise(groupMemberId, exerciseId);
|
||||
const completed = submissions.filter((submission) => submission.gradingState === 'completed');
|
||||
if (completed.length === 0) return false;
|
||||
|
||||
let bestPercent = 0;
|
||||
for (const submission of completed) {
|
||||
const total = Number(submission.total ?? 0);
|
||||
const percent = percentFromTotal(total, maxPoints);
|
||||
if (percent > bestPercent) bestPercent = percent;
|
||||
}
|
||||
|
||||
return bestPercent >= passThreshold;
|
||||
} catch (error) {
|
||||
console.error('isExercisePassedForMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to check exercise pass status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubmittedExerciseIdsForMember(courseId: string, groupMemberId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ exerciseId: schema.submission.exerciseId })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.submittedBy, groupMemberId),
|
||||
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))
|
||||
)
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.exerciseId).filter((id): id is string => !!id));
|
||||
} catch (error) {
|
||||
console.error('getSubmittedExerciseIdsForMember error:', error);
|
||||
throw new Error(`Failed to get submitted exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCompletedLessonIdsForProfile(courseId: string, profileId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ lessonId: schema.lessonCompletion.lessonId })
|
||||
.from(schema.lessonCompletion)
|
||||
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lesson.courseId, courseId),
|
||||
eq(schema.lessonCompletion.profileId, profileId),
|
||||
eq(schema.lessonCompletion.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.lessonId).filter((id): id is string => !!id));
|
||||
} catch (error) {
|
||||
console.error('getCompletedLessonIdsForProfile error:', error);
|
||||
throw new Error(`Failed to get completed lessons: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPassedExerciseIdsForMember(
|
||||
courseId: string,
|
||||
groupMemberId: string,
|
||||
policies: ExerciseProgressionPolicy[]
|
||||
): Promise<Set<string>> {
|
||||
const passedIds = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
policies
|
||||
.filter((policy) => policy.completionPolicy === 'passed')
|
||||
.map(async (policy) => {
|
||||
const threshold = policy.passThreshold ?? 100;
|
||||
const passed = await isExercisePassedForMember(policy.id, groupMemberId, threshold);
|
||||
if (passed) passedIds.add(policy.id);
|
||||
})
|
||||
);
|
||||
|
||||
return passedIds;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lesson';
|
||||
export * from './language';
|
||||
export * from './video-progress';
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TLessonVideoProgress, TNewLessonVideoProgress } from '@db/types';
|
||||
import { and, db, eq } from '@db/drizzle';
|
||||
|
||||
export async function getLessonVideoProgress(
|
||||
lessonId: string,
|
||||
profileId: string,
|
||||
assetId: string
|
||||
): Promise<TLessonVideoProgress | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonVideoProgress.lessonId, lessonId),
|
||||
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||
eq(schema.lessonVideoProgress.assetId, assetId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getLessonVideoProgress error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson video progress for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLessonVideoProgressForLesson(
|
||||
lessonId: string,
|
||||
profileId: string
|
||||
): Promise<TLessonVideoProgress[]> {
|
||||
try {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(eq(schema.lessonVideoProgress.lessonId, lessonId), eq(schema.lessonVideoProgress.profileId, profileId))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getLessonVideoProgressForLesson error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson video progress rows for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLessonVideoProgressValues(
|
||||
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
|
||||
): TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string } {
|
||||
return {
|
||||
...data,
|
||||
durationSeconds: data.durationSeconds != null ? Math.round(data.durationSeconds) : data.durationSeconds,
|
||||
watchedSeconds: data.watchedSeconds != null ? Math.round(data.watchedSeconds) : data.watchedSeconds,
|
||||
furthestSeconds: data.furthestSeconds != null ? Math.floor(data.furthestSeconds) : data.furthestSeconds,
|
||||
lastPositionSeconds:
|
||||
data.lastPositionSeconds != null ? Math.floor(data.lastPositionSeconds) : data.lastPositionSeconds
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertLessonVideoProgress(
|
||||
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
|
||||
): Promise<TLessonVideoProgress> {
|
||||
try {
|
||||
const normalized = normalizeLessonVideoProgressValues(data);
|
||||
const existing = await getLessonVideoProgress(normalized.lessonId, normalized.profileId, normalized.assetId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(schema.lessonVideoProgress)
|
||||
.set({ ...normalized, updatedAt: now })
|
||||
.where(eq(schema.lessonVideoProgress.id, existing.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update lesson video progress');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(schema.lessonVideoProgress)
|
||||
.values({ ...normalized, createdAt: now, updatedAt: now })
|
||||
.returning();
|
||||
|
||||
if (!inserted) {
|
||||
throw new Error('Failed to insert lesson video progress');
|
||||
}
|
||||
|
||||
return inserted;
|
||||
} catch (error) {
|
||||
console.error('upsertLessonVideoProgress error:', error);
|
||||
throw new Error(
|
||||
`Failed to upsert lesson video progress for lesson "${data.lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -689,6 +689,8 @@ export const course = pgTable(
|
||||
allowNewStudent: boolean;
|
||||
sectionDisplay?: Record<string, boolean>;
|
||||
isContentGroupingEnabled?: boolean;
|
||||
/** `free` = all unlocked content is accessible; `sequential` = prior items must be complete. */
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
/** Per-course AI tutor override; missing fields inherit from org defaults. */
|
||||
aiTutor?: {
|
||||
inheritFromOrg?: boolean;
|
||||
@@ -726,7 +728,10 @@ export const course = pgTable(
|
||||
accentColor: string;
|
||||
subtitle?: string;
|
||||
descriptionOverride?: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
signatories: [
|
||||
{ name: string; role: string; enabled?: boolean; signatureUrl?: string },
|
||||
{ name: string; role: string; enabled?: boolean; signatureUrl?: string }
|
||||
];
|
||||
idFormat?: string;
|
||||
};
|
||||
deadline?: string | null;
|
||||
@@ -975,12 +980,15 @@ export const lesson = pgTable(
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
order: bigint({ mode: 'number' }),
|
||||
isUnlocked: boolean('is_unlocked').default(false),
|
||||
completionPolicy: varchar('completion_policy').default('manual').notNull(),
|
||||
videoWatchThreshold: integer('video_watch_threshold').default(95),
|
||||
videos: jsonb().default([]).$type<
|
||||
{
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
watchEnforced?: boolean;
|
||||
/** Display name; stored on add/upload. Fallback: derive from key or type. */
|
||||
fileName?: string;
|
||||
metadata?: {
|
||||
@@ -1192,6 +1200,8 @@ export const exercise = pgTable(
|
||||
dueBy: timestamp('due_by', { mode: 'string' }),
|
||||
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull(),
|
||||
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
|
||||
completionPolicy: varchar('completion_policy').default('submitted').notNull(),
|
||||
passThreshold: integer('pass_threshold'),
|
||||
slug: varchar()
|
||||
},
|
||||
(table) => [
|
||||
@@ -1487,6 +1497,40 @@ export const organizationInviteAudit = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const lessonVideoProgress = pgTable(
|
||||
'lesson_video_progress',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
lessonId: uuid('lesson_id').notNull(),
|
||||
profileId: uuid('profile_id').notNull(),
|
||||
assetId: text('asset_id').notNull(),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
watchedSeconds: integer('watched_seconds').default(0).notNull(),
|
||||
furthestSeconds: integer('furthest_seconds').default(0).notNull(),
|
||||
lastPositionSeconds: integer('last_position_seconds').default(0).notNull(),
|
||||
isComplete: boolean('is_complete').default(false).notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.lessonId],
|
||||
foreignColumns: [lesson.id],
|
||||
name: 'lesson_video_progress_lesson_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.profileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'lesson_video_progress_profile_id_fkey'
|
||||
}),
|
||||
unique('lesson_video_progress_lesson_profile_asset_unique').on(table.lessonId, table.profileId, table.assetId)
|
||||
]
|
||||
);
|
||||
|
||||
export const lessonCompletion = pgTable(
|
||||
'lesson_completion',
|
||||
{
|
||||
|
||||
@@ -112,6 +112,9 @@ export type TNewOrganizationInviteAudit = typeof schema.organizationInviteAudit.
|
||||
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
|
||||
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
|
||||
|
||||
export type TLessonVideoProgress = typeof schema.lessonVideoProgress.$inferSelect;
|
||||
export type TNewLessonVideoProgress = typeof schema.lessonVideoProgress.$inferInsert;
|
||||
|
||||
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
|
||||
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ export function buildDesign(
|
||||
subtitle: overrides.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: overrides.descriptionOverride,
|
||||
signatories: overrides.signatories ?? [
|
||||
{ name: 'Margaret W. Holloway', role: 'Director of Studies' },
|
||||
{ name: 'James K. Reeves', role: 'Dean of Faculty' }
|
||||
{ name: 'Margaret W. Holloway', role: 'Director of Studies', enabled: true },
|
||||
{ name: 'James K. Reeves', role: 'Dean of Faculty', enabled: true }
|
||||
],
|
||||
idFormat: overrides.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
|
||||
@@ -33,10 +33,20 @@
|
||||
const MAX_SCALE = 1.5;
|
||||
|
||||
let stageElement = $state<HTMLDivElement | null>(null);
|
||||
let iframeElement = $state<HTMLIFrameElement | null>(null);
|
||||
let manualScale = $state<number | null>(typeof zoom === 'number' ? clamp(zoom) : null);
|
||||
let fitScale = $state(0.4);
|
||||
|
||||
const srcdoc = $derived(renderCertificateDocument(design, data));
|
||||
const renderedHtml = $derived(renderCertificateDocument(design, data));
|
||||
|
||||
$effect(() => {
|
||||
const html = renderedHtml;
|
||||
const doc = iframeElement?.contentDocument;
|
||||
if (!doc) return;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
});
|
||||
|
||||
const isFit = $derived(zoom === 'fit' && manualScale == null);
|
||||
const scale = $derived(isFit ? fitScale : (manualScale ?? clamp(typeof zoom === 'number' ? zoom : 0.4)));
|
||||
@@ -84,12 +94,12 @@
|
||||
>
|
||||
<div class="ui:origin-center" style:width="{width}px" style:height="{height}px" style:transform="scale({scale})">
|
||||
<iframe
|
||||
bind:this={iframeElement}
|
||||
title="Certificate preview"
|
||||
sandbox="allow-same-origin"
|
||||
class="ui:h-full ui:w-full ui:rounded-sm ui:border-0 ui:shadow-[0_18px_40px_rgba(0,0,0,0.18),0_6px_12px_rgba(0,0,0,0.12)]"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
{srcdoc}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/** Marker teachers use in the template for each blank (three underscores). */
|
||||
export const WORD_BANK_BLANK_MARKER = '___';
|
||||
|
||||
/** Any run of 3+ underscores counts as a single blank, so `___` and `______` behave the same. */
|
||||
export const WORD_BANK_BLANK_PATTERN = /_{3,}/g;
|
||||
|
||||
export type WordBankSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
|
||||
|
||||
export function countWordBankBlanks(template: string): number {
|
||||
if (!template) return 0;
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
return Math.max(0, parts.length - 1);
|
||||
const matches = template.match(WORD_BANK_BLANK_PATTERN);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
/** Splits template into alternating text runs and blank slots (indices0..n-1). */
|
||||
@@ -14,7 +17,7 @@ export function parseWordBankTemplate(template: string): WordBankSegment[] {
|
||||
const segments: WordBankSegment[] = [];
|
||||
if (!template) return segments;
|
||||
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
const parts = template.split(WORD_BANK_BLANK_PATTERN);
|
||||
let blankIndex = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
|
||||
@@ -75,6 +75,16 @@ class ImageCropperRootState {
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
onUseOriginal() {
|
||||
if (!this.tempUrl) return;
|
||||
|
||||
this.opts.src.current = this.tempUrl;
|
||||
this.open = false;
|
||||
this.opts.onCropped.current(this.tempUrl);
|
||||
this.tempUrl = undefined;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
|
||||
@@ -146,6 +156,16 @@ class ImageCropperCancelState {
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperUseOriginalState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onUseOriginal();
|
||||
}
|
||||
}
|
||||
|
||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||
|
||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||
@@ -187,3 +207,9 @@ export const useImageCropperCancel = () => {
|
||||
|
||||
return new ImageCropperCancelState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperUseOriginal = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperUseOriginalState(rootState);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '../../base/dialog';
|
||||
import Button from '../../base/button/button.svelte';
|
||||
import { cn } from '../../tools';
|
||||
import { useImageCropperDialog } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperDialogProps } from './types';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
|
||||
|
||||
@@ -12,12 +14,22 @@
|
||||
<Dialog.Root bind:open={dialogState.rootState.open}>
|
||||
<Dialog.Content
|
||||
{...rest}
|
||||
showCloseButton
|
||||
showCloseButton={false}
|
||||
class={cn(
|
||||
'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 ui:sm:ui:max-w-lg ui:sm:ui:rounded-lg ui:sm:ui:border-x',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="ui:absolute ui:end-4 ui:top-4"
|
||||
onclick={() => dialogState.rootState.onCancel()}
|
||||
>
|
||||
<XIcon />
|
||||
<span class="ui:sr-only">Close</span>
|
||||
</Button>
|
||||
<div class="ui:flex ui:flex-col ui:gap-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
|
||||
import { useImageCropperUseOriginal } from './image-cropper-context.svelte';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
onclick,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonElementProps = $props();
|
||||
|
||||
const useOriginalState = useImageCropperUseOriginal();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
bind:ref
|
||||
{size}
|
||||
{variant}
|
||||
onclick={(
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
useOriginalState.onclick();
|
||||
}}
|
||||
>
|
||||
<ImageIcon />
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<span>Don't Crop</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -6,8 +6,9 @@ import Cropper from './image-cropper-cropper.svelte';
|
||||
import Controls from './image-cropper-controls.svelte';
|
||||
import Crop from './image-cropper-crop.svelte';
|
||||
import Cancel from './image-cropper-cancel.svelte';
|
||||
import UseOriginal from './image-cropper-use-original.svelte';
|
||||
import { getFileFromUrl } from './utils';
|
||||
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, UseOriginal, getFileFromUrl };
|
||||
|
||||
export type * from './types';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import 'plyr/dist/plyr.css';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
|
||||
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
@@ -31,6 +32,7 @@
|
||||
let playerInstance = $state<Plyr | null>(null);
|
||||
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
|
||||
let playbackFailed = $state(false);
|
||||
let isPlayerReady = $state(false);
|
||||
let reloadInFlight = $state(false);
|
||||
let timeupdateCleanup: (() => void) | undefined;
|
||||
let firstPlayCleanup: (() => void) | undefined;
|
||||
@@ -60,7 +62,12 @@
|
||||
const isYouTube = $derived(isYoutubeUrl(src));
|
||||
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
|
||||
|
||||
function attachTimeUpdates(player: Plyr): () => void {
|
||||
function attachTimeUpdates(player: Plyr, element: HTMLVideoElement): () => void {
|
||||
const policy = options.seekPolicy;
|
||||
if (policy?.mode === 'locked_until_complete') {
|
||||
return attachSeekEnforcement(player, element, policy);
|
||||
}
|
||||
|
||||
const handler = () => options.onTimeUpdate?.(player.currentTime);
|
||||
player.on('timeupdate', handler);
|
||||
return () => {
|
||||
@@ -68,6 +75,134 @@
|
||||
};
|
||||
}
|
||||
|
||||
function attachSeekEnforcement(
|
||||
player: Plyr,
|
||||
element: HTMLVideoElement,
|
||||
policy: NonNullable<MediaPlayerOptions['seekPolicy']>
|
||||
): () => void {
|
||||
let furthestSeconds = policy.initialFurthestSeconds ?? 0;
|
||||
let playedSeconds = 0;
|
||||
let lastValidTime = element.currentTime;
|
||||
let isSeeking = false;
|
||||
let hasBlockedThisSeek = false;
|
||||
let lastHeartbeatAt = 0;
|
||||
const seekTolerance = 0.5;
|
||||
const heartbeatIntervalMs = 15_000;
|
||||
|
||||
const flushProgress = (force = false) => {
|
||||
const durationSeconds = element.duration;
|
||||
if (!durationSeconds || !Number.isFinite(durationSeconds)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastHeartbeatAt < heartbeatIntervalMs) return;
|
||||
|
||||
lastHeartbeatAt = now;
|
||||
policy.onProgress?.({
|
||||
positionSeconds: Math.floor(player.currentTime),
|
||||
playedDeltaSeconds: Math.round(playedSeconds),
|
||||
durationSeconds: Math.round(durationSeconds)
|
||||
});
|
||||
playedSeconds = 0;
|
||||
};
|
||||
|
||||
const onSeeking = () => {
|
||||
isSeeking = true;
|
||||
if (!hasBlockedThisSeek && player.currentTime > furthestSeconds + seekTolerance) {
|
||||
hasBlockedThisSeek = true;
|
||||
player.currentTime = furthestSeconds;
|
||||
policy.onSeekBlocked?.();
|
||||
}
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
isSeeking = false;
|
||||
hasBlockedThisSeek = false;
|
||||
lastValidTime = player.currentTime;
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (isSeeking) return;
|
||||
|
||||
const current = player.currentTime;
|
||||
const delta = current - lastValidTime;
|
||||
|
||||
if (delta > 0 && delta < 2) {
|
||||
playedSeconds += delta;
|
||||
furthestSeconds = Math.max(furthestSeconds, current);
|
||||
}
|
||||
|
||||
lastValidTime = current;
|
||||
options.onTimeUpdate?.(current);
|
||||
flushProgress(false);
|
||||
};
|
||||
|
||||
const onRateChange = () => {
|
||||
if (player.speed !== 1) {
|
||||
player.speed = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const onPause = () => flushProgress(true);
|
||||
|
||||
const onEnded = () => {
|
||||
const durationSeconds = element.duration;
|
||||
if (durationSeconds && Number.isFinite(durationSeconds)) {
|
||||
const tailSeconds = durationSeconds - lastValidTime;
|
||||
if (tailSeconds > 0) {
|
||||
playedSeconds += tailSeconds;
|
||||
}
|
||||
furthestSeconds = Math.max(furthestSeconds, durationSeconds);
|
||||
lastValidTime = durationSeconds;
|
||||
}
|
||||
|
||||
flushProgress(true);
|
||||
};
|
||||
|
||||
player.on('seeking', onSeeking);
|
||||
player.on('seeked', onSeeked);
|
||||
player.on('timeupdate', onTimeUpdate);
|
||||
player.on('ratechange', onRateChange);
|
||||
player.on('pause', onPause);
|
||||
player.on('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
flushProgress(true);
|
||||
player.off('seeking', onSeeking);
|
||||
player.off('seeked', onSeeked);
|
||||
player.off('timeupdate', onTimeUpdate);
|
||||
player.off('ratechange', onRateChange);
|
||||
player.off('pause', onPause);
|
||||
player.off('ended', onEnded);
|
||||
};
|
||||
}
|
||||
|
||||
function getPlyrControls(): string[] {
|
||||
if (!showControls) return [];
|
||||
|
||||
return PLYR_DEFAULT_CONTROLS;
|
||||
}
|
||||
|
||||
function handleVisibilityChange(): void {
|
||||
if (!options.seekPolicy?.pauseOnHidden) return;
|
||||
if (!document.hidden) return;
|
||||
|
||||
try {
|
||||
playerInstance?.pause();
|
||||
} catch {
|
||||
// Player may be mid-teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowBlur(): void {
|
||||
if (!options.seekPolicy?.pauseOnHidden) return;
|
||||
|
||||
try {
|
||||
playerInstance?.pause();
|
||||
} catch {
|
||||
// Player may be mid-teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function attachFirstPlay(player: Plyr): () => void {
|
||||
const handler = () => {
|
||||
if (hasFiredFirstPlay) return;
|
||||
@@ -172,6 +307,7 @@
|
||||
transcriptPanelCleanup = undefined;
|
||||
plyrErrorCleanup = undefined;
|
||||
mediaErrorCleanup = undefined;
|
||||
isPlayerReady = false;
|
||||
if (!playerInstance) return;
|
||||
|
||||
try {
|
||||
@@ -190,7 +326,7 @@
|
||||
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
const controls = getPlyrControls();
|
||||
|
||||
try {
|
||||
// Recheck after the dynamic import resolves — the component may have
|
||||
@@ -215,9 +351,12 @@
|
||||
}
|
||||
: {})
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance, videoElement);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
|
||||
playerInstance.on('ready', () => {
|
||||
isPlayerReady = true;
|
||||
});
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
} finally {
|
||||
plyrConstructInFlight = false;
|
||||
@@ -267,7 +406,7 @@
|
||||
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
const controls = getPlyrControls();
|
||||
|
||||
try {
|
||||
if (!isMounted || initGeneration !== youtubeInitGeneration || !containerElement || playerInstance) {
|
||||
@@ -284,6 +423,9 @@
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
|
||||
playerInstance.on('ready', () => {
|
||||
isPlayerReady = true;
|
||||
});
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
} finally {
|
||||
plyrConstructInFlight = false;
|
||||
@@ -494,6 +636,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onvisibilitychange={handleVisibilityChange} />
|
||||
<svelte:window onblur={handleWindowBlur} />
|
||||
|
||||
<div
|
||||
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
|
||||
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
|
||||
@@ -530,6 +675,20 @@
|
||||
</video>
|
||||
{/if}
|
||||
|
||||
{#if !isPlayerReady && !playbackFailed}
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:z-10 ui:flex ui:items-center ui:justify-center ui:bg-black/40"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={options.loadingLabel}
|
||||
>
|
||||
<LoaderCircleIcon class="ui:size-10 ui:animate-spin ui:text-white" aria-hidden="true" />
|
||||
{#if options.loadingLabel}
|
||||
<span class="ui:sr-only">{options.loadingLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playbackFailed && options.playbackErrorLabel}
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface MediaPlayerOptions {
|
||||
onPlayerReady?: (player: Plyr) => void;
|
||||
/** Shown when the media element fails to load or decode. */
|
||||
playbackErrorLabel?: string;
|
||||
/** Accessible label for the loading/buffering spinner overlay. */
|
||||
loadingLabel?: string;
|
||||
/** Label for the manual reload control shown after a playback failure. */
|
||||
playbackReloadLabel?: string;
|
||||
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
|
||||
@@ -66,4 +68,12 @@ export interface MediaPlayerOptions {
|
||||
* best-effort call that fails benignly.
|
||||
*/
|
||||
onBeforeHlsLoad?: () => Promise<void>;
|
||||
seekPolicy?: {
|
||||
mode: 'locked_until_complete';
|
||||
watchThresholdPercent: number;
|
||||
initialFurthestSeconds?: number;
|
||||
pauseOnHidden?: boolean;
|
||||
onProgress?: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => void;
|
||||
onSeekBlocked?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import PercentRingProgress from './percent-ring-progress.svelte';
|
||||
|
||||
export { PercentRingProgress };
|
||||
export { PercentRingProgress as default };
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
const RING_RADIUS = 45;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
interface Props {
|
||||
/** Progress value from 0 to 100 */
|
||||
value: number;
|
||||
size?: 'small' | 'default';
|
||||
}
|
||||
|
||||
let { value, size = 'small' }: Props = $props();
|
||||
|
||||
const clampedValue = $derived(Math.max(0, Math.min(100, value)));
|
||||
const labelPercentage = $derived(Math.round(clampedValue));
|
||||
const strokeDashoffset = $derived(RING_CIRCUMFERENCE * (1 - clampedValue / 100));
|
||||
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
|
||||
const labelClass = $derived(size === 'small' ? 'text-[10px]' : 'text-sm');
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<svg
|
||||
class="{sizeClass} -rotate-90"
|
||||
style="transform-box: fill-box; transform-origin: center;"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
class="text-green-600 transition-[stroke-dashoffset] duration-700 ease-out"
|
||||
stroke-dasharray={RING_CIRCUMFERENCE}
|
||||
stroke-dashoffset={strokeDashoffset}
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class={labelClass}>{labelPercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
|
||||
(question) => {
|
||||
const settings = question.settings;
|
||||
const template = String(settings?.template ?? '');
|
||||
const blankCount = Math.max(0, template.split('___').length - 1);
|
||||
const blankCount = (template.match(/_{3,}/g) ?? []).length;
|
||||
if (blankCount < 1) {
|
||||
return 'Word bank questions need at least one ___ blank in the template';
|
||||
}
|
||||
@@ -224,7 +224,9 @@ export const ZExerciseUpdate = z.object({
|
||||
slug: ZSlug.optional(),
|
||||
questions: z.array(ZExerciseUpdateQuestion).optional(),
|
||||
sections: z.array(ZExerciseSection).optional(),
|
||||
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional()
|
||||
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional(),
|
||||
completionPolicy: z.enum(['submitted', 'passed']).optional(),
|
||||
passThreshold: z.number().int().min(0).max(100).optional()
|
||||
});
|
||||
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export const ZLessonUpdate = z.object({
|
||||
public: z.boolean().optional(),
|
||||
slug: ZSlug.optional(),
|
||||
isComplete: z.boolean().optional(),
|
||||
completionPolicy: z.enum(['manual', 'video_watch', 'none']).optional(),
|
||||
videoWatchThreshold: z.number().int().min(1).max(100).optional(),
|
||||
videoUrl: z.url().optional(),
|
||||
slideUrl: z.url().optional(),
|
||||
videos: z
|
||||
@@ -38,6 +40,7 @@ export const ZLessonUpdate = z.object({
|
||||
link: z.string(),
|
||||
key: z.string().optional(),
|
||||
assetId: z.string().uuid().optional(),
|
||||
watchEnforced: z.boolean().optional(),
|
||||
fileName: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional()
|
||||
})
|
||||
@@ -128,3 +131,17 @@ export const ZLessonCompletionUpdate = z.object({
|
||||
isComplete: z.boolean()
|
||||
});
|
||||
export type TLessonCompletionUpdate = z.infer<typeof ZLessonCompletionUpdate>;
|
||||
|
||||
export const ZUpdateLessonWatchProgress = z.object({
|
||||
positionSeconds: z.number().min(0),
|
||||
playedDeltaSeconds: z
|
||||
.number()
|
||||
.transform((value) => Math.round(value))
|
||||
.pipe(z.number().min(0).max(120)),
|
||||
durationSeconds: z
|
||||
.number()
|
||||
.transform((value) => Math.round(value))
|
||||
.pipe(z.int().min(1)),
|
||||
assetId: z.string().optional()
|
||||
});
|
||||
export type TUpdateLessonWatchProgress = z.infer<typeof ZUpdateLessonWatchProgress>;
|
||||
|
||||
Reference in New Issue
Block a user