From 52d9bf18c378f528fd647124b9b27ef2954450bb Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 11 Jun 2026 14:26:42 +0100 Subject: [PATCH] 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. --- apps/api/src/routes/course/exercise.ts | 18 ++ apps/api/src/routes/course/lesson.ts | 99 ++++++- apps/api/src/services/course/access.ts | 36 +++ apps/api/src/services/course/utils.ts | 5 + .../api/src/services/submission/submission.ts | 33 ++- apps/api/src/utils/certificate.ts | 8 +- .../ai-assistant/utils/content-ask-ai-bar.ts | 28 +- .../lib/features/course/api/course.svelte.ts | 12 +- .../lib/features/course/api/lesson.svelte.ts | 72 ++++- .../ceritficate/certificate-design.svelte | 20 +- .../editor/certificate-editor-header.svelte | 15 +- .../editor/certificate-editor.svelte | 1 + .../editor/panels/content-panel.svelte | 182 ++++++++++-- .../store/certificate-editor.store.svelte.ts | 94 ++++-- .../unlocked-certificate.svelte | 7 +- .../exercise/exercise-settings-tab.svelte | 48 +++ .../course/components/exercise/store.ts | 4 + .../course/components/lesson/constants.ts | 2 + .../components/lesson/content-list.svelte | 5 +- .../lesson/content-navigation-actions.svelte | 115 +++++--- .../lesson/content-section-list.svelte | 5 +- .../course/components/lesson/index.ts | 1 + .../lesson/lesson-page-edit-header.svelte | 2 +- .../lesson/lesson-settings-tab.svelte | 183 ++++++++++++ .../lesson/video/lesson-video-player.svelte | 78 ++++- .../video/lesson-video-simple-card.svelte | 8 + .../lesson/video/video-card-dropdown.svelte | 65 ++-- .../lesson/video/video-card-utils.ts | 30 ++ .../components/lesson/video/video.svelte | 6 +- .../student-content-locked-notice.svelte | 23 ++ .../lib/features/course/pages/exercise.svelte | 39 ++- .../lib/features/course/pages/lesson.svelte | 62 +++- .../course/utils/certificate-utils.ts | 10 + .../course/utils/content-completion.ts | 36 +++ .../course/utils/content-lock-utils.ts | 25 ++ .../course/utils/exercise-page-utils.ts | 2 + .../course/utils/progression-utils.ts | 114 +++++++ .../features/course/utils/settings-store.ts | 2 + .../src/lib/features/course/utils/types.ts | 16 + .../media/api/media-manager.svelte.ts | 9 +- .../ai-credits/buy-tokens-card.svelte | 17 +- .../features/settings/pages/billing.svelte | 2 +- .../setup/components/setup-progress.svelte | 43 +-- .../src/lib/features/ui/upgrade-modal.svelte | 11 +- .../src/lib/features/ui/upload-image.svelte | 55 +++- .../src/lib/utils/constants/routes.ts | 2 +- .../src/lib/utils/translations/da.json | 63 +++- .../src/lib/utils/translations/de.json | 63 +++- .../src/lib/utils/translations/es.json | 63 +++- .../src/lib/utils/translations/fr.json | 63 +++- .../src/lib/utils/translations/hi.json | 63 +++- .../src/lib/utils/translations/pl.json | 63 +++- .../src/lib/utils/translations/pt.json | 63 +++- .../src/lib/utils/translations/ru.json | 63 +++- .../src/lib/utils/translations/vi.json | 63 +++- .../exercises/[exerciseId]/+page.server.ts | 2 + .../[id]/exercises/[exerciseId]/+page.svelte | 39 ++- .../routes/api/polar/buy-tokens/+server.ts | 41 +-- .../src/routes/api/polar/portal/+server.ts | 29 +- .../src/routes/api/polar/subscribe/+server.ts | 56 +++- apps/docs/DOCS_MIGRATION.md | 137 --------- packages/certificates/src/constants.ts | 4 +- .../certificates/src/templates/brutalist.ts | 13 +- .../certificates/src/templates/classique.ts | 14 +- .../certificates/src/templates/minimal.ts | 48 ++- packages/certificates/src/templates/noir.ts | 21 +- packages/certificates/src/templates/poster.ts | 34 ++- packages/certificates/src/templates/shared.ts | 94 +++++- packages/certificates/src/types.ts | 2 + packages/core/src/services/course/course.ts | 23 +- .../core/src/services/course/progression.ts | 277 ++++++++++++++++++ packages/core/src/services/course/utils.ts | 5 + packages/core/src/services/lesson/lesson.ts | 248 ++++++++++++++++ .../src/utils/lesson-watch-enforcement.ts | 50 ++++ packages/db/src/queries/course/content.ts | 34 ++- packages/db/src/queries/course/index.ts | 1 + packages/db/src/queries/course/progression.ts | 156 ++++++++++ packages/db/src/queries/lesson/index.ts | 1 + .../db/src/queries/lesson/video-progress.ts | 103 +++++++ packages/db/src/schema.ts | 46 ++- packages/db/src/types.ts | 3 + .../src/templates/certificate/fixtures.ts | 4 +- .../certificate-preview.svelte | 14 +- .../renderers/word-bank/word-bank-utils.ts | 9 +- .../image-cropper-context.svelte.ts | 26 ++ .../image-cropper/image-cropper-dialog.svelte | 14 +- .../image-cropper-use-original.svelte | 39 +++ packages/ui/src/custom/image-cropper/index.ts | 3 +- .../media-player/players/plyr-player.svelte | 167 ++++++++++- packages/ui/src/custom/media-player/types.ts | 10 + .../src/custom/percent-ring-progress/index.ts | 4 + .../percent-ring-progress.svelte | 52 ++++ .../utils/src/validation/exercise/exercise.ts | 6 +- .../utils/src/validation/lesson/lesson.ts | 17 ++ 94 files changed, 3499 insertions(+), 564 deletions(-) create mode 100644 apps/api/src/services/course/access.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/lesson-settings-tab.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/student-content-locked-notice.svelte create mode 100644 apps/dashboard/src/lib/features/course/utils/content-completion.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/progression-utils.ts delete mode 100644 apps/docs/DOCS_MIGRATION.md create mode 100644 packages/core/src/services/course/progression.ts create mode 100644 packages/core/src/utils/lesson-watch-enforcement.ts create mode 100644 packages/db/src/queries/course/progression.ts create mode 100644 packages/db/src/queries/lesson/video-progress.ts create mode 100644 packages/ui/src/custom/image-cropper/image-cropper-use-original.svelte create mode 100644 packages/ui/src/custom/percent-ring-progress/index.ts create mode 100644 packages/ui/src/custom/percent-ring-progress/percent-ring-progress.svelte diff --git a/apps/api/src/routes/course/exercise.ts b/apps/api/src/routes/course/exercise.ts index dacb9bb6..4533a1e8 100644 --- a/apps/api/src/routes/course/exercise.ts +++ b/apps/api/src/routes/course/exercise.ts @@ -36,6 +36,8 @@ import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key'; import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission'; +import { assertEnrolledStudentContentAccess } from '@api/services/course/access'; +import { ContentType } from '@cio/utils/constants'; import { zValidator } from '@hono/zod-validator'; export const exerciseRouter = new Hono() @@ -95,6 +97,15 @@ export const exerciseRouter = new Hono() await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise'); } + if (user?.id && !automationKey) { + await assertEnrolledStudentContentAccess({ + courseId: c.req.param('courseId')!, + profileId: user.id, + contentId: exerciseId, + type: ContentType.Exercise + }); + } + const exercise = await getExercise(exerciseId, undefined, user?.id); if (automationKey?.type === 'mcp') { @@ -260,6 +271,13 @@ export const exerciseRouter = new Hono() return c.json({ success: false, error: 'User is not a member of this course' }, 403); } + await assertEnrolledStudentContentAccess({ + courseId, + profileId: user.id, + contentId: exerciseId, + type: ContentType.Exercise + }); + const submission = await createSubmissionService(courseId, exerciseId, groupMemberId, answers); return c.json({ success: true, data: submission }, 201); diff --git a/apps/api/src/routes/course/lesson.ts b/apps/api/src/routes/course/lesson.ts index 7f38761c..b2278924 100644 --- a/apps/api/src/routes/course/lesson.ts +++ b/apps/api/src/routes/course/lesson.ts @@ -10,7 +10,8 @@ import { ZLessonHistoryQuery, ZLessonListQuery, ZLessonReorder, - ZLessonUpdate + ZLessonUpdate, + ZUpdateLessonWatchProgress } from '@cio/utils/validation/lesson'; import { createLesson, @@ -21,12 +22,17 @@ import { getLessonCommentsPaginated, getLessonCompletionService, getLessonHistoryService, + getLessonWatchProgressService, listLessons, reorderLessons, updateLessonCommentService, updateLessonService, + updateLessonWatchProgressService, upsertLessonCompletionService } from '@api/services/lesson'; +import { assertEnrolledStudentContentAccess } from '@api/services/course/access'; +import { evaluateCourseCertification } from '@api/services/course/completion'; +import { ContentType } from '@cio/utils/constants'; import { Hono } from '@api/utils/hono'; import { ZLessonDownloadContent } from '@cio/utils/validation/course'; @@ -63,10 +69,23 @@ export const lessonRouter = new Hono() }) .get('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => { try { + const user = c.get('user')!; + const courseId = c.req.param('courseId')!; const { lessonId } = c.req.valid('param'); - const lesson = await getLesson(lessonId); - return c.json({ success: true, data: lesson }, 200); + await assertEnrolledStudentContentAccess({ + courseId, + profileId: user.id, + contentId: lessonId, + type: ContentType.Lesson + }); + + const [lesson, watchProgress] = await Promise.all([ + getLesson(lessonId), + getLessonWatchProgressService(lessonId, user.id) + ]); + + return c.json({ success: true, data: { ...lesson, watchProgress } }, 200); } catch (error) { return handleError(c, error, 'Failed to fetch lesson'); } @@ -220,17 +239,91 @@ export const lessonRouter = new Hono() async (c) => { try { const user = c.get('user')!; + const courseId = c.req.param('courseId')!; const { lessonId } = c.req.valid('param'); const { isComplete } = c.req.valid('json'); + await assertEnrolledStudentContentAccess({ + courseId, + profileId: user.id, + contentId: lessonId, + type: ContentType.Lesson + }); + const completion = await upsertLessonCompletionService(lessonId, user.id, isComplete); + if (isComplete) { + void evaluateCourseCertification(courseId, user.id).catch((certError) => { + console.error('Failed to evaluate course certification after lesson completion:', certError); + }); + } + return c.json({ success: true, data: completion }, 200); } catch (error) { return handleError(c, error, 'Failed to update lesson completion'); } } ) + .get( + '/:lessonId/watch-progress', + authMiddleware, + courseMemberMiddleware, + zValidator('param', ZLessonGetParam), + async (c) => { + try { + const user = c.get('user')!; + const courseId = c.req.param('courseId')!; + const { lessonId } = c.req.valid('param'); + + await assertEnrolledStudentContentAccess({ + courseId, + profileId: user.id, + contentId: lessonId, + type: ContentType.Lesson + }); + + const watchProgress = await getLessonWatchProgressService(lessonId, user.id); + + return c.json({ success: true, data: watchProgress }, 200); + } catch (error) { + return handleError(c, error, 'Failed to get lesson watch progress'); + } + } + ) + .put( + '/:lessonId/watch-progress', + authMiddleware, + courseMemberMiddleware, + zValidator('param', ZLessonGetParam), + zValidator('json', ZUpdateLessonWatchProgress), + async (c) => { + try { + const user = c.get('user')!; + const courseId = c.req.param('courseId')!; + const { lessonId } = c.req.valid('param'); + const beat = c.req.valid('json'); + + await assertEnrolledStudentContentAccess({ + courseId, + profileId: user.id, + contentId: lessonId, + type: ContentType.Lesson + }); + + const watchProgress = await updateLessonWatchProgressService(lessonId, user.id, beat); + + if (watchProgress.didJustComplete) { + void evaluateCourseCertification(courseId, user.id).catch((certError) => { + console.error('Failed to evaluate course certification after video watch:', certError); + }); + } + + return c.json({ success: true, data: watchProgress }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update lesson watch progress'); + } + } + ) /** * GET /course/:courseId/lesson/:lessonId/history * Gets lesson version history for a lesson and locale diff --git a/apps/api/src/services/course/access.ts b/apps/api/src/services/course/access.ts new file mode 100644 index 00000000..d7558e73 --- /dev/null +++ b/apps/api/src/services/course/access.ts @@ -0,0 +1,36 @@ +import { ContentType } from '@cio/utils/constants'; +import { assertStudentCanAccessContent } from '@cio/core/services/course/progression'; +import { getCourseById, getCourseProgress } from '@cio/db/queries/course/course'; +import { getCourseContentItems } from '@cio/db/queries/course/content'; + +const DEFAULT_CONTENT_GROUPING = true; + +export async function assertEnrolledStudentContentAccess(params: { + courseId: string; + profileId: string; + contentId: string; + type: ContentType.Lesson | ContentType.Exercise; +}): Promise { + const [courseRow, progress, contentItems] = await Promise.all([ + getCourseById(params.courseId), + getCourseProgress(params.courseId, params.profileId), + getCourseContentItems(params.courseId, params.profileId) + ]); + + const course = courseRow[0]; + if (!course) return; + + const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING; + const progressionMode = course.metadata?.progressionMode ?? 'free'; + + await assertStudentCanAccessContent({ + courseId: params.courseId, + profileId: params.profileId, + roleId: progress.roleId, + contentId: params.contentId, + type: params.type, + progressionMode, + contentRows: contentItems, + isContentGroupingEnabled + }); +} diff --git a/apps/api/src/services/course/utils.ts b/apps/api/src/services/course/utils.ts index dff17323..397f043d 100644 --- a/apps/api/src/services/course/utils.ts +++ b/apps/api/src/services/course/utils.ts @@ -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 = { diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index e7d6b321..d44cd8ee 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -44,10 +44,36 @@ import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3'; import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance'; +import { evaluateCourseCertification } from '@api/services/course/completion'; +import { isExercisePassedForMember } from '@cio/db/queries/course/progression'; type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed'; type SubmissionOverallStatus = 'auto_graded' | 'manual_required' | 'hybrid'; +async function triggerCertificationIfExerciseComplete( + courseId: string, + exerciseId: string, + groupMemberId: string +): Promise { + const exercise = await getExerciseById(exerciseId); + if (!exercise) return; + + const profile = await getProfileByGroupMemberId(groupMemberId); + if (!profile?.id) return; + + const policy = exercise.completionPolicy ?? 'submitted'; + + if (policy === 'passed') { + const threshold = exercise.passThreshold ?? 100; + const passed = await isExercisePassedForMember(exerciseId, groupMemberId, threshold); + if (!passed) return; + } + + void evaluateCourseCertification(courseId, profile.id).catch((certError) => { + console.error('Failed to evaluate course certification after exercise submission:', certError); + }); +} + const LEGACY_BOARD_STATUS_LABELS: Record = { 1: 'Submitted', 2: 'In Progress', @@ -638,10 +664,7 @@ export async function createSubmissionService( }); } - const shouldAutoGrade = - (course?.type === 'SELF_PACED' || course?.type === 'COMPLIANCE') && - overallStatus === 'auto_graded' && - exerciseWithRelations.questions.length > 0; + const shouldAutoGrade = overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0; if (shouldAutoGrade) { const answeredIds = new Set(answerByQuestionId.keys()); @@ -701,6 +724,7 @@ export async function createSubmissionService( }); await syncComplianceProgressFromSubmission(courseId, submittedBy); + await triggerCertificationIfExerciseComplete(courseId, exerciseId, submittedBy); const enrichedAnswers = answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints; @@ -713,6 +737,7 @@ export async function createSubmissionService( }); await syncComplianceProgressFromSubmission(courseId, submittedBy); + await triggerCertificationIfExerciseComplete(courseId, exerciseId, submittedBy); return submission; } catch (error) { diff --git a/apps/api/src/utils/certificate.ts b/apps/api/src/utils/certificate.ts index ef2ab441..b48d6d62 100644 --- a/apps/api/src/utils/certificate.ts +++ b/apps/api/src/utils/certificate.ts @@ -35,11 +35,15 @@ export function resolveCertificateDesign(stored: unknown): CertificateDesign { const signatories: CertificateDesign['signatories'] = [ { name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name, - role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role + role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role, + enabled: storedSignatories?.[0]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].enabled, + signatureUrl: storedSignatories?.[0]?.signatureUrl }, { name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name, - role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role + role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role, + enabled: storedSignatories?.[1]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].enabled, + signatureUrl: storedSignatories?.[1]?.signatureUrl } ]; diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/content-ask-ai-bar.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/content-ask-ai-bar.ts index 9b39b57a..100a3916 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/utils/content-ask-ai-bar.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/content-ask-ai-bar.ts @@ -13,22 +13,40 @@ function findContentItem( return items.find((item) => item.id === contentId && item.type === contentType); } -export function isCourseContentLockedForStudent( +export type StudentContentLockReason = 'teacher_locked' | 'progression_locked'; + +export function getStudentContentLockReason( course: Course | null, contentId: string | undefined, contentType: typeof ContentType.Lesson | typeof ContentType.Exercise | null -): boolean { +): StudentContentLockReason | null { if (!contentId || !contentType) { - return false; + return null; } const item = findContentItem(course, contentId, contentType); if (!item) { - return false; + return null; } - return (item.isUnlocked ?? true) === false; + if ((item.isUnlocked ?? true) === false) { + return 'teacher_locked'; + } + + if (item.accessible === false) { + return 'progression_locked'; + } + + return null; +} + +export function isCourseContentLockedForStudent( + course: Course | null, + contentId: string | undefined, + contentType: typeof ContentType.Lesson | typeof ContentType.Exercise | null +): boolean { + return getStudentContentLockReason(course, contentId, contentType) !== null; } export function getContentAskAiBarWidthClass(options: { diff --git a/apps/dashboard/src/lib/features/course/api/course.svelte.ts b/apps/dashboard/src/lib/features/course/api/course.svelte.ts index cc24c59c..7c3bb214 100644 --- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts @@ -18,6 +18,7 @@ import type { import type { TCourseType } from '@cio/db/types'; import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course'; import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils'; +import { syncProgressionAccessToCourse } from '../utils/progression-utils'; import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { currentOrg } from '$lib/utils/store/org'; import { get } from 'svelte/store'; @@ -104,13 +105,13 @@ export class CourseApi extends BaseApiWithErrors { if (!hasUpdatedItem) return false; - this.course = { + this.course = syncProgressionAccessToCourse({ ...this.course, content: { ...content, sections } - }; + }); return true; } @@ -127,13 +128,13 @@ export class CourseApi extends BaseApiWithErrors { if (!hasUpdatedItem) return false; - this.course = { + this.course = syncProgressionAccessToCourse({ ...this.course, content: { ...content, items } - }; + }); return true; } @@ -654,7 +655,8 @@ export class CourseApi extends BaseApiWithErrors { imgUrl: '' }, allowNewStudent: false, - isContentGroupingEnabled: true + isContentGroupingEnabled: true, + progressionMode: 'free' }; } diff --git a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts index 3e823f68..f85b9ec6 100644 --- a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts @@ -1,4 +1,7 @@ import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { courseApi } from './course.svelte'; +import { toggleConfetti } from '$features/ui/confetti/store'; +import { updateLessonCompletionInCourseContent } from '../utils/content-completion'; import type { CourseSectionWithLessons, CreateCourseSectionRequest, @@ -20,8 +23,13 @@ import type { UpdateCourseSectionRequest, UpdateLessonCommentRequest, UpdateLessonCompletionRequest, - UpdateLessonRequest + UpdateLessonRequest, + UpdateLessonWatchProgressRequest, + GetLessonWatchProgressRequest, + type LessonWatchProgress, + type LessonWatchProgressUpdate } from '../utils/types'; +import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson'; import type { TCourseSectionCreate, TCourseSectionPromoteUngrouped, @@ -616,6 +624,68 @@ export class LessonApi extends BaseApiWithErrors { /** * Updates lesson completion */ + async getWatchProgress(courseId: string, lessonId: string): Promise { + const response = await this.execute({ + requestFn: () => + classroomio.course[':courseId'].lesson[':lessonId']['watch-progress'].$get({ + param: { courseId, lessonId } + }), + logContext: 'fetching lesson watch progress' + }); + + if (!response || !('data' in response)) return null; + + return response.data; + } + + async reportWatchProgress(courseId: string, lessonId: string, beat: TUpdateLessonWatchProgress) { + const payload: TUpdateLessonWatchProgress = { + ...beat, + durationSeconds: Math.round(beat.durationSeconds), + playedDeltaSeconds: Math.round(beat.playedDeltaSeconds), + positionSeconds: Math.floor(beat.positionSeconds) + }; + + return this.execute({ + requestFn: () => + classroomio.course[':courseId'].lesson[':lessonId']['watch-progress'].$put({ + param: { courseId, lessonId }, + json: payload + }), + logContext: 'reporting lesson watch progress', + onSuccess: (response) => { + const data = response.data as LessonWatchProgressUpdate | null; + if (!data || !this.lesson || this.lesson.id !== lessonId) return; + + if (data.didJustComplete) { + toggleConfetti(); + setTimeout(() => toggleConfetti(), 3000); + } + + if (courseApi.course?.content && data.didJustComplete) { + courseApi.course = updateLessonCompletionInCourseContent(courseApi.course, lessonId, true); + } + + this.lesson = { + ...this.lesson, + watchProgress: { + lastPositionSeconds: data.lastPositionSeconds, + watchedSeconds: data.watchedSeconds, + furthestSeconds: data.furthestSeconds, + durationSeconds: data.durationSeconds, + isComplete: data.isComplete, + didJustComplete: data.didJustComplete, + watchedPercent: data.watchedPercent, + videosComplete: data.videosComplete, + videosRequired: data.videosRequired, + assets: data.assets + }, + isComplete: data.isComplete ? true : this.lesson.isComplete + }; + } + }); + } + async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) { return this.execute({ requestFn: () => diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-design.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-design.svelte index 40ba7c8c..0437b289 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-design.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-design.svelte @@ -36,11 +36,15 @@ signatories: [ { name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name, - role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role + role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role, + enabled: stored?.signatories?.[0]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].enabled, + signatureUrl: stored?.signatories?.[0]?.signatureUrl }, { name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name, - role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role + role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role, + enabled: stored?.signatories?.[1]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].enabled, + signatureUrl: stored?.signatories?.[1]?.signatureUrl } ], idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat @@ -97,10 +101,14 @@ {$t('course.navItem.certificates.editor.field_signatories')}
-
{design.signatories[0].name}
-
{design.signatories[0].role}
-
{design.signatories[1].name}
-
{design.signatories[1].role}
+ {#if design.signatories[0].enabled} +
{design.signatories[0].name}
+
{design.signatories[0].role}
+ {/if} + {#if design.signatories[1].enabled} +
{design.signatories[1].name}
+
{design.signatories[1].role}
+ {/if}
diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor-header.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor-header.svelte index 45437047..0c6d08b0 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor-header.svelte @@ -11,12 +11,23 @@ templateLabel: string; isDirty: boolean; isSaving: boolean; + isSignatureUploading?: boolean; isFreePlan: boolean; onSave: () => void; onDiscard: () => void; } - let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props(); + let { + courseId, + courseTitle, + templateLabel, + isDirty, + isSaving, + isSignatureUploading = false, + isFreePlan, + onSave, + onDiscard + }: Props = $props();
diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor.svelte index 409b730a..4badf83c 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/certificate-editor.svelte @@ -66,6 +66,7 @@ templateLabel={activeTemplateMeta.label} isDirty={store.isDirty} isSaving={store.isSaving} + isSignatureUploading={store.isSignatureUploading} isFreePlan={$isFreePlan} onSave={() => store.save()} onDiscard={() => store.reset()} diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/panels/content-panel.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/panels/content-panel.svelte index e578413f..b72b4dc5 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/panels/content-panel.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/panels/content-panel.svelte @@ -1,8 +1,12 @@ @@ -44,34 +115,89 @@ {$t('course.navItem.certificates.editor.section_signatories')} - - - - - - - - - - - - + + + + {$t('course.navItem.certificates.editor.signatory_one_toggle')} + + + {#if certificateEditorStore.draft.signatories[0].enabled} + + + + + + + + + {$t('course.navItem.certificates.editor.signature_upload')} + + + {$t('course.navItem.certificates.editor.signature_upload_hint')} + + + + {/if} + + + + + + + + {$t('course.navItem.certificates.editor.signatory_two_toggle')} + + + {#if certificateEditorStore.draft.signatories[1].enabled} + + + + + + + + + {$t('course.navItem.certificates.editor.signature_upload')} + + + {$t('course.navItem.certificates.editor.signature_upload_hint')} + + + + {/if} + diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/store/certificate-editor.store.svelte.ts b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/store/certificate-editor.store.svelte.ts index e3816729..b755f9cd 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/editor/store/certificate-editor.store.svelte.ts +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/editor/store/certificate-editor.store.svelte.ts @@ -22,7 +22,40 @@ export interface CertificateEditorDraft { subtitle: string; descriptionOverride: string; idFormat: 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 } + ]; +} + +function toDraftSignatory( + signatory: CertificateDesign['signatories'][number] | undefined, + fallback: CertificateDesign['signatories'][number] +) { + return { + name: signatory?.name ?? fallback.name, + role: signatory?.role ?? fallback.role, + enabled: signatory?.enabled ?? fallback.enabled ?? true, + signatureUrl: signatory?.signatureUrl ?? '' + }; +} + +function isPersistableSignatureUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + + return !trimmed.startsWith('blob:') && !trimmed.startsWith('data:'); +} + +function fromDraftSignatory(signatory: CertificateEditorDraft['signatories'][number]) { + const signatureUrl = signatory.signatureUrl.trim(); + + return { + name: signatory.name, + role: signatory.role, + enabled: signatory.enabled, + signatureUrl: isPersistableSignatureUrl(signatureUrl) ? signatureUrl : undefined + }; } function toDraft(design: CertificateDesign): CertificateEditorDraft { @@ -33,8 +66,8 @@ function toDraft(design: CertificateDesign): CertificateEditorDraft { descriptionOverride: design.descriptionOverride ?? '', idFormat: design.idFormat ?? '', signatories: [ - { name: design.signatories[0]?.name ?? '', role: design.signatories[0]?.role ?? '' }, - { name: design.signatories[1]?.name ?? '', role: design.signatories[1]?.role ?? '' } + toDraftSignatory(design.signatories[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]), + toDraftSignatory(design.signatories[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1]) ] }; } @@ -46,10 +79,7 @@ function fromDraft(draft: CertificateEditorDraft): CertificateDesign { subtitle: draft.subtitle.trim() || undefined, descriptionOverride: draft.descriptionOverride.trim() || undefined, idFormat: draft.idFormat.trim() || undefined, - signatories: [ - { name: draft.signatories[0].name, role: draft.signatories[0].role }, - { name: draft.signatories[1].name, role: draft.signatories[1].role } - ] + signatories: [fromDraftSignatory(draft.signatories[0]), fromDraftSignatory(draft.signatories[1])] }; } @@ -64,14 +94,8 @@ function readStoredDesign(): CertificateDesign { subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle, descriptionOverride: stored?.descriptionOverride, signatories: [ - { - name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name, - role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role - }, - { - name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name, - role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role - } + toDraftSignatory(stored?.signatories?.[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]), + toDraftSignatory(stored?.signatories?.[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1]) ], idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat }; @@ -82,6 +106,7 @@ class CertificateEditorStore { draft = $state(toDraft(DEFAULT_CERTIFICATE_DESIGN)); initial = $state(toDraft(DEFAULT_CERTIFICATE_DESIGN)); isSaving = $state(false); + isSignatureUploading = $state(false); #initializedCourseId: string | null = null; readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial)); @@ -107,6 +132,17 @@ class CertificateEditorStore { this.draft.accentColor = color; } + setSignatorySignatureUrl(index: 0 | 1, signatureUrl: string) { + const signatory = this.draft.signatories[index]; + const nextSignatories = [...this.draft.signatories] as CertificateEditorDraft['signatories']; + nextSignatories[index] = { ...signatory, signatureUrl }; + + this.draft = { + ...this.draft, + signatories: nextSignatories + }; + } + /** * Returns a render-ready design with empty optional strings collapsed to * `undefined`, suitable for handing to `Certificate.Preview` / API payload. @@ -119,22 +155,40 @@ class CertificateEditorStore { const course = courseApi.course; if (!course?.id) return; + if (this.isSignatureUploading) { + snackbar.error(t.get('course.navItem.certificates.editor.signature_upload_in_progress')); + return; + } + this.isSaving = true; try { + const design = fromDraft(this.draft); const certificate = { ...(course.certificate ?? {}), - design: fromDraft(this.draft), + design, theme: this.draft.templateId }; const updated = await courseApi.update(course.id, { certificate }, { showSuccessToast: false }); if (updated) { - this.initial = { - ...this.draft, - signatories: [{ ...this.draft.signatories[0] }, { ...this.draft.signatories[1] }] - }; + if (courseApi.course) { + courseApi.course.certificate = { + ...(courseApi.course.certificate ?? {}), + design, + theme: this.draft.templateId + }; + } + + this.initial = toDraft(design); + this.#initializedCourseId = null; + this.syncFromCourse(course.id, true); snackbar.success(t.get('course.navItem.certificates.editor.saved')); + return; + } + + if (Object.keys(courseApi.errors).length > 0) { + snackbar.error(t.get('course.navItem.certificates.editor.save_failed')); } } finally { this.isSaving = false; diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte index 534b4818..d8eb5c20 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte @@ -11,6 +11,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import { classroomio } from '$lib/utils/services/api'; import type { CertificationEvaluationData } from '$features/course/utils/types'; + import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils'; import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; let isLoading = $state(false); @@ -59,7 +60,7 @@ return { studentName: $profile.fullname || 'Recipient', studentId: $profile.id || undefined, - issuedAt: new Date().toISOString() + issuedAt: normalizeCertificateIssuedAt(evaluation?.certificateEarnedAt) } as const; } @@ -90,7 +91,7 @@ ); } catch (error) { console.error('Error downloading', error); - snackbar.error($t('course.navItem.certificates.unexpected_error')); + snackbar.error('course.navItem.certificates.unexpected_error'); } isLoading = false; @@ -112,7 +113,7 @@ ); } catch (error) { console.error('Error downloading image', error); - snackbar.error($t('course.navItem.certificates.unexpected_error')); + snackbar.error('course.navItem.certificates.unexpected_error'); } isPngLoading = false; diff --git a/apps/dashboard/src/lib/features/course/components/exercise/exercise-settings-tab.svelte b/apps/dashboard/src/lib/features/course/components/exercise/exercise-settings-tab.svelte index dcf2578f..31a90ed2 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/exercise-settings-tab.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/exercise-settings-tab.svelte @@ -9,6 +9,7 @@ import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; import { slugifyTitle } from '@cio/utils/validation'; + import * as Select from '@cio/ui/base/select'; type Props = { exerciseId: string; @@ -31,6 +32,8 @@ await exerciseApi.update(courseApi.course.id, exerciseId, { allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts, + completionPolicy: $questionnaire.completionPolicy ?? 'submitted', + passThreshold: $questionnaire.passThreshold ?? 100, slug: isPublicCourse && slug ? slug : undefined }); saving = false; @@ -60,6 +63,51 @@ /> +
+ + { + if (value === 'submitted' || value === 'passed') { + questionnaire.update((state) => ({ ...state, completionPolicy: value })); + } + }} + > + + {($questionnaire.completionPolicy ?? 'submitted') === 'passed' + ? $t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed') + : $t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')} + + + + {$t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')} + + + {$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')} + + + + + {#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'} +
+ { + const parsed = Number(event.currentTarget.value); + if (!Number.isNaN(parsed)) { + questionnaire.update((state) => ({ ...state, passThreshold: parsed })); + } + }} + /> +
+ {/if} +
+ {#if isPublicCourse}
= writable({ sectionDisplayMode: 'one_question', totalSubmissions: 0, allowMultipleAttempts: false, + completionPolicy: 'submitted', + passThreshold: 100, slug: '' }); diff --git a/apps/dashboard/src/lib/features/course/components/lesson/constants.ts b/apps/dashboard/src/lib/features/course/components/lesson/constants.ts index 816cb020..799dadbb 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/constants.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/constants.ts @@ -39,6 +39,8 @@ export const tabs: MaterialTab[] = [ } ]; +export const SETTINGS_TAB_VALUE = 'settings'; + export function orderedTabs(tabs: MaterialTab[], settingTabs?: Tabs[]) { if (!Array.isArray(settingTabs)) return tabs; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte index a6cb38de..f1291646 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte @@ -200,7 +200,10 @@ } function isLockedForStudent(item: ContentDndItem, isStudent: boolean) { - return isStudent && (item.isUnlocked ?? true) === false; + if (!isStudent) return false; + if ((item.isUnlocked ?? true) === false) return true; + + return item.accessible === false; } function getItemLockKey(item: ContentDndItem) { diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte index bf0982b2..ff5f0d6d 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte @@ -6,6 +6,7 @@ import { CircleCheckIcon } from '$features/ui/icons'; import { Button } from '@cio/ui/base/button'; import * as Tooltip from '@cio/ui/base/tooltip'; + import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress'; import { isOrgStudent } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; import { courseApi, lessonApi } from '$features/course/api'; @@ -14,6 +15,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import type { CourseContentItem } from '$features/course/utils/types'; import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion'; interface Props { lessonId?: string; @@ -63,8 +65,54 @@ const showMarkComplete = $derived(!!lessonId && !exerciseId); const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null); - const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)); - const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete); + const isTeacherLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)); + const isProgressionLocked = $derived($isOrgStudent && currentLessonItem?.accessible === false && !isTeacherLocked); + const isVideoWatchLesson = $derived.by(() => { + if (!lessonId) return false; + + return ( + lessonApi.lesson?.completionPolicy === 'video_watch' || currentLessonItem?.completionPolicy === 'video_watch' + ); + }); + const watchedPercent = $derived.by(() => { + const progress = lessonApi.lesson?.watchProgress; + if (progress?.isComplete) return 100; + + if (typeof progress?.watchedPercent === 'number') { + return progress.watchedPercent; + } + + const durationSeconds = progress?.durationSeconds; + if (durationSeconds && durationSeconds > 0) { + return Math.min(100, ((progress?.watchedSeconds ?? 0) / durationSeconds) * 100); + } + + return 0; + }); + + const watchedPercentDisplay = $derived(Math.round(watchedPercent)); + + const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0); + const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0); + const isLessonLocked = $derived(isTeacherLocked || isProgressionLocked); + const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false)); + const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete)); + const isMarkCompleteDisabled = $derived( + isMarkingComplete || isLessonLocked || isLessonComplete || isVideoWatchLesson + ); + const showWatchProgress = $derived( + !!lessonId && $isOrgStudent && isVideoWatchLesson && !showVideoWatchCompleteState && !isLessonLocked + ); + + const watchProgressTooltip = $derived( + watchVideosRequired > 1 + ? t.get('course.navItem.lessons.watch_progress.videos_complete', { + complete: watchVideosComplete, + required: watchVideosRequired, + percent: watchedPercentDisplay + }) + : t.get('course.navItem.lessons.watch_progress.status', { percent: watchedPercentDisplay }) + ); async function markLessonComplete(currentLessonId: string) { isMarkingComplete = true; @@ -99,31 +147,7 @@ function updateCourseContentCompletion(currentLessonId: string, isComplete: boolean) { if (!courseApi.course?.content) return; - if (courseApi.course.content.grouped) { - courseApi.course = { - ...courseApi.course, - content: { - ...courseApi.course.content, - sections: courseApi.course.content.sections.map((section) => ({ - ...section, - items: section.items.map((item) => - item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item - ) - })) - } - }; - return; - } - - courseApi.course = { - ...courseApi.course, - content: { - ...courseApi.course.content, - items: courseApi.course.content.items.map((item) => - item.type === ContentType.Lesson && item.id === currentLessonId ? { ...item, isComplete } : item - ) - } - }; + courseApi.course = updateLessonCompletionInCourseContent(courseApi.course, currentLessonId, isComplete); } const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); @@ -145,20 +169,31 @@
- {#if showMarkComplete && lessonId} - - {/if} - + {#if showWatchProgress} + + + + + + + + {watchProgressTooltip} + + + {:else if showMarkComplete && lessonId && !isLessonLocked && (showVideoWatchCompleteState || !isVideoWatchLesson)} + + {/if} +
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte index 695c6b64..f299681f 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-section-list.svelte @@ -366,7 +366,10 @@ } function isLockedForStudent(item: ContentDndItem) { - return isStudentView && (item.isUnlocked ?? true) === false; + if (!isStudentView) return false; + if ((item.isUnlocked ?? true) === false) return true; + + return item.accessible === false; } function getItemLockKey(item: ContentDndItem) { diff --git a/apps/dashboard/src/lib/features/course/components/lesson/index.ts b/apps/dashboard/src/lib/features/course/components/lesson/index.ts index 934e3555..889ffabd 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/index.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/index.ts @@ -9,3 +9,4 @@ export { default as Video } from './video/video.svelte'; export { default as Document } from './document/document.svelte'; export { default as AddVideoModal } from './video/add-video-modal.svelte'; export { default as AddDocumentModal } from './document/add-document-modal.svelte'; +export { default as LessonSettingsTab } from './lesson-settings-tab.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte index bc3e33af..1109a4a8 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/lesson-page-edit-header.svelte @@ -79,7 +79,7 @@ {#if mode === MODES.edit && !$isOrgStudent} -
+
{#if isUnlocked} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/lesson-settings-tab.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-settings-tab.svelte new file mode 100644 index 00000000..27c72d6d --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/lesson-settings-tab.svelte @@ -0,0 +1,183 @@ + + +
+ + + {$t('course.navItem.lessons.settings.progression.heading')} + {$t('course.navItem.lessons.settings.progression.intro')} + + {$t('course.navItem.lessons.settings.progression.students_only_note')} + + + + + + {$t('course.navItem.lessons.completion_policy.label')} + + + + {policyTriggerLabel(completionPolicy)} + + + +
+ {$t('course.navItem.lessons.completion_policy.manual_label')} + + {$t('course.navItem.lessons.completion_policy.manual_description')} + +
+
+ +
+ {$t('course.navItem.lessons.completion_policy.video_watch_label')} + + {$t('course.navItem.lessons.completion_policy.video_watch_description')} + +
+
+ +
+ {$t('course.navItem.lessons.completion_policy.none_label')} + + {$t('course.navItem.lessons.completion_policy.none_description')} + +
+
+
+
+
+ +
+

{$t('course.navItem.lessons.settings.progression.option_guide_heading')}

+
    +
  • + + {$t('course.navItem.lessons.completion_policy.manual_label')}: + + {$t('course.navItem.lessons.completion_policy.manual_description')} +
  • +
  • + + {$t('course.navItem.lessons.completion_policy.video_watch_label')}: + + {$t('course.navItem.lessons.completion_policy.video_watch_description')} +
  • +
  • + + {$t('course.navItem.lessons.completion_policy.none_label')}: + + {$t('course.navItem.lessons.completion_policy.none_description')} +
  • +
+
+ + {#if completionPolicy === 'video_watch'} + + + + + + {$t('course.navItem.lessons.settings.progression.watch_videos_heading')} + {$t('course.navItem.lessons.settings.progression.watch_videos_helper')} + + {#if enforceableVideos.length === 0} + {$t('course.navItem.lessons.settings.progression.watch_videos_empty')} + {:else} + + {#each enforceableVideos as { video, index } (video.assetId ?? index)} + + toggleVideoWatchEnforced(index, checked === true)} + /> + + {getVideoTitle(video, index)} + {#if isHlsUploadVideo(video)} + (HLS) + {/if} + + + {/each} + + {/if} + + {#if showWatchVideosError} + {$t('course.navItem.lessons.settings.progression.watch_videos_required')} + {/if} + + {/if} +
+
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte index 0049d260..a8f9aa12 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte @@ -7,8 +7,11 @@ import { t } from '$lib/utils/functions/translations'; import { sidePanel } from '$features/side-panel'; import { classroomio } from '$lib/utils/services/api'; + import { lessonApi } from '$features/course/api'; + import { snackbar } from '$features/ui/snackbar/store'; import type { AssetTranscriptPayload } from '$features/media/utils/types'; - import type { LessonVideo } from './video-card-utils'; + import { isOrgStudent } from '$lib/utils/store/app'; + import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils'; import { lessonVideoBus } from './lesson-video-bus.svelte'; import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition'; @@ -83,9 +86,11 @@ interface Props { video: LessonVideo; + courseId: string; + lessonId: string; } - let { video }: Props = $props(); + let { video, courseId, lessonId }: Props = $props(); let localTranscript = $state(null); let localTranscriptLoading = $state(false); @@ -310,11 +315,76 @@ jobPoller.start(); } + const isEnforceableUpload = $derived(video.type === 'upload' || isHls); + + const isWatchEnforcedForVideo = $derived.by(() => { + const lesson = lessonApi.lesson; + if (!lesson || !uploadAssetId || !isEnforceableUpload) return false; + + const enforcedAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy); + + return enforcedAssetIds.includes(uploadAssetId); + }); + + const assetWatchProgress = $derived.by(() => { + const lesson = lessonApi.lesson; + if (!lesson?.watchProgress?.assets || !uploadAssetId) return null; + + return lesson.watchProgress.assets.find((asset) => asset.assetId === uploadAssetId) ?? null; + }); + + const seekPolicy = $derived.by(() => { + const lesson = lessonApi.lesson; + if (!$isOrgStudent || !lesson || lesson.completionPolicy !== 'video_watch' || !isWatchEnforcedForVideo) { + return undefined; + } + + if (assetWatchProgress?.isComplete) { + return undefined; + } + + return { + mode: 'locked_until_complete' as const, + watchThresholdPercent: lesson.videoWatchThreshold ?? 95, + initialFurthestSeconds: assetWatchProgress?.furthestSeconds ?? 0, + pauseOnHidden: true, + onProgress: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => { + if (!uploadAssetId) return; + + void lessonApi.reportWatchProgress(courseId, lessonId, { + ...payload, + assetId: uploadAssetId + }); + }, + onSeekBlocked: () => { + snackbar.error('course.navItem.lessons.watch_progress.seek_blocked'); + } + }; + }); + + async function restoreWatchProgress(): Promise { + if (!$isOrgStudent || !uploadAssetId) return; + + const cachedAsset = assetWatchProgress; + if (cachedAsset?.lastPositionSeconds && cachedAsset.lastPositionSeconds > 0) { + pendingResumeSeconds = cachedAsset.lastPositionSeconds; + return; + } + + const progress = await lessonApi.getWatchProgress(courseId, lessonId); + const assetProgress = progress?.assets?.find((asset) => asset.assetId === uploadAssetId); + if (assetProgress?.lastPositionSeconds && assetProgress.lastPositionSeconds > 0) { + pendingResumeSeconds = assetProgress.lastPositionSeconds; + } + } + onMount(() => { if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) { syncVideoBus(); } + void restoreWatchProgress(); + void (async () => { await loadTranscript(); if (!isMounted) return; @@ -399,9 +469,11 @@ onSourceLoaded: handleSourceLoaded, onBeforeHlsLoad: isHls && uploadAssetId ? () => mintHlsCookie(uploadAssetId) : undefined, transcriptPanelControl, + loadingLabel: $t('course.navItem.lessons.materials.tabs.video.loading'), playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'), playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'), - onPlaybackReload: handlePlaybackReload + onPlaybackReload: handlePlaybackReload, + seekPolicy }} />
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte index 06d39cd5..dbb3c4df 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte @@ -25,6 +25,8 @@ let { video, index, isEditMode, onRemove }: Props = $props(); + let isTranscribingForVideo = $state(false); + const thumbnailUrl = $derived(getVideoThumbnailUrl(video)); const title = $derived(getVideoTitle(video, index)); const durationSeconds = $derived(getVideoDurationSeconds(video)); @@ -143,6 +145,7 @@ {onRemove} onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)} onHlsMetadataUpdated={(metadata) => lessonApi.updateLessonVideoMetadata(index, metadata)} + onTranscribingChange={(v) => (isTranscribingForVideo = v)} menuPlacement="inline" /> {/if} @@ -150,6 +153,11 @@

