@anantagnanam/lms 0.1.0 → 0.2.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 (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +23 -2
  3. package/astro/client.ts +73 -151
  4. package/astro/components/CourseOverview.astro +25 -3
  5. package/astro/components/LearnerDashboard.astro +6 -0
  6. package/astro/components/LearningShell.astro +10 -1
  7. package/astro/components/LessonAvailabilityNote.astro +26 -0
  8. package/astro/components/LessonContent.astro +53 -0
  9. package/astro/components/LessonPlayer.astro +40 -61
  10. package/astro/components/MediaPlayer.astro +55 -4
  11. package/astro/media-player.ts +99 -0
  12. package/astro/pages/author-preview.astro +71 -0
  13. package/astro/pages/course.astro +10 -0
  14. package/astro/pages/lesson.astro +3 -0
  15. package/astro/pages/library.astro +173 -0
  16. package/astro/pages/paths.astro +73 -0
  17. package/astro/quiz-drafts.ts +87 -0
  18. package/astro/quiz-player.ts +362 -0
  19. package/astro/styles.css +12 -0
  20. package/dist/admin.d.ts +3 -1
  21. package/dist/admin.js +585 -116
  22. package/dist/admin.js.map +1 -1
  23. package/dist/astro.js +3 -0
  24. package/dist/astro.js.map +1 -1
  25. package/dist/{constants-C0kT94tc.js → constants-Cnx7k82Z.js} +25 -3
  26. package/dist/constants-Cnx7k82Z.js.map +1 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/runtime.d.ts +104 -12
  30. package/dist/runtime.js +896 -116
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/schemas.d.ts +68 -1
  33. package/dist/schemas.js +34 -2
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/server.d.ts +11 -1
  36. package/dist/server.js +13 -2
  37. package/dist/server.js.map +1 -1
  38. package/dist/types.d.ts +107 -4
  39. package/package.json +1 -1
  40. package/dist/constants-C0kT94tc.js.map +0 -1
@@ -1,6 +1,6 @@
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
4
  import type {
5
5
  CourseDetail,
6
6
  PublicLesson,
@@ -19,10 +19,12 @@ interface Props {
19
19
  const { course, lesson, enrollment = null, progress, preview = false } = Astro.props;
20
20
  const all = course.modules.flatMap((module) => module.lessons);
21
21
  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`;
22
+ const available = (id: string) =>
23
+ preview
24
+ ? Boolean(all.find((item) => item.id === id)?.preview)
25
+ : progress?.availability[id]?.available === true;
26
+ const previous = all.slice(0, index).findLast((item) => available(item.id));
27
+ const nextLesson = all.slice(index + 1).find((item) => available(item.id));
26
28
  const current = enrollment?.progress[lesson.id];
27
29
  const complete = Boolean(current?.completedAt);
28
30
  const attempt = current?.quizAttempts.at(-1);
@@ -42,6 +44,7 @@ const assignment = current?.assignments.at(-1);
42
44
  data-course-id={course.id}
43
45
  data-lesson-id={lesson.id}
44
46
  data-preview={preview ? "true" : "false"}
47
+ data-playback={current?.playback ? JSON.stringify(current.playback) : undefined}
45
48
  >
46
49
  <aside class="lms-course-sidebar">
47
50
  <a href={courseUrl(basePath, course.slug)} class="lms-sidebar-course">
@@ -64,20 +67,28 @@ const assignment = current?.assignments.at(-1);
64
67
  <ol>
65
68
  {module.lessons.map((entry) => (
66
69
  <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>
70
+ {available(entry.id) ? (
71
+ <a
72
+ href={lessonUrl(basePath, course.slug, entry.id)}
73
+ aria-current={entry.id === lesson.id ? "page" : undefined}
74
+ >
75
+ <span aria-hidden="true">
76
+ {enrollment?.progress[entry.id]?.completedAt ? "✓" : "○"}
77
+ </span>
78
+ <span>
79
+ {entry.title}
80
+ <small>
81
+ {entry.kind} · {entry.durationMinutes} min
82
+ </small>
83
+ </span>
84
+ </a>
85
+ ) : (
86
+ <div class="lms-locked-lesson">
87
+ <span>{entry.title}</span>
88
+ <LessonAvailabilityNote availability={progress?.availability[entry.id]} />
89
+ {preview && <small>Enroll to access this lesson.</small>}
90
+ </div>
91
+ )}
81
92
  </li>
82
93
  ))}
83
94
  </ol>
@@ -99,42 +110,7 @@ const assignment = current?.assignments.at(-1);
99
110
  <a href={courseUrl(basePath, course.slug)}>Enroll to save your progress →</a>
100
111
  </p>
101
112
  )}
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
- )}
113
+ <LessonContent lesson={lesson} />
138
114
  {!preview && lesson.kind === "quiz" && lesson.quiz && (
139
115
  <section class="lms-assessment">
140
116
  <p class="lms-eyebrow">CHECK YOUR UNDERSTANDING</p>
@@ -153,7 +129,7 @@ const assignment = current?.assignments.at(-1);
153
129
  data-lms-quiz
154
130
  data-course-id={course.id}
155
131
  data-lesson-id={lesson.id}
156
- data-next-url={nextUrl}
132
+ data-course-url={courseUrl(basePath, course.slug)}
157
133
  >
158
134
  <button class="lms-btn" type="button" data-lms-quiz-start>
159
135
  {attempt?.status === "in_progress" ? "Resume quiz" : "Start quiz"}
@@ -268,15 +244,18 @@ const assignment = current?.assignments.at(-1);
268
244
  </div>
269
245
  )}
270
246
  <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>
247
+ {previous ? (
248
+ <a href={lessonUrl(basePath, course.slug, previous.id)}>← Previous available lesson</a>
273
249
  ) : (
274
250
  <a href={courseUrl(basePath, course.slug)}>← Course overview</a>
275
251
  )}
276
- {index + 1 < all.length && (
277
- <a href={lessonUrl(basePath, course.slug, all[index + 1]!.id)}>Next lesson →</a>
252
+ {nextLesson && (
253
+ <a href={lessonUrl(basePath, course.slug, nextLesson.id)}>Next available lesson →</a>
278
254
  )}
279
255
  </nav>
256
+ {!nextLesson && progress?.blockedLessonId && (
257
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
258
+ )}
280
259
  {!preview && (
281
260
  <>
282
261
  <section class="lms-content-section">
@@ -4,8 +4,9 @@ interface Props {
4
4
  kind: "video" | "audio" | "live";
5
5
  title: string;
6
6
  startsAt?: string | null;
7
+ captions?: { src: string; language: string; label: string }[];
7
8
  }
8
- const { url, kind, title, startsAt } = Astro.props;
9
+ const { url, kind, title, startsAt, captions = [] } = Astro.props;
9
10
  let embed: string | null = null;
10
11
  try {
11
12
  const parsed = new URL(url, Astro.url.origin);
@@ -24,7 +25,7 @@ try {
24
25
  } catch {}
25
26
  ---
26
27
 
27
- <div class="lms-media">
28
+ <div class="lms-media" data-lms-media>
28
29
  {kind === "live" ? (
29
30
  <div class="lms-live-card">
30
31
  <p class="lms-eyebrow">LIVE LEARNING SESSION</p>
@@ -40,7 +41,23 @@ try {
40
41
  </a>
41
42
  </div>
42
43
  ) : kind === "audio" ? (
43
- <audio src={url} controls preload="metadata" aria-label={title}></audio>
44
+ <audio
45
+ src={url}
46
+ controls
47
+ preload="metadata"
48
+ aria-label={title}
49
+ crossorigin={captions.length ? "anonymous" : undefined}
50
+ >
51
+ {captions.map((track, index) => (
52
+ <track
53
+ kind="captions"
54
+ src={track.src}
55
+ srclang={track.language}
56
+ label={track.label}
57
+ default={index === 0}
58
+ />
59
+ ))}
60
+ </audio>
44
61
  ) : embed ? (
45
62
  <iframe
46
63
  src={embed}
@@ -50,7 +67,41 @@ try {
50
67
  referrerpolicy="strict-origin-when-cross-origin"
51
68
  ></iframe>
52
69
  ) : (
53
- <video src={url} controls preload="metadata" aria-label={title}></video>
70
+ <video
71
+ src={url}
72
+ controls
73
+ preload="metadata"
74
+ aria-label={title}
75
+ crossorigin={captions.length ? "anonymous" : undefined}
76
+ >
77
+ {captions.map((track, index) => (
78
+ <track
79
+ kind="captions"
80
+ src={track.src}
81
+ srclang={track.language}
82
+ label={track.label}
83
+ default={index === 0}
84
+ />
85
+ ))}
86
+ </video>
87
+ )}
88
+ {kind !== "live" && !embed && (
89
+ <div class="lms-actions">
90
+ <label>
91
+ Playback speed{" "}
92
+ <select data-lms-speed aria-label="Playback speed">
93
+ {[0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].map((rate) => (
94
+ <option value={rate} selected={rate === 1}>
95
+ {rate}×
96
+ </option>
97
+ ))}
98
+ </select>
99
+ </label>
100
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-playback-save hidden>
101
+ Save position
102
+ </button>
103
+ <span data-lms-feedback aria-live="polite"></span>
104
+ </div>
54
105
  )}
55
106
  {kind !== "live" && (
56
107
  <a class="lms-media-link" href={url} target="_blank" rel="noopener noreferrer">
@@ -0,0 +1,99 @@
1
+ import type { Playback } from "@anantagnanam/lms/types";
2
+
3
+ type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
4
+
5
+ /** Provider iframes own their controls. Only native media participates in resume. */
6
+ export function initializeMedia(call: Call) {
7
+ for (const root of document.querySelectorAll<HTMLElement>(
8
+ "[data-lms-media]:not([data-lms-bound])",
9
+ )) {
10
+ root.dataset.lmsBound = "true";
11
+ const media = root.querySelector<HTMLMediaElement>("video, audio");
12
+ if (!media) continue;
13
+ const lesson = root.closest<HTMLElement>("[data-lms-lesson]");
14
+ const speed = root.querySelector<HTMLSelectElement>("[data-lms-speed]")!;
15
+ const button = root.querySelector<HTMLButtonElement>("[data-lms-playback-save]")!;
16
+ const status = root.querySelector<HTMLElement>("[data-lms-feedback]")!;
17
+ const saved: Playback | undefined = lesson?.dataset.playback
18
+ ? JSON.parse(lesson.dataset.playback)
19
+ : undefined;
20
+ let revision = saved?.revision ?? 0;
21
+ let ready = false;
22
+ let busy = false;
23
+ let pending = false;
24
+ let failed = false;
25
+ let lastSave = Date.now();
26
+ const enabled = Boolean(lesson && lesson.dataset.preview !== "true");
27
+ button.hidden = !enabled;
28
+ const restore = () => {
29
+ if (ready) return;
30
+ ready = true;
31
+ if (saved) {
32
+ const position = Number.isFinite(media.duration)
33
+ ? Math.min(saved.positionSeconds, Math.max(0, media.duration - 0.1))
34
+ : saved.positionSeconds;
35
+ try {
36
+ media.currentTime = position;
37
+ media.playbackRate = saved.playbackRate;
38
+ } catch {
39
+ /* The browser may not have a seekable range yet. */
40
+ }
41
+ speed.value = String(media.playbackRate);
42
+ status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
43
+ }
44
+ };
45
+ const save = async () => {
46
+ if (!enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
47
+ if (busy) {
48
+ pending = true;
49
+ return;
50
+ }
51
+ busy = true;
52
+ button.disabled = true;
53
+ pending = false;
54
+ status.textContent = "Saving playback position…";
55
+ try {
56
+ const result = await call<Playback>("learner/playback", {
57
+ courseId: lesson!.dataset.courseId,
58
+ lessonId: lesson!.dataset.lessonId,
59
+ positionSeconds: Math.min(media.currentTime, 86_400),
60
+ playbackRate: media.playbackRate,
61
+ expectedRevision: revision,
62
+ });
63
+ revision = result.revision;
64
+ lastSave = Date.now();
65
+ status.textContent = "Playback position saved.";
66
+ } catch (error) {
67
+ // A lost response can follow a successful write. Stop instead of overwriting another tab.
68
+ failed = true;
69
+ status.textContent = `${error instanceof Error ? error.message : "Could not save playback."} Reload this lesson before saving again.`;
70
+ status.setAttribute("role", "alert");
71
+ } finally {
72
+ busy = false;
73
+ button.disabled = failed;
74
+ if (pending && !failed) void save();
75
+ }
76
+ };
77
+ media.addEventListener("loadedmetadata", restore);
78
+ if (media.readyState >= 1) restore();
79
+ speed.addEventListener("change", () => {
80
+ media.playbackRate = Number(speed.value);
81
+ });
82
+ media.addEventListener("ratechange", () => {
83
+ speed.value = String(media.playbackRate);
84
+ void save();
85
+ });
86
+ media.addEventListener("timeupdate", () => {
87
+ if (!busy && Date.now() - lastSave >= 15_000) void save();
88
+ });
89
+ media.addEventListener("pause", () => {
90
+ void save();
91
+ });
92
+ media.addEventListener("ended", () => {
93
+ void save();
94
+ });
95
+ button.addEventListener("click", () => {
96
+ void save();
97
+ });
98
+ }
99
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ import Layout from "virtual:anantagnanam-lms/layout";
3
+ import LearningShell from "../components/LearningShell.astro";
4
+ import LessonContent from "../components/LessonContent.astro";
5
+ import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
6
+ import { loginPath } from "virtual:anantagnanam-lms/config";
7
+ export const prerender = false;
8
+ Astro.response.headers.set("Cache-Control", "private, no-store");
9
+ Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
10
+ let result: Awaited<ReturnType<typeof lmsAuthorLessonPreview>> | undefined;
11
+ let error = "";
12
+ try {
13
+ result = await lmsAuthorLessonPreview(
14
+ Astro.locals,
15
+ { courseId: Astro.params.courseId!, lessonId: Astro.params.lessonId! },
16
+ Astro.url.origin,
17
+ );
18
+ } catch (cause) {
19
+ Astro.response.status = cause instanceof LmsError ? cause.status : 503;
20
+ error = cause instanceof Error ? cause.message : "Preview is unavailable.";
21
+ }
22
+ ---
23
+
24
+ <Layout
25
+ title={result ? `Preview: ${result.lesson.title}` : "Lesson preview"}
26
+ robots="noindex, nofollow"
27
+ >
28
+ <LearningShell>
29
+ {result ? (
30
+ <article class="lms-lesson">
31
+ <p class="lms-notice">
32
+ Saved author preview · {result.course.title}. Save changes in the course editor, then
33
+ refresh this page. Linked content uses its published version.
34
+ </p>
35
+ <h1>{result.lesson.title}</h1>
36
+ {result.lesson.summary && <p class="lms-lead">{result.lesson.summary}</p>}
37
+ <LessonContent lesson={result.lesson} />
38
+ {result.lesson.quiz && (
39
+ <section class="lms-assessment">
40
+ <h2>Lesson quiz</h2>
41
+ <p>
42
+ {result.lesson.quiz.questionCount} questions · Pass with{" "}
43
+ {result.lesson.quiz.passingPercent}% · {result.lesson.quiz.maxAttempts} attempts
44
+ </p>
45
+ <p>Assessment summary only. This preview does not create an attempt.</p>
46
+ </section>
47
+ )}
48
+ {result.lesson.assignment && (
49
+ <section class="lms-assessment">
50
+ <h2>Your assignment</h2>
51
+ <p class="lms-text">{result.lesson.assignment.instructions}</p>
52
+ <p>
53
+ {result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
54
+ to pass
55
+ </p>
56
+ </section>
57
+ )}
58
+ </article>
59
+ ) : (
60
+ <section class="lms-gate">
61
+ <h1>Lesson preview</h1>
62
+ <p>{error}</p>
63
+ {!Astro.locals.user && (
64
+ <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
65
+ Sign in →
66
+ </a>
67
+ )}
68
+ </section>
69
+ )}
70
+ </LearningShell>
71
+ </Layout>
@@ -60,6 +60,16 @@ try {
60
60
  robots={course ? undefined : "noindex, follow"}
61
61
  >
62
62
  <LearningShell>
63
+ {course && Astro.locals.user && (
64
+ <form class="lms-actions" data-lms-route="learner/saved/save">
65
+ <input type="hidden" name="courseId" value={course.id} />
66
+ <input type="hidden" name="saved" value="true" />
67
+ <button class="lms-btn lms-btn-outline" type="submit">
68
+ Save course to my library
69
+ </button>
70
+ <p data-lms-feedback aria-live="polite"></p>
71
+ </form>
72
+ )}
63
73
  {course ? (
64
74
  <CourseOverview
65
75
  course={course}
@@ -2,6 +2,7 @@
2
2
  import Layout from "virtual:anantagnanam-lms/layout";
3
3
  import LearningShell from "../components/LearningShell.astro";
4
4
  import LessonPlayer from "../components/LessonPlayer.astro";
5
+ import LessonAvailabilityNote from "../components/LessonAvailabilityNote.astro";
5
6
  import { lmsPublic, lmsLearner, courseUrl, loginUrl, LmsError } from "@anantagnanam/lms/server";
6
7
  import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
7
8
  import type {
@@ -27,6 +28,7 @@ try {
27
28
  }>(Astro.locals, "learner/course-by-slug", { slug: Astro.params.slug }, Astro.url.origin);
28
29
  course = result.course;
29
30
  enrollment = result.enrollment;
31
+ progress = result.progress;
30
32
  } else
31
33
  course = await lmsPublic<CourseDetail>(
32
34
  Astro.locals,
@@ -84,6 +86,7 @@ try {
84
86
  <p class="lms-eyebrow">ONE STEP AT A TIME</p>
85
87
  <h1>Continue your learning</h1>
86
88
  <p>{error}</p>
89
+ <LessonAvailabilityNote availability={progress?.availability[Astro.params.lessonId!]} />
87
90
  {!Astro.locals.user && (
88
91
  <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
89
92
  Sign in →
@@ -0,0 +1,173 @@
1
+ ---
2
+ import Layout from "virtual:anantagnanam-lms/layout";
3
+ import LearningShell from "../components/LearningShell.astro";
4
+ import { lmsLearner, loginUrl, courseUrl, LmsError } from "@anantagnanam/lms/server";
5
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
6
+ import type { Page, CourseCard, LearnerPath, LearnerPreferences } from "@anantagnanam/lms/types";
7
+ export const prerender = false;
8
+ Astro.response.headers.set("Cache-Control", "private, no-store");
9
+ if (!Astro.locals.user)
10
+ return Astro.redirect(loginUrl(loginPath, Astro.url.pathname + Astro.url.search));
11
+ let saved: Page<{ courseId: string; course: CourseCard | null }> = { items: [], hasMore: false };
12
+ let paths: Page<LearnerPath> = { items: [], hasMore: false };
13
+ let preferences: { value: LearnerPreferences; revision: string | null } | undefined;
14
+ let error = "";
15
+ try {
16
+ [saved, paths, preferences] = await Promise.all([
17
+ lmsLearner<typeof saved>(
18
+ Astro.locals,
19
+ "learner/saved",
20
+ { cursor: Astro.url.searchParams.get("saved") ?? undefined },
21
+ Astro.url.origin,
22
+ ),
23
+ lmsLearner<typeof paths>(
24
+ Astro.locals,
25
+ "learner/paths",
26
+ { cursor: Astro.url.searchParams.get("paths") ?? undefined },
27
+ Astro.url.origin,
28
+ ),
29
+ lmsLearner<NonNullable<typeof preferences>>(
30
+ Astro.locals,
31
+ "learner/preferences",
32
+ {},
33
+ Astro.url.origin,
34
+ ),
35
+ ]);
36
+ } catch (cause) {
37
+ Astro.response.status = cause instanceof LmsError ? cause.status : 503;
38
+ error = cause instanceof Error ? cause.message : "Your library is unavailable.";
39
+ }
40
+ const more = (key: string, cursor: string) => {
41
+ const url = new URL(Astro.url);
42
+ url.searchParams.set(key, cursor);
43
+ return url.href;
44
+ };
45
+ ---
46
+
47
+ <Layout title="My library and preferences" robots="noindex, follow">
48
+ <LearningShell>
49
+ <h1>My library &amp; preferences</h1>
50
+ {error && <p role="alert">{error}</p>}
51
+ <section>
52
+ <h2>Saved courses</h2>
53
+ {!error && !saved.items.length && (
54
+ <p>Save a course from its overview to find it here later.</p>
55
+ )}
56
+ {saved.items.map(({ courseId, course }) => (
57
+ <article class="lms-learning-card">
58
+ <h3>
59
+ {course ? (
60
+ <a href={courseUrl(basePath, course.slug)}>{course.title}</a>
61
+ ) : (
62
+ "Course currently unavailable"
63
+ )}
64
+ </h3>
65
+ <form data-lms-route="learner/saved/save" data-lms-reload>
66
+ <input type="hidden" name="courseId" value={courseId} />
67
+ <input type="hidden" name="saved" value="false" />
68
+ <button class="lms-text-button" type="submit">
69
+ Remove from saved courses
70
+ </button>
71
+ <p data-lms-feedback aria-live="polite"></p>
72
+ </form>
73
+ </article>
74
+ ))}
75
+ {saved.hasMore && saved.cursor && (
76
+ <a href={more("saved", saved.cursor)}>More saved courses →</a>
77
+ )}
78
+ </section>
79
+ <section>
80
+ <h2>My learning paths</h2>
81
+ <p>
82
+ Your joined paths keep their original course lists when administrators edit the program.
83
+ </p>
84
+ {!error && !paths.items.length && <a href={`${basePath}/paths`}>Explore learning paths</a>}
85
+ {paths.items.map((path) => (
86
+ <article class="lms-learning-card">
87
+ <h3>{path.title}</h3>
88
+ <p>
89
+ {path.completed} of {path.total} courses completed
90
+ </p>
91
+ <progress value={path.completed} max={path.total} aria-label={`${path.title} progress`}>
92
+ {path.completed}/{path.total}
93
+ </progress>
94
+ <ol>
95
+ {path.courses.map(({ course, completed }) => (
96
+ <li>
97
+ {course ? (
98
+ <a href={courseUrl(basePath, course.slug)}>{course.title}</a>
99
+ ) : (
100
+ "Course currently unavailable"
101
+ )}
102
+ {completed && " · Completed"}
103
+ </li>
104
+ ))}
105
+ </ol>
106
+ </article>
107
+ ))}
108
+ {paths.hasMore && paths.cursor && (
109
+ <a href={more("paths", paths.cursor)}>More joined paths →</a>
110
+ )}
111
+ </section>
112
+ {preferences && (
113
+ <section class="lms-learning-card">
114
+ <h2>Learning preferences</h2>
115
+ <form class="lms-form" data-lms-route="learner/preferences/save" data-lms-reload>
116
+ <input type="hidden" name="expectedRevision" value={preferences.revision ?? ""} />
117
+ <label>
118
+ <span>Email updates</span>
119
+ <select name="emailNotifications">
120
+ <option value="true" selected={preferences.value.emailNotifications}>
121
+ Enabled when the academy sends email
122
+ </option>
123
+ <option value="false" selected={!preferences.value.emailNotifications}>
124
+ Off
125
+ </option>
126
+ </select>
127
+ </label>
128
+ <label>
129
+ <span>Weekly lesson goal</span>
130
+ <input
131
+ type="number"
132
+ name="weeklyGoalLessons"
133
+ min="0"
134
+ max="100"
135
+ value={preferences.value.weeklyGoalLessons}
136
+ />
137
+ </label>
138
+ <p>
139
+ Set 0 for no goal. Your goal is a personal target; it does not change deadlines or
140
+ completion requirements.
141
+ </p>
142
+ <button class="lms-btn" type="submit">
143
+ Save preferences
144
+ </button>
145
+ <p data-lms-feedback aria-live="polite"></p>
146
+ </form>
147
+ </section>
148
+ )}
149
+ <section class="lms-learning-card" data-lms-export>
150
+ <h2>Download my learning data</h2>
151
+ <p>
152
+ Download each section as JSON, five records per page. Continue until all pages are
153
+ downloaded. Exports include only your records and omit quiz answer keys.
154
+ </p>
155
+ <label>
156
+ Data section{" "}
157
+ <select data-lms-export-section>
158
+ <option value="enrollments">Learning history, notes and assessments</option>
159
+ <option value="discussions">Discussion contributions</option>
160
+ <option value="reviews">Course reviews</option>
161
+ <option value="notifications">Notifications</option>
162
+ <option value="saved_courses">Saved courses</option>
163
+ <option value="path_enrollments">Joined paths</option>
164
+ <option value="cohort_enrollments">Cohort membership</option>
165
+ </select>
166
+ </label>
167
+ <button type="button" class="lms-btn" data-lms-export-download>
168
+ Download first page
169
+ </button>
170
+ <p data-lms-feedback aria-live="polite"></p>
171
+ </section>
172
+ </LearningShell>
173
+ </Layout>