feat: add sequential progression and video watch lesson completion
Gate student content by completion order, track per-video watch progress with seek enforcement, and auto-complete lessons at the configured threshold. Show ring progress in the lesson header, keep watch progress on rewatch, and sync progression locks client-side so the next item unlocks after completion.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void> {
|
||||
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<number, string> = {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LessonWatchProgress | null> {
|
||||
const response = await this.execute<GetLessonWatchProgressRequest>({
|
||||
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<UpdateLessonWatchProgressRequest>({
|
||||
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<UpdateLessonCompletionRequest>({
|
||||
requestFn: () =>
|
||||
|
||||
+11
-3
@@ -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')}
|
||||
</span>
|
||||
<div class="text-right text-xs">
|
||||
{#if design.signatories[0].enabled}
|
||||
<div class="font-medium">{design.signatories[0].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
|
||||
<div class="mt-1 font-medium">{design.signatories[1].name}</div>
|
||||
{/if}
|
||||
{#if design.signatories[1].enabled}
|
||||
<div class:mt-1={design.signatories[0].enabled} class="font-medium">{design.signatories[1].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
+13
-2
@@ -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();
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -66,7 +77,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!isDirty || isSaving}
|
||||
disabled={!isDirty || isSaving || isSignatureUploading}
|
||||
loading={isSaving}
|
||||
onclick={onSave}
|
||||
>
|
||||
|
||||
+1
@@ -66,6 +66,7 @@
|
||||
templateLabel={activeTemplateMeta.label}
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
isSignatureUploading={store.isSignatureUploading}
|
||||
isFreePlan={$isFreePlan}
|
||||
onSave={() => store.save()}
|
||||
onDiscard={() => store.reset()}
|
||||
|
||||
+126
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { UploadImage } from '$features/ui';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +14,73 @@
|
||||
}
|
||||
|
||||
let { disabled = false }: Props = $props();
|
||||
|
||||
let signatoryOneAvatar: File | undefined = $state();
|
||||
let signatoryTwoAvatar: File | undefined = $state();
|
||||
let signatoryOnePreview = $state('');
|
||||
let signatoryTwoPreview = $state('');
|
||||
let isSignatoryOneUploading = $state(false);
|
||||
let isSignatoryTwoUploading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
certificateEditorStore.isSignatureUploading = isSignatoryOneUploading || isSignatoryTwoUploading;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSignatoryOneUploading) return;
|
||||
|
||||
signatoryOnePreview = certificateEditorStore.draft.signatories[0].signatureUrl;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSignatoryTwoUploading) return;
|
||||
|
||||
signatoryTwoPreview = certificateEditorStore.draft.signatories[1].signatureUrl;
|
||||
});
|
||||
|
||||
async function uploadSignatorySignature(index: 0 | 1, avatar: File) {
|
||||
if (index === 0) {
|
||||
isSignatoryOneUploading = true;
|
||||
} else {
|
||||
isSignatoryTwoUploading = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureUrl = await uploadImage(avatar);
|
||||
certificateEditorStore.setSignatorySignatureUrl(index, signatureUrl);
|
||||
|
||||
if (index === 0) {
|
||||
signatoryOnePreview = signatureUrl;
|
||||
signatoryOneAvatar = undefined;
|
||||
} else {
|
||||
signatoryTwoPreview = signatureUrl;
|
||||
signatoryTwoAvatar = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading signature image:', error);
|
||||
snackbar.error('snackbar.landing_page_settings.error.try_again');
|
||||
} finally {
|
||||
if (index === 0) {
|
||||
isSignatoryOneUploading = false;
|
||||
} else {
|
||||
isSignatoryTwoUploading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const avatar = signatoryOneAvatar;
|
||||
if (!avatar || isSignatoryOneUploading) return;
|
||||
|
||||
void uploadSignatorySignature(0, avatar);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const avatar = signatoryTwoAvatar;
|
||||
if (!avatar || isSignatoryTwoUploading) return;
|
||||
|
||||
void uploadSignatorySignature(1, avatar);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Field.Group>
|
||||
@@ -43,6 +114,14 @@
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_signatories')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={certificateEditorStore.draft.signatories[0].enabled} {disabled} />
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signatory_one_toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if certificateEditorStore.draft.signatories[0].enabled}
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
@@ -58,6 +137,35 @@
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
|
||||
<UploadImage
|
||||
bind:avatar={signatoryOneAvatar}
|
||||
bind:src={signatoryOnePreview}
|
||||
shape="rounded"
|
||||
widthHeight="h-[56px] w-[120px]"
|
||||
previewVariant="signature"
|
||||
isDisabled={disabled}
|
||||
bind:isUploading={isSignatoryOneUploading}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.signature_upload_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={certificateEditorStore.draft.signatories[1].enabled} {disabled} />
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signatory_two_toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if certificateEditorStore.draft.signatories[1].enabled}
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_two_name')}
|
||||
@@ -72,6 +180,24 @@
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
|
||||
<UploadImage
|
||||
bind:avatar={signatoryTwoAvatar}
|
||||
bind:src={signatoryTwoPreview}
|
||||
shape="rounded"
|
||||
widthHeight="h-[56px] w-[120px]"
|
||||
previewVariant="signature"
|
||||
isDisabled={disabled}
|
||||
bind:isUploading={isSignatoryTwoUploading}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.signature_upload_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
|
||||
+73
-19
@@ -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<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
initial = $state<CertificateEditorDraft>(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;
|
||||
|
||||
+4
-3
@@ -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;
|
||||
|
||||
+48
@@ -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 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<Label class="mb-2 block text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_policy')}
|
||||
</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={$questionnaire.completionPolicy ?? 'submitted'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'submitted' || value === 'passed') {
|
||||
questionnaire.update((state) => ({ ...state, completionPolicy: value }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{($questionnaire.completionPolicy ?? 'submitted') === 'passed'
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="submitted">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
|
||||
</Select.Item>
|
||||
<Select.Item value="passed">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
{#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'}
|
||||
<div class="mt-4">
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
|
||||
value={String($questionnaire.passThreshold ?? 100)}
|
||||
onInputChange={(event) => {
|
||||
const parsed = Number(event.currentTarget.value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
questionnaire.update((state) => ({ ...state, passThreshold: parsed }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isPublicCourse}
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<InputField
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface QuestionnaireState {
|
||||
sectionDisplayMode: ExerciseSectionDisplayMode;
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
completionPolicy?: 'submitted' | 'passed';
|
||||
passThreshold?: number | null;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
@@ -102,6 +104,8 @@ export const questionnaire: Writable<QuestionnaireState> = writable({
|
||||
sectionDisplayMode: 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: false,
|
||||
completionPolicy: 'submitted',
|
||||
passThreshold: 100,
|
||||
slug: ''
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+66
-31
@@ -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 @@
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showMarkComplete && lessonId}
|
||||
<Tooltip.Provider>
|
||||
{#if showWatchProgress}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex shrink-0" aria-label={watchProgressTooltip}>
|
||||
<PercentRingProgress value={watchedPercent} size="small" />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom" sideOffset={4}>
|
||||
{watchProgressTooltip}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if showMarkComplete && lessonId && !isLessonLocked && (showVideoWatchCompleteState || !isVideoWatchLesson)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onclick={() => markLessonComplete(lessonId)}
|
||||
loading={isMarkingComplete}
|
||||
disabled={isMarkCompleteDisabled}
|
||||
disabled={isMarkCompleteDisabled || showVideoWatchCompleteState}
|
||||
>
|
||||
<CircleCheckIcon size={14} filled={isLessonComplete} />
|
||||
<CircleCheckIcon size={14} filled={isLessonComplete || showVideoWatchCompleteState} />
|
||||
<span class="text-xs">{$t('course.navItem.lessons.mark_as')} {$t('course.navItem.lessons.complete')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Provider>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
|
||||
{#if mode === MODES.edit && !$isOrgStudent}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<IconButton onclick={onToggleLock} aria-label={lockLabel} title={lockLabel}>
|
||||
{#if isUnlocked}
|
||||
<UnlockIcon size={18} />
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
|
||||
|
||||
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
|
||||
|
||||
const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual');
|
||||
const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95);
|
||||
const videos = $derived(lessonApi.lesson?.videos ?? []);
|
||||
|
||||
const enforceableVideos = $derived(
|
||||
videos
|
||||
.map((video, index) => ({ video, index }))
|
||||
.filter(({ video }) => video.type === 'upload' && Boolean(video.assetId))
|
||||
);
|
||||
|
||||
const showWatchVideosError = $derived(
|
||||
completionPolicy === 'video_watch' &&
|
||||
enforceableVideos.length > 0 &&
|
||||
!enforceableVideos.some(({ video }) => video.watchEnforced)
|
||||
);
|
||||
|
||||
function policyTriggerLabel(policy: LessonCompletionPolicy): string {
|
||||
if (policy === 'video_watch') return t.get('course.navItem.lessons.completion_policy.video_watch_label');
|
||||
if (policy === 'none') return t.get('course.navItem.lessons.completion_policy.none_label');
|
||||
|
||||
return t.get('course.navItem.lessons.completion_policy.manual_label');
|
||||
}
|
||||
|
||||
function handleCompletionPolicyChange(value: string) {
|
||||
if (value !== 'manual' && value !== 'video_watch' && value !== 'none') return;
|
||||
|
||||
lessonApi.updateLessonState('completionPolicy', value);
|
||||
}
|
||||
|
||||
function handleVideoWatchThresholdChange(event: Event) {
|
||||
const parsed = Number((event.currentTarget as HTMLInputElement).value);
|
||||
if (Number.isNaN(parsed)) return;
|
||||
|
||||
lessonApi.updateLessonState('videoWatchThreshold', parsed);
|
||||
}
|
||||
|
||||
function toggleVideoWatchEnforced(index: number, checked: boolean) {
|
||||
const currentVideos = lessonApi.lesson?.videos;
|
||||
if (!currentVideos) return;
|
||||
|
||||
const updatedVideos = currentVideos.map((video, videoIndex) =>
|
||||
videoIndex === index ? { ...video, watchEnforced: checked } : video
|
||||
);
|
||||
|
||||
lessonApi.updateLessonState('videos', updatedVideos);
|
||||
}
|
||||
|
||||
function isVideoWatchEnforced(video: LessonVideo): boolean {
|
||||
return Boolean(video.watchEnforced);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<Field.Group class="w-full max-w-xl">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description>
|
||||
<Field.Description class="ui:text-muted-foreground">
|
||||
{$t('course.navItem.lessons.settings.progression.students_only_note')}
|
||||
</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="lesson-completion-policy">
|
||||
{$t('course.navItem.lessons.completion_policy.label')}
|
||||
</Field.Label>
|
||||
<Select.Root type="single" value={completionPolicy} onValueChange={handleCompletionPolicyChange}>
|
||||
<Select.Trigger id="lesson-completion-policy" class="w-full max-w-xs">
|
||||
{policyTriggerLabel(completionPolicy)}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-w-sm">
|
||||
<Select.Item value="manual">
|
||||
<div class="flex flex-col items-start gap-0.5">
|
||||
<span>{$t('course.navItem.lessons.completion_policy.manual_label')}</span>
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('course.navItem.lessons.completion_policy.manual_description')}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="video_watch">
|
||||
<div class="flex flex-col items-start gap-0.5">
|
||||
<span>{$t('course.navItem.lessons.completion_policy.video_watch_label')}</span>
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="none">
|
||||
<div class="flex flex-col items-start gap-0.5">
|
||||
<span>{$t('course.navItem.lessons.completion_policy.none_label')}</span>
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('course.navItem.lessons.completion_policy.none_description')}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="font-medium">{$t('course.navItem.lessons.settings.progression.option_guide_heading')}</p>
|
||||
<ul class="ui:text-muted-foreground list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
<span class="ui:text-foreground font-medium">
|
||||
{$t('course.navItem.lessons.completion_policy.manual_label')}:
|
||||
</span>
|
||||
{$t('course.navItem.lessons.completion_policy.manual_description')}
|
||||
</li>
|
||||
<li>
|
||||
<span class="ui:text-foreground font-medium">
|
||||
{$t('course.navItem.lessons.completion_policy.video_watch_label')}:
|
||||
</span>
|
||||
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
|
||||
</li>
|
||||
<li>
|
||||
<span class="ui:text-foreground font-medium">
|
||||
{$t('course.navItem.lessons.completion_policy.none_label')}:
|
||||
</span>
|
||||
{$t('course.navItem.lessons.completion_policy.none_description')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if completionPolicy === 'video_watch'}
|
||||
<Field.Field>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('course.navItem.lessons.completion_policy.watch_threshold')}
|
||||
value={String(videoWatchThreshold)}
|
||||
onInputChange={handleVideoWatchThresholdChange}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.lessons.settings.progression.watch_videos_heading')}</Field.Label>
|
||||
<Field.Description
|
||||
>{$t('course.navItem.lessons.settings.progression.watch_videos_helper')}</Field.Description
|
||||
>
|
||||
|
||||
{#if enforceableVideos.length === 0}
|
||||
<Field.Description
|
||||
>{$t('course.navItem.lessons.settings.progression.watch_videos_empty')}</Field.Description
|
||||
>
|
||||
{:else}
|
||||
<Field.Group class="gap-3">
|
||||
{#each enforceableVideos as { video, index } (video.assetId ?? index)}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`watch-enforced-${video.assetId}`}
|
||||
checked={isVideoWatchEnforced(video)}
|
||||
onCheckedChange={(checked) => toggleVideoWatchEnforced(index, checked === true)}
|
||||
/>
|
||||
<Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal">
|
||||
{getVideoTitle(video, index)}
|
||||
{#if isHlsUploadVideo(video)}
|
||||
<span class="ui:text-muted-foreground ml-1 text-xs">(HLS)</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
</Field.Group>
|
||||
{/if}
|
||||
|
||||
{#if showWatchVideosError}
|
||||
<Field.Error>{$t('course.navItem.lessons.settings.progression.watch_videos_required')}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</div>
|
||||
+75
-3
@@ -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<AssetTranscriptPayload | null>(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<void> {
|
||||
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
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+8
@@ -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 @@
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
|
||||
{channelLine}
|
||||
</p>
|
||||
{#if isEditMode && isTranscribingForVideo}
|
||||
<p class="mt-0.5 text-sm leading-snug text-orange-500">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.simple_card.transcription_in_progress')}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">{metaLine}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+47
-16
@@ -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<string, unknown>) => 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<OrganizationAsset | null>(null);
|
||||
@@ -47,6 +55,11 @@
|
||||
let hasTranscript = $state(false);
|
||||
let hasCheckedTranscript = $state(false);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | 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;
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
<LessonVideoPlayer {video} />
|
||||
<LessonVideoPlayer {video} {courseId} {lessonId} />
|
||||
{/key}
|
||||
{/snippet}
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { StudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||
import {
|
||||
getStudentContentLockDescriptionKey,
|
||||
getStudentContentLockTitleKey
|
||||
} from '$features/course/utils/content-lock-utils';
|
||||
|
||||
interface Props {
|
||||
reason: StudentContentLockReason;
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise;
|
||||
}
|
||||
|
||||
let { reason, contentType }: Props = $props();
|
||||
|
||||
const titleKey = $derived(getStudentContentLockTitleKey(reason));
|
||||
const descriptionKey = $derived(getStudentContentLockDescriptionKey(reason, contentType));
|
||||
</script>
|
||||
|
||||
<Empty title={$t(titleKey)} description={$t(descriptionKey)} icon={LockIcon} variant="page" class="text-center" />
|
||||
@@ -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 @@
|
||||
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-col gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<span>{exerciseDisplayTitle}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="outline" class="font-normal">
|
||||
@@ -502,15 +522,12 @@
|
||||
{#snippet child()}
|
||||
<div class="overflow-x-hidden pb-20">
|
||||
{#if $isStudentExperience}
|
||||
{#if isExerciseUnlocked}
|
||||
{#if isExerciseAccessible}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={VideoIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
<StudentContentLockedNotice
|
||||
reason={isExerciseProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
contentType={ContentType.Exercise}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
@@ -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 @@
|
||||
<LessonPageEditHeader
|
||||
{mode}
|
||||
title={lessonTitle}
|
||||
isUnlocked={isLessonUnlocked}
|
||||
isUnlocked={lessonApi.lesson?.isUnlocked ?? false}
|
||||
{isDeletingLesson}
|
||||
onTitleChange={handleLessonTitleChange}
|
||||
onToggleLock={handleToggleLessonLock}
|
||||
@@ -421,13 +448,10 @@
|
||||
<div
|
||||
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
|
||||
>
|
||||
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={VideoIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
{#if $isOrgStudent && !isLessonUnlocked}
|
||||
<StudentContentLockedNotice
|
||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
contentType={ContentType.Lesson}
|
||||
/>
|
||||
{:else if mode === MODES.edit}
|
||||
<UnderlineTabs.Root
|
||||
@@ -449,6 +473,10 @@
|
||||
{/if}
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
<UnderlineTabs.Trigger value={SETTINGS_TAB_VALUE}>
|
||||
<SettingsIcon size={16} />
|
||||
{$t('course.navItem.lessons.materials.tabs.settings.title')}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<!-- End Tabs List -->
|
||||
|
||||
@@ -466,7 +494,7 @@
|
||||
|
||||
<!-- Video Tab -->
|
||||
<UnderlineTabs.Content value={String(getValue('course.navItem.lessons.materials.tabs.video.title') || '')}>
|
||||
<Video {mode} />
|
||||
<Video {mode} {courseId} {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Video Tab -->
|
||||
|
||||
@@ -475,12 +503,18 @@
|
||||
<Document {mode} />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Document Tab -->
|
||||
|
||||
<!-- Settings Tab (fixed, not part of lessonTabsOrder) -->
|
||||
<UnderlineTabs.Content value={SETTINGS_TAB_VALUE}>
|
||||
<LessonSettingsTab />
|
||||
</UnderlineTabs.Content>
|
||||
<!-- End Settings Tab -->
|
||||
</UnderlineTabs.Root>
|
||||
{:else if lessonApi.lesson && !isMaterialsEmpty}
|
||||
{#key lessonId}
|
||||
<div class="mb-20 flex w-full flex-col gap-2" in:fade={{ delay: 500 }} out:fade>
|
||||
{#each viewModeComponents as Component, index (index)}
|
||||
<Component {mode} {lessonId} />
|
||||
<Component {mode} {lessonId} {courseId} />
|
||||
{/each}
|
||||
|
||||
{#if $currentOrg.customization?.apps?.comments}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 ?? ''
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
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<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>
|
||||
): 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<CourseSettings>({
|
||||
lessonDownload: false,
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true,
|
||||
progressionMode: 'free',
|
||||
callout: null
|
||||
});
|
||||
|
||||
@@ -126,6 +126,22 @@ export type UpdateLessonCompletionResponse = InferResponseType<UpdateLessonCompl
|
||||
export type UpdateLessonCompletionSuccess = Extract<UpdateLessonCompletionResponse, { success: true }>;
|
||||
export type UpdateLessonCompletionData = UpdateLessonCompletionSuccess['data'];
|
||||
|
||||
export type GetLessonWatchProgressRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$get'];
|
||||
export type GetLessonWatchProgressSuccess = Extract<
|
||||
InferResponseType<GetLessonWatchProgressRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type LessonWatchProgress = GetLessonWatchProgressSuccess['data'];
|
||||
|
||||
export type UpdateLessonWatchProgressRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['watch-progress']['$put'];
|
||||
export type UpdateLessonWatchProgressSuccess = Extract<
|
||||
InferResponseType<UpdateLessonWatchProgressRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type LessonWatchProgressUpdate = UpdateLessonWatchProgressSuccess['data'];
|
||||
|
||||
// Get lesson history types
|
||||
export type GetLessonHistoryRequest =
|
||||
(typeof classroomio.course)[':courseId']['lesson'][':lessonId']['history']['$get'];
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+2
-15
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
{#if isLoading}
|
||||
<!-- Loading skeleton - round circle -->
|
||||
<Skeleton class="{sizeClass} rounded-full" />
|
||||
{:else}
|
||||
<svg class="{sizeClass} -rotate-90" viewBox="0 0 100 100">
|
||||
<!-- Background circle -->
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
class="text-green-600"
|
||||
stroke-dasharray={2 * Math.PI * 45}
|
||||
stroke-dashoffset={2 * Math.PI * 45 * (1 - progressPercentage / 100)}
|
||||
style="transition: stroke-dashoffset 0.5s ease-in-out;"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Center text -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class={size === 'small' ? 'text-[10px]' : 'text-sm'}>{progressPercentage}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<Skeleton class="{sizeClass} shrink-0 rounded-full" />
|
||||
{:else}
|
||||
<PercentRingProgress value={progressPercentage} {size} />
|
||||
{/if}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="flex w-fit p-3 {flexDirection} items-center justify-between gap-2">
|
||||
@@ -94,11 +110,13 @@
|
||||
disabled={isDisabled || isUploading}
|
||||
>
|
||||
<ImageCropper.UploadTrigger aria-disabled={isDisabled || isUploading}>
|
||||
<div class="avatar-container {widthHeight || 'h-[128px] w-[128px]'} pointer border-2 border-gray-200 {shape}">
|
||||
<div class="avatar-container {widthHeight || 'h-[128px] w-[128px]'} pointer {previewContainerClass}">
|
||||
<ImageCropper.Preview>
|
||||
{#snippet child({ src: imageSrc })}
|
||||
<div class="group relative h-full w-full">
|
||||
<img class="h-full w-full {shape}" src={imageSrc || defaultImg} alt="" />
|
||||
{#if imageSrc || !isSignaturePreview}
|
||||
<img class={previewImageClass} src={imageSrc || defaultImg} alt="" />
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 {shape}"
|
||||
>
|
||||
@@ -132,9 +150,30 @@
|
||||
<ImageCropper.Dialog>
|
||||
<ImageCropper.Cropper cropShape={shape === 'rounded-full' ? 'round' : 'rect'} />
|
||||
<ImageCropper.Controls>
|
||||
{#if isSignaturePreview}
|
||||
<ImageCropper.UseOriginal>
|
||||
{$t('course.navItem.certificates.editor.signature_dont_crop')}
|
||||
</ImageCropper.UseOriginal>
|
||||
{:else}
|
||||
<ImageCropper.Cancel />
|
||||
{/if}
|
||||
<ImageCropper.Crop />
|
||||
</ImageCropper.Controls>
|
||||
</ImageCropper.Dialog>
|
||||
</ImageCropper.Root>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.signature-preview-bg {
|
||||
background-color: #fff;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%);
|
||||
background-size: 12px 12px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 6px,
|
||||
6px -6px,
|
||||
-6px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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}$`,
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?",
|
||||
|
||||
@@ -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ć?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
|
||||
{#if isLockedForStudent}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
/>
|
||||
{#if $isOrgStudent && !isCourseReady}
|
||||
<div class="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else}
|
||||
<ExercisePage
|
||||
exerciseId={data.exerciseId}
|
||||
|
||||
@@ -2,23 +2,25 @@ import { Polar } from '@polar-sh/sdk';
|
||||
import { TOKEN_PACK } from '@cio/utils/plans';
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestEvent } from './$types';
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
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 (e) => e.url.searchParams.get('customerId')!,
|
||||
getCustomerId: async () => customerId,
|
||||
server: dev ? 'sandbox' : 'production'
|
||||
});
|
||||
});
|
||||
|
||||
return handler(event);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -52,8 +52,8 @@ export const DEFAULT_CERTIFICATE_DESIGN: CertificateDesign = {
|
||||
accentColor: DEFAULT_ACCENT_COLOR,
|
||||
subtitle: 'Awarded with Highest Distinction',
|
||||
signatories: [
|
||||
{ name: 'Course Facilitator', role: 'Facilitator' },
|
||||
{ name: 'Organization Lead', role: 'Director' }
|
||||
{ name: 'Course Facilitator', role: 'Facilitator', enabled: true },
|
||||
{ name: 'Organization Lead', role: 'Director', enabled: true }
|
||||
],
|
||||
idFormat: 'N° {seq}'
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, type TemplateRenderer } from './shared';
|
||||
import { escapeHtml, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
|
||||
|
||||
export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -38,14 +38,8 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
</div>
|
||||
<div class="stamp">Verified</div>
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="lbl">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="lbl">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'name', roleClass: 'lbl', roleFirst: true })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -200,6 +194,7 @@ export const renderBrutalist: TemplateRenderer = ({ design, data }) => {
|
||||
z-index: 3;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
|
||||
import { escapeHtml, getYear, renderSignatoryBlock, SIGNATURE_IMAGE_STYLES, type TemplateRenderer } from './shared';
|
||||
|
||||
export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -20,19 +20,13 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="recipient">${escapeHtml(data.recipientName)}</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="footer">
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryOne.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
<div class="seal">
|
||||
<div class="star">★</div>
|
||||
<div class="yr">${getYear(data.date)}</div>
|
||||
<div class="lbl">${escapeHtml(data.certificateId)}</div>
|
||||
</div>
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -140,6 +134,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
gap: 40px;
|
||||
}
|
||||
.t-classique .sig {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border-top: 1px solid ${accent};
|
||||
padding-top: 6px;
|
||||
@@ -190,6 +185,7 @@ export const renderClassique: TemplateRenderer = ({ design, data }) => {
|
||||
letter-spacing: 0.2em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { escapeHtml, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
renderFooterMetaBlock,
|
||||
renderSignatoryBlock,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -8,7 +14,6 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
|
||||
const body = `
|
||||
<div class="cert t-minimal">
|
||||
<span class="accent-bar" aria-hidden="true"></span>
|
||||
<div class="top">
|
||||
<span>${escapeHtml(data.orgName)}</span>
|
||||
<span>${escapeHtml(data.certificateId)} · ${escapeHtml(data.date)}</span>
|
||||
@@ -23,22 +28,10 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">Issued</div>
|
||||
<div class="v">${escapeHtml(data.date)}</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
<div class="k">Reference</div>
|
||||
<div class="v">${escapeHtml(data.certificateId)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
|
||||
${renderFooterMetaBlock('Reference', data.certificateId, { wrapperClass: 'ref', labelClass: 'k', valueClass: 'v' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -52,14 +45,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.t-minimal .accent-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
background: ${accent};
|
||||
border-left: 12px solid ${accent};
|
||||
}
|
||||
.t-minimal .top {
|
||||
display: flex;
|
||||
@@ -145,11 +131,20 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
.t-minimal .footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #0a0a0a;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.t-minimal .footer .sig-content,
|
||||
.t-minimal .footer .sig-text {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.t-minimal .footer .sig-img-slot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.t-minimal .footer .k {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.25em;
|
||||
@@ -165,6 +160,7 @@ export const renderMinimal: TemplateRenderer = ({ design, data }) => {
|
||||
}
|
||||
.t-minimal .footer .ref .k { color: ${accent}; }
|
||||
.t-minimal .footer .ref .v { color: ${accent}; }
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { escapeHtml, getYear, shadeColor, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
getYear,
|
||||
renderSignatoryBlock,
|
||||
shadeColor,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -27,18 +34,12 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
<div class="recipient">${escapeHtml(data.recipientName)}</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="footer">
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryOne.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryOne.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
<div class="medal">
|
||||
<div class="yr">${getYear(data.date)}</div>
|
||||
<div class="lbl">★ AWARD ★</div>
|
||||
</div>
|
||||
<div class="sig">
|
||||
<div class="name">${escapeHtml(signatoryTwo.name)}</div>
|
||||
<div class="label">${escapeHtml(signatoryTwo.role)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryTwo, { wrapperClass: 'sig', nameClass: 'name', roleClass: 'label' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -158,6 +159,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
gap: 40px;
|
||||
}
|
||||
.t-noir .sig {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
border-top: 1px solid ${accent};
|
||||
padding-top: 6px;
|
||||
@@ -206,6 +208,7 @@ export const renderNoir: TemplateRenderer = ({ design, data }) => {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { escapeHtml, getYear, type TemplateRenderer } from './shared';
|
||||
import {
|
||||
escapeHtml,
|
||||
getYear,
|
||||
renderFooterMetaBlock,
|
||||
renderSignatoryBlock,
|
||||
SIGNATURE_IMAGE_STYLES,
|
||||
type TemplateRenderer
|
||||
} from './shared';
|
||||
|
||||
export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
const accent = design.accentColor;
|
||||
@@ -26,18 +33,9 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
</div>
|
||||
<div class="description">${escapeHtml(description)}</div>
|
||||
<div class="bottom">
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryOne.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryOne.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">${escapeHtml(signatoryTwo.role)}</div>
|
||||
<div class="v">${escapeHtml(signatoryTwo.name)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="k">Issued</div>
|
||||
<div class="v">${escapeHtml(data.date)}</div>
|
||||
</div>
|
||||
${renderSignatoryBlock(signatoryOne, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderSignatoryBlock(signatoryTwo, { nameClass: 'v', roleClass: 'k', roleFirst: true })}
|
||||
${renderFooterMetaBlock('Issued', data.date, { labelClass: 'k', valueClass: 'v' })}
|
||||
</div>
|
||||
</div>
|
||||
<div class="corner-num">${getYear(data.date)}</div>
|
||||
@@ -162,11 +160,20 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 24px;
|
||||
border-top: 2px solid #1a1a1a;
|
||||
padding-top: 18px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.t-poster .bottom .sig-content,
|
||||
.t-poster .bottom .sig-text {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.t-poster .bottom .sig-img-slot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.t-poster .bottom .k {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
@@ -192,6 +199,7 @@ export const renderPoster: TemplateRenderer = ({ design, data }) => {
|
||||
z-index: 3;
|
||||
line-height: 0.8;
|
||||
}
|
||||
${SIGNATURE_IMAGE_STYLES}
|
||||
`;
|
||||
|
||||
return { body, styles };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CertificateDesign, CertificateRenderData } from '../types';
|
||||
import type { CertificateDesign, CertificateRenderData, CertificateSignatory } from '../types';
|
||||
|
||||
export function escapeHtml(input: unknown): string {
|
||||
return String(input ?? '').replace(/[&<>"']/g, (char) => {
|
||||
@@ -53,12 +53,100 @@ export interface TemplateRenderOutput {
|
||||
|
||||
export type TemplateRenderer = (args: TemplateRenderArgs) => TemplateRenderOutput;
|
||||
|
||||
export interface SignatoryBlockOptions {
|
||||
wrapperClass?: string;
|
||||
nameClass: string;
|
||||
roleClass: string;
|
||||
roleFirst?: boolean;
|
||||
}
|
||||
|
||||
export function renderSignatoryBlock(sig: CertificateSignatory, options: SignatoryBlockOptions): string {
|
||||
if (sig.enabled === false) return '';
|
||||
|
||||
const sigImg = sig.signatureUrl ? `<img class="sig-img" src="${escapeHtml(sig.signatureUrl)}" alt="" />` : '';
|
||||
|
||||
const nameEl = `<div class="${options.nameClass}">${escapeHtml(sig.name)}</div>`;
|
||||
const roleEl = `<div class="${options.roleClass}">${escapeHtml(sig.role)}</div>`;
|
||||
const content = options.roleFirst ? `${roleEl}${nameEl}` : `${nameEl}${roleEl}`;
|
||||
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
|
||||
|
||||
return `
|
||||
<div${wrapperClass}>
|
||||
<div class="sig-content">
|
||||
<div class="sig-img-slot">${sigImg}</div>
|
||||
<div class="sig-text">${content}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface FooterMetaBlockOptions {
|
||||
wrapperClass?: string;
|
||||
labelClass: string;
|
||||
valueClass: string;
|
||||
}
|
||||
|
||||
export function renderFooterMetaBlock(label: string, value: string, options: FooterMetaBlockOptions): string {
|
||||
const wrapperClass = options.wrapperClass ? ` class="${options.wrapperClass}"` : '';
|
||||
|
||||
return `
|
||||
<div${wrapperClass}>
|
||||
<div class="sig-content">
|
||||
<div class="sig-img-slot"></div>
|
||||
<div class="sig-text">
|
||||
<div class="${options.labelClass}">${escapeHtml(label)}</div>
|
||||
<div class="${options.valueClass}">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const SIGNATURE_IMAGE_STYLES = `
|
||||
.sig-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.sig-img-slot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sig-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.sig-img {
|
||||
display: block;
|
||||
max-height: 48px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FONTS_LINK_HREF =
|
||||
'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Cinzel:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Bodoni+Moda:ital,wght@0,400;0,700;1,400&family=Archivo+Black&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;700&display=swap';
|
||||
|
||||
export const BASE_STYLES = `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 1100px; height: 780px; background: transparent; }
|
||||
body { -webkit-font-smoothing: antialiased; }
|
||||
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; }
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.cert { width: 1100px; height: 780px; position: relative; overflow: hidden; box-shadow: inset 0 0 0 3px rgba(0,0,0,0.12); }
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,8 @@ export type CertificateTemplateId = (typeof CERTIFICATE_TEMPLATE_IDS)[number];
|
||||
export interface CertificateSignatory {
|
||||
name: string;
|
||||
role: string;
|
||||
enabled: boolean;
|
||||
signatureUrl?: string;
|
||||
}
|
||||
|
||||
export interface CertificateDesign {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-ex
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { annotateCourseContentWithProgression } from './progression';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
@@ -136,13 +137,31 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
|
||||
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
const progressionMode = course.metadata?.progressionMode ?? 'free';
|
||||
const roleId = profileId
|
||||
? (course.group?.members?.find((member) => member.profileId === profileId)?.roleId ?? null)
|
||||
: null;
|
||||
|
||||
const content = profileId
|
||||
? await annotateCourseContentWithProgression({
|
||||
courseId: course.id,
|
||||
profileId,
|
||||
roleId,
|
||||
progressionMode,
|
||||
contentRows: course.contentItems,
|
||||
isContentGroupingEnabled
|
||||
})
|
||||
: buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
|
||||
const { contentItems, org: courseOrg, ...rest } = course;
|
||||
|
||||
const base = {
|
||||
...rest,
|
||||
content
|
||||
content,
|
||||
metadata: {
|
||||
...course.metadata,
|
||||
progressionMode
|
||||
}
|
||||
};
|
||||
|
||||
if (courseOrg) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import type { CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import {
|
||||
getCompletedLessonIdsForProfile,
|
||||
getExerciseProgressionPolicies,
|
||||
getLessonProgressionPolicies,
|
||||
getPassedExerciseIdsForMember,
|
||||
getSubmittedExerciseIdsForMember,
|
||||
type ExerciseProgressionPolicy,
|
||||
type LessonProgressionPolicy
|
||||
} from '@cio/db/queries/course/progression';
|
||||
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
|
||||
import { buildCourseContent, type CourseContent, type CourseContentItem } from './utils';
|
||||
|
||||
export type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
export type AnnotatedCourseContentItem = CourseContentItem & {
|
||||
accessible?: boolean;
|
||||
lockReason?: ProgressionLockReason | null;
|
||||
completionPolicy?: string | null;
|
||||
videoWatchThreshold?: number | null;
|
||||
passThreshold?: number | null;
|
||||
};
|
||||
|
||||
export type AnnotatedCourseContent = {
|
||||
grouped: boolean;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
order: number | null;
|
||||
items: AnnotatedCourseContentItem[];
|
||||
}>;
|
||||
items: AnnotatedCourseContentItem[];
|
||||
};
|
||||
|
||||
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function flattenNavigableItems(content: CourseContent): CourseContentItem[] {
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
|
||||
return sortNavigableItems(
|
||||
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonBlocksProgression(policy: string | undefined): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
function exerciseBlocksProgression(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isItemProgressComplete(params: {
|
||||
item: CourseContentItem;
|
||||
lessonPolicy?: LessonProgressionPolicy;
|
||||
exercisePolicy?: ExerciseProgressionPolicy;
|
||||
completedLessonIds: Set<string>;
|
||||
submittedExerciseIds: Set<string>;
|
||||
passedExerciseIds: Set<string>;
|
||||
}): boolean {
|
||||
const { item, lessonPolicy, exercisePolicy, completedLessonIds, submittedExerciseIds, passedExerciseIds } = params;
|
||||
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return completedLessonIds.has(item.id);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
const policy = exercisePolicy?.completionPolicy ?? 'submitted';
|
||||
if (policy === 'passed') {
|
||||
return passedExerciseIds.has(item.id);
|
||||
}
|
||||
|
||||
return submittedExerciseIds.has(item.id) || Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function annotateNavigableAccess(params: {
|
||||
navigableItems: CourseContentItem[];
|
||||
lessonPolicyById: Map<string, LessonProgressionPolicy>;
|
||||
exercisePolicyById: Map<string, ExerciseProgressionPolicy>;
|
||||
progressionMode: 'free' | 'sequential';
|
||||
completedLessonIds: Set<string>;
|
||||
submittedExerciseIds: Set<string>;
|
||||
passedExerciseIds: Set<string>;
|
||||
}): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
let priorBlockingComplete = true;
|
||||
|
||||
for (const item of params.navigableItems) {
|
||||
const lessonPolicy = params.lessonPolicyById.get(item.id);
|
||||
const exercisePolicy = params.exercisePolicyById.get(item.id);
|
||||
const teacherLocked = !(item.isUnlocked ?? false);
|
||||
const progressionLocked = params.progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const complete = isItemProgressComplete({
|
||||
item,
|
||||
lessonPolicy,
|
||||
exercisePolicy,
|
||||
completedLessonIds: params.completedLessonIds,
|
||||
submittedExerciseIds: params.submittedExerciseIds,
|
||||
passedExerciseIds: params.passedExerciseIds
|
||||
});
|
||||
|
||||
const blocks =
|
||||
item.type === ContentType.Lesson
|
||||
? lessonBlocksProgression(lessonPolicy?.completionPolicy ?? 'manual')
|
||||
: exerciseBlocksProgression();
|
||||
|
||||
if (blocks && !complete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
}
|
||||
|
||||
function applyAccessToContent(
|
||||
content: CourseContent,
|
||||
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>,
|
||||
lessonPolicyById: Map<string, LessonProgressionPolicy>,
|
||||
exercisePolicyById: Map<string, ExerciseProgressionPolicy>
|
||||
): AnnotatedCourseContent {
|
||||
const annotateItem = (item: CourseContentItem): AnnotatedCourseContentItem => {
|
||||
const access = accessById.get(item.id);
|
||||
const lessonPolicy = lessonPolicyById.get(item.id);
|
||||
const exercisePolicy = exercisePolicyById.get(item.id);
|
||||
|
||||
return {
|
||||
...item,
|
||||
accessible: access?.accessible ?? true,
|
||||
lockReason: access?.lockReason ?? null,
|
||||
completionPolicy: lessonPolicy?.completionPolicy ?? exercisePolicy?.completionPolicy ?? null,
|
||||
videoWatchThreshold: lessonPolicy?.videoWatchThreshold ?? null,
|
||||
passThreshold: exercisePolicy?.passThreshold ?? null
|
||||
};
|
||||
};
|
||||
|
||||
if (!content.grouped) {
|
||||
return {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items: content.items.map(annotateItem)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
sections: content.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map(annotateItem)
|
||||
})),
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
export async function annotateCourseContentWithProgression(params: {
|
||||
courseId: string;
|
||||
profileId: string;
|
||||
roleId: number | null;
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
contentRows: CourseContentItemRow[];
|
||||
isContentGroupingEnabled: boolean;
|
||||
}): Promise<AnnotatedCourseContent> {
|
||||
const content = buildCourseContent(params.contentRows, params.isContentGroupingEnabled);
|
||||
|
||||
if (params.roleId !== ROLE.STUDENT) {
|
||||
const [lessonPolicies, exercisePolicies] = await Promise.all([
|
||||
getLessonProgressionPolicies(params.courseId),
|
||||
getExerciseProgressionPolicies(params.courseId)
|
||||
]);
|
||||
|
||||
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
|
||||
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
|
||||
for (const item of flattenNavigableItems(content)) {
|
||||
accessById.set(item.id, { accessible: true, lockReason: null });
|
||||
}
|
||||
|
||||
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
|
||||
}
|
||||
|
||||
const progressionMode = params.progressionMode ?? 'free';
|
||||
const groupMemberId = await getGroupMemberIdByCourseAndProfile(params.courseId, params.profileId);
|
||||
|
||||
const [lessonPolicies, exercisePolicies, completedLessonIds, submittedExerciseIds] = await Promise.all([
|
||||
getLessonProgressionPolicies(params.courseId),
|
||||
getExerciseProgressionPolicies(params.courseId),
|
||||
getCompletedLessonIdsForProfile(params.courseId, params.profileId),
|
||||
groupMemberId
|
||||
? getSubmittedExerciseIdsForMember(params.courseId, groupMemberId)
|
||||
: Promise.resolve(new Set<string>())
|
||||
]);
|
||||
|
||||
const passedExerciseIds = groupMemberId
|
||||
? await getPassedExerciseIdsForMember(params.courseId, groupMemberId, exercisePolicies)
|
||||
: new Set<string>();
|
||||
|
||||
const lessonPolicyById = new Map(lessonPolicies.map((policy) => [policy.id, policy]));
|
||||
const exercisePolicyById = new Map(exercisePolicies.map((policy) => [policy.id, policy]));
|
||||
|
||||
const accessById = annotateNavigableAccess({
|
||||
navigableItems: flattenNavigableItems(content),
|
||||
lessonPolicyById,
|
||||
exercisePolicyById,
|
||||
progressionMode,
|
||||
completedLessonIds,
|
||||
submittedExerciseIds,
|
||||
passedExerciseIds
|
||||
});
|
||||
|
||||
return applyAccessToContent(content, accessById, lessonPolicyById, exercisePolicyById);
|
||||
}
|
||||
|
||||
export async function assertStudentCanAccessContent(params: {
|
||||
courseId: string;
|
||||
profileId: string;
|
||||
roleId: number | null;
|
||||
contentId: string;
|
||||
type: ContentType.Lesson | ContentType.Exercise;
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
contentRows: CourseContentItemRow[];
|
||||
isContentGroupingEnabled: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.roleId !== ROLE.STUDENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotated = await annotateCourseContentWithProgression({
|
||||
courseId: params.courseId,
|
||||
profileId: params.profileId,
|
||||
roleId: params.roleId,
|
||||
progressionMode: params.progressionMode,
|
||||
contentRows: params.contentRows,
|
||||
isContentGroupingEnabled: params.isContentGroupingEnabled
|
||||
});
|
||||
|
||||
const items = annotated.grouped ? annotated.sections.flatMap((section) => section.items) : annotated.items;
|
||||
const target = items.find((item) => item.id === params.contentId && item.type === params.type);
|
||||
|
||||
if (!target) {
|
||||
throw new AppError('Content not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (target.accessible === false) {
|
||||
throw new AppError('Content is locked', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export type CourseContentItem = {
|
||||
documentsCount: number | null;
|
||||
questionCount: number | null;
|
||||
dueBy: string | null;
|
||||
accessible?: boolean;
|
||||
lockReason?: 'teacher_locked' | 'progression_blocked' | null;
|
||||
completionPolicy?: string | null;
|
||||
videoWatchThreshold?: number | null;
|
||||
passThreshold?: number | null;
|
||||
};
|
||||
|
||||
export type CourseContentSection = {
|
||||
|
||||
@@ -12,14 +12,19 @@ import {
|
||||
getLessonCommentsByLessonIdPaginated,
|
||||
getLessonCompletion,
|
||||
getLessonVersionHistory,
|
||||
getLessonVideoProgress,
|
||||
getLessonVideoProgressForLesson,
|
||||
getLessonsByCourseId,
|
||||
updateLesson,
|
||||
updateLessonComment,
|
||||
upsertLessonCompletion,
|
||||
upsertLessonVideoProgress,
|
||||
type LessonById
|
||||
} from '@cio/db/queries/lesson';
|
||||
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
|
||||
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
|
||||
/**
|
||||
@@ -372,6 +377,19 @@ export async function getLessonCompletionService(lessonId: string, profileId: st
|
||||
*/
|
||||
export async function upsertLessonCompletionService(lessonId: string, profileId: string, isComplete: boolean) {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.completionPolicy === 'video_watch' && isComplete) {
|
||||
throw new AppError(
|
||||
'This lesson completes automatically when the video watch requirement is met',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const completionData: TNewLessonCompletion = {
|
||||
lessonId,
|
||||
profileId,
|
||||
@@ -380,6 +398,10 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
|
||||
|
||||
return await upsertLessonCompletion(completionData);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson completion',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
@@ -388,6 +410,232 @@ export async function upsertLessonCompletionService(lessonId: string, profileId:
|
||||
}
|
||||
}
|
||||
|
||||
const WATCH_PROGRESS_WALL_CLOCK_TOLERANCE = 1.5;
|
||||
|
||||
export type LessonWatchProgressAssetResult = {
|
||||
assetId: string;
|
||||
lastPositionSeconds: number;
|
||||
watchedSeconds: number;
|
||||
furthestSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
watchedPercent: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
export type LessonWatchProgressResult = {
|
||||
lastPositionSeconds: number;
|
||||
watchedSeconds: number;
|
||||
furthestSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
isComplete: boolean;
|
||||
didJustComplete: boolean;
|
||||
watchedPercent: number;
|
||||
videosComplete: number;
|
||||
videosRequired: number;
|
||||
assets: LessonWatchProgressAssetResult[];
|
||||
};
|
||||
|
||||
function getWatchedPercent(watchedSeconds: number, durationSeconds: number | null | undefined): number {
|
||||
if (!durationSeconds || durationSeconds <= 0) return 0;
|
||||
|
||||
return Math.min(100, Math.round((watchedSeconds / durationSeconds) * 100));
|
||||
}
|
||||
|
||||
function buildAggregateWatchProgress(
|
||||
rows: Awaited<ReturnType<typeof getLessonVideoProgressForLesson>>,
|
||||
requiredAssetIds: string[]
|
||||
): LessonWatchProgressResult | null {
|
||||
if (requiredAssetIds.length === 0) return null;
|
||||
|
||||
const rowByAssetId = new Map(rows.map((row) => [row.assetId, row]));
|
||||
const assets: LessonWatchProgressAssetResult[] = requiredAssetIds.map((assetId) => {
|
||||
const row = rowByAssetId.get(assetId);
|
||||
|
||||
const isAssetComplete = row?.isComplete ?? false;
|
||||
|
||||
return {
|
||||
assetId,
|
||||
lastPositionSeconds: row?.lastPositionSeconds ?? 0,
|
||||
watchedSeconds: row?.watchedSeconds ?? 0,
|
||||
furthestSeconds: row?.furthestSeconds ?? 0,
|
||||
durationSeconds: row?.durationSeconds ?? null,
|
||||
watchedPercent: isAssetComplete ? 100 : getWatchedPercent(row?.watchedSeconds ?? 0, row?.durationSeconds),
|
||||
isComplete: isAssetComplete
|
||||
};
|
||||
});
|
||||
|
||||
const videosComplete = assets.filter((asset) => asset.isComplete).length;
|
||||
const videosRequired = requiredAssetIds.length;
|
||||
const isComplete = videosRequired > 0 && videosComplete === videosRequired;
|
||||
const watchedPercent = isComplete
|
||||
? 100
|
||||
: assets.length > 0
|
||||
? Math.min(...assets.map((asset) => asset.watchedPercent))
|
||||
: 0;
|
||||
const primaryAsset = assets[0];
|
||||
|
||||
return {
|
||||
lastPositionSeconds: primaryAsset?.lastPositionSeconds ?? 0,
|
||||
watchedSeconds: primaryAsset?.watchedSeconds ?? 0,
|
||||
furthestSeconds: primaryAsset?.furthestSeconds ?? 0,
|
||||
durationSeconds: primaryAsset?.durationSeconds ?? null,
|
||||
isComplete,
|
||||
didJustComplete: false,
|
||||
watchedPercent,
|
||||
videosComplete,
|
||||
videosRequired,
|
||||
assets
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLessonWatchProgressService(
|
||||
lessonId: string,
|
||||
profileId: string
|
||||
): Promise<LessonWatchProgressResult | null> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson || lesson.completionPolicy !== 'video_watch') return null;
|
||||
|
||||
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
|
||||
if (requiredAssetIds.length === 0) return null;
|
||||
|
||||
const progressRows = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const filteredRows = progressRows.filter((row) => requiredAssetIds.includes(row.assetId));
|
||||
|
||||
return buildAggregateWatchProgress(filteredRows, requiredAssetIds);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get lesson watch progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLessonWatchProgressService(
|
||||
lessonId: string,
|
||||
profileId: string,
|
||||
beat: TUpdateLessonWatchProgress
|
||||
): Promise<LessonWatchProgressResult> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.completionPolicy !== 'video_watch') {
|
||||
throw new AppError('This lesson does not require video watch tracking', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (!beat.assetId) {
|
||||
throw new AppError('Video asset is required for watch progress', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const requiredAssetIds = resolveWatchEnforcedAssetIds(lesson.videos, lesson.completionPolicy);
|
||||
if (!requiredAssetIds.includes(beat.assetId)) {
|
||||
throw new AppError('This video is not configured for watch enforcement', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const existing = await getLessonVideoProgress(lessonId, profileId, beat.assetId);
|
||||
const threshold = lesson.videoWatchThreshold ?? 95;
|
||||
const now = Date.now();
|
||||
|
||||
if (existing?.isComplete) {
|
||||
const aggregate = buildAggregateWatchProgress(
|
||||
await getLessonVideoProgressForLesson(lessonId, profileId),
|
||||
requiredAssetIds
|
||||
);
|
||||
|
||||
return {
|
||||
...(aggregate ?? {
|
||||
lastPositionSeconds: existing.lastPositionSeconds ?? beat.positionSeconds,
|
||||
watchedSeconds: existing.watchedSeconds ?? 0,
|
||||
furthestSeconds: existing.furthestSeconds ?? 0,
|
||||
durationSeconds: existing.durationSeconds ?? beat.durationSeconds,
|
||||
isComplete: true,
|
||||
didJustComplete: false,
|
||||
watchedPercent: 100,
|
||||
videosComplete: requiredAssetIds.length,
|
||||
videosRequired: requiredAssetIds.length,
|
||||
assets: []
|
||||
}),
|
||||
didJustComplete: false
|
||||
};
|
||||
}
|
||||
|
||||
const positionSeconds = Math.floor(beat.positionSeconds);
|
||||
|
||||
if (existing?.updatedAt) {
|
||||
const elapsedSeconds = (now - new Date(existing.updatedAt).getTime()) / 1000;
|
||||
const maxAllowedDelta = Math.max(5, elapsedSeconds * WATCH_PROGRESS_WALL_CLOCK_TOLERANCE);
|
||||
|
||||
if (beat.playedDeltaSeconds > maxAllowedDelta) {
|
||||
throw new AppError('Invalid watch progress update', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const previousFurthest = existing?.furthestSeconds ?? 0;
|
||||
const playedDeltaSeconds = Math.max(0, Math.round(beat.playedDeltaSeconds));
|
||||
const reachedEnd = positionSeconds >= beat.durationSeconds - 1;
|
||||
let watchedSeconds = Math.min(beat.durationSeconds, (existing?.watchedSeconds ?? 0) + playedDeltaSeconds);
|
||||
|
||||
if (reachedEnd) {
|
||||
watchedSeconds = beat.durationSeconds;
|
||||
}
|
||||
|
||||
const furthestSeconds = Math.max(previousFurthest, positionSeconds, reachedEnd ? beat.durationSeconds : 0);
|
||||
const watchPercent = (watchedSeconds / beat.durationSeconds) * 100;
|
||||
const assetComplete = watchPercent >= threshold;
|
||||
|
||||
const rowsBeforeUpdate = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const priorAggregate = buildAggregateWatchProgress(rowsBeforeUpdate, requiredAssetIds);
|
||||
const wasLessonComplete = priorAggregate?.isComplete ?? false;
|
||||
|
||||
await upsertLessonVideoProgress({
|
||||
lessonId,
|
||||
profileId,
|
||||
assetId: beat.assetId,
|
||||
durationSeconds: beat.durationSeconds,
|
||||
watchedSeconds,
|
||||
furthestSeconds,
|
||||
lastPositionSeconds: reachedEnd ? beat.durationSeconds : positionSeconds,
|
||||
isComplete: assetComplete,
|
||||
completedAt: assetComplete ? new Date().toISOString() : null
|
||||
});
|
||||
|
||||
const allRows = await getLessonVideoProgressForLesson(lessonId, profileId);
|
||||
const aggregate = buildAggregateWatchProgress(allRows, requiredAssetIds);
|
||||
const didJustComplete = (aggregate?.isComplete ?? false) && !wasLessonComplete;
|
||||
|
||||
if (didJustComplete) {
|
||||
await upsertLessonCompletion({
|
||||
lessonId,
|
||||
profileId,
|
||||
isComplete: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!aggregate) {
|
||||
throw new AppError('Failed to aggregate lesson watch progress', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
...aggregate,
|
||||
didJustComplete
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson watch progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets lesson version history for a lesson and locale
|
||||
* @param lessonId Lesson ID
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
type LessonVideo = {
|
||||
type: string;
|
||||
assetId?: string | null;
|
||||
watchEnforced?: boolean | null;
|
||||
link?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function isHlsUploadVideo(video: LessonVideo): boolean {
|
||||
if (video.type !== 'upload') return false;
|
||||
|
||||
const metadata = video.metadata;
|
||||
if (metadata && typeof metadata === 'object' && metadata.hls === true) return true;
|
||||
|
||||
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
|
||||
}
|
||||
|
||||
export function isEnforceableLessonVideo(video: LessonVideo): boolean {
|
||||
return video.type === 'upload' && Boolean(video.assetId);
|
||||
}
|
||||
|
||||
export function isEnforceableLessonVideoIncludingHls(video: LessonVideo): boolean {
|
||||
return isEnforceableLessonVideo(video) || isHlsUploadVideo(video);
|
||||
}
|
||||
|
||||
export function resolveWatchEnforcedAssetIds(
|
||||
videos: LessonVideo[] | null | undefined,
|
||||
completionPolicy: string | null | undefined
|
||||
): string[] {
|
||||
const lessonVideos = videos ?? [];
|
||||
const flaggedAssetIds = lessonVideos
|
||||
.filter((video) => video.watchEnforced && video.assetId && isEnforceableLessonVideoIncludingHls(video))
|
||||
.map((video) => video.assetId as string);
|
||||
|
||||
if (flaggedAssetIds.length > 0) {
|
||||
return Array.from(new Set(flaggedAssetIds));
|
||||
}
|
||||
|
||||
if (completionPolicy !== 'video_watch') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
lessonVideos
|
||||
.filter((video) => video.assetId && isEnforceableLessonVideoIncludingHls(video))
|
||||
.map((video) => video.assetId as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -42,13 +42,33 @@ export async function getCourseContentItems(courseId: string, profileId?: string
|
||||
|
||||
const exerciseCompletionSql = profileId
|
||||
? drizzleSql`COALESCE(
|
||||
(
|
||||
CASE
|
||||
WHEN exercise.completion_policy = 'passed' THEN (
|
||||
SELECT true
|
||||
FROM submission
|
||||
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
|
||||
WHERE submission.exercise_id = exercise.id
|
||||
AND groupmember.profile_id = ${profileId}
|
||||
AND submission.grading_state = 'completed'
|
||||
AND (
|
||||
SELECT CASE
|
||||
WHEN COALESCE(SUM(question.points), 0) <= 0 THEN false
|
||||
ELSE ROUND((submission.total::numeric / SUM(question.points)::numeric) * 100)
|
||||
>= COALESCE(exercise.pass_threshold, 100)
|
||||
END
|
||||
FROM question
|
||||
WHERE question.exercise_id = exercise.id
|
||||
)
|
||||
LIMIT 1
|
||||
)
|
||||
ELSE (
|
||||
SELECT true
|
||||
FROM submission
|
||||
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
|
||||
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
|
||||
LIMIT 1
|
||||
),
|
||||
)
|
||||
END,
|
||||
false
|
||||
)`
|
||||
: drizzleSql`false`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './course';
|
||||
export * from './compliance';
|
||||
export * from './content';
|
||||
export * from './progression';
|
||||
export * from './content-batch';
|
||||
export * from './people';
|
||||
export * from './invite';
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import { getExerciseTitleAndMaxPoints, getStudentSubmissionsForExercise } from './certification-exercise';
|
||||
|
||||
export type LessonProgressionPolicy = {
|
||||
id: string;
|
||||
completionPolicy: string;
|
||||
videoWatchThreshold: number | null;
|
||||
isUnlocked: boolean | null;
|
||||
};
|
||||
|
||||
export type ExerciseProgressionPolicy = {
|
||||
id: string;
|
||||
completionPolicy: string;
|
||||
passThreshold: number | null;
|
||||
isUnlocked: boolean | null;
|
||||
};
|
||||
|
||||
export async function getLessonProgressionPolicies(courseId: string): Promise<LessonProgressionPolicy[]> {
|
||||
try {
|
||||
return db
|
||||
.select({
|
||||
id: schema.lesson.id,
|
||||
completionPolicy: schema.lesson.completionPolicy,
|
||||
videoWatchThreshold: schema.lesson.videoWatchThreshold,
|
||||
isUnlocked: schema.lesson.isUnlocked
|
||||
})
|
||||
.from(schema.lesson)
|
||||
.where(eq(schema.lesson.courseId, courseId));
|
||||
} catch (error) {
|
||||
console.error('getLessonProgressionPolicies error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionPolicies(courseId: string): Promise<ExerciseProgressionPolicy[]> {
|
||||
try {
|
||||
return db
|
||||
.select({
|
||||
id: schema.exercise.id,
|
||||
completionPolicy: schema.exercise.completionPolicy,
|
||||
passThreshold: schema.exercise.passThreshold,
|
||||
isUnlocked: schema.exercise.isUnlocked
|
||||
})
|
||||
.from(schema.exercise)
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
|
||||
} catch (error) {
|
||||
console.error('getExerciseProgressionPolicies error:', error);
|
||||
throw new Error(
|
||||
`Failed to get exercise progression policies: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function percentFromTotal(total: number, maxPoints: number): number {
|
||||
if (maxPoints <= 0) return 0;
|
||||
|
||||
return Math.round((total / maxPoints) * 100);
|
||||
}
|
||||
|
||||
export async function isExercisePassedForMember(
|
||||
exerciseId: string,
|
||||
groupMemberId: string,
|
||||
passThreshold: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { maxPoints } = await getExerciseTitleAndMaxPoints(exerciseId);
|
||||
if (maxPoints <= 0) return false;
|
||||
|
||||
const submissions = await getStudentSubmissionsForExercise(groupMemberId, exerciseId);
|
||||
const completed = submissions.filter((submission) => submission.gradingState === 'completed');
|
||||
if (completed.length === 0) return false;
|
||||
|
||||
let bestPercent = 0;
|
||||
for (const submission of completed) {
|
||||
const total = Number(submission.total ?? 0);
|
||||
const percent = percentFromTotal(total, maxPoints);
|
||||
if (percent > bestPercent) bestPercent = percent;
|
||||
}
|
||||
|
||||
return bestPercent >= passThreshold;
|
||||
} catch (error) {
|
||||
console.error('isExercisePassedForMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to check exercise pass status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubmittedExerciseIdsForMember(courseId: string, groupMemberId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ exerciseId: schema.submission.exerciseId })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.submittedBy, groupMemberId),
|
||||
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))
|
||||
)
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.exerciseId).filter((id): id is string => !!id));
|
||||
} catch (error) {
|
||||
console.error('getSubmittedExerciseIdsForMember error:', error);
|
||||
throw new Error(`Failed to get submitted exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCompletedLessonIdsForProfile(courseId: string, profileId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ lessonId: schema.lessonCompletion.lessonId })
|
||||
.from(schema.lessonCompletion)
|
||||
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lesson.courseId, courseId),
|
||||
eq(schema.lessonCompletion.profileId, profileId),
|
||||
eq(schema.lessonCompletion.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.lessonId).filter((id): id is string => !!id));
|
||||
} catch (error) {
|
||||
console.error('getCompletedLessonIdsForProfile error:', error);
|
||||
throw new Error(`Failed to get completed lessons: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPassedExerciseIdsForMember(
|
||||
courseId: string,
|
||||
groupMemberId: string,
|
||||
policies: ExerciseProgressionPolicy[]
|
||||
): Promise<Set<string>> {
|
||||
const passedIds = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
policies
|
||||
.filter((policy) => policy.completionPolicy === 'passed')
|
||||
.map(async (policy) => {
|
||||
const threshold = policy.passThreshold ?? 100;
|
||||
const passed = await isExercisePassedForMember(policy.id, groupMemberId, threshold);
|
||||
if (passed) passedIds.add(policy.id);
|
||||
})
|
||||
);
|
||||
|
||||
return passedIds;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lesson';
|
||||
export * from './language';
|
||||
export * from './video-progress';
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TLessonVideoProgress, TNewLessonVideoProgress } from '@db/types';
|
||||
import { and, db, eq } from '@db/drizzle';
|
||||
|
||||
export async function getLessonVideoProgress(
|
||||
lessonId: string,
|
||||
profileId: string,
|
||||
assetId: string
|
||||
): Promise<TLessonVideoProgress | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lessonVideoProgress.lessonId, lessonId),
|
||||
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||
eq(schema.lessonVideoProgress.assetId, assetId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getLessonVideoProgress error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson video progress for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLessonVideoProgressForLesson(
|
||||
lessonId: string,
|
||||
profileId: string
|
||||
): Promise<TLessonVideoProgress[]> {
|
||||
try {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.lessonVideoProgress)
|
||||
.where(
|
||||
and(eq(schema.lessonVideoProgress.lessonId, lessonId), eq(schema.lessonVideoProgress.profileId, profileId))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getLessonVideoProgressForLesson error:', error);
|
||||
throw new Error(
|
||||
`Failed to get lesson video progress rows for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLessonVideoProgressValues(
|
||||
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
|
||||
): TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string } {
|
||||
return {
|
||||
...data,
|
||||
durationSeconds: data.durationSeconds != null ? Math.round(data.durationSeconds) : data.durationSeconds,
|
||||
watchedSeconds: data.watchedSeconds != null ? Math.round(data.watchedSeconds) : data.watchedSeconds,
|
||||
furthestSeconds: data.furthestSeconds != null ? Math.floor(data.furthestSeconds) : data.furthestSeconds,
|
||||
lastPositionSeconds:
|
||||
data.lastPositionSeconds != null ? Math.floor(data.lastPositionSeconds) : data.lastPositionSeconds
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertLessonVideoProgress(
|
||||
data: TNewLessonVideoProgress & { lessonId: string; profileId: string; assetId: string }
|
||||
): Promise<TLessonVideoProgress> {
|
||||
try {
|
||||
const normalized = normalizeLessonVideoProgressValues(data);
|
||||
const existing = await getLessonVideoProgress(normalized.lessonId, normalized.profileId, normalized.assetId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(schema.lessonVideoProgress)
|
||||
.set({ ...normalized, updatedAt: now })
|
||||
.where(eq(schema.lessonVideoProgress.id, existing.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update lesson video progress');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(schema.lessonVideoProgress)
|
||||
.values({ ...normalized, createdAt: now, updatedAt: now })
|
||||
.returning();
|
||||
|
||||
if (!inserted) {
|
||||
throw new Error('Failed to insert lesson video progress');
|
||||
}
|
||||
|
||||
return inserted;
|
||||
} catch (error) {
|
||||
console.error('upsertLessonVideoProgress error:', error);
|
||||
throw new Error(
|
||||
`Failed to upsert lesson video progress for lesson "${data.lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -689,6 +689,8 @@ export const course = pgTable(
|
||||
allowNewStudent: boolean;
|
||||
sectionDisplay?: Record<string, boolean>;
|
||||
isContentGroupingEnabled?: boolean;
|
||||
/** `free` = all unlocked content is accessible; `sequential` = prior items must be complete. */
|
||||
progressionMode?: 'free' | 'sequential';
|
||||
/** Per-course AI tutor override; missing fields inherit from org defaults. */
|
||||
aiTutor?: {
|
||||
inheritFromOrg?: boolean;
|
||||
@@ -726,7 +728,10 @@ export const course = pgTable(
|
||||
accentColor: string;
|
||||
subtitle?: string;
|
||||
descriptionOverride?: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
signatories: [
|
||||
{ name: string; role: string; enabled?: boolean; signatureUrl?: string },
|
||||
{ name: string; role: string; enabled?: boolean; signatureUrl?: string }
|
||||
];
|
||||
idFormat?: string;
|
||||
};
|
||||
deadline?: string | null;
|
||||
@@ -975,12 +980,15 @@ export const lesson = pgTable(
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
order: bigint({ mode: 'number' }),
|
||||
isUnlocked: boolean('is_unlocked').default(false),
|
||||
completionPolicy: varchar('completion_policy').default('manual').notNull(),
|
||||
videoWatchThreshold: integer('video_watch_threshold').default(95),
|
||||
videos: jsonb().default([]).$type<
|
||||
{
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
watchEnforced?: boolean;
|
||||
/** Display name; stored on add/upload. Fallback: derive from key or type. */
|
||||
fileName?: string;
|
||||
metadata?: {
|
||||
@@ -1192,6 +1200,8 @@ export const exercise = pgTable(
|
||||
dueBy: timestamp('due_by', { mode: 'string' }),
|
||||
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull(),
|
||||
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
|
||||
completionPolicy: varchar('completion_policy').default('submitted').notNull(),
|
||||
passThreshold: integer('pass_threshold'),
|
||||
slug: varchar()
|
||||
},
|
||||
(table) => [
|
||||
@@ -1487,6 +1497,40 @@ export const organizationInviteAudit = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const lessonVideoProgress = pgTable(
|
||||
'lesson_video_progress',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
lessonId: uuid('lesson_id').notNull(),
|
||||
profileId: uuid('profile_id').notNull(),
|
||||
assetId: text('asset_id').notNull(),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
watchedSeconds: integer('watched_seconds').default(0).notNull(),
|
||||
furthestSeconds: integer('furthest_seconds').default(0).notNull(),
|
||||
lastPositionSeconds: integer('last_position_seconds').default(0).notNull(),
|
||||
isComplete: boolean('is_complete').default(false).notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.lessonId],
|
||||
foreignColumns: [lesson.id],
|
||||
name: 'lesson_video_progress_lesson_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.profileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'lesson_video_progress_profile_id_fkey'
|
||||
}),
|
||||
unique('lesson_video_progress_lesson_profile_asset_unique').on(table.lessonId, table.profileId, table.assetId)
|
||||
]
|
||||
);
|
||||
|
||||
export const lessonCompletion = pgTable(
|
||||
'lesson_completion',
|
||||
{
|
||||
|
||||
@@ -112,6 +112,9 @@ export type TNewOrganizationInviteAudit = typeof schema.organizationInviteAudit.
|
||||
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
|
||||
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
|
||||
|
||||
export type TLessonVideoProgress = typeof schema.lessonVideoProgress.$inferSelect;
|
||||
export type TNewLessonVideoProgress = typeof schema.lessonVideoProgress.$inferInsert;
|
||||
|
||||
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
|
||||
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ export function buildDesign(
|
||||
subtitle: overrides.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: overrides.descriptionOverride,
|
||||
signatories: overrides.signatories ?? [
|
||||
{ name: 'Margaret W. Holloway', role: 'Director of Studies' },
|
||||
{ name: 'James K. Reeves', role: 'Dean of Faculty' }
|
||||
{ name: 'Margaret W. Holloway', role: 'Director of Studies', enabled: true },
|
||||
{ name: 'James K. Reeves', role: 'Dean of Faculty', enabled: true }
|
||||
],
|
||||
idFormat: overrides.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
|
||||
@@ -33,10 +33,20 @@
|
||||
const MAX_SCALE = 1.5;
|
||||
|
||||
let stageElement = $state<HTMLDivElement | null>(null);
|
||||
let iframeElement = $state<HTMLIFrameElement | null>(null);
|
||||
let manualScale = $state<number | null>(typeof zoom === 'number' ? clamp(zoom) : null);
|
||||
let fitScale = $state(0.4);
|
||||
|
||||
const srcdoc = $derived(renderCertificateDocument(design, data));
|
||||
const renderedHtml = $derived(renderCertificateDocument(design, data));
|
||||
|
||||
$effect(() => {
|
||||
const html = renderedHtml;
|
||||
const doc = iframeElement?.contentDocument;
|
||||
if (!doc) return;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
});
|
||||
|
||||
const isFit = $derived(zoom === 'fit' && manualScale == null);
|
||||
const scale = $derived(isFit ? fitScale : (manualScale ?? clamp(typeof zoom === 'number' ? zoom : 0.4)));
|
||||
@@ -84,12 +94,12 @@
|
||||
>
|
||||
<div class="ui:origin-center" style:width="{width}px" style:height="{height}px" style:transform="scale({scale})">
|
||||
<iframe
|
||||
bind:this={iframeElement}
|
||||
title="Certificate preview"
|
||||
sandbox="allow-same-origin"
|
||||
class="ui:h-full ui:w-full ui:rounded-sm ui:border-0 ui:shadow-[0_18px_40px_rgba(0,0,0,0.18),0_6px_12px_rgba(0,0,0,0.12)]"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
{srcdoc}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/** Marker teachers use in the template for each blank (three underscores). */
|
||||
export const WORD_BANK_BLANK_MARKER = '___';
|
||||
|
||||
/** Any run of 3+ underscores counts as a single blank, so `___` and `______` behave the same. */
|
||||
export const WORD_BANK_BLANK_PATTERN = /_{3,}/g;
|
||||
|
||||
export type WordBankSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
|
||||
|
||||
export function countWordBankBlanks(template: string): number {
|
||||
if (!template) return 0;
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
return Math.max(0, parts.length - 1);
|
||||
const matches = template.match(WORD_BANK_BLANK_PATTERN);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
/** Splits template into alternating text runs and blank slots (indices0..n-1). */
|
||||
@@ -14,7 +17,7 @@ export function parseWordBankTemplate(template: string): WordBankSegment[] {
|
||||
const segments: WordBankSegment[] = [];
|
||||
if (!template) return segments;
|
||||
|
||||
const parts = template.split(WORD_BANK_BLANK_MARKER);
|
||||
const parts = template.split(WORD_BANK_BLANK_PATTERN);
|
||||
let blankIndex = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
|
||||
@@ -75,6 +75,16 @@ class ImageCropperRootState {
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
onUseOriginal() {
|
||||
if (!this.tempUrl) return;
|
||||
|
||||
this.opts.src.current = this.tempUrl;
|
||||
this.open = false;
|
||||
this.opts.onCropped.current(this.tempUrl);
|
||||
this.tempUrl = undefined;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
|
||||
@@ -146,6 +156,16 @@ class ImageCropperCancelState {
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperUseOriginalState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onUseOriginal();
|
||||
}
|
||||
}
|
||||
|
||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||
|
||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||
@@ -187,3 +207,9 @@ export const useImageCropperCancel = () => {
|
||||
|
||||
return new ImageCropperCancelState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperUseOriginal = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperUseOriginalState(rootState);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '../../base/dialog';
|
||||
import Button from '../../base/button/button.svelte';
|
||||
import { cn } from '../../tools';
|
||||
import { useImageCropperDialog } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperDialogProps } from './types';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
|
||||
|
||||
@@ -12,12 +14,22 @@
|
||||
<Dialog.Root bind:open={dialogState.rootState.open}>
|
||||
<Dialog.Content
|
||||
{...rest}
|
||||
showCloseButton
|
||||
showCloseButton={false}
|
||||
class={cn(
|
||||
'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 ui:sm:ui:max-w-lg ui:sm:ui:rounded-lg ui:sm:ui:border-x',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="ui:absolute ui:end-4 ui:top-4"
|
||||
onclick={() => dialogState.rootState.onCancel()}
|
||||
>
|
||||
<XIcon />
|
||||
<span class="ui:sr-only">Close</span>
|
||||
</Button>
|
||||
<div class="ui:flex ui:flex-col ui:gap-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
|
||||
import { useImageCropperUseOriginal } from './image-cropper-context.svelte';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
onclick,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonElementProps = $props();
|
||||
|
||||
const useOriginalState = useImageCropperUseOriginal();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
bind:ref
|
||||
{size}
|
||||
{variant}
|
||||
onclick={(
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
useOriginalState.onclick();
|
||||
}}
|
||||
>
|
||||
<ImageIcon />
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<span>Don't Crop</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -6,8 +6,9 @@ import Cropper from './image-cropper-cropper.svelte';
|
||||
import Controls from './image-cropper-controls.svelte';
|
||||
import Crop from './image-cropper-crop.svelte';
|
||||
import Cancel from './image-cropper-cancel.svelte';
|
||||
import UseOriginal from './image-cropper-use-original.svelte';
|
||||
import { getFileFromUrl } from './utils';
|
||||
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, UseOriginal, getFileFromUrl };
|
||||
|
||||
export type * from './types';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import 'plyr/dist/plyr.css';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
|
||||
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
@@ -31,6 +32,7 @@
|
||||
let playerInstance = $state<Plyr | null>(null);
|
||||
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
|
||||
let playbackFailed = $state(false);
|
||||
let isPlayerReady = $state(false);
|
||||
let reloadInFlight = $state(false);
|
||||
let timeupdateCleanup: (() => void) | undefined;
|
||||
let firstPlayCleanup: (() => void) | undefined;
|
||||
@@ -60,7 +62,12 @@
|
||||
const isYouTube = $derived(isYoutubeUrl(src));
|
||||
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
|
||||
|
||||
function attachTimeUpdates(player: Plyr): () => void {
|
||||
function attachTimeUpdates(player: Plyr, element: HTMLVideoElement): () => void {
|
||||
const policy = options.seekPolicy;
|
||||
if (policy?.mode === 'locked_until_complete') {
|
||||
return attachSeekEnforcement(player, element, policy);
|
||||
}
|
||||
|
||||
const handler = () => options.onTimeUpdate?.(player.currentTime);
|
||||
player.on('timeupdate', handler);
|
||||
return () => {
|
||||
@@ -68,6 +75,134 @@
|
||||
};
|
||||
}
|
||||
|
||||
function attachSeekEnforcement(
|
||||
player: Plyr,
|
||||
element: HTMLVideoElement,
|
||||
policy: NonNullable<MediaPlayerOptions['seekPolicy']>
|
||||
): () => void {
|
||||
let furthestSeconds = policy.initialFurthestSeconds ?? 0;
|
||||
let playedSeconds = 0;
|
||||
let lastValidTime = element.currentTime;
|
||||
let isSeeking = false;
|
||||
let hasBlockedThisSeek = false;
|
||||
let lastHeartbeatAt = 0;
|
||||
const seekTolerance = 0.5;
|
||||
const heartbeatIntervalMs = 15_000;
|
||||
|
||||
const flushProgress = (force = false) => {
|
||||
const durationSeconds = element.duration;
|
||||
if (!durationSeconds || !Number.isFinite(durationSeconds)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastHeartbeatAt < heartbeatIntervalMs) return;
|
||||
|
||||
lastHeartbeatAt = now;
|
||||
policy.onProgress?.({
|
||||
positionSeconds: Math.floor(player.currentTime),
|
||||
playedDeltaSeconds: Math.round(playedSeconds),
|
||||
durationSeconds: Math.round(durationSeconds)
|
||||
});
|
||||
playedSeconds = 0;
|
||||
};
|
||||
|
||||
const onSeeking = () => {
|
||||
isSeeking = true;
|
||||
if (!hasBlockedThisSeek && player.currentTime > furthestSeconds + seekTolerance) {
|
||||
hasBlockedThisSeek = true;
|
||||
player.currentTime = furthestSeconds;
|
||||
policy.onSeekBlocked?.();
|
||||
}
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
isSeeking = false;
|
||||
hasBlockedThisSeek = false;
|
||||
lastValidTime = player.currentTime;
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (isSeeking) return;
|
||||
|
||||
const current = player.currentTime;
|
||||
const delta = current - lastValidTime;
|
||||
|
||||
if (delta > 0 && delta < 2) {
|
||||
playedSeconds += delta;
|
||||
furthestSeconds = Math.max(furthestSeconds, current);
|
||||
}
|
||||
|
||||
lastValidTime = current;
|
||||
options.onTimeUpdate?.(current);
|
||||
flushProgress(false);
|
||||
};
|
||||
|
||||
const onRateChange = () => {
|
||||
if (player.speed !== 1) {
|
||||
player.speed = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const onPause = () => flushProgress(true);
|
||||
|
||||
const onEnded = () => {
|
||||
const durationSeconds = element.duration;
|
||||
if (durationSeconds && Number.isFinite(durationSeconds)) {
|
||||
const tailSeconds = durationSeconds - lastValidTime;
|
||||
if (tailSeconds > 0) {
|
||||
playedSeconds += tailSeconds;
|
||||
}
|
||||
furthestSeconds = Math.max(furthestSeconds, durationSeconds);
|
||||
lastValidTime = durationSeconds;
|
||||
}
|
||||
|
||||
flushProgress(true);
|
||||
};
|
||||
|
||||
player.on('seeking', onSeeking);
|
||||
player.on('seeked', onSeeked);
|
||||
player.on('timeupdate', onTimeUpdate);
|
||||
player.on('ratechange', onRateChange);
|
||||
player.on('pause', onPause);
|
||||
player.on('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
flushProgress(true);
|
||||
player.off('seeking', onSeeking);
|
||||
player.off('seeked', onSeeked);
|
||||
player.off('timeupdate', onTimeUpdate);
|
||||
player.off('ratechange', onRateChange);
|
||||
player.off('pause', onPause);
|
||||
player.off('ended', onEnded);
|
||||
};
|
||||
}
|
||||
|
||||
function getPlyrControls(): string[] {
|
||||
if (!showControls) return [];
|
||||
|
||||
return PLYR_DEFAULT_CONTROLS;
|
||||
}
|
||||
|
||||
function handleVisibilityChange(): void {
|
||||
if (!options.seekPolicy?.pauseOnHidden) return;
|
||||
if (!document.hidden) return;
|
||||
|
||||
try {
|
||||
playerInstance?.pause();
|
||||
} catch {
|
||||
// Player may be mid-teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowBlur(): void {
|
||||
if (!options.seekPolicy?.pauseOnHidden) return;
|
||||
|
||||
try {
|
||||
playerInstance?.pause();
|
||||
} catch {
|
||||
// Player may be mid-teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function attachFirstPlay(player: Plyr): () => void {
|
||||
const handler = () => {
|
||||
if (hasFiredFirstPlay) return;
|
||||
@@ -172,6 +307,7 @@
|
||||
transcriptPanelCleanup = undefined;
|
||||
plyrErrorCleanup = undefined;
|
||||
mediaErrorCleanup = undefined;
|
||||
isPlayerReady = false;
|
||||
if (!playerInstance) return;
|
||||
|
||||
try {
|
||||
@@ -190,7 +326,7 @@
|
||||
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
const controls = getPlyrControls();
|
||||
|
||||
try {
|
||||
// Recheck after the dynamic import resolves — the component may have
|
||||
@@ -215,9 +351,12 @@
|
||||
}
|
||||
: {})
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance, videoElement);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
|
||||
playerInstance.on('ready', () => {
|
||||
isPlayerReady = true;
|
||||
});
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
} finally {
|
||||
plyrConstructInFlight = false;
|
||||
@@ -267,7 +406,7 @@
|
||||
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
const controls = getPlyrControls();
|
||||
|
||||
try {
|
||||
if (!isMounted || initGeneration !== youtubeInitGeneration || !containerElement || playerInstance) {
|
||||
@@ -284,6 +423,9 @@
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
|
||||
playerInstance.on('ready', () => {
|
||||
isPlayerReady = true;
|
||||
});
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
} finally {
|
||||
plyrConstructInFlight = false;
|
||||
@@ -494,6 +636,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onvisibilitychange={handleVisibilityChange} />
|
||||
<svelte:window onblur={handleWindowBlur} />
|
||||
|
||||
<div
|
||||
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
|
||||
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
|
||||
@@ -530,6 +675,20 @@
|
||||
</video>
|
||||
{/if}
|
||||
|
||||
{#if !isPlayerReady && !playbackFailed}
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:z-10 ui:flex ui:items-center ui:justify-center ui:bg-black/40"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={options.loadingLabel}
|
||||
>
|
||||
<LoaderCircleIcon class="ui:size-10 ui:animate-spin ui:text-white" aria-hidden="true" />
|
||||
{#if options.loadingLabel}
|
||||
<span class="ui:sr-only">{options.loadingLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playbackFailed && options.playbackErrorLabel}
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface MediaPlayerOptions {
|
||||
onPlayerReady?: (player: Plyr) => void;
|
||||
/** Shown when the media element fails to load or decode. */
|
||||
playbackErrorLabel?: string;
|
||||
/** Accessible label for the loading/buffering spinner overlay. */
|
||||
loadingLabel?: string;
|
||||
/** Label for the manual reload control shown after a playback failure. */
|
||||
playbackReloadLabel?: string;
|
||||
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
|
||||
@@ -66,4 +68,12 @@ export interface MediaPlayerOptions {
|
||||
* best-effort call that fails benignly.
|
||||
*/
|
||||
onBeforeHlsLoad?: () => Promise<void>;
|
||||
seekPolicy?: {
|
||||
mode: 'locked_until_complete';
|
||||
watchThresholdPercent: number;
|
||||
initialFurthestSeconds?: number;
|
||||
pauseOnHidden?: boolean;
|
||||
onProgress?: (payload: { positionSeconds: number; playedDeltaSeconds: number; durationSeconds: number }) => void;
|
||||
onSeekBlocked?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import PercentRingProgress from './percent-ring-progress.svelte';
|
||||
|
||||
export { PercentRingProgress };
|
||||
export { PercentRingProgress as default };
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
const RING_RADIUS = 45;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
interface Props {
|
||||
/** Progress value from 0 to 100 */
|
||||
value: number;
|
||||
size?: 'small' | 'default';
|
||||
}
|
||||
|
||||
let { value, size = 'small' }: Props = $props();
|
||||
|
||||
const clampedValue = $derived(Math.max(0, Math.min(100, value)));
|
||||
const labelPercentage = $derived(Math.round(clampedValue));
|
||||
const strokeDashoffset = $derived(RING_CIRCUMFERENCE * (1 - clampedValue / 100));
|
||||
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
|
||||
const labelClass = $derived(size === 'small' ? 'text-[10px]' : 'text-sm');
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<svg
|
||||
class="{sizeClass} -rotate-90"
|
||||
style="transform-box: fill-box; transform-origin: center;"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
class="text-green-600 transition-[stroke-dashoffset] duration-700 ease-out"
|
||||
stroke-dasharray={RING_CIRCUMFERENCE}
|
||||
stroke-dashoffset={strokeDashoffset}
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class={labelClass}>{labelPercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
|
||||
(question) => {
|
||||
const settings = question.settings;
|
||||
const template = String(settings?.template ?? '');
|
||||
const blankCount = Math.max(0, template.split('___').length - 1);
|
||||
const blankCount = (template.match(/_{3,}/g) ?? []).length;
|
||||
if (blankCount < 1) {
|
||||
return 'Word bank questions need at least one ___ blank in the template';
|
||||
}
|
||||
@@ -224,7 +224,9 @@ export const ZExerciseUpdate = z.object({
|
||||
slug: ZSlug.optional(),
|
||||
questions: z.array(ZExerciseUpdateQuestion).optional(),
|
||||
sections: z.array(ZExerciseSection).optional(),
|
||||
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional()
|
||||
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional(),
|
||||
completionPolicy: z.enum(['submitted', 'passed']).optional(),
|
||||
passThreshold: z.number().int().min(0).max(100).optional()
|
||||
});
|
||||
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export const ZLessonUpdate = z.object({
|
||||
public: z.boolean().optional(),
|
||||
slug: ZSlug.optional(),
|
||||
isComplete: z.boolean().optional(),
|
||||
completionPolicy: z.enum(['manual', 'video_watch', 'none']).optional(),
|
||||
videoWatchThreshold: z.number().int().min(1).max(100).optional(),
|
||||
videoUrl: z.url().optional(),
|
||||
slideUrl: z.url().optional(),
|
||||
videos: z
|
||||
@@ -38,6 +40,7 @@ export const ZLessonUpdate = z.object({
|
||||
link: z.string(),
|
||||
key: z.string().optional(),
|
||||
assetId: z.string().uuid().optional(),
|
||||
watchEnforced: z.boolean().optional(),
|
||||
fileName: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional()
|
||||
})
|
||||
@@ -128,3 +131,17 @@ export const ZLessonCompletionUpdate = z.object({
|
||||
isComplete: z.boolean()
|
||||
});
|
||||
export type TLessonCompletionUpdate = z.infer<typeof ZLessonCompletionUpdate>;
|
||||
|
||||
export const ZUpdateLessonWatchProgress = z.object({
|
||||
positionSeconds: z.number().min(0),
|
||||
playedDeltaSeconds: z
|
||||
.number()
|
||||
.transform((value) => Math.round(value))
|
||||
.pipe(z.number().min(0).max(120)),
|
||||
durationSeconds: z
|
||||
.number()
|
||||
.transform((value) => Math.round(value))
|
||||
.pipe(z.int().min(1)),
|
||||
assetId: z.string().optional()
|
||||
});
|
||||
export type TUpdateLessonWatchProgress = z.infer<typeof ZUpdateLessonWatchProgress>;
|
||||
|
||||
Reference in New Issue
Block a user