{channelLine}

+ {#if isEditMode && isTranscribingForVideo} +

+ {$t('course.navItem.lessons.materials.tabs.video.simple_card.transcription_in_progress')} +

+ {/if}

{metaLine}

diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte index 988f062f..1a992a2c 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte @@ -14,7 +14,7 @@ import { sidePanel } from '$features/side-panel'; import { snackbar } from '$features/ui/snackbar/store'; import { t } from '$lib/utils/functions/translations'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { canGenerateHls1080, getHls1080Status, @@ -32,11 +32,19 @@ onRemove: () => void; onThumbnailSaved?: (thumbnailUrl: string) => void; onHlsMetadataUpdated?: (metadata: Record) => void; + onTranscribingChange?: (value: boolean) => void; /** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */ menuPlacement?: 'inline' | 'corner'; } - let { video, onRemove, onThumbnailSaved, onHlsMetadataUpdated, menuPlacement = 'inline' }: Props = $props(); + let { + video, + onRemove, + onThumbnailSaved, + onHlsMetadataUpdated, + onTranscribingChange, + menuPlacement = 'inline' + }: Props = $props(); let manageThumbsOpen = $state(false); let manageThumbsAsset = $state(null); @@ -47,6 +55,11 @@ let hasTranscript = $state(false); let hasCheckedTranscript = $state(false); let activePoller: JobPoller | null = null; + + function setIsTranscribing(value: boolean) { + isTranscribing = value; + onTranscribingChange?.(value); + } let isGenerating1080 = $state(false); let sourceFileInput: HTMLInputElement | undefined = $state(); @@ -61,6 +74,12 @@ isHlsUploadVideo(video) && !hasHls1080Rendition(video) && !isGenerating1080 && hls1080Status === 'unavailable' ); + onMount(() => { + if (canGenerateTranscript) { + void ensureTranscriptChecked(); + } + }); + onDestroy(() => { activePoller?.stop(); }); @@ -69,8 +88,19 @@ if (hasCheckedTranscript || !assetId) return; hasCheckedTranscript = true; - const data = await mediaApi.getAssetTranscript(assetId); - hasTranscript = !!data?.segments?.length; + + const [transcriptData, runs] = await Promise.all([ + mediaApi.getAssetTranscript(assetId), + jobsApi.getMediaJobsForAsset(assetId) + ]); + + hasTranscript = !!transcriptData?.segments?.length; + + const latest = runs?.[0]; + if (latest && (latest.job.status === 'pending' || latest.job.status === 'running')) { + setIsTranscribing(true); + void startTranscriptionPoll(assetId); + } } async function ensureHlsMetadataChecked() { @@ -99,13 +129,13 @@ const latest = runs?.[0]; if (!latest) { - isTranscribing = false; + setIsTranscribing(false); return; } if (latest.job.status === 'completed') { - isTranscribing = false; + setIsTranscribing(false); hasTranscript = true; snackbar.success('snackbar.media_manager.transcription_completed'); @@ -113,7 +143,7 @@ } if (latest.job.status === 'failed' || latest.job.status === 'canceled') { - isTranscribing = false; + setIsTranscribing(false); return; } @@ -124,7 +154,7 @@ if (envelope.job.status === 'completed') { activePoller?.stop(); activePoller = null; - isTranscribing = false; + setIsTranscribing(false); hasTranscript = true; snackbar.success('snackbar.media_manager.transcription_completed'); } @@ -132,7 +162,7 @@ if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') { activePoller?.stop(); activePoller = null; - isTranscribing = false; + setIsTranscribing(false); } } }); @@ -143,10 +173,15 @@ if (!assetId) return; const ok = await mediaApi.generateTranscript(assetId); - if (!ok) return; - isTranscribing = true; - await startTranscriptionPoll(assetId); + if (ok) { + setIsTranscribing(true); + await startTranscriptionPoll(assetId); + return; + } + + hasCheckedTranscript = false; + await ensureTranscriptChecked(); } async function handleViewTranscript() { @@ -231,11 +266,7 @@ } } - const generateLabel = $derived( - isTranscribing - ? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress') - : t.get('course.navItem.lessons.materials.tabs.video.generate_transcript') - ); + const generateLabel = t.get('course.navItem.lessons.materials.tabs.video.generate_transcript'); const generate1080Label = $derived( isGenerating1080 diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts index d432b86e..a3e6169e 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-utils.ts @@ -168,3 +168,33 @@ export function formatVideoCreatedAt(isoString: string | null | undefined): stri if (Number.isNaN(d.getTime())) return null; return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d); } + +export function isEnforceableLessonVideo(video: LessonVideo): boolean { + return video.type === 'upload' && Boolean((video as LessonVideo & { assetId?: string }).assetId); +} + +export function resolveWatchEnforcedAssetIds( + videos: LessonVideo[] | null | undefined, + completionPolicy: string | null | undefined +): string[] { + const lessonVideos = videos ?? []; + const flaggedAssetIds = lessonVideos + .filter((video) => video.watchEnforced && isEnforceableLessonVideo(video)) + .map((video) => (video as LessonVideo & { assetId: string }).assetId); + + if (flaggedAssetIds.length > 0) { + return Array.from(new Set(flaggedAssetIds)); + } + + if (completionPolicy !== 'video_watch') { + return []; + } + + return Array.from( + new Set( + lessonVideos + .filter((video) => isEnforceableLessonVideo(video)) + .map((video) => (video as LessonVideo & { assetId: string }).assetId) + ) + ); +} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte index dea6b9d8..e9fb2118 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video.svelte @@ -13,9 +13,11 @@ interface Props { mode?: (typeof MODES)[keyof typeof MODES]; + courseId: string; + lessonId: string; } - let { mode = MODES.view }: Props = $props(); + let { mode = MODES.view, courseId, lessonId }: Props = $props(); const videos = $derived(lessonApi.lesson?.videos || []); @@ -42,7 +44,7 @@ {#snippet content(video)} {#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link} - + {/key} {/snippet} diff --git a/apps/dashboard/src/lib/features/course/components/student-content-locked-notice.svelte b/apps/dashboard/src/lib/features/course/components/student-content-locked-notice.svelte new file mode 100644 index 00000000..67587889 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/student-content-locked-notice.svelte @@ -0,0 +1,23 @@ + + + diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 1ff876c6..d2b988bc 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -52,8 +52,7 @@ hydrateExercisePageData // , refreshExercisePageData } from '$features/course/utils/exercise-page-utils'; - import { Empty } from '@cio/ui/custom/empty'; - import VideoIcon from '@lucide/svelte/icons/video'; + import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte'; import { getQuestionTypeId, getQuestionTypeKeyFromId @@ -324,7 +323,23 @@ (item) => item.type === ContentType.Exercise && item.id === exerciseId ) ); - const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false); + const isCourseContentReady = $derived(courseApi.course?.id != null); + const isExerciseTeacherLocked = $derived((exerciseContentItem?.isUnlocked ?? true) === false); + const isExerciseProgressionLocked = $derived( + $isOrgStudent && exerciseContentItem?.accessible === false && !isExerciseTeacherLocked + ); + const isExerciseAccessible = $derived.by(() => { + if ($isOrgStudent && (!isCourseContentReady || !exerciseContentItem)) { + return false; + } + + return !isExerciseTeacherLocked && !isExerciseProgressionLocked; + }); + const exerciseDisplayTitle = $derived( + exerciseContentItem?.title || + $questionnaire.title || + t.get('course.navItem.lessons.exercises.all_exercises.heading') + ); const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type)); const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC'); @@ -355,6 +370,11 @@ }); } + $inspect('$isStudentExperience', $isStudentExperience); + $inspect('isExerciseAccessible', isExerciseAccessible); + $inspect('isExerciseAccessible', isExerciseAccessible); + $inspect('mySubmissions', mySubmissions); + function getFirstActiveSectionId() { const [firstActiveSection] = [...($questionnaire.sections ?? [])] .filter((section) => !section.deletedAt) @@ -384,7 +404,7 @@ - {$questionnaire.title || 'Exercise'} + {exerciseDisplayTitle} {#if teacherAutoGradeBadge === 'auto'} @@ -502,15 +522,12 @@ {#snippet child()}
{#if $isStudentExperience} - {#if isExerciseUnlocked} + {#if isExerciseAccessible} {:else} - {/if} {:else} diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte index ad93e944..dbdfa4f2 100644 --- a/apps/dashboard/src/lib/features/course/pages/lesson.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -12,6 +12,7 @@ import Pencil from '@lucide/svelte/icons/pencil'; // import HistoryIcon from '@lucide/svelte/icons/history'; import VideoIcon from '@lucide/svelte/icons/video'; + import SettingsIcon from '@lucide/svelte/icons/settings'; import MODES from '$lib/utils/constants/mode'; import { profile } from '$lib/utils/store/user'; @@ -44,13 +45,16 @@ Video, Document, AddVideoModal, - AddDocumentModal + AddDocumentModal, + LessonSettingsTab } from '$features/course/components/lesson'; import type { TLocale } from '@cio/db/types'; - import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants'; + import { orderedTabs, SETTINGS_TAB_VALUE, tabs as materialTabs } from '$features/course/components/lesson/constants'; import { getViewModeComponents } from '$features/course/components/lesson/utils'; import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft'; + import { getOrderedNavigableContent } from '$features/course/utils/content'; + import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte'; interface Props { courseId: string; @@ -71,8 +75,23 @@ hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit; }); - const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson'); - const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false); + const currentLessonContentItem = $derived( + getOrderedNavigableContent(courseApi.course).find( + (item) => item.type === ContentType.Lesson && item.id === lessonId + ) + ); + const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson'); + const isTeacherLocked = $derived.by(() => { + if ($isOrgStudent && currentLessonContentItem) { + return (currentLessonContentItem.isUnlocked ?? false) === false; + } + + return (lessonApi.lesson?.isUnlocked ?? false) === false; + }); + const isProgressionLocked = $derived( + $isOrgStudent && currentLessonContentItem?.accessible === false && !isTeacherLocked + ); + const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked); const lessonSlug = $derived(lessonApi.lesson?.slug ?? ''); const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC'); @@ -145,7 +164,13 @@ let currentTabValue = $state(''); $effect(() => { if (!tabs.length || currentTabValue) return; + const urlTab = $page.url.searchParams.get('tab'); + if (urlTab === SETTINGS_TAB_VALUE) { + currentTabValue = SETTINGS_TAB_VALUE; + return; + } + currentTabValue = urlTab ?? String(tabs[0].value); }); @@ -202,6 +227,8 @@ lessonApi.update(courseApi.course?.id || '', lessonId, { title: lessonApi.lesson.title || undefined, isUnlocked: lessonApi.lesson.isUnlocked ?? undefined, + completionPolicy: lessonApi.lesson.completionPolicy ?? undefined, + videoWatchThreshold: lessonApi.lesson.videoWatchThreshold ?? undefined, slideUrl: lessonApi.lesson.slideUrl || undefined, videos: lessonApi.lesson.videos || [], documents: lessonApi.lesson.documents || [], @@ -363,7 +390,7 @@ - {#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked} - {:else if mode === MODES.edit} {/each} + + + {$t('course.navItem.lessons.materials.tabs.settings.title')} + @@ -466,7 +494,7 @@ - @@ -475,12 +503,18 @@ + + + + + + {:else if lessonApi.lesson && !isMaterialsEmpty} {#key lessonId}
{#each viewModeComponents as Component, index (index)} - + {/each} {#if $currentOrg.customization?.apps?.comments} diff --git a/apps/dashboard/src/lib/features/course/utils/certificate-utils.ts b/apps/dashboard/src/lib/features/course/utils/certificate-utils.ts index 1f804030..e8b1933f 100644 --- a/apps/dashboard/src/lib/features/course/utils/certificate-utils.ts +++ b/apps/dashboard/src/lib/features/course/utils/certificate-utils.ts @@ -11,3 +11,13 @@ export function parseCertificateThemeId(raw: string | null | undefined): Certifi return resolveTemplateId(raw); } + +/** Normalizes DB/API timestamps to an ISO-8601 UTC string accepted by Zod `.datetime()`. */ +export function normalizeCertificateIssuedAt(value: string | null | undefined): string { + if (!value) return new Date().toISOString(); + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return new Date().toISOString(); + + return parsed.toISOString(); +} diff --git a/apps/dashboard/src/lib/features/course/utils/content-completion.ts b/apps/dashboard/src/lib/features/course/utils/content-completion.ts new file mode 100644 index 00000000..d1f07967 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/content-completion.ts @@ -0,0 +1,36 @@ +import { ContentType } from '@cio/utils/constants/content'; +import type { Course } from './types'; +import { syncProgressionAccessToCourse } from './progression-utils'; + +export function updateLessonCompletionInCourseContent(course: Course, lessonId: string, isComplete: boolean): Course { + if (!course.content) return course; + + let updatedCourse: Course; + + if (course.content.grouped) { + updatedCourse = { + ...course, + content: { + ...course.content, + sections: course.content.sections.map((section) => ({ + ...section, + items: section.items.map((item) => + item.type === ContentType.Lesson && item.id === lessonId ? { ...item, isComplete } : item + ) + })) + } + }; + } else { + updatedCourse = { + ...course, + content: { + ...course.content, + items: course.content.items.map((item) => + item.type === ContentType.Lesson && item.id === lessonId ? { ...item, isComplete } : item + ) + } + }; + } + + return syncProgressionAccessToCourse(updatedCourse); +} diff --git a/apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts b/apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts new file mode 100644 index 00000000..7cd7a492 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts @@ -0,0 +1,25 @@ +import { ContentType } from '@cio/utils/constants/content'; +import type { StudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar'; + +export function getStudentContentLockTitleKey(reason: StudentContentLockReason): string { + if (reason === 'progression_locked') { + return 'course.navItem.lessons.content_progression_locked_title'; + } + + return 'course.navItem.lessons.content_locked_title'; +} + +export function getStudentContentLockDescriptionKey( + reason: StudentContentLockReason, + contentType: typeof ContentType.Lesson | typeof ContentType.Exercise +): string { + if (reason === 'progression_locked') { + if (contentType === ContentType.Exercise) { + return 'course.navItem.lessons.content_progression_locked_description_exercise'; + } + + return 'course.navItem.lessons.content_progression_locked_description_lesson'; + } + + return 'course.navItem.lessons.content_locked_description'; +} diff --git a/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts index 8ec3e589..8097caff 100644 --- a/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts +++ b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts @@ -86,6 +86,8 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question', totalSubmissions: 0, allowMultipleAttempts: !!exercise.allowMultipleAttempts, + completionPolicy: (exercise.completionPolicy as 'submitted' | 'passed' | undefined) ?? 'submitted', + passThreshold: exercise.passThreshold ?? 100, slug: exercise.slug ?? '' }); diff --git a/apps/dashboard/src/lib/features/course/utils/progression-utils.ts b/apps/dashboard/src/lib/features/course/utils/progression-utils.ts new file mode 100644 index 00000000..b7331e28 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/progression-utils.ts @@ -0,0 +1,114 @@ +import { ContentType } from '@cio/utils/constants/content'; +import type { Course, CourseContentItem } from './types'; + +type ProgressionLockReason = 'teacher_locked' | 'progression_blocked'; + +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 getNavigableItems(course: Course): CourseContentItem[] { + if (!course.content) return []; + + const items = course.content.grouped + ? course.content.sections.flatMap((section) => section.items) + : course.content.items; + + return sortNavigableItems( + items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise) + ); +} + +function lessonBlocksProgression(policy: string | null | undefined): boolean { + return policy !== 'none'; +} + +function isItemProgressComplete(item: CourseContentItem): boolean { + return Boolean(item.isComplete); +} + +function computeProgressionAccess( + navigableItems: CourseContentItem[], + progressionMode: 'free' | 'sequential' +): Map { + const accessById = new Map(); + let priorBlockingComplete = true; + + for (const item of navigableItems) { + const teacherLocked = !(item.isUnlocked ?? false); + const progressionLocked = 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); + const blocks = item.type === ContentType.Lesson ? lessonBlocksProgression(item.completionPolicy ?? 'manual') : true; + + if (blocks && !complete) { + priorBlockingComplete = false; + } + } + + return accessById; +} + +function annotateContentItem( + item: CourseContentItem, + accessById: Map +): CourseContentItem { + const access = accessById.get(item.id); + if (!access) return item; + + return { + ...item, + accessible: access.accessible, + lockReason: access.lockReason + }; +} + +/** Recomputes student progression locks on in-memory course content after completion changes. */ +export function syncProgressionAccessToCourse(course: Course): Course { + if (!course.content) return course; + + const progressionMode = course.metadata?.progressionMode === 'sequential' ? 'sequential' : 'free'; + const accessById = computeProgressionAccess(getNavigableItems(course), progressionMode); + + if (course.content.grouped) { + return { + ...course, + content: { + ...course.content, + sections: course.content.sections.map((section) => ({ + ...section, + items: section.items.map((item) => annotateContentItem(item, accessById)) + })) + } + }; + } + + return { + ...course, + content: { + ...course.content, + items: course.content.items.map((item) => annotateContentItem(item, accessById)) + } + }; +} diff --git a/apps/dashboard/src/lib/features/course/utils/settings-store.ts b/apps/dashboard/src/lib/features/course/utils/settings-store.ts index ba967abe..957eff5d 100644 --- a/apps/dashboard/src/lib/features/course/utils/settings-store.ts +++ b/apps/dashboard/src/lib/features/course/utils/settings-store.ts @@ -13,6 +13,7 @@ type CourseSettings = { lessonDownload: boolean; isPublished: boolean; isContentGroupingEnabled: boolean; + progressionMode: 'free' | 'sequential'; callout: TCourseCallout | null; }; @@ -32,5 +33,6 @@ export const settings = writable({ lessonDownload: false, isPublished: false, isContentGroupingEnabled: true, + progressionMode: 'free', callout: null }); diff --git a/apps/dashboard/src/lib/features/course/utils/types.ts b/apps/dashboard/src/lib/features/course/utils/types.ts index 942a1382..06f670e6 100644 --- a/apps/dashboard/src/lib/features/course/utils/types.ts +++ b/apps/dashboard/src/lib/features/course/utils/types.ts @@ -126,6 +126,22 @@ export type UpdateLessonCompletionResponse = InferResponseType; export type UpdateLessonCompletionData = UpdateLessonCompletionSuccess['data']; +export type GetLessonWatchProgressRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$get']; +export type GetLessonWatchProgressSuccess = Extract< + InferResponseType, + { success: true } +>; +export type LessonWatchProgress = GetLessonWatchProgressSuccess['data']; + +export type UpdateLessonWatchProgressRequest = + (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$put']; +export type UpdateLessonWatchProgressSuccess = Extract< + InferResponseType, + { success: true } +>; +export type LessonWatchProgressUpdate = UpdateLessonWatchProgressSuccess['data']; + // Get lesson history types export type GetLessonHistoryRequest = (typeof classroomio.course)[':courseId']['lesson'][':lessonId']['history']['$get']; diff --git a/apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts b/apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts index 7e0edaff..516998e6 100644 --- a/apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts +++ b/apps/dashboard/src/lib/features/media/api/media-manager.svelte.ts @@ -517,8 +517,13 @@ export class MediaApi extends BaseApiWithErrors { ok = true; snackbar.success('snackbar.media_manager.transcription_started'); }, - onError: () => { - snackbar.error('snackbar.media_manager.transcription_start_failed'); + onError: (error) => { + const isConflict = typeof error === 'object' && error !== null && 'code' in error && error.code === 'CONFLICT'; + snackbar.error( + isConflict + ? 'snackbar.media_manager.transcription_already_running' + : 'snackbar.media_manager.transcription_start_failed' + ); } }); diff --git a/apps/dashboard/src/lib/features/settings/components/ai-credits/buy-tokens-card.svelte b/apps/dashboard/src/lib/features/settings/components/ai-credits/buy-tokens-card.svelte index d5e58fd4..4bd43858 100644 --- a/apps/dashboard/src/lib/features/settings/components/ai-credits/buy-tokens-card.svelte +++ b/apps/dashboard/src/lib/features/settings/components/ai-credits/buy-tokens-card.svelte @@ -27,7 +27,7 @@ } function buy() { - if (!$profile.id || !$profile.email || !$currentOrg.id || !$currentOrg.siteName) { + if (!$profile.id || !$currentOrg.id) { snackbar.error('snackbar.ai_credits.checkout_failed'); return; @@ -37,20 +37,7 @@ const checkoutURL = new URL('/api/polar/buy-tokens', page.url); checkoutURL.searchParams.set('quantity', String(quantity)); - checkoutURL.searchParams.set('customerEmail', $profile.email?.replace('@test.com', '+test@digdippa.com') ?? ''); - - if ($profile.fullname) { - checkoutURL.searchParams.set('customerName', $profile.fullname); - } - - checkoutURL.searchParams.set( - 'metadata', - JSON.stringify({ - orgId: $currentOrg.id, - orgSlug: $currentOrg.siteName, - triggeredBy: $profile.id - }) - ); + checkoutURL.searchParams.set('orgId', $currentOrg.id); onBeforeRedirect?.(); window.location.href = checkoutURL.toString(); diff --git a/apps/dashboard/src/lib/features/settings/pages/billing.svelte b/apps/dashboard/src/lib/features/settings/pages/billing.svelte index 4a4a8fe3..c0158646 100644 --- a/apps/dashboard/src/lib/features/settings/pages/billing.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/billing.svelte @@ -19,7 +19,7 @@ try { if ($currentOrgPlan?.provider === 'polar') { const url = new URL('/api/polar/portal', window.location.origin); - url.searchParams.set('customerId', $currentOrgPlan?.customerId || ''); + url.searchParams.set('orgId', $currentOrg.id); window.open(url.toString(), '_blank'); } diff --git a/apps/dashboard/src/lib/features/setup/components/setup-progress.svelte b/apps/dashboard/src/lib/features/setup/components/setup-progress.svelte index 7f596b31..6a49292a 100644 --- a/apps/dashboard/src/lib/features/setup/components/setup-progress.svelte +++ b/apps/dashboard/src/lib/features/setup/components/setup-progress.svelte @@ -2,6 +2,7 @@ import { calculateProgress, setupProgressApi } from '../api/setup-progress.svelte'; import type { SetupItem } from '../api/setup-progress.svelte'; import { Skeleton } from '@cio/ui/base/skeleton'; + import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress'; let { setupItems, size = 'default' } = $props<{ setupItems?: SetupItem[]; @@ -14,40 +15,8 @@ const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14'); -
- {#if isLoading} - - - {:else} - - - - - - - -
-

{progressPercentage}%

-
- {/if} -
+{#if isLoading} + +{:else} + +{/if} diff --git a/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte b/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte index 0ceaea32..7debd6ac 100644 --- a/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte +++ b/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte @@ -47,16 +47,7 @@ const checkoutURL = new URL('/api/polar/subscribe', page.url); checkoutURL.searchParams.set('products', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID); - checkoutURL.searchParams.set('customerEmail', $profile.email); - checkoutURL.searchParams.set('customerName', $profile.fullname); - checkoutURL.searchParams.set( - 'metadata', - JSON.stringify({ - triggeredBy: $currentOrg.memberId, - orgId: $currentOrg.id, - orgSlug: $currentOrg.siteName - }) - ); + checkoutURL.searchParams.set('orgId', $currentOrg.id); window.location.href = checkoutURL.toString(); } catch (error) { diff --git a/apps/dashboard/src/lib/features/ui/upload-image.svelte b/apps/dashboard/src/lib/features/ui/upload-image.svelte index 9395cc26..61bceb4d 100644 --- a/apps/dashboard/src/lib/features/ui/upload-image.svelte +++ b/apps/dashboard/src/lib/features/ui/upload-image.svelte @@ -18,6 +18,7 @@ maxFileSizeInMb?: number; // Default max file size 2MB flexDirection?: string; isUploading?: boolean; + previewVariant?: 'avatar' | 'signature'; change?: () => void; } @@ -31,27 +32,33 @@ maxFileSizeInMb = 2, flexDirection = 'flex-col', isUploading = $bindable(false), + previewVariant = 'avatar', change }: Props = $props(); const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png'; + const isSignaturePreview = $derived(previewVariant === 'signature'); - // Local image source for the cropper (never null) - let cropperSrc = $derived(src || ''); + let cropperSrc = $state(''); + + $effect(() => { + cropperSrc = src || ''; + }); const onCropped = async (croppedUrl: string) => { // Convert the cropped data URL to a File object const response = await fetch(croppedUrl); const blob = await response.blob(); // getCroppedImg outputs PNG format, so use blob.type (which will be 'image/png') and matching filename - const file = new File([blob], 'cropped-image.png', { type: blob.type }); + const fileName = isSignaturePreview ? 'signature.png' : 'cropped-image.png'; + const file = new File([blob], fileName, { type: blob.type }); const validation = validateImageUpload(file); if (!validation.isValid) { snackbar.error(validation.error || 'Invalid file type'); errorMessage = $t('snackbar.landing_page_settings.error.try_again'); - cropperSrc = ''; + src = ''; return; } @@ -61,7 +68,6 @@ // Update the src preview src = croppedUrl; - cropperSrc = croppedUrl; // trigger onchange to parent change?.(); @@ -82,6 +88,16 @@ // Otherwise, it's an unsupported file type errorMessage = `${$t('settings.profile.profile_picture.validation_error')} Unsupported file type: ${file.type}`; }; + + const previewContainerClass = $derived( + isSignaturePreview + ? `signature-preview-bg border-2 border-dashed border-gray-300 ${shape}` + : `border-2 border-gray-200 ${shape}` + ); + + const previewImageClass = $derived( + isSignaturePreview ? `h-full w-full object-contain ${shape}` : `h-full w-full ${shape}` + );
@@ -94,11 +110,13 @@ disabled={isDisabled || isUploading} > -
+
{#snippet child({ src: imageSrc })}
- + {#if imageSrc || !isSignaturePreview} + + {/if}
@@ -132,9 +150,30 @@ - + {#if isSignaturePreview} + + {$t('course.navItem.certificates.editor.signature_dont_crop')} + + {:else} + + {/if}
+ + diff --git a/apps/dashboard/src/lib/utils/constants/routes.ts b/apps/dashboard/src/lib/utils/constants/routes.ts index 8b04ce41..43e37660 100644 --- a/apps/dashboard/src/lib/utils/constants/routes.ts +++ b/apps/dashboard/src/lib/utils/constants/routes.ts @@ -38,7 +38,7 @@ export const PUBLIC_ROUTES = [ '^/csp-report$' ]; -export const PUBLIC_API_ROUTES = ['/api/polar', '/api/lmz', '/api/verify']; +export const PUBLIC_API_ROUTES = ['/api/polar/webhook', '/api/lmz', '/api/verify']; export const ROUTES_TO_HIDE_NAV = [ `^${ROUTE.LOGIN}$`, diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index d619c71e..f751ea3f 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Regenererer thumbnails...", "thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder", "transcript_saved": "Udskrift opdateret", - "transcript_save_failed": "Kunne ikke gemme transskription" + "transcript_save_failed": "Kunne ikke gemme transskription", + "transcription_already_running": "Et transskriptionsjob er allerede i gang for denne video" }, "tags": { "group_created": "Taggruppe oprettet", @@ -743,6 +744,10 @@ "drag": "Træk og slip etiketterne for at omarrangere dine materialefaner", "content_grouping_title": "Indholdsgruppering", "content_grouping_description": "Gruppelektioner og øvelser efter afsnit i indholdslisten.", + "progression_mode_title": "Elevprogression", + "progression_mode_description": "Styr om elever kan få adgang til alt ulåst indhold eller skal fuldføre elementer i rækkefølge.", + "progression_mode_free": "Fri — ulåst indhold er tilgængeligt", + "progression_mode_sequential": "Sekventiel — fuldfør tidligere elementer for at låse næste op", "lesson_download": "Hent lektioner", "available": "Gør lektionen tilgængelig til download i pdf for tilmeldte studerende", "course_download": "Kursus download", @@ -1022,7 +1027,14 @@ "field_template": "Skabelon", "field_accent": "Accent", "field_signatories": "Underskrivere", - "customize_design": "Tilpas design" + "customize_design": "Tilpas design", + "signatory_one_toggle": "Vis underskriver 1", + "signatory_two_toggle": "Vis underskriver 2", + "signature_upload": "Signaturbillede", + "signature_upload_hint": "Upload et signaturbillede (valgfrit)", + "signature_dont_crop": "Beskær ikke", + "signature_upload_in_progress": "Vent på, at signaturoverførslen er færdig, før du gemmer.", + "save_failed": "Kunne ikke gemme certifikatdesignet. Tjek formularen og prøv igen." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Skjul afsnit", "expand_section": "Udvid afsnit" }, - "more": "Handlinger" + "more": "Handlinger", + "settings_completion_policy": "Fuldførelsesregel", + "settings_completion_submitted": "Indsendt - enhver indsendelse tæller", + "settings_completion_passed": "Bestået - minimumsscore kræves", + "settings_pass_threshold": "Beståelsesgrænse (%)" }, "new_exercise_modal": { "heading": "Ny øvelse", @@ -1803,6 +1819,9 @@ "button": "knappen", "button_done": "Færdig", "tabs": { + "settings": { + "title": "Indstillinger" + }, "document": { "title": "Dokumenter", "add_document": "Tilføj dokument", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drev", "kind_generic": "Indlejret", "course_fallback": "Kursus", - "menu_aria": "Video muligheder" + "menu_aria": "Video muligheder", + "transcription_in_progress": "(Transskriptionsgenerering i gang)" }, "view_transcript": "Se transskription", "playback_error": "Indlæsningen af denne video mislykkedes.", @@ -1926,7 +1946,8 @@ "generate_1080": "Generer 1080p", "generate_1080_in_progress": "Genererer 1080p...", "generate_1080_ready": "1080p tilgængelig", - "generate_1080_unavailable": "1080p ikke tilgængelig for denne kildevideo" + "generate_1080_unavailable": "1080p ikke tilgængelig for denne kildevideo", + "loading": "Indlæser video..." }, "slide": { "title": "Slide", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Dette indhold er låst", "content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere.", + "content_progression_locked_title": "Fuldfør tidligere indhold først", + "content_progression_locked_description": "Dette indhold låses op, når du har fuldført det forrige påkrævede element i kurset.", + "content_progression_locked_description_lesson": "Denne lektion låses op, når du har fuldført det forrige påkrævede element i kurset.", + "content_progression_locked_description_exercise": "Denne øvelse låses op, når du har fuldført det forrige påkrævede element i kurset.", + "completion_policy": { + "label": "Fuldførelsesregel", + "manual_label": "Manuel", + "manual_description": "Elever markerer selv lektionen som fuldført.", + "video_watch_label": "Videoafspilning", + "video_watch_description": "Lektionen fuldføres automatisk, når elever har set de valgte videoer.", + "none_label": "Ingen", + "none_description": "Denne lektion blokerer ikke kursusprogression.", + "watch_threshold": "Segrænse (%)" + }, + "settings": { + "progression": { + "heading": "Progression", + "intro": "Vælg hvordan elever fuldfører denne lektion og bevæger sig gennem kurset.", + "option_guide_heading": "Hvad hver mulighed betyder", + "watch_videos_heading": "Videoer der håndhæves", + "watch_videos_helper": "Kun uploadede videoer kan håndhæves. YouTube og eksterne indlejringer kan ikke spores.", + "watch_videos_empty": "Tilføj en uploadet video i fanen Video for at bruge visningskontrol.", + "watch_videos_required": "Vælg mindst én video ved videoafspilning.", + "students_only_note": "Disse regler gælder kun for elever. Du kan forhåndsvise og navigere frit som underviser." + } + }, + "watch_progress": { + "status": "Set {percent}% af videoindholdet", + "videos_complete": "Set {percent}% af det krævede videoindhold ({complete} af {required} videoer)", + "seek_blocked": "Du kan ikke springe frem i denne video", + "complete_via_watch": "Denne lektion fuldføres, når du har set nok af videoen" + }, "saving": "Gemmer...", "saved": "Gemt", "draft_recovery": "Der blev fundet et ikke-gemt udkast til denne lektion. Vil du gendanne den?", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 2eb7624c..317a0c81 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Miniaturansichten werden neu generiert…", "thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden", "transcript_saved": "Transkript aktualisiert", - "transcript_save_failed": "Das Transkript konnte nicht gespeichert werden" + "transcript_save_failed": "Das Transkript konnte nicht gespeichert werden", + "transcription_already_running": "Für dieses Video wird bereits ein Transkriptionsauftrag ausgeführt" }, "tags": { "group_created": "Tag-Gruppe erstellt", @@ -743,6 +744,10 @@ "drag": "Ziehen Sie die Beschriftungen per Drag-and-Drop, um Ihre Materialregisterkarten neu anzuordnen", "content_grouping_title": "Inhaltsgruppierung", "content_grouping_description": "Gruppieren Sie Lektionen und Übungen nach Abschnitten in der Inhaltsliste.", + "progression_mode_title": "Schülerfortschritt", + "progression_mode_description": "Steuern Sie, ob Schüler auf alle freigeschalteten Inhalte zugreifen können oder Elemente der Reihe nach abschließen müssen.", + "progression_mode_free": "Frei — freigeschaltete Inhalte sind zugänglich", + "progression_mode_sequential": "Sequenziell — vorherige Elemente abschließen, um das nächste freizuschalten", "lesson_download": "Lektionen herunterladen", "available": "Stellen Sie die Lektion für registrierte Studierende zum Download im PDF-Format zur Verfügung", "course_download": "Kurs-Download", @@ -1022,7 +1027,14 @@ "field_template": "Vorlage", "field_accent": "Akzent", "field_signatories": "Unterzeichner", - "customize_design": "Design anpassen" + "customize_design": "Design anpassen", + "signatory_one_toggle": "Unterzeichner 1 anzeigen", + "signatory_two_toggle": "Unterzeichner 2 anzeigen", + "signature_upload": "Signaturbild", + "signature_upload_hint": "Laden Sie ein Signaturbild hoch (optional)", + "signature_dont_crop": "Nicht beschneiden", + "signature_upload_in_progress": "Warten Sie, bis der Signatur-Upload abgeschlossen ist, bevor Sie speichern.", + "save_failed": "Das Zertifikatdesign konnte nicht gespeichert werden. Überprüfen Sie das Formular und versuchen Sie es erneut." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Abschnitt einklappen", "expand_section": "Abschnitt ausklappen" }, - "more": "Aktionen" + "more": "Aktionen", + "settings_completion_policy": "Abschlussregel", + "settings_completion_submitted": "Eingereicht – jede Einreichung zählt", + "settings_completion_passed": "Bestanden – Mindestpunktzahl erforderlich", + "settings_pass_threshold": "Bestehensschwelle (%)" }, "new_exercise_modal": { "heading": "Neue Übung", @@ -1803,6 +1819,9 @@ "button": "Taste", "button_done": "Fertig", "tabs": { + "settings": { + "title": "Einstellungen" + }, "document": { "title": "Dokumente", "add_document": "Dokument hinzufügen", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drive", "kind_generic": "Eingebettet", "course_fallback": "Kurs", - "menu_aria": "Videooptionen" + "menu_aria": "Videooptionen", + "transcription_in_progress": "(Transkripterstellung läuft)" }, "view_transcript": "Transkription ansehen", "playback_error": "Das Laden dieses Videos ist fehlgeschlagen.", @@ -1926,7 +1946,8 @@ "generate_1080": "Generieren Sie 1080p", "generate_1080_in_progress": "Generiert 1080p…", "generate_1080_ready": "1080p verfügbar", - "generate_1080_unavailable": "1080p ist für dieses Quellvideo nicht verfügbar" + "generate_1080_unavailable": "1080p ist für dieses Quellvideo nicht verfügbar", + "loading": "Video wird geladen…" }, "slide": { "title": "Rutsche", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Dieser Inhalt ist gesperrt", "content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei.", + "content_progression_locked_title": "Schließen Sie zuerst den vorherigen Inhalt ab", + "content_progression_locked_description": "Dieser Inhalt wird freigeschaltet, nachdem Sie den vorherigen erforderlichen Kurseintrag abgeschlossen haben.", + "content_progression_locked_description_lesson": "Diese Lektion wird freigeschaltet, nachdem Sie den vorherigen erforderlichen Kurseintrag abgeschlossen haben.", + "content_progression_locked_description_exercise": "Diese Übung wird freigeschaltet, nachdem Sie den vorherigen erforderlichen Kurseintrag abgeschlossen haben.", + "completion_policy": { + "label": "Abschlussregel", + "manual_label": "Manuell", + "manual_description": "Schüler markieren die Lektion selbst als abgeschlossen.", + "video_watch_label": "Video ansehen", + "video_watch_description": "Die Lektion wird automatisch abgeschlossen, nachdem Schüler die ausgewählten Videos angesehen haben.", + "none_label": "Keine", + "none_description": "Diese Lektion blockiert den Kursfortschritt nicht.", + "watch_threshold": "Wiedergabeschwelle (%)" + }, + "settings": { + "progression": { + "heading": "Fortschritt", + "intro": "Wählen Sie, wie Schüler diese Lektion abschließen und durch den Kurs gehen.", + "option_guide_heading": "Was jede Option bedeutet", + "watch_videos_heading": "Zu überwachende Videos", + "watch_videos_helper": "Nur hochgeladene Videos können erzwungen werden. YouTube und externe Einbettungen können nicht verfolgt werden.", + "watch_videos_empty": "Fügen Sie im Video-Tab ein hochgeladenes Video hinzu, um die Wiedergabeüberwachung zu nutzen.", + "watch_videos_required": "Wählen Sie mindestens ein Video für die Video-Wiedergabeüberwachung.", + "students_only_note": "Diese Regeln gelten nur für Schüler. Als Lehrkraft können Sie frei in der Vorschau navigieren." + } + }, + "watch_progress": { + "status": "{percent}% des Videoinhalts angesehen", + "videos_complete": "{percent}% des erforderlichen Videoinhalts angesehen ({complete} von {required} Videos)", + "seek_blocked": "Sie können in diesem Video nicht vorspulen", + "complete_via_watch": "Diese Lektion wird abgeschlossen, wenn Sie genug vom Video gesehen haben" + }, "saving": "Sparen…", "saved": "Gespeichert", "draft_recovery": "Für diese Lektion wurde ein nicht gespeicherter Entwurf gefunden. Möchten Sie es wiederherstellen?", diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 23745656..400a0c46 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Regenerando miniaturas…", "thumbnail_regen_failed": "No se pudieron regenerar las miniaturas", "transcript_saved": "Transcripción actualizada", - "transcript_save_failed": "No se pudo guardar la transcripción" + "transcript_save_failed": "No se pudo guardar la transcripción", + "transcription_already_running": "Ya se está realizando un trabajo de transcripción para este vídeo." }, "tags": { "group_created": "Grupo de etiquetas creado", @@ -743,6 +744,10 @@ "drag": "Arrastre y suelte las etiquetas para reordenar las pestañas de materiales", "content_grouping_title": "Agrupación de contenidos", "content_grouping_description": "Agrupe lecciones y ejercicios por sección en la lista de contenidos.", + "progression_mode_title": "Progresión del estudiante", + "progression_mode_description": "Controla si los estudiantes pueden acceder a todo el contenido desbloqueado o deben completar los elementos en orden.", + "progression_mode_free": "Libre — el contenido desbloqueado es accesible", + "progression_mode_sequential": "Secuencial — completar elementos previos para desbloquear el siguiente", "lesson_download": "Lecciones DescargarCargar", "available": "Haga que la lección esté disponible para descargar en pdf para estudiantes registrados", "course_download": "Descarga del curso", @@ -1022,7 +1027,14 @@ "field_template": "Plantilla", "field_accent": "acento", "field_signatories": "Firmantes", - "customize_design": "Personalizar diseño" + "customize_design": "Personalizar diseño", + "signatory_one_toggle": "Mostrar firmante 1", + "signatory_two_toggle": "Mostrar firmante 2", + "signature_upload": "Imagen de firma", + "signature_upload_hint": "Sube una imagen de firma (opcional)", + "signature_dont_crop": "No recortar", + "signature_upload_in_progress": "Espere a que finalice la carga de la firma antes de guardar.", + "save_failed": "No se pudo guardar el diseño del certificado. Revisa el formulario y vuelve a intentarlo." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Contraer sección", "expand_section": "Expandir sección" }, - "more": "Acciones" + "more": "Acciones", + "settings_completion_policy": "Regla de finalización", + "settings_completion_submitted": "Enviado: cualquier envío cuenta", + "settings_completion_passed": "Aprobado: puntuación mínima requerida", + "settings_pass_threshold": "Umbral de aprobación (%)" }, "new_exercise_modal": { "heading": "Nuevo ejercicio", @@ -1803,6 +1819,9 @@ "button": "botón", "button_done": "hecho", "tabs": { + "settings": { + "title": "Configuración" + }, "document": { "title": "Documentos", "add_document": "Agregar documento", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drive", "kind_generic": "Incrustado", "course_fallback": "Curso", - "menu_aria": "Opciones de vídeo" + "menu_aria": "Opciones de vídeo", + "transcription_in_progress": "(Generación de transcripción en progreso)" }, "view_transcript": "Ver transcripción", "playback_error": "Error al cargar este vídeo.", @@ -1926,7 +1946,8 @@ "generate_1080": "Generar 1080p", "generate_1080_in_progress": "Generando 1080p…", "generate_1080_ready": "1080p disponible", - "generate_1080_unavailable": "1080p no disponible para este vídeo fuente" + "generate_1080_unavailable": "1080p no disponible para este vídeo fuente", + "loading": "Cargando vídeo…" }, "slide": { "title": "diapositiva", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Este contenido está bloqueado.", "content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde.", + "content_progression_locked_title": "Completa primero el contenido anterior", + "content_progression_locked_description": "Este contenido se desbloquea después de terminar el elemento requerido anterior del curso.", + "content_progression_locked_description_lesson": "Esta lección se desbloquea después de terminar el elemento requerido anterior del curso.", + "content_progression_locked_description_exercise": "Este ejercicio se desbloquea después de terminar el elemento requerido anterior del curso.", + "completion_policy": { + "label": "Regla de finalización", + "manual_label": "Manual", + "manual_description": "Los estudiantes marcan la lección como completada.", + "video_watch_label": "Ver video", + "video_watch_description": "La lección se completa automáticamente después de que los estudiantes vean los videos seleccionados.", + "none_label": "Ninguna", + "none_description": "Esta lección no bloquea la progresión del curso.", + "watch_threshold": "Umbral de visualización (%)" + }, + "settings": { + "progression": { + "heading": "Progresión", + "intro": "Elige cómo los estudiantes completan esta lección y avanzan en el curso.", + "option_guide_heading": "Qué significa cada opción", + "watch_videos_heading": "Videos a aplicar", + "watch_videos_helper": "Solo los videos subidos pueden aplicarse. YouTube y las incrustaciones externas no se pueden rastrear.", + "watch_videos_empty": "Añade un video subido en la pestaña Video para usar el seguimiento de visualización.", + "watch_videos_required": "Selecciona al menos un video para el seguimiento de visualización.", + "students_only_note": "Estas reglas aplican solo a estudiantes. Puedes previsualizar y navegar libremente al enseñar." + } + }, + "watch_progress": { + "status": "Visto el {percent}% del contenido del video", + "videos_complete": "Visto el {percent}% del contenido de video requerido ({complete} de {required} videos)", + "seek_blocked": "No puedes adelantar en este video", + "complete_via_watch": "Esta lección se completa cuando ves suficiente del video" + }, "saving": "Guardando…", "saved": "Guardado", "draft_recovery": "Se encontró un borrador no guardado para esta lección. ¿Quieres restaurarlo?", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index c99f45e5..5780c8e9 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Régénération des vignettes…", "thumbnail_regen_failed": "Échec de la régénération des miniatures", "transcript_saved": "Transcription mise à jour", - "transcript_save_failed": "Impossible d'enregistrer la transcription" + "transcript_save_failed": "Impossible d'enregistrer la transcription", + "transcription_already_running": "Un travail de transcription est déjà en cours pour cette vidéo" }, "tags": { "group_created": "Groupe de balises créé", @@ -743,6 +744,10 @@ "drag": "Faites glisser et déposez les étiquettes pour réorganiser vos onglets de matériaux", "content_grouping_title": "Regroupement de contenu", "content_grouping_description": "Cours collectifs et exercices par section dans la liste des contenus.", + "progression_mode_title": "Progression des étudiants", + "progression_mode_description": "Contrôlez si les étudiants peuvent accéder à tout le contenu débloqué ou doivent terminer les éléments dans l'ordre.", + "progression_mode_free": "Libre — le contenu débloqué est accessible", + "progression_mode_sequential": "Séquentiel — terminer les éléments précédents pour débloquer le suivant", "lesson_download": "Téléchargement des leçons", "available": "Rendre la leçon disponible en téléchargement en pdf pour les étudiants inscrits", "course_download": "Téléchargement du cours", @@ -1022,7 +1027,14 @@ "field_template": "Modèle", "field_accent": "Accent", "field_signatories": "Signataires", - "customize_design": "Personnaliser le design" + "customize_design": "Personnaliser le design", + "signatory_one_toggle": "Afficher le signataire 1", + "signatory_two_toggle": "Afficher le signataire 2", + "signature_upload": "Image signature", + "signature_upload_hint": "Téléchargez une image de signature (facultatif)", + "signature_dont_crop": "Ne pas recadrer", + "signature_upload_in_progress": "Attendez la fin du téléchargement de la signature avant de sauvegarder.", + "save_failed": "Impossible d'enregistrer la conception du certificat. Vérifiez le formulaire et réessayez." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Réduire la section", "expand_section": "Développer la section" }, - "more": "Actions" + "more": "Actions", + "settings_completion_policy": "Règle d'achèvement", + "settings_completion_submitted": "Soumis – toute soumission compte", + "settings_completion_passed": "Réussi – note minimale requise", + "settings_pass_threshold": "Seuil de réussite (%)" }, "new_exercise_modal": { "heading": "Nouvel exercice", @@ -1803,6 +1819,9 @@ "button": "bouton", "button_done": "Terminé", "tabs": { + "settings": { + "title": "Paramètres" + }, "document": { "title": "Documents", "add_document": "Ajouter un document", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drive", "kind_generic": "Intégré", "course_fallback": "Cours", - "menu_aria": "Options vidéo" + "menu_aria": "Options vidéo", + "transcription_in_progress": "(Génération de transcription en cours)" }, "view_transcript": "Voir la transcription", "playback_error": "Le chargement de cette vidéo a échoué.", @@ -1926,7 +1946,8 @@ "generate_1080": "Générer 1080p", "generate_1080_in_progress": "Génération de 1080p…", "generate_1080_ready": "1080p disponible", - "generate_1080_unavailable": "1080p indisponible pour cette vidéo source" + "generate_1080_unavailable": "1080p indisponible pour cette vidéo source", + "loading": "Chargement de la vidéo…" }, "slide": { "title": "Diapositive", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Ce contenu est verrouillé", "content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard.", + "content_progression_locked_title": "Terminez d'abord le contenu précédent", + "content_progression_locked_description": "Ce contenu se débloque après avoir terminé l'élément requis précédent du cours.", + "content_progression_locked_description_lesson": "Cette leçon se débloque après avoir terminé l'élément requis précédent du cours.", + "content_progression_locked_description_exercise": "Cet exercice se débloque après avoir terminé l'élément requis précédent du cours.", + "completion_policy": { + "label": "Règle d'achèvement", + "manual_label": "Manuel", + "manual_description": "Les étudiants marquent eux-mêmes la leçon comme terminée.", + "video_watch_label": "Visionnage vidéo", + "video_watch_description": "La leçon se termine automatiquement après que les étudiants aient regardé les vidéos sélectionnées.", + "none_label": "Aucune", + "none_description": "Cette leçon ne bloque pas la progression du cours.", + "watch_threshold": "Seuil de visionnage (%)" + }, + "settings": { + "progression": { + "heading": "Progression", + "intro": "Choisissez comment les étudiants terminent cette leçon et avancent dans le cours.", + "option_guide_heading": "Ce que signifie chaque option", + "watch_videos_heading": "Vidéos à appliquer", + "watch_videos_helper": "Seules les vidéos téléversées peuvent être appliquées. YouTube et les intégrations externes ne peuvent pas être suivies.", + "watch_videos_empty": "Ajoutez une vidéo téléversée dans l'onglet Vidéo pour utiliser le suivi de visionnage.", + "watch_videos_required": "Sélectionnez au moins une vidéo pour le suivi de visionnage.", + "students_only_note": "Ces règles s'appliquent aux étudiants uniquement. Vous pouvez prévisualiser et naviguer librement en mode enseignant." + } + }, + "watch_progress": { + "status": "{percent}% du contenu vidéo regardé", + "videos_complete": "{percent}% du contenu vidéo requis regardé ({complete} sur {required} vidéos)", + "seek_blocked": "Vous ne pouvez pas avancer dans cette vidéo", + "complete_via_watch": "Cette leçon se termine lorsque vous avez suffisamment regardé la vidéo" + }, "saving": "Sauvegarde…", "saved": "Enregistré", "draft_recovery": "Un brouillon non enregistré a été trouvé pour cette leçon. Souhaitez-vous le restaurer ?", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 6c56df39..dc9799b0 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "थंबनेल पुनर्जीवित हो रहे हैं…", "thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल", "transcript_saved": "प्रतिलेख अद्यतन किया गया", - "transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका" + "transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका", + "transcription_already_running": "इस वीडियो के लिए प्रतिलेखन कार्य पहले से ही प्रगति पर है" }, "tags": { "group_created": "टैग समूह बनाया गया", @@ -743,6 +744,10 @@ "drag": "अपने सामग्री टैब को पुनः क्रमित करने के लिए लेबल खींचें और छोड़ें", "content_grouping_title": "सामग्री समूहन", "content_grouping_description": "सामग्री सूची में अनुभाग के अनुसार समूह पाठ और अभ्यास।", + "progression_mode_title": "छात्र प्रगति", + "progression_mode_description": "नियंत्रित करें कि छात्र सभी अनलॉक सामग्री तक पहुँच सकें या क्रम में आइटम पूरे करें।", + "progression_mode_free": "मुक्त — अनलॉक सामग्री सुलभ है", + "progression_mode_sequential": "क्रमिक — अगला अनलॉक करने के लिए पिछले आइटम पूरे करें", "lesson_download": "पाठ डाउनलोड करें", "available": "पंजीकृत छात्रों के लिए पाठ को पीडीएफ में डाउनलोड के लिए उपलब्ध कराएं", "course_download": "कोर्स डाउनलोड करें", @@ -1022,7 +1027,14 @@ "field_template": "टेम्पलेट", "field_accent": "उच्चारण", "field_signatories": "हस्ताक्षरकर्ता", - "customize_design": "डिज़ाइन अनुकूलित करें" + "customize_design": "डिज़ाइन अनुकूलित करें", + "signatory_one_toggle": "हस्ताक्षरकर्ता दिखाएँ 1", + "signatory_two_toggle": "हस्ताक्षरकर्ता दिखाएँ 2", + "signature_upload": "हस्ताक्षर छवि", + "signature_upload_hint": "एक हस्ताक्षर छवि अपलोड करें (वैकल्पिक)", + "signature_dont_crop": "फसल मत करो", + "signature_upload_in_progress": "सहेजने से पहले हस्ताक्षर अपलोड समाप्त होने तक प्रतीक्षा करें।", + "save_failed": "प्रमाणपत्र डिज़ाइन सहेजा नहीं जा सका. फ़ॉर्म की जाँच करें और पुनः प्रयास करें." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "अनुभाग संकुचित करें", "expand_section": "अनुभाग विस्तारित करें" }, - "more": "क्रियाएँ" + "more": "क्रियाएँ", + "settings_completion_policy": "समापन नियम", + "settings_completion_submitted": "सबमिट किया गया - कोई भी सबमिशन मायने रखता है", + "settings_completion_passed": "उत्तीर्ण - न्यूनतम अंक आवश्यक", + "settings_pass_threshold": "पास सीमा (%)" }, "new_exercise_modal": { "heading": "नया व्यायाम", @@ -1803,6 +1819,9 @@ "button": "बटन", "button_done": "हो गया", "tabs": { + "settings": { + "title": "सेटिंग्स" + }, "document": { "title": "दस्तावेज़", "add_document": "दस्तावेज़ जोड़ें", @@ -1918,7 +1937,8 @@ "kind_google_drive": "गूगल ड्राइव", "kind_generic": "एंबेडेड", "course_fallback": "पाठ्यक्रम", - "menu_aria": "वीडियो विकल्प" + "menu_aria": "वीडियो विकल्प", + "transcription_in_progress": "(प्रतिलेख निर्माण प्रगति पर है)" }, "view_transcript": "प्रतिलेखन देखें", "playback_error": "इस वीडियो को लोड करना विफल रहा.", @@ -1926,7 +1946,8 @@ "generate_1080": "1080p जनरेट करें", "generate_1080_in_progress": "1080p जेनरेट हो रहा है...", "generate_1080_ready": "1080p उपलब्ध", - "generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है" + "generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है", + "loading": "वीडियो लोड हो रहा है..." }, "slide": { "title": "फिसल पट्टी", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "यह सामग्री लॉक है", "content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।", + "content_progression_locked_title": "पहले पिछली सामग्री पूरी करें", + "content_progression_locked_description": "पिछले आवश्यक कोर्स आइटम को पूरा करने के बाद यह सामग्री अनलॉक होती है।", + "content_progression_locked_description_lesson": "पिछले आवश्यक कोर्स आइटम को पूरा करने के बाद यह पाठ अनलॉक होता है।", + "content_progression_locked_description_exercise": "पिछले आवश्यक कोर्स आइटम को पूरा करने के बाद यह अभ्यास अनलॉक होता है।", + "completion_policy": { + "label": "पूर्णता नियम", + "manual_label": "मैन्युअल", + "manual_description": "छात्र स्वयं पाठ को पूर्ण चिह्नित करते हैं।", + "video_watch_label": "वीडियो देखें", + "video_watch_description": "चयनित वीडियो देखने के बाद पाठ स्वतः पूर्ण हो जाता है।", + "none_label": "कोई नहीं", + "none_description": "यह पाठ कोर्स प्रगति को रोकता नहीं है।", + "watch_threshold": "देखने की सीमा (%)" + }, + "settings": { + "progression": { + "heading": "प्रगति", + "intro": "चुनें कि छात्र इस पाठ को कैसे पूरा करें और कोर्स में आगे बढ़ें।", + "option_guide_heading": "प्रत्येक विकल्प का अर्थ", + "watch_videos_heading": "लागू करने के लिए वीडियो", + "watch_videos_helper": "केवल अपलोड किए गए वीडियो लागू किए जा सकते हैं। YouTube और बाहरी एम्बेड ट्रैक नहीं हो सकते।", + "watch_videos_empty": "देखने की निगरानी के लिए वीडियो टैब में अपलोड किया वीडियो जोड़ें।", + "watch_videos_required": "वीडियो देखने के नियम के लिए कम से कम एक वीडियो चुनें।", + "students_only_note": "ये नियम केवल छात्रों पर लागू होते हैं। शिक्षक स्वतंत्र रूप से पूर्वावलोकन और नेविगेट कर सकते हैं।" + } + }, + "watch_progress": { + "status": "वीडियो सामग्री का {percent}% देखा", + "videos_complete": "आवश्यक वीडियो सामग्री का {percent}% देखा ({required} में से {complete} वीडियो)", + "seek_blocked": "आप इस वीडियो में आगे नहीं जा सकते", + "complete_via_watch": "पर्याप्त वीडियो देखने पर यह पाठ पूर्ण होता है" + }, "saving": "सहेजा जा रहा है...", "saved": "सहेजा गया", "draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 382f9e65..968245d3 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Regeneruję miniatury…", "thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur", "transcript_saved": "Transkrypcja zaktualizowana", - "transcript_save_failed": "Nie udało się zapisać transkrypcji" + "transcript_save_failed": "Nie udało się zapisać transkrypcji", + "transcription_already_running": "Zadanie transkrypcji tego filmu wideo jest już w toku" }, "tags": { "group_created": "Utworzono grupę tagów", @@ -743,6 +744,10 @@ "drag": "Przeciągnij i upuść etykiety, aby zmienić kolejność zakładek materiałów", "content_grouping_title": "Grupowanie treści", "content_grouping_description": "Grupuj lekcje i ćwiczenia według sekcji na liście treści.", + "progression_mode_title": "Postęp ucznia", + "progression_mode_description": "Kontroluj, czy uczniowie mogą uzyskać dostęp do całej odblokowanej treści, czy muszą kończyć elementy po kolei.", + "progression_mode_free": "Wolny — odblokowana treść jest dostępna", + "progression_mode_sequential": "Sekwencyjny — ukończ poprzednie elementy, aby odblokować następny", "lesson_download": "Lekcje do pobrania", "available": "Udostępnij lekcję do pobrania w formacie PDF zarejestrowanym studentom", "course_download": "Pobieranie kursu", @@ -1022,7 +1027,14 @@ "field_template": "Szablon", "field_accent": "Akcent", "field_signatories": "Sygnatariusze", - "customize_design": "Dostosuj projekt" + "customize_design": "Dostosuj projekt", + "signatory_one_toggle": "Pokaż sygnatariusza 1", + "signatory_two_toggle": "Pokaż sygnatariusza 2", + "signature_upload": "Podpisowy obraz", + "signature_upload_hint": "Prześlij obraz podpisu (opcjonalnie)", + "signature_dont_crop": "Nie przycinaj", + "signature_upload_in_progress": "Przed zapisaniem poczekaj na zakończenie przesyłania podpisu.", + "save_failed": "Nie można zapisać projektu certyfikatu. Sprawdź formularz i spróbuj ponownie." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Zwiń sekcję", "expand_section": "Rozwiń sekcję" }, - "more": "Działania" + "more": "Działania", + "settings_completion_policy": "Zasada uzupełnienia", + "settings_completion_submitted": "Przesłane — liczy się każde przesłanie", + "settings_completion_passed": "Zaliczony — wymagany minimalny wynik", + "settings_pass_threshold": "Próg zaliczenia (%)" }, "new_exercise_modal": { "heading": "Nowe ćwiczenie", @@ -1803,6 +1819,9 @@ "button": "przycisk", "button_done": "Gotowe", "tabs": { + "settings": { + "title": "Ustawienia" + }, "document": { "title": "Dokumenty", "add_document": "Dodaj dokument", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Dysk Google", "kind_generic": "Wbudowany", "course_fallback": "Kurs", - "menu_aria": "Opcje wideo" + "menu_aria": "Opcje wideo", + "transcription_in_progress": "(Trwa generowanie transkrypcji)" }, "view_transcript": "Zobacz transkrypcję", "playback_error": "Ładowanie tego filmu nie powiodło się.", @@ -1926,7 +1946,8 @@ "generate_1080": "Wygeneruj 1080p", "generate_1080_in_progress": "Generowanie 1080p…", "generate_1080_ready": "Dostępna rozdzielczość 1080p", - "generate_1080_unavailable": "Rozdzielczość 1080p jest niedostępna dla tego wideo źródłowego" + "generate_1080_unavailable": "Rozdzielczość 1080p jest niedostępna dla tego wideo źródłowego", + "loading": "Ładowanie wideo…" }, "slide": { "title": "Przesuń", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Ta zawartość jest zablokowana", "content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później.", + "content_progression_locked_title": "Najpierw ukończ poprzednią treść", + "content_progression_locked_description": "Ta treść odblokowuje się po ukończeniu poprzedniego wymaganego elementu kursu.", + "content_progression_locked_description_lesson": "Ta lekcja odblokowuje się po ukończeniu poprzedniego wymaganego elementu kursu.", + "content_progression_locked_description_exercise": "To ćwiczenie odblokowuje się po ukończeniu poprzedniego wymaganego elementu kursu.", + "completion_policy": { + "label": "Zasada ukończenia", + "manual_label": "Ręcznie", + "manual_description": "Uczniowie sami oznaczają lekcję jako ukończoną.", + "video_watch_label": "Obejrzenie wideo", + "video_watch_description": "Lekcja kończy się automatycznie po obejrzeniu wybranych filmów.", + "none_label": "Brak", + "none_description": "Ta lekcja nie blokuje postępu w kursie.", + "watch_threshold": "Próg obejrzenia (%)" + }, + "settings": { + "progression": { + "heading": "Postęp", + "intro": "Wybierz, jak uczniowie kończą tę lekcję i przechodzą przez kurs.", + "option_guide_heading": "Co oznacza każda opcja", + "watch_videos_heading": "Filmy do egzekwowania", + "watch_videos_helper": "Tylko przesłane filmy mogą być egzekwowane. YouTube i zewnętrzne osadzenia nie mogą być śledzone.", + "watch_videos_empty": "Dodaj przesłany film w zakładce Wideo, aby użyć egzekwowania obejrzenia.", + "watch_videos_required": "Wybierz co najmniej jeden film przy egzekwowaniu obejrzenia.", + "students_only_note": "Te zasady dotyczą tylko uczniów. Jako nauczyciel możesz swobodnie przeglądać i nawigować." + } + }, + "watch_progress": { + "status": "Obejrzano {percent}% treści wideo", + "videos_complete": "Obejrzano {percent}% wymaganej treści wideo ({complete} z {required} filmów)", + "seek_blocked": "Nie możesz przewinąć do przodu w tym filmie", + "complete_via_watch": "Ta lekcja kończy się, gdy obejrzysz wystarczającą część filmu" + }, "saving": "Zapisuję…", "saved": "Zapisano", "draft_recovery": "Znaleziono niezapisaną wersję roboczą tej lekcji. Czy chcesz go przywrócić?", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index f76c3e51..9d008177 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Regenerando miniaturas…", "thumbnail_regen_failed": "Falha ao regenerar miniaturas", "transcript_saved": "Transcrição atualizada", - "transcript_save_failed": "Não foi possível salvar a transcrição" + "transcript_save_failed": "Não foi possível salvar a transcrição", + "transcription_already_running": "Um trabalho de transcrição já está em andamento para este vídeo" }, "tags": { "group_created": "Grupo de tags criado", @@ -743,6 +744,10 @@ "drag": "Arraste e solte os rótulos para reordenar suas guias de materiais", "content_grouping_title": "Agrupamento de conteúdo", "content_grouping_description": "Aulas e exercícios em grupo por seção na lista de conteúdo.", + "progression_mode_title": "Progressão do aluno", + "progression_mode_description": "Controle se os alunos podem acessar todo o conteúdo desbloqueado ou devem concluir os itens em ordem.", + "progression_mode_free": "Livre — conteúdo desbloqueado é acessível", + "progression_mode_sequential": "Sequencial — concluir itens anteriores para desbloquear o próximo", "lesson_download": "Lições baixadas", "available": "Disponibilizar a Lição para Download em PDF para alunos matriculados", "course_download": "Download do curso", @@ -1022,7 +1027,14 @@ "field_template": "Modelo", "field_accent": "Sotaque", "field_signatories": "Signatários", - "customize_design": "Personalizar design" + "customize_design": "Personalizar design", + "signatory_one_toggle": "Mostrar signatário 1", + "signatory_two_toggle": "Mostrar signatário 2", + "signature_upload": "Imagem de assinatura", + "signature_upload_hint": "Faça upload de uma imagem de assinatura (opcional)", + "signature_dont_crop": "Não corte", + "signature_upload_in_progress": "Aguarde o término do upload da assinatura antes de salvar.", + "save_failed": "Não foi possível salvar o design do certificado. Verifique o formulário e tente novamente." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Recolher seção", "expand_section": "Expandir seção" }, - "more": "Ações" + "more": "Ações", + "settings_completion_policy": "Regra de conclusão", + "settings_completion_submitted": "Enviado – qualquer envio conta", + "settings_completion_passed": "Aprovado – pontuação mínima exigida", + "settings_pass_threshold": "Limite de aprovação (%)" }, "new_exercise_modal": { "heading": "Novo exercício", @@ -1803,6 +1819,9 @@ "button": "botão", "button_done": "Concluído", "tabs": { + "settings": { + "title": "Configurações" + }, "document": { "title": "Documentos", "add_document": "Adicionar documento", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drive", "kind_generic": "Incorporado", "course_fallback": "Curso", - "menu_aria": "Opções de vídeo" + "menu_aria": "Opções de vídeo", + "transcription_in_progress": "(Geração de transcrição em andamento)" }, "view_transcript": "Ver transcrição", "playback_error": "Falha ao carregar este vídeo.", @@ -1926,7 +1946,8 @@ "generate_1080": "Gerar 1080p", "generate_1080_in_progress": "Gerando 1080p…", "generate_1080_ready": "1080p disponível", - "generate_1080_unavailable": "1080p indisponível para este vídeo de origem" + "generate_1080_unavailable": "1080p indisponível para este vídeo de origem", + "loading": "Carregando vídeo…" }, "slide": { "title": "Deslizar", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Este conteúdo está bloqueado", "content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde.", + "content_progression_locked_title": "Conclua o conteúdo anterior primeiro", + "content_progression_locked_description": "Este conteúdo é desbloqueado depois de concluir o item obrigatório anterior do curso.", + "content_progression_locked_description_lesson": "Esta lição é desbloqueada depois de concluir o item obrigatório anterior do curso.", + "content_progression_locked_description_exercise": "Este exercício é desbloqueado depois de concluir o item obrigatório anterior do curso.", + "completion_policy": { + "label": "Regra de conclusão", + "manual_label": "Manual", + "manual_description": "Os alunos marcam a lição como concluída.", + "video_watch_label": "Assistir vídeo", + "video_watch_description": "A lição conclui automaticamente depois que os alunos assistem aos vídeos selecionados.", + "none_label": "Nenhuma", + "none_description": "Esta lição não bloqueia a progressão do curso.", + "watch_threshold": "Limite de visualização (%)" + }, + "settings": { + "progression": { + "heading": "Progressão", + "intro": "Escolha como os alunos concluem esta lição e avançam no curso.", + "option_guide_heading": "O que cada opção significa", + "watch_videos_heading": "Vídeos a aplicar", + "watch_videos_helper": "Apenas vídeos enviados podem ser aplicados. YouTube e incorporações externas não podem ser rastreados.", + "watch_videos_empty": "Adicione um vídeo enviado na aba Vídeo para usar o rastreamento de visualização.", + "watch_videos_required": "Selecione pelo menos um vídeo para rastreamento de visualização.", + "students_only_note": "Estas regras aplicam-se apenas aos alunos. Você pode pré-visualizar e navegar livremente ao ensinar." + } + }, + "watch_progress": { + "status": "Assistiu {percent}% do conteúdo do vídeo", + "videos_complete": "Assistiu {percent}% do conteúdo de vídeo obrigatório ({complete} de {required} vídeos)", + "seek_blocked": "Você não pode avançar neste vídeo", + "complete_via_watch": "Esta lição conclui quando você assiste o suficiente do vídeo" + }, "saving": "Salvando…", "saved": "Salvo", "draft_recovery": "Um rascunho não salvo foi encontrado para esta lição. Você gostaria de restaurá-lo?", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index ec0bd4a8..32e3d701 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Восстановление миниатюр…", "thumbnail_regen_failed": "Не удалось восстановить миниатюры.", "transcript_saved": "Стенограмма обновлена", - "transcript_save_failed": "Не удалось сохранить расшифровку" + "transcript_save_failed": "Не удалось сохранить расшифровку", + "transcription_already_running": "Транскрипция этого видео уже выполняется." }, "tags": { "group_created": "Группа тегов создана.", @@ -743,6 +744,10 @@ "drag": "Перетащите метки, чтобы изменить порядок вкладок материалов.", "content_grouping_title": "Группировка контента", "content_grouping_description": "Группируйте уроки и упражнения по разделам в списке контента.", + "progression_mode_title": "Прогресс студента", + "progression_mode_description": "Управляйте доступом студентов ко всему разблокированному контенту или требуйте прохождения по порядку.", + "progression_mode_free": "Свободный — разблокированный контент доступен", + "progression_mode_sequential": "Последовательный — завершите предыдущие элементы, чтобы открыть следующий", "lesson_download": "Уроки СкачатьСкачать", "available": "Сделайте урок доступным для скачивания в формате pdf для зарегистрированных студентов.", "course_download": "Загрузка курса", @@ -1022,7 +1027,14 @@ "field_template": "Шаблон", "field_accent": "Акцент", "field_signatories": "Подписавшиеся", - "customize_design": "Настроить дизайн" + "customize_design": "Настроить дизайн", + "signatory_one_toggle": "Показать подписанта 1", + "signatory_two_toggle": "Показать подписанта 2", + "signature_upload": "Изображение подписи", + "signature_upload_hint": "Загрузите изображение подписи (необязательно)", + "signature_dont_crop": "Не обрезать", + "signature_upload_in_progress": "Прежде чем сохранять, дождитесь завершения загрузки подписи.", + "save_failed": "Не удалось сохранить дизайн сертификата. Проверьте форму и повторите попытку." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Свернуть раздел", "expand_section": "Развернуть раздел" }, - "more": "Действия" + "more": "Действия", + "settings_completion_policy": "Правило завершения", + "settings_completion_submitted": "Отправлено — любая отправка засчитывается", + "settings_completion_passed": "Пройдено — требуется минимальный балл", + "settings_pass_threshold": "Порог прохождения (%)" }, "new_exercise_modal": { "heading": "Новое упражнение", @@ -1803,6 +1819,9 @@ "button": "кнопка", "button_done": "Готово", "tabs": { + "settings": { + "title": "Настройки" + }, "document": { "title": "Документы", "add_document": "Добавить документ", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Гугл Диск", "kind_generic": "Встроенный", "course_fallback": "Курс", - "menu_aria": "Параметры видео" + "menu_aria": "Параметры видео", + "transcription_in_progress": "(Идет генерация стенограммы)" }, "view_transcript": "Посмотреть транскрипцию", "playback_error": "Не удалось загрузить это видео.", @@ -1926,7 +1946,8 @@ "generate_1080": "Создать 1080p", "generate_1080_in_progress": "Генерация 1080p…", "generate_1080_ready": "доступно 1080p", - "generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео." + "generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео.", + "loading": "Загрузка видео…" }, "slide": { "title": "Слайд", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Этот контент заблокирован", "content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже.", + "content_progression_locked_title": "Сначала завершите предыдущий материал", + "content_progression_locked_description": "Этот контент откроется после завершения предыдущего обязательного элемента курса.", + "content_progression_locked_description_lesson": "Этот урок откроется после завершения предыдущего обязательного элемента курса.", + "content_progression_locked_description_exercise": "Это упражнение откроется после завершения предыдущего обязательного элемента курса.", + "completion_policy": { + "label": "Правило завершения", + "manual_label": "Вручную", + "manual_description": "Студенты сами отмечают урок как завершённый.", + "video_watch_label": "Просмотр видео", + "video_watch_description": "Урок завершается автоматически после просмотра выбранных видео.", + "none_label": "Нет", + "none_description": "Этот урок не блокирует прохождение курса.", + "watch_threshold": "Порог просмотра (%)" + }, + "settings": { + "progression": { + "heading": "Прогресс", + "intro": "Выберите, как студенты завершают этот урок и проходят курс.", + "option_guide_heading": "Что означает каждый вариант", + "watch_videos_heading": "Видео для контроля", + "watch_videos_helper": "Контроль возможен только для загруженных видео. YouTube и внешние встраивания отслеживать нельзя.", + "watch_videos_empty": "Добавьте загруженное видео на вкладке Видео для контроля просмотра.", + "watch_videos_required": "Выберите хотя бы одно видео для контроля просмотра.", + "students_only_note": "Эти правила действуют только для студентов. Преподаватель может свободно просматривать и переходить между уроками." + } + }, + "watch_progress": { + "status": "Просмотрено {percent}% видеоконтента", + "videos_complete": "Просмотрено {percent}% обязательного видеоконтента ({complete} из {required} видео)", + "seek_blocked": "Нельзя перематывать вперёд в этом видео", + "complete_via_watch": "Урок завершается, когда вы достаточно просмотрите видео" + }, "saving": "Сохранение…", "saved": "Сохранено", "draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index ec7e40bb..482e47fa 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -352,7 +352,8 @@ "thumbnail_regen_started": "Đang tạo lại hình thu nhỏ…", "thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ", "transcript_saved": "Đã cập nhật bản ghi", - "transcript_save_failed": "Không thể lưu bản ghi" + "transcript_save_failed": "Không thể lưu bản ghi", + "transcription_already_running": "Công việc chép lời đang được thực hiện cho video này" }, "tags": { "group_created": "Đã tạo nhóm thẻ", @@ -743,6 +744,10 @@ "drag": "Kéo và thả nhãn để sắp xếp lại các tab tài liệu của bạn", "content_grouping_title": "Nhóm nội dung", "content_grouping_description": "Nhóm các bài học, bài tập theo từng phần trong danh sách nội dung.", + "progression_mode_title": "Tiến trình học viên", + "progression_mode_description": "Kiểm soát học viên có thể truy cập mọi nội dung đã mở khóa hay phải hoàn thành theo thứ tự.", + "progression_mode_free": "Tự do — nội dung đã mở khóa có thể truy cập", + "progression_mode_sequential": "Tuần tự — hoàn thành mục trước để mở mục tiếp theo", "lesson_download": "Bài học Tải về", "available": "Cung cấp Bài học để tải xuống dưới dạng pdf cho học viên đã đăng ký", "course_download": "Tải xuống khóa học", @@ -1022,7 +1027,14 @@ "field_template": "mẫu", "field_accent": "Giọng", "field_signatories": "Người ký", - "customize_design": "Tùy chỉnh thiết kế" + "customize_design": "Tùy chỉnh thiết kế", + "signatory_one_toggle": "Hiển thị người ký 1", + "signatory_two_toggle": "Hiển thị người ký 2", + "signature_upload": "Hình ảnh chữ ký", + "signature_upload_hint": "Tải lên hình ảnh chữ ký (tùy chọn)", + "signature_dont_crop": "Đừng cắt xén", + "signature_upload_in_progress": "Đợi quá trình tải lên chữ ký hoàn tất trước khi lưu.", + "save_failed": "Không thể lưu thiết kế chứng chỉ. Hãy kiểm tra biểu mẫu và thử lại." } }, "people": { @@ -1758,7 +1770,11 @@ "collapse_section": "Thu gọn phần", "expand_section": "Mở rộng phần" }, - "more": "hành động" + "more": "hành động", + "settings_completion_policy": "Quy tắc hoàn thành", + "settings_completion_submitted": "Đã gửi - mọi lần gửi đều được tính", + "settings_completion_passed": "Đạt - yêu cầu điểm tối thiểu", + "settings_pass_threshold": "Ngưỡng vượt qua (%)" }, "new_exercise_modal": { "heading": "Bài tập mới", @@ -1803,6 +1819,9 @@ "button": "nút", "button_done": "Xong", "tabs": { + "settings": { + "title": "Cài đặt" + }, "document": { "title": "Tài liệu", "add_document": "Thêm tài liệu", @@ -1918,7 +1937,8 @@ "kind_google_drive": "Google Drive", "kind_generic": "Đã nhúng", "course_fallback": "Khóa học", - "menu_aria": "Tùy chọn video" + "menu_aria": "Tùy chọn video", + "transcription_in_progress": "(Đang tạo bản ghi)" }, "view_transcript": "Xem bản chép lời", "playback_error": "Tải video này không thành công.", @@ -1926,7 +1946,8 @@ "generate_1080": "Tạo 1080p", "generate_1080_in_progress": "Đang tạo 1080p…", "generate_1080_ready": "1080p có sẵn", - "generate_1080_unavailable": "1080p không có sẵn cho video nguồn này" + "generate_1080_unavailable": "1080p không có sẵn cho video nguồn này", + "loading": "Đang tải video…" }, "slide": { "title": "Trượt", @@ -1985,6 +2006,38 @@ }, "content_locked_title": "Nội dung này đã bị khóa", "content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau.", + "content_progression_locked_title": "Hoàn thành nội dung trước đó trước", + "content_progression_locked_description": "Nội dung này mở khóa sau khi bạn hoàn thành mục bắt buộc trước đó trong khóa học.", + "content_progression_locked_description_lesson": "Bài học này mở khóa sau khi bạn hoàn thành mục bắt buộc trước đó trong khóa học.", + "content_progression_locked_description_exercise": "Bài tập này mở khóa sau khi bạn hoàn thành mục bắt buộc trước đó trong khóa học.", + "completion_policy": { + "label": "Quy tắc hoàn thành", + "manual_label": "Thủ công", + "manual_description": "Học viên tự đánh dấu bài học là hoàn thành.", + "video_watch_label": "Xem video", + "video_watch_description": "Bài học tự hoàn thành sau khi học viên xem các video đã chọn.", + "none_label": "Không", + "none_description": "Bài học này không chặn tiến trình khóa học.", + "watch_threshold": "Ngưỡng xem (%)" + }, + "settings": { + "progression": { + "heading": "Tiến trình", + "intro": "Chọn cách học viên hoàn thành bài học này và đi qua khóa học.", + "option_guide_heading": "Ý nghĩa từng tùy chọn", + "watch_videos_heading": "Video cần áp dụng", + "watch_videos_helper": "Chỉ video đã tải lên mới có thể áp dụng. YouTube và nhúng bên ngoài không thể theo dõi.", + "watch_videos_empty": "Thêm video đã tải lên trong tab Video để dùng theo dõi xem.", + "watch_videos_required": "Chọn ít nhất một video khi dùng theo dõi xem.", + "students_only_note": "Các quy tắc này chỉ áp dụng cho học viên. Giảng viên có thể xem trước và điều hướng tự do." + } + }, + "watch_progress": { + "status": "Đã xem {percent}% nội dung video", + "videos_complete": "Đã xem {percent}% nội dung video bắt buộc ({complete}/{required} video)", + "seek_blocked": "Bạn không thể tua vượt trong video này", + "complete_via_watch": "Bài học hoàn thành khi bạn xem đủ video" + }, "saving": "Đang lưu…", "saved": "Đã lưu", "draft_recovery": "Đã tìm thấy một bản nháp chưa được lưu cho bài học này. Bạn có muốn khôi phục nó không?", diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.server.ts b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.server.ts index 562648c9..c09a52bb 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.server.ts +++ b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.server.ts @@ -7,6 +7,7 @@ export const load = async ({ params, cookies }) => { const exerciseId = params.exerciseId || ''; if (!courseId || !exerciseId) { + console.log('courseId', courseId, 'exerciseId', exerciseId); return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] }; } @@ -31,6 +32,7 @@ export const load = async ({ params, cookies }) => { const mySubmissions = mySubmissionData; const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : []; + console.log('submissions', submissions.length, 'mySubmissions', mySubmissions.length); return { courseId, diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte index adfcfab6..cb481c8c 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte @@ -2,19 +2,38 @@ import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; - import { Empty } from '@cio/ui/custom/empty'; - import LockIcon from '@lucide/svelte/icons/lock'; + import { Spinner } from '@cio/ui/base/spinner'; import * as Page from '@cio/ui/base/page'; import { ExercisePage } from '$features/course/pages'; import { questionnaireMetaData, reset } from '$features/course/components/exercise/store'; + import { courseApi } from '$features/course/api'; import { isOrgStudent } from '$lib/utils/store/app'; - import { t } from '$lib/utils/functions/translations'; import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils'; + import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar'; + import { ContentType } from '@cio/utils/constants/content'; let { data = $bindable() } = $props(); const path = `/courses/${data.courseId}/lessons`; - const isLockedForStudent = $derived($isOrgStudent && data.exercise?.isUnlocked === false); + const isCourseReady = $derived(courseApi.course?.id === data.courseId); + + const contentLockReason = $derived( + getStudentContentLockReason(courseApi.course, data.exerciseId, ContentType.Exercise) + ); + + const isLockedForStudent = $derived.by(() => { + if (!$isOrgStudent) return false; + + if (data.exercise?.isUnlocked === false) { + return true; + } + + if (!data.exercise || contentLockReason !== null) { + return true; + } + + return false; + }); $effect(() => { if (!data.exercise || isLockedForStudent) return; @@ -32,14 +51,10 @@ - {#if isLockedForStudent} - + {#if $isOrgStudent && !isCourseReady} +
+ +
{:else} { + const { user, profile, organizations } = locals; + + if (!user || !profile) { + return new Response('Unauthorized', { status: 401 }); + } + + const orgId = url.searchParams.get('orgId'); + const org = organizations?.find((candidate) => candidate.id === orgId); + + if (!org?.siteName) { + return new Response('You are not a member of this organization', { status: 403 }); + } -export const GET = async ({ url }: { url: URL }) => { const quantityRaw = Number(url.searchParams.get('quantity') ?? '1'); const quantity = Math.min(100, Math.max(1, Number.isFinite(quantityRaw) ? Math.trunc(quantityRaw) : 1)); - let metadata: { orgId?: string; orgSlug?: string; triggeredBy?: string }; - - try { - metadata = JSON.parse(url.searchParams.get('metadata') ?? '{}'); - } catch { - return new Response('Invalid metadata JSON', { status: 400 }); - } - - if (!metadata.orgId || !metadata.orgSlug) { - return new Response('Missing orgId / orgSlug in metadata', { status: 400 }); - } - const productId = env.POLAR_TOKEN_PACK_PRODUCT_ID; if (!productId) { @@ -35,6 +37,7 @@ export const GET = async ({ url }: { url: URL }) => { }); const totalPriceCents = TOKEN_PACK.PRICE_USD_CENTS * quantity; + const customerEmail = (profile.email ?? user.email)?.replace('@test.com', '+test@digdippa.com'); const checkout = await polar.checkouts.create({ products: [productId], @@ -47,14 +50,14 @@ export const GET = async ({ url }: { url: URL }) => { } ] }, - customerEmail: url.searchParams.get('customerEmail') ?? undefined, - customerName: url.searchParams.get('customerName') ?? undefined, - successUrl: `${url.origin}/org/${metadata.orgSlug}/settings/ai-credits?tokens=success`, + customerEmail, + customerName: profile.fullname || undefined, + successUrl: `${url.origin}/org/${org.siteName}/settings/ai-credits?tokens=success`, metadata: { kind: 'token_pack', - orgId: metadata.orgId, - orgSlug: metadata.orgSlug, - triggeredBy: metadata.triggeredBy ?? '', + orgId: org.id, + orgSlug: org.siteName, + triggeredBy: profile.id, tokensPerUnit: String(TOKEN_PACK.TOKENS_PER_UNIT), quantity: String(quantity) } diff --git a/apps/dashboard/src/routes/api/polar/portal/+server.ts b/apps/dashboard/src/routes/api/polar/portal/+server.ts index 4b952a40..7ab51e20 100644 --- a/apps/dashboard/src/routes/api/polar/portal/+server.ts +++ b/apps/dashboard/src/routes/api/polar/portal/+server.ts @@ -1,9 +1,28 @@ import { CustomerPortal } from '@polar-sh/sveltekit'; import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; +import type { RequestEvent } from './$types'; -export const GET = CustomerPortal({ - accessToken: env.POLAR_ACCESS_TOKEN!, - getCustomerId: async (e) => e.url.searchParams.get('customerId')!, - server: dev ? 'sandbox' : 'production' -}); +export const GET = async (event: RequestEvent) => { + const { user, organizations } = event.locals; + + if (!user) { + return new Response('Unauthorized', { status: 401 }); + } + + const orgId = event.url.searchParams.get('orgId'); + const org = organizations?.find((candidate) => candidate.id === orgId); + const customerId = org?.plan?.customerId; + + if (!customerId) { + return new Response('No billing customer found for this organization', { status: 404 }); + } + + const handler = CustomerPortal({ + accessToken: env.POLAR_ACCESS_TOKEN!, + getCustomerId: async () => customerId, + server: dev ? 'sandbox' : 'production' + }); + + return handler(event); +}; diff --git a/apps/dashboard/src/routes/api/polar/subscribe/+server.ts b/apps/dashboard/src/routes/api/polar/subscribe/+server.ts index 117b0dd5..413f373f 100644 --- a/apps/dashboard/src/routes/api/polar/subscribe/+server.ts +++ b/apps/dashboard/src/routes/api/polar/subscribe/+server.ts @@ -1,16 +1,58 @@ +import { Polar } from '@polar-sh/sdk'; +import { PLANS } from '@cio/utils/plans'; import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; -import { Checkout } from '@polar-sh/sveltekit'; +import type { RequestEvent } from './$types'; -export const GET = ({ ...params }) => { - const metadata = JSON.parse(params.url.searchParams.get('metadata') ?? '{}'); - const successUrl = `/org/${metadata.orgSlug}?upgrade=true&confirmation=true`; +const SUBSCRIPTION_PRODUCT_IDS = new Set( + Object.values(PLANS) + .flatMap((plan) => { + const cta = plan.CTA as { PRODUCT_ID?: string; PRODUCT_ID_YEARLY?: string }; + return [cta.PRODUCT_ID, cta.PRODUCT_ID_YEARLY]; + }) + .filter((id): id is string => Boolean(id)) +); - const request = Checkout({ +export const GET = async ({ url, locals }: RequestEvent) => { + const { user, profile, organizations } = locals; + + if (!user || !profile) { + return new Response('Unauthorized', { status: 401 }); + } + + const orgId = url.searchParams.get('orgId'); + const org = organizations?.find((candidate) => candidate.id === orgId); + + if (!org?.siteName || !org.member) { + return new Response('You are not a member of this organization', { status: 403 }); + } + + const productId = url.searchParams.get('products'); + + if (!productId || !SUBSCRIPTION_PRODUCT_IDS.has(productId)) { + return new Response('Unknown product', { status: 400 }); + } + + if (!env.POLAR_ACCESS_TOKEN) { + return new Response('POLAR_ACCESS_TOKEN is not configured', { status: 500 }); + } + + const polar = new Polar({ accessToken: env.POLAR_ACCESS_TOKEN, - successUrl, server: dev ? 'sandbox' : 'production' }); - return request(params); + const checkout = await polar.checkouts.create({ + products: [productId], + customerEmail: profile.email ?? user.email, + customerName: profile.fullname || undefined, + successUrl: `${url.origin}/org/${org.siteName}?upgrade=true&confirmation=true`, + metadata: { + orgId: org.id, + orgSlug: org.siteName, + triggeredBy: String(org.member.id) + } + }); + + return Response.redirect(checkout.url, 303); }; diff --git a/apps/docs/DOCS_MIGRATION.md b/apps/docs/DOCS_MIGRATION.md deleted file mode 100644 index 6411df16..00000000 --- a/apps/docs/DOCS_MIGRATION.md +++ /dev/null @@ -1,137 +0,0 @@ -# Migrating Docs from docs.classroomio.com to classroomio.com/docs - -## Summary - -This guide documents the configuration to serve the docs at `classroomio.com/docs` instead of `docs.classroomio.com` using Vercel rewrites with TanStack Start. - -## Changes Made - -### 1. Docs App Configuration (`apps/docs`) - -**a) Updated `apps/docs/vite.config.ts`:** -```typescript -export default defineConfig({ - server: { - port: 3000 - }, - base: '/docs', // ← Added: Vite base path for asset resolution - plugins: [ - mdx(await import('./source.config')), - tailwindcss(), - tsConfigPaths({ - projects: ['./tsconfig.json'] - }), - tanstackStart(), - react(), - nitro({ - baseURL: '/docs' // ← Added: Nitro base URL for server routing - }) - ] -}); -``` - -**b) Updated `apps/docs/src/router.tsx`:** -```typescript -export function getRouter() { - return createTanStackRouter({ - basepath: '/docs', // ← Added: TanStack Router base path - defaultNotFoundComponent: NotFound, - defaultPreload: 'intent', - routeTree, - scrollRestoration: true - }); -} -``` - -These three configurations work together: -- `base: '/docs'` - Tells Vite to prefix all asset paths with `/docs` -- `baseURL: '/docs'` - Tells Nitro to handle all routes under `/docs` -- `basepath: '/docs'` - Tells TanStack Router that routes are under `/docs` - -### 2. Main Site Configuration (`apps/classroomio-com`) - -**Created `apps/classroomio-com/vercel.json`**: -```json -{ - "rewrites": [ - { - "source": "/docs/", - "destination": "https://ciodocs.vercel.app/docs/" - }, - { - "source": "/docs/:path*", - "destination": "https://ciodocs.vercel.app/docs/:path*" - } - ] -} -``` - -This proxies all `/docs/*` requests from `classroomio.com` to your docs deployment. - -### 3. Cleanup - -- Removed empty `apps/classroomio-com/src/routes/(redirects)/docs/` folder that would conflict with the Vercel rewrite - -## How It Works - -1. **User visits:** `classroomio.com/docs/home` -2. **Vercel receives:** Request for `/docs/home` -3. **Vercel rewrites to:** `ciodocs.vercel.app/docs/home` -4. **Docs app serves:** The page from its `/docs/home` route (configured with `basepath: '/docs'`) -5. **User sees:** Content at `classroomio.com/docs/home` (URL unchanged) - -The key is that **both the rewrite and the docs app preserve the `/docs` path**, keeping everything aligned. - -## Deployment Steps - -### Step 1: Deploy/Redeploy the Docs App - -The docs app needs to be built and deployed with the `basepath: '/docs'` configuration: - -1. Make sure changes to `apps/docs/src/router.tsx` are committed -2. Deploy or redeploy to `ciodocs.vercel.app` -3. Vercel will automatically detect and deploy the changes - -**Vercel Project Settings for Docs:** -- **Root Directory:** `apps/docs` -- **Build Command:** `pnpm build` -- **Output Directory:** `.output` -- **Domain:** `ciodocs.vercel.app` - -### Step 2: Deploy/Redeploy the Main Site - -The main site needs the `vercel.json` rewrite configuration: - -1. Make sure `apps/classroomio-com/vercel.json` is committed -2. Deploy or redeploy to `classroomio.com` - -**Vercel Project Settings for Main Site:** -- **Root Directory:** `apps/classroomio-com` -- **Build Command:** `pnpm build` -- **Output Directory:** `.svelte-kit` -- **Domain:** `classroomio.com` - -### Step 3: Test - -Once both are deployed: - -1. ✅ Visit `classroomio.com/docs` - should show docs homepage -2. ✅ Visit `classroomio.com/docs/quickstart/signup` - should show specific doc pages -3. ✅ Check that styles and JavaScript load correctly -4. ✅ Test navigation within docs - -## Architecture - -``` -User Request: classroomio.com/docs/quickstart/signup - ↓ -Vercel (classroomio.com) - reads vercel.json - ↓ -Rewrite to: ciodocs.vercel.app/docs/quickstart/signup - ↓ -Docs App (TanStack Start with basepath: '/docs') - ↓ -Serves: /quickstart/signup route - ↓ -User sees content at: classroomio.com/docs/quickstart/signup -``` diff --git a/packages/certificates/src/constants.ts b/packages/certificates/src/constants.ts index 8cd50298..a7887a49 100644 --- a/packages/certificates/src/constants.ts +++ b/packages/certificates/src/constants.ts @@ -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}' }; diff --git a/packages/certificates/src/templates/brutalist.ts b/packages/certificates/src/templates/brutalist.ts index a79878c8..0405a7bd 100644 --- a/packages/certificates/src/templates/brutalist.ts +++ b/packages/certificates/src/templates/brutalist.ts @@ -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 }) => {
Verified
`; @@ -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 }; diff --git a/packages/certificates/src/templates/classique.ts b/packages/certificates/src/templates/classique.ts index 802fb004..85eb4faa 100644 --- a/packages/certificates/src/templates/classique.ts +++ b/packages/certificates/src/templates/classique.ts @@ -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 }) => {
${escapeHtml(data.recipientName)}
${escapeHtml(description)}
`; @@ -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 }; diff --git a/packages/certificates/src/templates/minimal.ts b/packages/certificates/src/templates/minimal.ts index 2bf79cd8..45756db0 100644 --- a/packages/certificates/src/templates/minimal.ts +++ b/packages/certificates/src/templates/minimal.ts @@ -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 = `
-
${escapeHtml(data.orgName)} ${escapeHtml(data.certificateId)} · ${escapeHtml(data.date)} @@ -23,22 +28,10 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
${escapeHtml(description)}
`; @@ -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 }; diff --git a/packages/certificates/src/templates/noir.ts b/packages/certificates/src/templates/noir.ts index 1f6f55ae..0f42d314 100644 --- a/packages/certificates/src/templates/noir.ts +++ b/packages/certificates/src/templates/noir.ts @@ -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 }) => {
${escapeHtml(data.recipientName)}
${escapeHtml(description)}
`; @@ -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 }; diff --git a/packages/certificates/src/templates/poster.ts b/packages/certificates/src/templates/poster.ts index a534cde6..2aebc71d 100644 --- a/packages/certificates/src/templates/poster.ts +++ b/packages/certificates/src/templates/poster.ts @@ -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 }) => {
${escapeHtml(description)}
-
-
${escapeHtml(signatoryOne.role)}
-
${escapeHtml(signatoryOne.name)}
-
-
-
${escapeHtml(signatoryTwo.role)}
-
${escapeHtml(signatoryTwo.name)}
-
-
-
Issued
-
${escapeHtml(data.date)}
-
+ ${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })} + ${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })} + ${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
${getYear(data.date)}
@@ -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 }; diff --git a/packages/certificates/src/templates/shared.ts b/packages/certificates/src/templates/shared.ts index 06861bda..9443bd9e 100644 --- a/packages/certificates/src/templates/shared.ts +++ b/packages/certificates/src/templates/shared.ts @@ -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 ? `` : ''; + + const nameEl = `
${escapeHtml(sig.name)}
`; + const roleEl = `
${escapeHtml(sig.role)}
`; + const content = options.roleFirst ? `${roleEl}${nameEl}` : `${nameEl}${roleEl}`; + const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : ''; + + return ` + +
+
${sigImg}
+
${content}
+
+ + `; +} + +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 ` + +
+
+
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+
+ + `; +} + +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); } `; diff --git a/packages/certificates/src/types.ts b/packages/certificates/src/types.ts index 97f62d0b..218d724f 100644 --- a/packages/certificates/src/types.ts +++ b/packages/certificates/src/types.ts @@ -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 { diff --git a/packages/core/src/services/course/course.ts b/packages/core/src/services/course/course.ts index 977ada2a..5150db8c 100644 --- a/packages/core/src/services/course/course.ts +++ b/packages/core/src/services/course/course.ts @@ -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) { diff --git a/packages/core/src/services/course/progression.ts b/packages/core/src/services/course/progression.ts new file mode 100644 index 00000000..442fd323 --- /dev/null +++ b/packages/core/src/services/course/progression.ts @@ -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; + submittedExerciseIds: Set; + passedExerciseIds: Set; +}): 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; + exercisePolicyById: Map; + progressionMode: 'free' | 'sequential'; + completedLessonIds: Set; + submittedExerciseIds: Set; + passedExerciseIds: Set; +}): Map { + const accessById = new Map(); + 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, + lessonPolicyById: Map, + exercisePolicyById: Map +): 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 { + 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(); + + 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()) + ]); + + const passedExerciseIds = groupMemberId + ? await getPassedExerciseIdsForMember(params.courseId, groupMemberId, exercisePolicies) + : new Set(); + + 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 { + 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); + } +} diff --git a/packages/core/src/services/course/utils.ts b/packages/core/src/services/course/utils.ts index dff17323..397f043d 100644 --- a/packages/core/src/services/course/utils.ts +++ b/packages/core/src/services/course/utils.ts @@ -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 = { diff --git a/packages/core/src/services/lesson/lesson.ts b/packages/core/src/services/lesson/lesson.ts index 134906d6..7fd92a10 100644 --- a/packages/core/src/services/lesson/lesson.ts +++ b/packages/core/src/services/lesson/lesson.ts @@ -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>, + 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 { + 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 { + 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 diff --git a/packages/core/src/utils/lesson-watch-enforcement.ts b/packages/core/src/utils/lesson-watch-enforcement.ts new file mode 100644 index 00000000..11894034 --- /dev/null +++ b/packages/core/src/utils/lesson-watch-enforcement.ts @@ -0,0 +1,50 @@ +type LessonVideo = { + type: string; + assetId?: string | null; + watchEnforced?: boolean | null; + link?: string; + metadata?: Record | 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) + ) + ); +} diff --git a/packages/db/src/queries/course/content.ts b/packages/db/src/queries/course/content.ts index aba75b80..bb5e95d5 100644 --- a/packages/db/src/queries/course/content.ts +++ b/packages/db/src/queries/course/content.ts @@ -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`; diff --git a/packages/db/src/queries/course/index.ts b/packages/db/src/queries/course/index.ts index 9ef361d7..6d33fd3b 100644 --- a/packages/db/src/queries/course/index.ts +++ b/packages/db/src/queries/course/index.ts @@ -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'; diff --git a/packages/db/src/queries/course/progression.ts b/packages/db/src/queries/course/progression.ts new file mode 100644 index 00000000..6322037e --- /dev/null +++ b/packages/db/src/queries/course/progression.ts @@ -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 { + 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 { + 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 { + 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> { + 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> { + 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> { + const passedIds = new Set(); + + 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; +} diff --git a/packages/db/src/queries/lesson/index.ts b/packages/db/src/queries/lesson/index.ts index 7f5be91c..c2b6c82b 100644 --- a/packages/db/src/queries/lesson/index.ts +++ b/packages/db/src/queries/lesson/index.ts @@ -1,2 +1,3 @@ export * from './lesson'; export * from './language'; +export * from './video-progress'; diff --git a/packages/db/src/queries/lesson/video-progress.ts b/packages/db/src/queries/lesson/video-progress.ts new file mode 100644 index 00000000..fc90c083 --- /dev/null +++ b/packages/db/src/queries/lesson/video-progress.ts @@ -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 { + 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 { + 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 { + 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'}` + ); + } +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0f7dfc3e..b6df4fc2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -689,6 +689,8 @@ export const course = pgTable( allowNewStudent: boolean; sectionDisplay?: Record; 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', { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index fd2dcb11..909fbf97 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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; diff --git a/packages/storybook/src/templates/certificate/fixtures.ts b/packages/storybook/src/templates/certificate/fixtures.ts index da5aebdf..7712ac64 100644 --- a/packages/storybook/src/templates/certificate/fixtures.ts +++ b/packages/storybook/src/templates/certificate/fixtures.ts @@ -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 }; diff --git a/packages/ui/src/custom/certificate-preview/certificate-preview.svelte b/packages/ui/src/custom/certificate-preview/certificate-preview.svelte index e89cd3aa..dda50278 100644 --- a/packages/ui/src/custom/certificate-preview/certificate-preview.svelte +++ b/packages/ui/src/custom/certificate-preview/certificate-preview.svelte @@ -33,10 +33,20 @@ const MAX_SCALE = 1.5; let stageElement = $state(null); + let iframeElement = $state(null); let manualScale = $state(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 @@ >
diff --git a/packages/ui/src/custom/exercise-question/renderers/word-bank/word-bank-utils.ts b/packages/ui/src/custom/exercise-question/renderers/word-bank/word-bank-utils.ts index 0412a86c..a7162302 100644 --- a/packages/ui/src/custom/exercise-question/renderers/word-bank/word-bank-utils.ts +++ b/packages/ui/src/custom/exercise-question/renderers/word-bank/word-bank-utils.ts @@ -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++) { diff --git a/packages/ui/src/custom/image-cropper/image-cropper-context.svelte.ts b/packages/ui/src/custom/image-cropper/image-cropper-context.svelte.ts index 13ad88fb..c537bd75 100644 --- a/packages/ui/src/custom/image-cropper/image-cropper-context.svelte.ts +++ b/packages/ui/src/custom/image-cropper/image-cropper-context.svelte.ts @@ -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('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); +}; diff --git a/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte b/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte index b69d3532..b2bc66b0 100644 --- a/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte +++ b/packages/ui/src/custom/image-cropper/image-cropper-dialog.svelte @@ -1,8 +1,10 @@ + + diff --git a/packages/ui/src/custom/image-cropper/index.ts b/packages/ui/src/custom/image-cropper/index.ts index d81fbcdb..01ead9f1 100644 --- a/packages/ui/src/custom/image-cropper/index.ts +++ b/packages/ui/src/custom/image-cropper/index.ts @@ -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'; diff --git a/packages/ui/src/custom/media-player/players/plyr-player.svelte b/packages/ui/src/custom/media-player/players/plyr-player.svelte index bf93725d..b409a8fa 100644 --- a/packages/ui/src/custom/media-player/players/plyr-player.svelte +++ b/packages/ui/src/custom/media-player/players/plyr-player.svelte @@ -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(null); let transcriptButtonElement = $state(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 + ): () => 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 @@ }); + + +
{/if} + {#if !isPlayerReady && !playbackFailed} +
+
+ {/if} + {#if playbackFailed && options.playbackErrorLabel}
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; + seekPolicy?: { + mode: 'locked_until_complete'; + watchThresholdPercent: number; + initialFurthestSeconds?: number; + pauseOnHidden?: boolean; + onProgress?: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => void; + onSeekBlocked?: () => void; + }; } diff --git a/packages/ui/src/custom/percent-ring-progress/index.ts b/packages/ui/src/custom/percent-ring-progress/index.ts new file mode 100644 index 00000000..b1b06596 --- /dev/null +++ b/packages/ui/src/custom/percent-ring-progress/index.ts @@ -0,0 +1,4 @@ +import PercentRingProgress from './percent-ring-progress.svelte'; + +export { PercentRingProgress }; +export { PercentRingProgress as default }; diff --git a/packages/ui/src/custom/percent-ring-progress/percent-ring-progress.svelte b/packages/ui/src/custom/percent-ring-progress/percent-ring-progress.svelte new file mode 100644 index 00000000..ac19cdbb --- /dev/null +++ b/packages/ui/src/custom/percent-ring-progress/percent-ring-progress.svelte @@ -0,0 +1,52 @@ + + +
+ +
+

{labelPercentage}%

+
+
diff --git a/packages/utils/src/validation/exercise/exercise.ts b/packages/utils/src/validation/exercise/exercise.ts index 23ba4394..a2d28cd8 100644 --- a/packages/utils/src/validation/exercise/exercise.ts +++ b/packages/utils/src/validation/exercise/exercise.ts @@ -90,7 +90,7 @@ const QUESTION_VALIDATION_RULES: Record { 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; diff --git a/packages/utils/src/validation/lesson/lesson.ts b/packages/utils/src/validation/lesson/lesson.ts index 2853c175..99bfcb2c 100644 --- a/packages/utils/src/validation/lesson/lesson.ts +++ b/packages/utils/src/validation/lesson/lesson.ts @@ -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; + +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;