@anantagnanam/lms 0.2.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 (40) hide show
  1. package/README.md +19 -2
  2. package/astro/assignment-player.ts +231 -0
  3. package/astro/client.ts +24 -1
  4. package/astro/components/AssignmentFeedback.astro +32 -0
  5. package/astro/components/AssignmentRubric.astro +27 -0
  6. package/astro/components/LearnerDashboard.astro +3 -0
  7. package/astro/components/LearningShell.astro +6 -0
  8. package/astro/components/LessonContent.astro +16 -2
  9. package/astro/components/LessonPlayer.astro +126 -38
  10. package/astro/draft-queue.ts +95 -0
  11. package/astro/media-player.ts +26 -1
  12. package/astro/pages/author-preview.astro +2 -0
  13. package/astro/pages/study.astro +180 -0
  14. package/astro/quiz-drafts.ts +12 -76
  15. package/astro/study-tools.ts +386 -0
  16. package/astro/styles.css +18 -0
  17. package/astro/transcript-search.ts +56 -0
  18. package/dist/admin.js +172 -12
  19. package/dist/admin.js.map +1 -1
  20. package/dist/assignments-CsuAjxGO.js +75 -0
  21. package/dist/assignments-CsuAjxGO.js.map +1 -0
  22. package/dist/astro.js +1 -0
  23. package/dist/astro.js.map +1 -1
  24. package/dist/{constants-Cnx7k82Z.js → constants-DyQbwNbP.js} +2 -2
  25. package/dist/{constants-Cnx7k82Z.js.map → constants-DyQbwNbP.js.map} +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/runtime.d.ts +26 -5
  29. package/dist/runtime.js +307 -16
  30. package/dist/runtime.js.map +1 -1
  31. package/dist/schemas.d.ts +69 -3
  32. package/dist/schemas.js +47 -6
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/server.d.ts +9 -2
  35. package/dist/server.js +6 -3
  36. package/dist/server.js.map +1 -1
  37. package/dist/types.d.ts +67 -2
  38. package/package.json +1 -1
  39. package/dist/errors-ChzJS9al.js +0 -16
  40. package/dist/errors-ChzJS9al.js.map +0 -1
@@ -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
+ }
@@ -22,8 +22,23 @@ export function initializeMedia(call: Call) {
22
22
  let busy = false;
23
23
  let pending = false;
24
24
  let failed = false;
25
+ let disposed = false;
25
26
  let lastSave = Date.now();
26
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
+ };
27
42
  button.hidden = !enabled;
28
43
  const restore = () => {
29
44
  if (ready) return;
@@ -41,9 +56,10 @@ export function initializeMedia(call: Call) {
41
56
  speed.value = String(media.playbackRate);
42
57
  status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
43
58
  }
59
+ seekBookmark();
44
60
  };
