@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,386 @@
1
+ import { API_BASE } from "@anantagnanam/lms";
2
+ import type { Notebook, StudyNote } from "@anantagnanam/lms/types";
3
+ type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
4
+ const unsaved = new Map<HTMLElement, () => boolean>();
5
+ const atRisk = () => [...unsaved].some(([root, dirty]) => root.isConnected && dirty());
6
+ const beforeUnload = (event: BeforeUnloadEvent) => {
7
+ if (atRisk()) event.preventDefault();
8
+ };
9
+ const beforeNavigate = (event: Event) => {
10
+ if (
11
+ !event.defaultPrevented &&
12
+ atRisk() &&
13
+ !window.confirm("Some notes may not be saved. Leave this page?")
14
+ )
15
+ event.preventDefault();
16
+ };
17
+ /** Cover both full navigation and hosts that opt into Astro's client router. */
18
+ export function protectStudyWork(root: HTMLElement, dirty: () => boolean) {
19
+ unsaved.set(root, dirty);
20
+ if (unsaved.size !== 1) return;
21
+ window.addEventListener("beforeunload", beforeUnload);
22
+ document.addEventListener("astro:before-preparation", beforeNavigate);
23
+ document.addEventListener(
24
+ "astro:before-swap",
25
+ () => {
26
+ unsaved.clear();
27
+ window.removeEventListener("beforeunload", beforeUnload);
28
+ document.removeEventListener("astro:before-preparation", beforeNavigate);
29
+ },
30
+ { once: true },
31
+ );
32
+ }
33
+ const node = <K extends keyof HTMLElementTagNameMap>(tag: K, text = "") => {
34
+ const element = document.createElement(tag);
35
+ element.textContent = text;
36
+ return element;
37
+ };
38
+ const time = (value: number) =>
39
+ `${Math.floor(value / 60)}:${String(Math.floor(value % 60)).padStart(2, "0")}`;
40
+ const button = (text: string) => {
41
+ const item = node("button", text);
42
+ item.type = "button";
43
+ item.className = "lms-btn lms-btn-outline";
44
+ return item;
45
+ };
46
+ const tell = (root: Element, message: string, error = false) => {
47
+ const target = root.querySelector<HTMLElement>("[data-lms-feedback]")!;
48
+ target.textContent = message;
49
+ target.setAttribute("role", error ? "alert" : "status");
50
+ };
51
+ function download(value: Blob, filename: string) {
52
+ const url = URL.createObjectURL(value);
53
+ const link = node("a");
54
+ link.href = url;
55
+ link.download = filename;
56
+ document.body.append(link);
57
+ link.click();
58
+ link.remove();
59
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
60
+ }
61
+
62
+ export function initializeStudyTools(call: Call) {
63
+ for (const root of document.querySelectorAll<HTMLElement>(
64
+ "[data-lms-quick-note]:not([data-lms-bound])",
65
+ )) {
66
+ root.dataset.lmsBound = "true";
67
+ const form = root.querySelector<HTMLFormElement>("form")!;
68
+ const body = form.elements.namedItem("body") as HTMLTextAreaElement;
69
+ const star = form.elements.namedItem("starred") as HTMLInputElement;
70
+ const capture = root.querySelector<HTMLButtonElement>("[data-lms-capture-time]")!;
71
+ const clear = root.querySelector<HTMLButtonElement>("[data-lms-clear-time]")!;
72
+ const stamp = root.querySelector<HTMLElement>("[data-lms-note-time]")!;
73
+ const media = root.closest("[data-lms-lesson]")?.querySelector<HTMLMediaElement>("audio,video");
74
+ let position: number | null = null;
75
+ let id = crypto.randomUUID();
76
+ let pending: Record<string, unknown> | undefined;
77
+ let busy = false;
78
+ protectStudyWork(root, () => busy || !!pending || !!body.value.trim() || position !== null);
79
+ capture.hidden = !media;
80
+ capture.addEventListener("click", () => {
81
+ if (!media || busy || pending) return;
82
+ position = Math.min(86_400, Math.max(0, Math.floor(media.currentTime)));
83
+ stamp.textContent = `Bookmark at ${time(position)}`;
84
+ clear.hidden = false;
85
+ });
86
+ clear.addEventListener("click", () => {
87
+ if (busy || pending) return;
88
+ position = null;
89
+ stamp.textContent = "";
90
+ clear.hidden = true;
91
+ });
92
+ form.addEventListener("submit", async (event) => {
93
+ event.preventDefault();
94
+ if (busy) return;
95
+ if (!pending && !body.value.trim() && position === null) {
96
+ tell(root, "Write a note or capture the playback time.", true);
97
+ return;
98
+ }
99
+ pending ??= {
100
+ courseId: root.dataset.courseId,
101
+ lessonId: root.dataset.lessonId,
102
+ id,
103
+ body: body.value,
104
+ starred: star.checked,
105
+ archived: false,
106
+ positionSeconds: position,
107
+ expectedRevision: 0,
108
+ };
109
+ busy = true;
110
+ form
111
+ .querySelectorAll<HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement>(
112
+ "input,button,textarea",
113
+ )
114
+ .forEach((field) => (field.disabled = true));
115
+ try {
116
+ await call("learner/notebook/save", pending);
117
+ pending = undefined;
118
+ body.value = "";
119
+ star.checked = false;
120
+ position = null;
121
+ stamp.textContent = "";
122
+ clear.hidden = true;
123
+ id = crypto.randomUUID();
124
+ tell(root, "Note saved in your private course notebook.");
125
+ } catch (error) {
126
+ const failure = error as { status?: number };
127
+ if (failure.status && failure.status < 500 && ![408, 429].includes(failure.status))
128
+ pending = undefined;
129
+ tell(
130
+ root,
131
+ `${error instanceof Error ? error.message : "Could not save."}${pending ? " Retry to confirm the same note." : " Your work is kept here."}`,
132
+ true,
133
+ );
134
+ } finally {
135
+ busy = false;
136
+ form
137
+ .querySelectorAll<HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement>(
138
+ "input,button,textarea",
139
+ )
140
+ .forEach((field) => (field.disabled = !!pending && field.type !== "submit"));
141
+ }
142
+ });
143
+ }
144
+ for (const root of document.querySelectorAll<HTMLElement>(
145
+ "[data-lms-notebook]:not([data-lms-bound])",
146
+ )) {
147
+ root.dataset.lmsBound = "true";
148
+ let notebook = JSON.parse(root.dataset.notebook!) as Notebook;
149
+ delete root.dataset.notebook;
150
+ const list = root.querySelector<HTMLElement>("[data-lms-note-list]")!;
151
+ const search = root.querySelector<HTMLInputElement>("[data-lms-note-search]")!;
152
+ const filter = root.querySelector<HTMLSelectElement>("[data-lms-note-filter]")!;
153
+ const refresh = root.querySelector<HTMLButtonElement>("[data-lms-notebook-refresh]")!;
154
+ let editing = false;
155
+ let dirty = () => false;
156
+ protectStudyWork(root, () => dirty());
157
+ const controls = (value: boolean) => {
158
+ editing = value;
159
+ if (!value) dirty = () => false;
160
+ search.disabled = filter.disabled = refresh.disabled = value;
161
+ };
162
+ const lessonLink = (lessonId: string, position: number | null) => {
163
+ if (!notebook.availableLessonIds.includes(lessonId))
164
+ return node("span", "Lesson access currently unavailable; your saved notes are retained.");
165
+ const link = node("a", position === null ? "Open lesson →" : `Jump to ${time(position)} →`);
166
+ link.href = `${root.dataset.basePath}/courses/${encodeURIComponent(notebook.course.slug)}/lessons/${encodeURIComponent(lessonId)}${position === null ? "" : `#t=${position}`}`;
167
+ return link;
168
+ };
169
+ const render = () => {
170
+ list.replaceChildren();
171
+ const query = search.value.trim().toLocaleLowerCase();
172
+ const match = (body: string, title: string) =>
173
+ `${title}\n${body}`.toLocaleLowerCase().includes(query);
174
+ const notes = notebook.notes
175
+ .filter(
176
+ (note) =>
177
+ (filter.value === "archived" ? note.archived : !note.archived) &&
178
+ (filter.value !== "starred" || note.starred) &&
179
+ match(note.body, note.lessonTitle),
180
+ )
181
+ .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
182
+ for (const note of notes) {
183
+ const article = node("article");
184
+ article.className = "lms-notebook-entry";
185
+ const heading = node("h4", `${note.starred ? "★ " : ""}${note.lessonTitle}`);
186
+ const body = node("p", note.body || "Playback bookmark");
187
+ body.className = "lms-text";
188
+ article.append(heading, body, lessonLink(note.lessonId, note.positionSeconds));
189
+ if (notebook.availableLessonIds.includes(note.lessonId)) {
190
+ const edit = button(note.archived ? "Edit or restore note" : "Edit note");
191
+ article.append(edit);
192
+ edit.addEventListener("click", () => {
193
+ if (editing) return;
194
+ controls(true);
195
+ const form = node("form");
196
+ form.className = "lms-form";
197
+ const label = node("label", "Note text");
198
+ const field = node("textarea");
199
+ field.value = note.body;
200
+ field.maxLength = 5000;
201
+ field.rows = 4;
202
+ label.append(field);
203
+ const starred = node("input");
204
+ starred.type = "checkbox";
205
+ starred.checked = note.starred;
206
+ const starLabel = node("label", " Starred ");
207
+ starLabel.prepend(starred);
208
+ const archived = node("input");
209
+ archived.type = "checkbox";
210
+ archived.checked = note.archived;
211
+ const archiveLabel = node("label", " Archived (can be restored) ");
212
+ archiveLabel.prepend(archived);
213
+ const save = button("Save note");
214
+ save.type = "submit";
215
+ const cancel = button("Cancel editing");
216
+ const feedback = node("p");
217
+ feedback.setAttribute("aria-live", "polite");
218
+ form.append(label, starLabel, archiveLabel, save, cancel, feedback);
219
+ article.replaceChildren(heading, form);
220
+ field.focus();
221
+ let request: Record<string, unknown> | undefined;
222
+ let saving = false;
223
+ dirty = () =>
224
+ saving ||
225
+ !!request ||
226
+ field.value !== note.body ||
227
+ starred.checked !== note.starred ||
228
+ archived.checked !== note.archived;
229
+ cancel.addEventListener("click", () => {
230
+ if (saving || request) return;
231
+ controls(false);
232
+ render();
233
+ });
234
+ form.addEventListener("submit", async (event) => {
235
+ event.preventDefault();
236
+ if (saving) return;
237
+ request ??= {
238
+ ...note,
239
+ courseId: notebook.course.id,
240
+ expectedRevision: note.revision,
241
+ body: field.value,
242
+ starred: starred.checked,
243
+ archived: archived.checked,
244
+ };
245
+ saving = true;
246
+ save.disabled = cancel.disabled = true;
247
+ field.readOnly = true;
248
+ starred.disabled = archived.disabled = true;
249
+ try {
250
+ const saved = await call<StudyNote>("learner/notebook/save", request);
251
+ notebook.notes = notebook.notes.map((item) =>
252
+ item.id === saved.id ? saved : item,
253
+ );
254
+ request = undefined;
255
+ controls(false);
256
+ render();
257
+ tell(root, "Note saved.");
258
+ } catch (error) {
259
+ const failure = error as { status?: number; code?: string };
260
+ if (failure.status && failure.status < 500 && ![408, 429].includes(failure.status))
261
+ request = undefined;
262
+ feedback.textContent = `${error instanceof Error ? error.message : "Save not confirmed."} ${request ? "Retry to confirm this same edit." : "Copy your edits before cancelling and reloading saved notes."}`;
263
+ feedback.setAttribute("role", "alert");
264
+ } finally {
265
+ saving = false;
266
+ save.disabled = false;
267
+ cancel.disabled = !!request;
268
+ field.readOnly = !!request;
269
+ starred.disabled = archived.disabled = !!request;
270
+ }
271
+ });
272
+ });
273
+ }
274
+ list.append(article);
275
+ }
276
+ const summaries =
277
+ filter.value === "active"
278
+ ? notebook.summaries.filter((item) => match(item.body, item.lessonTitle))
279
+ : [];
280
+ for (const summary of summaries) {
281
+ const article = node("article");
282
+ article.className = "lms-notebook-entry";
283
+ const body = node("p", summary.body);
284
+ body.className = "lms-text";
285
+ article.append(
286
+ node("h4", `${summary.lessonTitle} · Lesson summary`),
287
+ body,
288
+ lessonLink(summary.lessonId, null),
289
+ );
290
+ list.append(article);
291
+ }
292
+ if (!notes.length && !summaries.length)
293
+ list.append(
294
+ node("p", "No notes match this view. Add a note from a lesson or change your filters."),
295
+ );
296
+ };
297
+ search.addEventListener("input", render);
298
+ filter.addEventListener("change", render);
299
+ refresh.addEventListener("click", async () => {
300
+ if (editing) return;
301
+ controls(true);
302
+ try {
303
+ notebook = await call<Notebook>(
304
+ "learner/notebook",
305
+ { courseId: notebook.course.id },
306
+ "GET",
307
+ );
308
+ render();
309
+ tell(root, "Saved notes loaded.");
310
+ } catch (error) {
311
+ tell(root, error instanceof Error ? error.message : "Could not load notes.", true);
312
+ } finally {
313
+ controls(false);
314
+ }
315
+ });
316
+ render();
317
+ }
318
+ for (const root of document.querySelectorAll<HTMLElement>(
319
+ "[data-lms-agenda]:not([data-lms-bound])",
320
+ )) {
321
+ root.dataset.lmsBound = "true";
322
+ const timezone = root.querySelector<HTMLSelectElement>("[data-lms-timezone]");
323
+ if (!timezone) continue;
324
+ const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
325
+ const zones = [
326
+ ...new Set([
327
+ "UTC",
328
+ local,
329
+ ...(typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : []),
330
+ ]),
331
+ ];
332
+ timezone.replaceChildren(
333
+ ...zones.map((zone) => {
334
+ const option = node("option", zone);
335
+ option.value = zone;
336
+ return option;
337
+ }),
338
+ );
339
+ timezone.value = local;
340
+ const dates = () =>
341
+ root.querySelectorAll<HTMLTimeElement>("[data-lms-date]").forEach(
342
+ (item) =>
343
+ (item.textContent =
344
+ new Intl.DateTimeFormat(undefined, {
345
+ timeZone: timezone.value,
346
+ dateStyle: "medium",
347
+ timeStyle: "short",
348
+ }).format(new Date(item.dateTime)) + ` (${timezone.value})`),
349
+ );
350
+ timezone.addEventListener("change", dates);
351
+ dates();
352
+ root
353
+ .querySelector<HTMLInputElement>("[data-lms-agenda-upcoming]")!
354
+ .addEventListener("change", (event) => {
355
+ let visible = 0;
356
+ root.querySelectorAll<HTMLElement>("[data-lms-agenda-event]").forEach((item) => {
357
+ item.hidden =
358
+ (event.target as HTMLInputElement).checked && item.dataset.status !== "upcoming";
359
+ if (!item.hidden) visible++;
360
+ });
361
+ root.querySelector<HTMLElement>("[data-lms-agenda-empty]")!.hidden = visible > 0;
362
+ });
363
+ const downloadButton = root.querySelector<HTMLButtonElement>("[data-lms-calendar-download]")!;
364
+ downloadButton.addEventListener("click", async () => {
365
+ downloadButton.disabled = true;
366
+ try {
367
+ const response = await fetch(
368
+ `${API_BASE}/learner/agenda/calendar?courseId=${encodeURIComponent(root.dataset.courseId!)}`,
369
+ {
370
+ headers: { "X-EmDash-Request": "1" },
371
+ credentials: "same-origin",
372
+ signal: AbortSignal.timeout(20_000),
373
+ },
374
+ );
375
+ if (!response.ok)
376
+ throw new Error("Calendar unavailable. Reload to check your current course access.");
377
+ download(await response.blob(), "lms-course-agenda.ics");
378
+ tell(root, "Calendar downloaded. This snapshot does not update automatically.");
379
+ } catch (error) {
380
+ tell(root, error instanceof Error ? error.message : "Calendar download failed.", true);
381
+ } finally {
382
+ downloadButton.disabled = false;
383
+ }
384
+ });
385
+ }
386
+ }
package/astro/styles.css CHANGED
@@ -1,3 +1,21 @@
1
+ .lms-notebook-entry,
2
+ .lms-agenda-list > li {
3
+ padding: 1rem;
4
+ border-block-end: 1px solid var(--lms-border, #d8e0da);
5
+ overflow-wrap: anywhere;
6
+ }
7
+ .lms-agenda-list {
8
+ padding: 0;
9
+ list-style: none;
10
+ }
11
+ [data-lms-transcript] mark {
12
+ background: #fff1a8;
13
+ color: #16261c;
14
+ }
15
+ [data-lms-transcript] mark[data-current] {
16
+ background: #ffd66b;
17
+ outline: 2px solid #725600;
18
+ }
1
19
  .lms-root {
2
20
  --lms-bg: var(--bg, #f7faf8);
3
21
  --lms-surface: var(--surface, #fff);
@@ -0,0 +1,56 @@
1
+ /** Search plain transcript text; never interpret authored text or queries as HTML. */
2
+ export function initializeTranscripts() {
3
+ for (const root of document.querySelectorAll<HTMLElement>(
4
+ "[data-lms-transcript]:not([data-lms-bound])",
5
+ )) {
6
+ root.dataset.lmsBound = "true";
7
+ const field = root.querySelector<HTMLInputElement>("[data-lms-transcript-search]")!;
8
+ const content = root.querySelector<HTMLElement>("[data-lms-transcript-text]")!;
9
+ const status = root.querySelector<HTMLElement>("[data-lms-transcript-status]")!;
10
+ const previous = root.querySelector<HTMLButtonElement>("[data-lms-transcript-previous]")!;
11
+ const next = root.querySelector<HTMLButtonElement>("[data-lms-transcript-next]")!;
12
+ const text = content.textContent ?? "";
13
+ let marks: HTMLElement[] = [];
14
+ let selected = -1;
15
+ let capped = false;
16
+ const select = (index: number, scroll = true) => {
17
+ if (!marks.length) return;
18
+ marks[selected]?.removeAttribute("data-current");
19
+ selected = (index + marks.length) % marks.length;
20
+ marks[selected]!.setAttribute("data-current", "true");
21
+ status.textContent = `Match ${selected + 1} of ${marks.length}${capped ? " (first 200 shown; refine your search)" : ""}.`;
22
+ if (scroll) marks[selected]!.scrollIntoView({ block: "center", behavior: "smooth" });
23
+ };
24
+ field.addEventListener("input", () => {
25
+ const query = field.value.trim();
26
+ marks = [];
27
+ selected = -1;
28
+ capped = false;
29
+ content.replaceChildren();
30
+ // Unicode case-insensitive regex keeps match offsets in the original string.
31
+ const pattern = query
32
+ ? new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu")
33
+ : null;
34
+ let offset = 0;
35
+ if (pattern)
36
+ for (const match of text.matchAll(pattern)) {
37
+ if (marks.length === 200) {
38
+ capped = true;
39
+ break;
40
+ }
41
+ content.append(document.createTextNode(text.slice(offset, match.index)));
42
+ const mark = document.createElement("mark");
43
+ mark.textContent = match[0];
44
+ marks.push(mark);
45
+ content.append(mark);
46
+ offset = match.index! + match[0].length;
47
+ }
48
+ content.append(document.createTextNode(text.slice(offset)));
49
+ previous.disabled = next.disabled = marks.length === 0;
50
+ status.textContent = query ? "No matches." : "";
51
+ if (marks.length) select(0, false);
52
+ });
53
+ previous.addEventListener("click", () => select(selected - 1));
54
+ next.addEventListener("click", () => select(selected + 1));
55
+ }
56
+ }