@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
@@ -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,95 @@
1
+ export type DraftState = "saved" | "pending" | "saving" | "failed" | "conflict";
2
+ export interface DraftWrite<T> {
3
+ value: T;
4
+ expectedDraftRevision: number;
5
+ }
6
+ const fingerprint = (value: object) =>
7
+ JSON.stringify(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
8
+
9
+ /** Serialize writes and preserve the exact unacknowledged request for retries. */
10
+ export class DraftQueue<T extends object> {
11
+ revision: number;
12
+ state: DraftState = "saved";
13
+ private latest: T;
14
+ private acknowledged: string;
15
+ private retry?: DraftWrite<T>;
16
+ private running?: Promise<void>;
17
+ private disposed = false;
18
+ constructor(
19
+ value: T,
20
+ revision: number,
21
+ private send: (write: DraftWrite<T>) => Promise<{ draftRevision: number }>,
22
+ private changed: (state: DraftState) => void,
23
+ ) {
24
+ this.latest = structuredClone(value);
25
+ this.acknowledged = fingerprint(value);
26
+ this.revision = revision;
27
+ }
28
+ get dirty() {
29
+ return !!this.retry || fingerprint(this.latest) !== this.acknowledged;
30
+ }
31
+ private emit(state: DraftState) {
32
+ this.state = state;
33
+ if (!this.disposed) this.changed(state);
34
+ }
35
+ update(value: T) {
36
+ this.latest = structuredClone(value);
37
+ if (!["failed", "conflict", "saving"].includes(this.state))
38
+ this.emit(this.dirty ? "pending" : "saved");
39
+ }
40
+ flush(): Promise<void> {
41
+ if (this.disposed) return Promise.reject(new Error("This form has closed."));
42
+ if (this.state === "conflict")
43
+ return Promise.reject(new Error("Review the saved draft before continuing."));
44
+ if (this.running) return this.running;
45
+ this.running = this.drain().finally(() => {
46
+ this.running = undefined;
47
+ });
48
+ return this.running;
49
+ }
50
+ private async drain() {
51
+ while (this.dirty && !this.disposed) {
52
+ const write = this.retry ?? {
53
+ value: structuredClone(this.latest),
54
+ expectedDraftRevision: this.revision,
55
+ };
56
+ this.retry = write;
57
+ this.emit("saving");
58
+ try {
59
+ const result = await this.send(structuredClone(write));
60
+ this.revision = result.draftRevision;
61
+ this.acknowledged = fingerprint(write.value);
62
+ this.retry = undefined;
63
+ } catch (error) {
64
+ const failure = error as { code?: string; status?: number };
65
+ // A definite rejection did not persist the write. Allow corrected values on retry.
66
+ if (
67
+ failure.status &&
68
+ failure.status < 500 &&
69
+ failure.status !== 408 &&
70
+ failure.status !== 429 &&
71
+ failure.code !== "DRAFT_CONFLICT"
72
+ )
73
+ this.retry = undefined;
74
+ this.emit((error as { code?: string })?.code === "DRAFT_CONFLICT" ? "conflict" : "failed");
75
+ throw error;
76
+ }
77
+ }
78
+ this.emit(this.dirty ? "pending" : "saved");
79
+ }
80
+ conflict() {
81
+ this.emit("conflict");
82
+ }
83
+ /** Only after the learner has reviewed the current saved draft. */
84
+ reconcile(value: T, revision: number, keepLocal: boolean) {
85
+ if (this.running) throw new Error("Wait for the current save to finish.");
86
+ this.revision = revision;
87
+ this.acknowledged = fingerprint(value);
88
+ this.retry = undefined;
89
+ if (!keepLocal) this.latest = structuredClone(value);
90
+ this.emit(this.dirty ? "pending" : "saved");
91
+ }
92
+ dispose() {
93
+ this.disposed = true;
94
+ }
95
+ }
@@ -0,0 +1,124 @@
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 disposed = false;
26
+ let lastSave = Date.now();
27
+ const enabled = Boolean(lesson && lesson.dataset.preview !== "true");
28
+ const seekBookmark = () => {
29
+ const raw = new URLSearchParams(window.location.hash.slice(1)).get("t");
30
+ if (!ready || disposed || raw === null || !/^\d+(?:\.\d+)?$/.test(raw)) return;
31
+ const position = Number(raw);
32
+ if (!Number.isFinite(position) || position > 86_400) return;
33
+ try {
34
+ media.currentTime = Number.isFinite(media.duration)
35
+ ? Math.min(position, Math.max(0, media.duration - 0.1))
36
+ : position;
37
+ status.textContent = `Bookmark at ${Math.floor(media.currentTime / 60)}:${String(Math.floor(media.currentTime % 60)).padStart(2, "0")}. Press play when ready.`;
38
+ } catch {
39
+ status.textContent = "This media is not seekable yet.";
40
+ }
41
+ };
42
+ button.hidden = !enabled;
43
+ const restore = () => {
44
+ if (ready) return;
45
+ ready = true;
46
+ if (saved) {
47
+ const position = Number.isFinite(media.duration)
48
+ ? Math.min(saved.positionSeconds, Math.max(0, media.duration - 0.1))
49
+ : saved.positionSeconds;
50
+ try {
51
+ media.currentTime = position;
52
+ media.playbackRate = saved.playbackRate;
53
+ } catch {
54
+ /* The browser may not have a seekable range yet. */
55
+ }
56
+ speed.value = String(media.playbackRate);
57
+ status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
58
+ }
59
+ seekBookmark();
60
+ };
61
+ const save = async () => {
62
+ if (disposed || !enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
63
+ if (busy) {
64
+ pending = true;
65
+ return;
66
+ }
67
+ busy = true;
68
+ button.disabled = true;
69
+ pending = false;
70
+ status.textContent = "Saving playback position…";
71
+ try {
72
+ const result = await call<Playback>("learner/playback", {
73
+ courseId: lesson!.dataset.courseId,
74
+ lessonId: lesson!.dataset.lessonId,
75
+ positionSeconds: Math.min(media.currentTime, 86_400),
76
+ playbackRate: media.playbackRate,
77
+ expectedRevision: revision,
78
+ });
79
+ revision = result.revision;
80
+ lastSave = Date.now();
81
+ status.textContent = "Playback position saved.";
82
+ } catch (error) {
83
+ // A lost response can follow a successful write. Stop instead of overwriting another tab.
84
+ failed = true;
85
+ status.textContent = `${error instanceof Error ? error.message : "Could not save playback."} Reload this lesson before saving again.`;
86
+ status.setAttribute("role", "alert");
87
+ } finally {
88
+ busy = false;
89
+ button.disabled = failed;
90
+ if (pending && !failed) void save();
91
+ }
92
+ };
93
+ media.addEventListener("loadedmetadata", restore);
94
+ if (media.readyState >= 1) restore();
95
+ speed.addEventListener("change", () => {
96
+ media.playbackRate = Number(speed.value);
97
+ });
98
+ media.addEventListener("ratechange", () => {
99
+ speed.value = String(media.playbackRate);
100
+ void save();
101
+ });
102
+ media.addEventListener("timeupdate", () => {
103
+ if (!busy && Date.now() - lastSave >= 15_000) void save();
104
+ });
105
+ media.addEventListener("pause", () => {
106
+ void save();
107
+ });
108
+ media.addEventListener("ended", () => {
109
+ void save();
110
+ });
111
+ button.addEventListener("click", () => {
112
+ void save();
113
+ });
114
+ window.addEventListener("hashchange", seekBookmark);
115
+ document.addEventListener(
116
+ "astro:before-swap",
117
+ () => {
118
+ disposed = true;
119
+ window.removeEventListener("hashchange", seekBookmark);
120
+ },
121
+ { once: true },
122
+ );
123
+ }
124
+ }
@@ -0,0 +1,73 @@
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 AssignmentRubric from "../components/AssignmentRubric.astro";
6
+ import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
7
+ import { loginPath } from "virtual:anantagnanam-lms/config";
8
+ export const prerender = false;
9
+ Astro.response.headers.set("Cache-Control", "private, no-store");
10
+ Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
11
+ let result: Awaited<ReturnType<typeof lmsAuthorLessonPreview>> | undefined;
12
+ let error = "";
13
+ try {
14
+ result = await lmsAuthorLessonPreview(
15
+ Astro.locals,
16
+ { courseId: Astro.params.courseId!, lessonId: Astro.params.lessonId! },
17
+ Astro.url.origin,
18
+ );
19
+ } catch (cause) {
20
+ Astro.response.status = cause instanceof LmsError ? cause.status : 503;
21
+ error = cause instanceof Error ? cause.message : "Preview is unavailable.";
22
+ }
23
+ ---
24
+
25
+ <Layout
26
+ title={result ? `Preview: ${result.lesson.title}` : "Lesson preview"}
27
+ robots="noindex, nofollow"
28
+ >
29
+ <LearningShell>
30
+ {result ? (
31
+ <article class="lms-lesson">
32
+ <p class="lms-notice">
33
+ Saved author preview · {result.course.title}. Save changes in the course editor, then
34
+ refresh this page. Linked content uses its published version.
35
+ </p>
36
+ <h1>{result.lesson.title}</h1>
37
+ {result.lesson.summary && <p class="lms-lead">{result.lesson.summary}</p>}
38
+ <LessonContent lesson={result.lesson} />
39
+ {result.lesson.quiz && (
40
+ <section class="lms-assessment">
41
+ <h2>Lesson quiz</h2>
42
+ <p>
43
+ {result.lesson.quiz.questionCount} questions · Pass with{" "}
44
+ {result.lesson.quiz.passingPercent}% · {result.lesson.quiz.maxAttempts} attempts
45
+ </p>
46
+ <p>Assessment summary only. This preview does not create an attempt.</p>
47
+ </section>
48
+ )}
49
+ {result.lesson.assignment && (
50
+ <section class="lms-assessment">
51
+ <h2>Your assignment</h2>
52
+ <p class="lms-text">{result.lesson.assignment.instructions}</p>
53
+ <AssignmentRubric rubric={result.lesson.assignment.rubric} />
54
+ <p>
55
+ {result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
56
+ to pass
57
+ </p>
58
+ </section>
59
+ )}
60
+ </article>
61
+ ) : (
62
+ <section class="lms-gate">
63
+ <h1>Lesson preview</h1>
64
+ <p>{error}</p>
65
+ {!Astro.locals.user && (
66
+ <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
67
+ Sign in →
68
+ </a>
69
+ )}
70
+ </section>
71
+ )}
72
+ </LearningShell>
73
+ </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>
@@ -0,0 +1,73 @@
1
+ ---
2
+ import Layout from "virtual:anantagnanam-lms/layout";
3
+ import LearningShell from "../components/LearningShell.astro";
4
+ import { lmsPublic, courseUrl, loginUrl, LmsError } from "@anantagnanam/lms/server";
5
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
6
+ import type { Page, PublicPath } from "@anantagnanam/lms/types";
7
+ export const prerender = false;
8
+ Astro.response.headers.set("Cache-Control", "private, no-store");
9
+ let page: Page<PublicPath> = { items: [], hasMore: false };
10
+ let error = "";
11
+ try {
12
+ page = await lmsPublic<typeof page>(
13
+ Astro.locals,
14
+ "paths",
15
+ { cursor: Astro.url.searchParams.get("cursor") ?? undefined },
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 : "Learning paths unavailable.";
21
+ }
22
+ const next = new URL(Astro.url);
23
+ next.searchParams.set("cursor", page.cursor ?? "");
24
+ ---
25
+
26
+ <Layout title="Learning paths">
27
+ <LearningShell>
28
+ <h1>Learning paths</h1>
29
+ <p class="lms-lead">
30
+ Follow an ordered program of courses and track your progress in your library.
31
+ </p>
32
+ <p>
33
+ Join a path to save its course list. Enroll in each course separately; approval and
34
+ prerequisites still apply.
35
+ </p>
36
+ {error && <p role="alert">{error}</p>}
37
+ {!error && !page.items.length && <p>No learning paths have been published yet.</p>}
38
+ {page.items.map((path) => (
39
+ <article class="lms-learning-card">
40
+ <h2>{path.title}</h2>
41
+ <p class="lms-text">{path.description}</p>
42
+ <ol>
43
+ {path.courses.map((course) => (
44
+ <li>
45
+ {course ? (
46
+ <a href={courseUrl(basePath, course.slug)}>{course.title}</a>
47
+ ) : (
48
+ "Course currently unavailable"
49
+ )}
50
+ </li>
51
+ ))}
52
+ </ol>
53
+ {Astro.locals.user ? (
54
+ <form data-lms-route="learner/path/join">
55
+ <input type="hidden" name="pathId" value={path.id} />
56
+ <button class="lms-btn" type="submit">
57
+ Join path
58
+ </button>
59
+ <a class="lms-btn lms-btn-outline" href={`${basePath}/my-learning/library`}>
60
+ View my paths
61
+ </a>
62
+ <p data-lms-feedback aria-live="polite"></p>
63
+ </form>
64
+ ) : (
65
+ <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
66
+ Sign in to join
67
+ </a>
68
+ )}
69
+ </article>
70
+ ))}
71
+ {page.hasMore && page.cursor && <a href={next.href}>More learning paths →</a>}
72
+ </LearningShell>
73
+ </Layout>