@anantagnanam/lms 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +40 -2
  3. package/astro/assignment-player.ts +231 -0
  4. package/astro/client.ts +97 -152
  5. package/astro/components/AssignmentFeedback.astro +32 -0
  6. package/astro/components/AssignmentRubric.astro +27 -0
  7. package/astro/components/CourseOverview.astro +25 -3
  8. package/astro/components/LearnerDashboard.astro +9 -0
  9. package/astro/components/LearningShell.astro +16 -1
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +67 -0
  12. package/astro/components/LessonPlayer.astro +166 -99
  13. package/astro/components/MediaPlayer.astro +55 -4
  14. package/astro/draft-queue.ts +95 -0
  15. package/astro/media-player.ts +124 -0
  16. package/astro/pages/author-preview.astro +73 -0
  17. package/astro/pages/course.astro +10 -0
  18. package/astro/pages/lesson.astro +3 -0
  19. package/astro/pages/library.astro +173 -0
  20. package/astro/pages/paths.astro +73 -0
  21. package/astro/pages/study.astro +180 -0
  22. package/astro/quiz-drafts.ts +23 -0
  23. package/astro/quiz-player.ts +362 -0
  24. package/astro/study-tools.ts +386 -0
  25. package/astro/styles.css +30 -0
  26. package/astro/transcript-search.ts +56 -0
  27. package/dist/admin.d.ts +3 -1
  28. package/dist/admin.js +756 -127
  29. package/dist/admin.js.map +1 -1
  30. package/dist/assignments-CsuAjxGO.js +75 -0
  31. package/dist/assignments-CsuAjxGO.js.map +1 -0
  32. package/dist/astro.js +4 -0
  33. package/dist/astro.js.map +1 -1
  34. package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
  35. package/dist/constants-DyQbwNbP.js.map +1 -0
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/runtime.d.ts +129 -16
  39. package/dist/runtime.js +1201 -130
  40. package/dist/runtime.js.map +1 -1
  41. package/dist/schemas.d.ts +136 -3
  42. package/dist/schemas.js +80 -7
  43. package/dist/schemas.js.map +1 -1
  44. package/dist/server.d.ts +18 -1
  45. package/dist/server.js +17 -3
  46. package/dist/server.js.map +1 -1
  47. package/dist/types.d.ts +172 -4
  48. package/package.json +1 -1
  49. package/dist/constants-C0kT94tc.js.map +0 -1
  50. package/dist/errors-ChzJS9al.js +0 -16
  51. package/dist/errors-ChzJS9al.js.map +0 -1
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { AssignmentSubmission } from "@anantagnanam/lms/types";
3
+ import AssignmentRubric from "./AssignmentRubric.astro";
4
+ interface Props {
5
+ submission: AssignmentSubmission;
6
+ }
7
+ const { submission } = Astro.props;
8
+ ---
9
+
10
+ <div class="lms-notice">
11
+ <strong>
12
+ {submission.status === "submitted"
13
+ ? "Submitted · awaiting feedback"
14
+ : `Graded · ${submission.points}/${submission.maxPoints} · ${submission.points! >= submission.passingPoints ? "Passed" : "Below passing grade"}`}
15
+ </strong>
16
+ <p>Submitted {new Date(submission.submittedAt).toLocaleString("en-IN")}</p>
17
+ {submission.gradedAt && <p>Graded {new Date(submission.gradedAt).toLocaleString("en-IN")}</p>}
18
+ {submission.feedback && <p class="lms-text">{submission.feedback}</p>}
19
+ <AssignmentRubric
20
+ rubric={submission.rubric}
21
+ scores={submission.status === "graded" ? submission.rubricScores : undefined}
22
+ />
23
+ <details>
24
+ <summary>Your submitted work</summary>
25
+ <p class="lms-text">{submission.body}</p>
26
+ {submission.attachmentUrl && (
27
+ <a href={submission.attachmentUrl} target="_blank" rel="noopener noreferrer">
28
+ Open attachment ↗
29
+ </a>
30
+ )}
31
+ </details>
32
+ </div>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import type { RubricCriterion, RubricScore } from "@anantagnanam/lms/types";
3
+ interface Props {
4
+ rubric?: RubricCriterion[];
5
+ scores?: RubricScore[];
6
+ }
7
+ const { rubric = [], scores } = Astro.props;
8
+ ---
9
+
10
+ {rubric.length > 0 && (
11
+ <div class="lms-rubric">
12
+ <h3>{scores ? "Criterion feedback" : "Grading rubric"}</h3>
13
+ <ul>
14
+ {rubric.map((criterion) => {
15
+ const score = scores?.find((item) => item.criterionId === criterion.id);
16
+ return (
17
+ <li>
18
+ <strong>{criterion.title}</strong> · {score ? `${score.points}/` : ""}
19
+ {criterion.maxPoints} points
20
+ {criterion.description && <p class="lms-text">{criterion.description}</p>}
21
+ {score?.feedback && <p class="lms-text">{score.feedback}</p>}
22
+ </li>
23
+ );
24
+ })}
25
+ </ul>
26
+ </div>
27
+ )}
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { Image } from "emdash/ui";
3
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
3
4
  import type {
4
5
  CourseDetail,
5
6
  PublicEnrollment,
@@ -26,12 +27,17 @@ const {
26
27
  cohortId,
27
28
  cohortMembership = null,
28
29
  } = Astro.props;
29
- const firstLesson = course.modules.flatMap((module) => module.lessons)[0];
30
30
  const active =
31
31
  enrollment &&
32
32
  ["active", "completed"].includes(enrollment.status) &&
33
33
  enrollment.effectiveAccess === "granted";
34
- const next = progress?.nextLessonId ?? firstLesson?.id;
34
+ const next = progress?.nextLessonId;
35
+ const revisit =
36
+ !next && enrollment?.status === "completed"
37
+ ? course.modules
38
+ .flatMap((module) => module.lessons)
39
+ .find((lesson) => progress?.availability[lesson.id]?.available)
40
+ : undefined;
35
41
  ---
36
42
 
37
43
  <nav class="lms-breadcrumbs" aria-label="Breadcrumb">
@@ -73,6 +79,14 @@ const next = progress?.nextLessonId ?? firstLesson?.id;
73
79
  {enrollment.status === "completed" ? "Revisit lessons" : "Continue learning"} →
74
80
  </a>
75
81
  )}