45
61
  const save = async () => {
46
- if (!enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
62
+ if (disposed || !enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
47
63
  if (busy) {
48
64
  pending = true;
49
65
  return;
@@ -95,5 +111,14 @@ export function initializeMedia(call: Call) {
95
111
  button.addEventListener("click", () => {
96
112
  void save();
97
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
+ );
98
123
  }
99
124
  }
@@ -2,6 +2,7 @@
2
2
  import Layout from "virtual:anantagnanam-lms/layout";
3
3
  import LearningShell from "../components/LearningShell.astro";
4
4
  import LessonContent from "../components/LessonContent.astro";
5
+ import AssignmentRubric from "../components/AssignmentRubric.astro";
5
6
  import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
6
7
  import { loginPath } from "virtual:anantagnanam-lms/config";
7
8
  export const prerender = false;
@@ -49,6 +50,7 @@ try {
49
50
  <section class="lms-assessment">
50
51
  <h2>Your assignment</h2>
51
52
  <p class="lms-text">{result.lesson.assignment.instructions}</p>
53
+ <AssignmentRubric rubric={result.lesson.assignment.rubric} />
52
54
  <p>
53
55
  {result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
54
56
  to pass
@@ -0,0 +1,180 @@
1
+ ---
2
+ import Layout from "virtual:anantagnanam-lms/layout";
3
+ import LearningShell from "../components/LearningShell.astro";
4
+ import { lmsLearner, loginUrl, lessonUrl, LmsError } from "@anantagnanam/lms/server";
5
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
6
+ import type { Notebook, CourseAgenda, Page } from "@anantagnanam/lms/types";
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
+ if (!Astro.locals.user)
11
+ return Astro.redirect(loginUrl(loginPath, Astro.url.pathname + Astro.url.search));
12
+ let courses: Page<{ courseId: string; title: string; slug: string | null }> = {
13
+ items: [],
14
+ hasMore: false,
15
+ };
16
+ let notebook: Notebook | undefined;
17
+ let agenda: CourseAgenda | undefined;
18
+ let error = "";
19
+ let agendaError = "";
20
+ const courseId = Astro.url.searchParams.get("courseId");
21
+ try {
22
+ courses = await lmsLearner(
23
+ Astro.locals,
24
+ "learner/study/courses",
25
+ { cursor: Astro.url.searchParams.get("cursor") ?? undefined, limit: 24 },
26
+ Astro.url.origin,
27
+ );
28
+ if (courseId) {
29
+ notebook = await lmsLearner(Astro.locals, "learner/notebook", { courseId }, Astro.url.origin);
30
+ try {
31
+ agenda = await lmsLearner(Astro.locals, "learner/agenda", { courseId }, Astro.url.origin);
32
+ } catch (cause) {
33
+ agendaError = cause instanceof Error ? cause.message : "Course agenda is unavailable.";
34
+ }
35
+ }
36
+ } catch (cause) {
37
+ Astro.response.status = cause instanceof LmsError ? cause.status : 503;
38
+ error = cause instanceof Error ? cause.message : "Study tools are unavailable.";
39
+ }
40
+ ---
41
+
42
+ <Layout title="My notebook and agenda" robots="noindex, nofollow">
43
+ <LearningShell>
44
+ <h1>My notebook &amp; agenda</h1>
45
+ <p>Revisit important ideas and plan your next course activity.</p>
46
+ {error && <p role="alert">{error}</p>}
47
+ <details open={!courseId}>
48
+ <summary>Choose an enrolled course</summary>
49
+ <ul>
50
+ {courses.items.map((course) => (
51
+ <li>
52
+ <a
53
+ href={`?courseId=${encodeURIComponent(course.courseId)}`}
54
+ aria-current={course.courseId === courseId ? "page" : undefined}
55
+ >
56
+ {course.title}
57
+ </a>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ {!courses.items.length && !error && <p>No enrolled courses on this page.</p>}
62
+ {courses.hasMore && courses.cursor && (
63
+ <a href={`?cursor=${encodeURIComponent(courses.cursor)}`}>More courses →</a>
64
+ )}
65
+ </details>
66
+ {notebook && (
67
+ <>
68
+ <h2>{notebook.course.title}</h2>
69
+ <section
70
+ class="lms-learning-card"
71
+ data-lms-notebook
72
+ data-notebook={JSON.stringify(notebook)}
73
+ data-base-path={basePath}
74
+ >
75
+ <h3>Private notebook</h3>
76
+ <p>
77
+ Add notes from a lesson. Search this course’s notes, star important ideas, or archive
78
+ and restore entries. Saved lesson summaries are preserved below.
79
+ </p>
80
+ <div class="lms-actions">
81
+ <label>
82
+ Search notes <input type="search" maxlength={200} data-lms-note-search />
83
+ </label>
84
+ <label>
85
+ Show{" "}
86
+ <select data-lms-note-filter>
87
+ <option value="active">Active notes</option>
88
+ <option value="starred">Starred notes</option>
89
+ <option value="archived">Archived notes</option>
90
+ </select>
91
+ </label>
92
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-notebook-refresh>
93
+ Reload saved notes
94
+ </button>
95
+ </div>
96
+ <p data-lms-feedback aria-live="polite"></p>
97
+ <div data-lms-note-list></div>
98
+ <noscript>
99
+ Enable JavaScript to search and edit notebook entries. Your learner data export also
100
+ includes these notes.
101
+ </noscript>
102
+ </section>
103
+ <section class="lms-learning-card" data-lms-agenda data-course-id={courseId}>
104
+ <h3>Course agenda</h3>
105
+ {agendaError && <p>{agendaError} Your private notebook remains available.</p>}
106
+ {agenda && (
107
+ <>
108
+ <p>
109
+ Assignment deadlines, live sessions and upcoming lesson releases from your enrolled
110
+ course edition. Lesson access and prerequisites still apply.
111
+ </p>
112
+ <div class="lms-actions">
113
+ <label>
114
+ Display timezone{" "}
115
+ <select data-lms-timezone>
116
+ <option value="UTC">UTC</option>
117
+ </select>
118
+ </label>
119
+ <label>
120
+ <input type="checkbox" data-lms-agenda-upcoming /> Upcoming only
121
+ </label>
122
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-calendar-download>
123
+ Download upcoming calendar (.ics)
124
+ </button>
125
+ </div>
126
+ <p>
127
+ The download is a snapshot for your calendar app. It does not update automatically;
128
+ revisit the course for changes.
129
+ </p>
130
+ <p data-lms-feedback aria-live="polite"></p>
131
+ <ul class="lms-agenda-list">
132
+ {agenda.events.map((event) => (
133
+ <li data-lms-agenda-event data-status={event.status}>
134
+ <strong>
135
+ {
136
+ {
137
+ assignment: "Assignment due",
138
+ live: "Live session",
139
+ release: "Lesson release",
140
+ }[event.kind]
141
+ }
142
+ : {event.title}
143
+ </strong>
144
+ <p>
145
+ <time datetime={event.startsAt} data-lms-date>
146
+ {new Date(event.startsAt).toLocaleString("en-GB", { timeZone: "UTC" })} UTC
147
+ </time>
148
+ {event.endsAt && (
149
+ <>
150
+ {" "}
151
+ –{" "}
152
+ <time datetime={event.endsAt} data-lms-date>
153
+ {new Date(event.endsAt).toLocaleString("en-GB", { timeZone: "UTC" })}{" "}
154
+ UTC
155
+ </time>
156
+ </>
157
+ )}
158
+ </p>
159
+ <p class="lms-badge">{event.status}</p>
160
+ {event.available ? (
161
+ <a href={lessonUrl(basePath, agenda.course.slug, event.lessonId)}>
162
+ Open lesson →
163
+ </a>
164
+ ) : (
165
+ <p>{event.reason}</p>
166
+ )}
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ {!agenda.events.length && <p>This course has no dated activities.</p>}
171
+ <p data-lms-agenda-empty hidden>
172
+ No upcoming activities in this view.
173
+ </p>
174
+ </>
175
+ )}
176
+ </section>
177
+ </>
178
+ )}
179
+ </LearningShell>
180
+ </Layout>
@@ -1,87 +1,23 @@
1
1
  import type { Answers } from "@anantagnanam/lms/types";
2
-
3
- export type DraftState = "saved" | "pending" | "saving" | "failed" | "conflict";
2
+ import { DraftQueue, type DraftState } from "./draft-queue.js";
3
+ export type { DraftState } from "./draft-queue.js";
4
4
  export interface DraftWrite {
5
5
  answers: Answers;
6
6
  expectedDraftRevision: number;
7
7
  }
8
- const fingerprint = (answers: Answers) =>
9
- JSON.stringify(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b)));
10
-
11
- /** One writer per form. Keep an unacknowledged request intact for lost-response retries. */
12
- export class QuizDraftQueue {
13
- revision: number;
14
- state: DraftState = "saved";
15
- private latest: Answers;
16
- private acknowledged: string;
17
- private retry?: DraftWrite;
18
- private running?: Promise<void>;
19
- private disposed = false;
8
+ /** Retain the quiz interface while sharing the reliable writer with assignments. */
9
+ export class QuizDraftQueue extends DraftQueue<Answers> {
20
10
  constructor(
21
11
  answers: Answers,
22
12
  revision: number,
23
- private send: (write: DraftWrite) => Promise<{ draftRevision: number }>,
24
- private changed: (state: DraftState) => void,
13
+ send: (write: DraftWrite) => Promise<{ draftRevision: number }>,
14
+ changed: (state: DraftState) => void,
25
15
  ) {
26
- this.latest = structuredClone(answers);
27
- this.acknowledged = fingerprint(answers);
28
- this.revision = revision;
29
- }
30
- get dirty() {
31
- return !!this.retry || fingerprint(this.latest) !== this.acknowledged;
32
- }
33
- private emit(state: DraftState) {
34
- this.state = state;
35
- if (!this.disposed) this.changed(state);
36
- }
37
- update(answers: Answers) {
38
- this.latest = structuredClone(answers);
39
- if (!["failed", "conflict", "saving"].includes(this.state))
40
- this.emit(this.dirty ? "pending" : "saved");
41
- }
42
- flush(): Promise<void> {
43
- if (this.disposed) return Promise.reject(new Error("This quiz form has closed."));
44
- if (this.state === "conflict")
45
- return Promise.reject(new Error("Review the saved answers before continuing."));
46
- if (this.running) return this.running;
47
- this.running = this.drain().finally(() => {
48
- this.running = undefined;
49
- });
50
- return this.running;
51
- }
52
- private async drain() {
53
- while (this.dirty && !this.disposed) {
54
- const write = this.retry ?? {
55
- answers: structuredClone(this.latest),
56
- expectedDraftRevision: this.revision,
57
- };
58
- this.retry = write;
59
- this.emit("saving");
60
- try {
61
- const result = await this.send(structuredClone(write));
62
- this.revision = result.draftRevision;
63
- this.acknowledged = fingerprint(write.answers);
64
- this.retry = undefined;
65
- } catch (error) {
66
- this.emit((error as { code?: string })?.code === "DRAFT_CONFLICT" ? "conflict" : "failed");
67
- throw error;
68
- }
69
- }
70
- this.emit(this.dirty ? "pending" : "saved");
71
- }
72
- /** Called only after the learner reviews the current remote draft. */
73
- conflict() {
74
- this.emit("conflict");
75
- }
76
- reconcile(answers: Answers, revision: number, keepLocal: boolean) {
77
- if (this.running) throw new Error("Wait for the current save to finish.");
78
- this.revision = revision;
79
- this.acknowledged = fingerprint(answers);
80
- this.retry = undefined;
81
- if (!keepLocal) this.latest = structuredClone(answers);
82
- this.emit(this.dirty ? "pending" : "saved");
83
- }
84
- dispose() {
85
- this.disposed = true;
16
+ super(
17
+ answers,
18
+ revision,
19
+ (write) => send({ answers: write.value, expectedDraftRevision: write.expectedDraftRevision }),
20
+ changed,
21
+ );
86
22
  }
87
23
  }