82
+ {revisit && (
83
+ <a class="lms-btn" href={lessonUrl(basePath, course.slug, revisit.id)}>
84
+ Revisit lessons →
85
+ </a>
86
+ )}
87
+ {!next && progress.blockedLessonId && (
88
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
89
+ )}
76
90
  {enrollment.certificate && (
77
91
  <a
78
92
  class="lms-btn lms-btn-outline"
@@ -97,6 +111,9 @@ const next = progress?.nextLessonId ?? firstLesson?.id;
97
111
  : enrollment.decisionReason || "Contact an administrator to manage your access."}
98
112
  </p>
99
113
  <a href={`${basePath}/my-learning`}>Open My Learning →</a>
114
+ {progress?.blockedLessonId && (
115
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
116
+ )}
100
117
  </div>
101
118
  ) : !Astro.locals.user ? (
102
119
  <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname + Astro.url.search)}>
@@ -222,7 +239,11 @@ const next = progress?.nextLessonId ?? firstLesson?.id;
222
239
  : String(index + 1).padStart(2, "0")}
223
240
  </span>
224
241
  <div>
225
- {active || lesson.preview ? (
242
+ {(
243
+ enrollment && ["active", "completed"].includes(enrollment.status)
244
+ ? progress?.availability[lesson.id]?.available
245
+ : lesson.preview
246
+ ) ? (
226
247
  <a href={lessonUrl(basePath, course.slug, lesson.id)}>{lesson.title}</a>
227
248
  ) : (
228
249
  <strong>{lesson.title}</strong>
@@ -231,6 +252,7 @@ const next = progress?.nextLessonId ?? firstLesson?.id;
231
252
  {lesson.kind} · {lesson.durationMinutes} min
232
253
  {!lesson.required ? " · optional" : ""}
233
254
  </small>
255
+ <LessonAvailabilityNote availability={progress?.availability[lesson.id]} />
234
256
  </div>
235
257
  {lesson.preview && <span class="lms-badge">Preview</span>}
236
258
  </li>
@@ -2,6 +2,7 @@
2
2
  import type { CourseCard, PublicEnrollment, ProgressSummary, Page } from "@anantagnanam/lms/types";
3
3
  import { courseUrl, lessonUrl, certificateUrl } from "@anantagnanam/lms/server";
4
4
  import { basePath } from "virtual:anantagnanam-lms/config";
5
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
5
6
  interface Props {
6
7
  page: Page<{ course: CourseCard; enrollment: PublicEnrollment; progress: ProgressSummary }>;
7
8
  error?: string;
@@ -66,6 +67,11 @@ next.searchParams.set("cursor", page.cursor ?? "");
66
67
  </p>
67
68
  )}
68
69
  <div class="lms-actions">
70
+ {!progress.nextLessonId && progress.blockedLessonId && (
71
+ <LessonAvailabilityNote
72
+ availability={progress.availability[progress.blockedLessonId]}
73
+ />
74
+ )}
69
75
  {["active", "completed"].includes(enrollment.status) &&
70
76
  enrollment.effectiveAccess === "granted" &&
71
77
  progress.nextLessonId && (
@@ -76,6 +82,9 @@ next.searchParams.set("cursor", page.cursor ?? "");
76
82
  <a class="lms-btn lms-btn-outline" href={courseUrl(basePath, course.slug)}>
77
83
  Course overview
78
84
  </a>
85
+ <a href={`${basePath}/my-learning/study?courseId=${encodeURIComponent(course.id)}`}>
86
+ Notebook &amp; agenda
87
+ </a>
79
88
  {enrollment.certificate && (
80
89
  <a
81
90
  class="lms-btn lms-btn-outline"
@@ -9,15 +9,30 @@ const path = Astro.url.pathname;
9
9
  <a href={basePath} aria-current={path === basePath ? "page" : undefined}>
10
10
  Explore courses
11
11
  </a>
12
+ <a href={`${basePath}/paths`} aria-current={path === `${basePath}/paths` ? "page" : undefined}>
13
+ Learning paths
14
+ </a>
15
+ <a
16
+ href={`${basePath}/my-learning/library`}
17
+ aria-current={path === `${basePath}/my-learning/library` ? "page" : undefined}
18
+ >
19
+ My library &amp; preferences
20
+ </a>
12
21
  <a
13
22
  href={`${basePath}/my-learning`}
14
- aria-current={path.includes("/my-learning") ? "page" : undefined}
23
+ aria-current={path === `${basePath}/my-learning` ? "page" : undefined}
15
24
  >
16
25
  My learning
17
26
  </a>
18
27
  {Astro.locals.user && Astro.locals.user.role >= 20 && (
19
28
  <a href={`${basePath}/teach`}>Course studio</a>
20
29
  )}
30
+ <a
31
+ href={`${basePath}/my-learning/study`}
32
+ aria-current={path === `${basePath}/my-learning/study` ? "page" : undefined}
33
+ >
34
+ Notebook &amp; agenda
35
+ </a>
21
36
  </nav>
22
37
  <main id="lms-main" class="lms-container">
23
38
  <slot />
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { LessonAvailability } from "@anantagnanam/lms/types";
3
+ interface Props {
4
+ availability?: LessonAvailability;
5
+ }
6
+ const { availability } = Astro.props;
7
+ ---
8
+
9
+ {availability && !availability.available && (
10
+ <small class="lms-lock-note">
11
+ {availability.reason}
12
+ {availability.releaseAt && (
13
+ <span>
14
+ {" "}
15
+ Available from{" "}
16
+ <time datetime={availability.releaseAt}>
17
+ {new Date(availability.releaseAt).toLocaleString("en-IN", {
18
+ timeZone: "UTC",
19
+ timeZoneName: "short",
20
+ })}
21
+ </time>
22
+ .
23
+ </span>
24
+ )}
25
+ </small>
26
+ )}
@@ -0,0 +1,67 @@
1
+ ---
2
+ import { PortableText, type TypedObject } from "emdash/ui";
3
+ import MediaPlayer from "./MediaPlayer.astro";
4
+ import type { PublicLesson } from "@anantagnanam/lms/types";
5
+ interface Props {
6
+ lesson: PublicLesson;
7
+ }
8
+ const { lesson } = Astro.props;
9
+ ---
10
+
11
+ {lesson.mediaUrl && ["video", "audio", "live"].includes(lesson.kind) && (
12
+ <MediaPlayer
13
+ url={lesson.mediaUrl}
14
+ kind={lesson.kind as "video" | "audio" | "live"}
15
+ title={lesson.title}
16
+ startsAt={lesson.liveStartsAt}
17
+ captions={lesson.captions ?? []}
18
+ />
19
+ )}
20
+ {lesson.transcript && (
21
+ <details class="lms-resources" data-lms-transcript>
22
+ <summary>Read transcript</summary>
23
+ <div class="lms-actions">
24
+ <label>
25
+ Search transcript <input type="search" maxlength={200} data-lms-transcript-search />
26
+ </label>
27
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-transcript-previous disabled>
28
+ Previous match
29
+ </button>
30
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-transcript-next disabled>
31
+ Next match
32
+ </button>
33
+ </div>
34
+ <p data-lms-transcript-status aria-live="polite"></p>
35
+ <p class="lms-text" data-lms-transcript-text>
36
+ {lesson.transcript}
37
+ </p>
38
+ </details>
39
+ )}
40
+ <div class="lms-lesson-body">
41
+ {lesson.portableText.length > 0 ? (
42
+ <PortableText value={lesson.portableText as TypedObject[]} />
43
+ ) : (
44
+ lesson.body
45
+ .split(/\n\s*\n/)
46
+ .filter(Boolean)
47
+ .map((paragraph) => <p class="lms-text">{paragraph}</p>)
48
+ )}
49
+ </div>
50
+ {lesson.resources.length > 0 && (
51
+ <section class="lms-resources">
52
+ <h2>Keep exploring</h2>
53
+ <ul>
54
+ {lesson.resources.map((resource) => (
55
+ <li>
56
+ <a href={resource.url} target="_blank" rel="noopener noreferrer">
57
+ <span aria-hidden="true">↗</span>
58
+ <span>
59
+ {resource.title}
60
+ {resource.description && <small>{resource.description}</small>}
61
+ </span>
62
+ </a>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </section>
67
+ )}
@@ -1,13 +1,15 @@
1
1
  ---
2
- import { PortableText, type TypedObject } from "emdash/ui";
3
- import MediaPlayer from "./MediaPlayer.astro";
2
+ import LessonContent from "./LessonContent.astro";
3
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
4
+ import AssignmentRubric from "./AssignmentRubric.astro";
5
+ import AssignmentFeedback from "./AssignmentFeedback.astro";
4
6
  import type {
5
7
  CourseDetail,
6
8
  PublicLesson,
7
9
  PublicEnrollment,
8
10
  ProgressSummary,
9
11
  } from "@anantagnanam/lms/types";
10
- import { courseUrl, lessonUrl, certificateUrl } from "@anantagnanam/lms/server";
12
+ import { courseUrl, lessonUrl, certificateUrl, assignmentBlock } from "@anantagnanam/lms/server";
11
13
  import { basePath } from "virtual:anantagnanam-lms/config";
12
14
  interface Props {
13
15
  course: CourseDetail;
@@ -19,14 +21,24 @@ interface Props {
19
21
  const { course, lesson, enrollment = null, progress, preview = false } = Astro.props;
20
22
  const all = course.modules.flatMap((module) => module.lessons);
21
23
  const index = all.findIndex((item) => item.id === lesson.id);
22
- const nextLesson = all[index + 1];
23
- const nextUrl = nextLesson
24
- ? lessonUrl(basePath, course.slug, nextLesson.id)
25
- : `${basePath}/my-learning`;
24
+ const available = (id: string) =>
25
+ preview
26
+ ? Boolean(all.find((item) => item.id === id)?.preview)
27
+ : progress?.availability[id]?.available === true;
28
+ const previous = all.slice(0, index).findLast((item) => available(item.id));
29
+ const nextLesson = all.slice(index + 1).find((item) => available(item.id));
26
30
  const current = enrollment?.progress[lesson.id];
27
31
  const complete = Boolean(current?.completedAt);
28
32
  const attempt = current?.quizAttempts.at(-1);
29
33
  const assignment = current?.assignments.at(-1);
34
+ const draft = current?.assignmentDraft;
35
+ const assignmentClosed = lesson.assignment
36
+ ? assignmentBlock(
37
+ lesson.assignment,
38
+ current ?? { completedAt: null, assignments: [] },
39
+ new Date(),
40
+ )
41
+ : null;
30
42
  ---
31
43
 
32
44
  <nav class="lms-breadcrumbs" aria-label="Breadcrumb">
@@ -42,6 +54,7 @@ const assignment = current?.assignments.at(-1);
42
54
  data-course-id={course.id}
43
55
  data-lesson-id={lesson.id}
44
56
  data-preview={preview ? "true" : "false"}
57
+ data-playback={current?.playback ? JSON.stringify(current.playback) : undefined}
45
58
  >
46
59
  <aside class="lms-course-sidebar">
47
60
  <a href={courseUrl(basePath, course.slug)} class="lms-sidebar-course">
@@ -64,20 +77,28 @@ const assignment = current?.assignments.at(-1);
64
77
  <ol>
65
78
  {module.lessons.map((entry) => (
66
79
  <li>
67
- <a
68
- href={lessonUrl(basePath, course.slug, entry.id)}
69
- aria-current={entry.id === lesson.id ? "page" : undefined}
70
- >
71
- <span aria-hidden="true">
72
- {enrollment?.progress[entry.id]?.completedAt ? "✓" : "○"}
73
- </span>
74
- <span>
75
- {entry.title}
76
- <small>
77
- {entry.kind} · {entry.durationMinutes} min
78
- </small>
79
- </span>
80
- </a>
80
+ {available(entry.id) ? (
81
+ <a
82
+ href={lessonUrl(basePath, course.slug, entry.id)}
83
+ aria-current={entry.id === lesson.id ? "page" : undefined}
84
+ >
85
+ <span aria-hidden="true">
86
+ {enrollment?.progress[entry.id]?.completedAt ? "✓" : "○"}
87
+ </span>
88
+ <span>
89
+ {entry.title}
90
+ <small>
91
+ {entry.kind} · {entry.durationMinutes} min
92
+ </small>
93
+ </span>
94
+ </a>
95
+ ) : (
96
+ <div class="lms-locked-lesson">
97
+ <span>{entry.title}</span>
98
+ <LessonAvailabilityNote availability={progress?.availability[entry.id]} />
99
+ {preview && <small>Enroll to access this lesson.</small>}
100
+ </div>
101
+ )}
81
102
  </li>
82
103
  ))}
83
104
  </ol>
@@ -99,42 +120,7 @@ const assignment = current?.assignments.at(-1);
99
120
  <a href={courseUrl(basePath, course.slug)}>Enroll to save your progress →</a>
100
121
  </p>
101
122
  )}
102
- {lesson.mediaUrl && ["video", "audio", "live"].includes(lesson.kind) && (
103
- <MediaPlayer
104
- url={lesson.mediaUrl}
105
- kind={lesson.kind as "video" | "audio" | "live"}
106
- title={lesson.title}
107
- startsAt={lesson.liveStartsAt}
108
- />
109
- )}
110
- <div class="lms-lesson-body">
111
- {lesson.portableText.length > 0 ? (
112
- <PortableText value={lesson.portableText as TypedObject[]} />
113
- ) : (
114
- lesson.body
115
- .split(/\n\s*\n/)
116
- .filter(Boolean)
117
- .map((paragraph) => <p class="lms-text">{paragraph}</p>)
118
- )}
119
- </div>
120
- {lesson.resources.length > 0 && (
121
- <section class="lms-resources">
122
- <h2>Keep exploring</h2>
123
- <ul>
124
- {lesson.resources.map((resource) => (
125
- <li>
126
- <a href={resource.url} target="_blank" rel="noopener noreferrer">
127
- <span aria-hidden="true">↗</span>
128
- <span>
129
- {resource.title}
130
- {resource.description && <small>{resource.description}</small>}
131
- </span>
132
- </a>
133
- </li>
134
- ))}
135
- </ul>
136
- </section>
137
- )}
123
+ <LessonContent lesson={lesson} />
138
124
  {!preview && lesson.kind === "quiz" && lesson.quiz && (
139
125
  <section class="lms-assessment">
140
126
  <p class="lms-eyebrow">CHECK YOUR UNDERSTANDING</p>
@@ -153,7 +139,7 @@ const assignment = current?.assignments.at(-1);
153
139
  data-lms-quiz
154
140
  data-course-id={course.id}
155
141
  data-lesson-id={lesson.id}
156
- data-next-url={nextUrl}
142
+ data-course-url={courseUrl(basePath, course.slug)}
157
143
  >
158
144
  <button class="lms-btn" type="button" data-lms-quiz-start>
159
145
  {attempt?.status === "in_progress" ? "Resume quiz" : "Start quiz"}
@@ -192,45 +178,90 @@ const assignment = current?.assignments.at(-1);
192
178
  ? ` · Due ${new Date(lesson.assignment.dueAt).toLocaleString("en-IN")}`
193
179
  : ""}
194
180
  </p>
195
- {assignment && (
196
- <div class="lms-notice">
197
- <strong>
198
- {assignment.status === "submitted"
199
- ? "Submitted · awaiting feedback"
200
- : `Graded · ${assignment.points}/${assignment.maxPoints}`}
201
- </strong>
202
- <p class="lms-text">{assignment.feedback}</p>
203
- <details>
204
- <summary>Your submission</summary>
205
- <p class="lms-text">{assignment.body}</p>
206
- {assignment.attachmentUrl && (
207
- <a href={assignment.attachmentUrl} target="_blank" rel="noopener noreferrer">
208
- Open attachment ↗
209
- </a>
210
- )}
211
- </details>
212
- </div>
181
+ <AssignmentRubric rubric={lesson.assignment.rubric} />
182
+ {assignment && <AssignmentFeedback submission={assignment} />}
183
+ {(current?.assignments.length ?? 0) > 1 && (
184
+ <details>
185
+ <summary>Earlier submissions and feedback ({current!.assignments.length - 1})</summary>
186
+ {current!.assignments
187
+ .slice(0, -1)
188
+ .toReversed()
189
+ .map((item) => (
190
+ <AssignmentFeedback submission={item} />
191
+ ))}
192
+ </details>
213
193
  )}
214
- {!complete &&
215
- (!assignment ||
216
- (assignment.status === "graded" && lesson.assignment.allowResubmission)) && (
217
- <form class="lms-form" data-lms-route="learner/assignment" data-lms-reload>
218
- <input type="hidden" name="courseId" value={course.id} />
219
- <input type="hidden" name="lessonId" value={lesson.id} />
220
- <label>
221
- <span>Your work</span>
222
- <textarea name="body" required rows={8} maxlength={20_000}></textarea>
223
- </label>
224
- <label>
225
- <span>Attachment link (optional)</span>
226
- <input name="attachmentUrl" type="url" placeholder="https://…" />
227
- </label>
228
- <button class="lms-btn" type="submit">
229
- Submit assignment
194
+ {assignmentClosed ? (
195
+ <>
196
+ <p class="lms-notice">{assignmentClosed.message}</p>
197
+ {(draft?.body || draft?.attachmentUrl) && (
198
+ <details open>
199
+ <summary>Your saved draft (not submitted)</summary>
200
+ <p class="lms-text">{draft.body}</p>
201
+ {draft.attachmentUrl && (
202
+ <a href={draft.attachmentUrl} target="_blank" rel="noopener noreferrer">
203
+ Open draft attachment ↗
204
+ </a>
205
+ )}
206
+ </details>
207
+ )}
208
+ </>
209
+ ) : (
210
+ <form
211
+ class="lms-form"
212
+ data-lms-assignment
213
+ data-course-id={course.id}
214
+ data-lesson-id={lesson.id}
215
+ data-draft-revision={draft?.revision ?? 0}
216
+ >
217
+ <p>Drafts save automatically. Only you can see your draft until you submit it.</p>
218
+ <label>
219
+ <span>Your work</span>
220
+ <textarea
221
+ name="body"
222
+ required
223
+ rows={8}
224
+ maxlength={20_000}
225
+ >{draft?.body ?? ""}</textarea>
226
+ </label>
227
+ <label>
228
+ <span>Attachment link (optional)</span>
229
+ <input
230
+ name="attachmentUrl"
231
+ type="url"
232
+ placeholder="https://…"
233
+ value={draft?.attachmentUrl ?? ""}
234
+ />
235
+ </label>
236
+ <button class="lms-btn lms-btn-outline" type="button" data-lms-save-draft>
237
+ Save draft
238
+ </button>
239
+ <button class="lms-btn" type="submit">
240
+ Submit assignment
241
+ </button>
242
+ <p data-lms-feedback aria-live="polite">
243
+ {draft?.updatedAt ? "Saved draft restored." : "Your draft is empty."}
244
+ </p>
245
+ <button class="lms-btn lms-btn-outline" type="button" data-lms-review-draft hidden>
246
+ Review saved work
247
+ </button>
248
+ <section
249
+ class="lms-notice"
250
+ data-lms-assignment-conflict
251
+ hidden
252
+ aria-label="Review saved work"
253
+ >
254
+ <h3>Saved work</h3>
255
+ <p class="lms-text" data-lms-saved-draft></p>
256
+ <button class="lms-btn lms-btn-outline" type="button" data-lms-use-saved>
257
+ Use saved draft
230
258
  </button>
231
- <p data-lms-feedback aria-live="polite"></p>
232
- </form>
233
- )}
259
+ <button class="lms-btn lms-btn-outline" type="button" data-lms-keep-local>
260
+ Keep my local draft
261
+ </button>
262
+ </section>
263
+ </form>
264
+ )}
234
265
  </section>
235
266
  )}
236
267
  {!preview && !["quiz", "assignment"].includes(lesson.kind) && (
@@ -268,21 +299,25 @@ const assignment = current?.assignments.at(-1);
268
299
  </div>
269
300
  )}
270
301
  <nav class="lms-lesson-navigation" aria-label="Lesson navigation">
271
- {index > 0 ? (
272
- <a href={lessonUrl(basePath, course.slug, all[index - 1]!.id)}>← Previous lesson</a>
302
+ {previous ? (
303
+ <a href={lessonUrl(basePath, course.slug, previous.id)}>← Previous available lesson</a>
273
304
  ) : (
274
305
  <a href={courseUrl(basePath, course.slug)}>← Course overview</a>
275
306
  )}
276
- {index + 1 < all.length && (
277
- <a href={lessonUrl(basePath, course.slug, all[index + 1]!.id)}>Next lesson →</a>
307
+ {nextLesson && (
308
+ <a href={lessonUrl(basePath, course.slug, nextLesson.id)}>Next available lesson →</a>
278
309
  )}
279
310
  </nav>
311
+ {!nextLesson && progress?.blockedLessonId && (
312
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
313
+ )}
280
314
  {!preview && (
281
315
  <>
282
316
  <section class="lms-content-section">
283
317
  <h2>A note to your future self</h2>
284
318
  <p class="lms-muted">Only you can see these notes.</p>
285
319
  <form class="lms-form" data-lms-route="learner/note">
320
+ <input type="hidden" name="expectedRevision" value={current?.noteRevision ?? 0} />
286
321
  <input type="hidden" name="courseId" value={course.id} />
287
322
  <input type="hidden" name="lessonId" value={lesson.id} />
288
323
  <label>
@@ -294,6 +329,38 @@ const assignment = current?.assignments.at(-1);
294
329
  </button>
295
330
  <p data-lms-feedback aria-live="polite"></p>
296
331
  </form>
332
+ <p>
333
+ <a href={`${basePath}/my-learning/study?courseId=${encodeURIComponent(course.id)}`}>
334
+ Open course notebook &amp; agenda →
335
+ </a>
336
+ </p>
337
+ <section data-lms-quick-note data-course-id={course.id} data-lesson-id={lesson.id}>
338
+ <h3>Notes &amp; bookmarks</h3>
339
+ <p>
340
+ Save a separate thought or bookmark the current moment in native audio/video. Only you
341
+ can see it.
342
+ </p>
343
+ <form class="lms-form">
344
+ <label>
345
+ <span>New note</span>
346
+ <textarea name="body" rows={3} maxlength={5000}></textarea>
347
+ </label>
348
+ <label>
349
+ <input type="checkbox" name="starred" /> Star this note
350
+ </label>
351
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-capture-time hidden>
352
+ Use current playback time
353
+ </button>
354
+ <p data-lms-note-time></p>
355
+ <button type="button" class="lms-text-button" data-lms-clear-time hidden>
356
+ Remove timestamp
357
+ </button>
358
+ <button type="submit" class="lms-btn lms-btn-outline">
359
+ Add to notebook
360
+ </button>
361
+ <p data-lms-feedback aria-live="polite"></p>
362
+ </form>
363
+ </section>
297
364
  </section>
298
365
  <section
299
366
  class="lms-content-section"