@anantagnanam/lms 0.0.0-stage → 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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/astro/client.ts +355 -0
  4. package/astro/components/CertificateCard.astro +83 -0
  5. package/astro/components/CourseCard.astro +61 -0
  6. package/astro/components/CourseCatalog.astro +96 -0
  7. package/astro/components/CourseOverview.astro +308 -0
  8. package/astro/components/LearnerDashboard.astro +127 -0
  9. package/astro/components/LearningShell.astro +37 -0
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +53 -0
  12. package/astro/components/LessonPlayer.astro +302 -0
  13. package/astro/components/MediaPlayer.astro +111 -0
  14. package/astro/components/index.ts +7 -0
  15. package/astro/layouts/AcademyLayout.astro +84 -0
  16. package/astro/media-player.ts +99 -0
  17. package/astro/pages/author-preview.astro +71 -0
  18. package/astro/pages/certificate.astro +57 -0
  19. package/astro/pages/cohort.astro +102 -0
  20. package/astro/pages/course.astro +90 -0
  21. package/astro/pages/dashboard.astro +34 -0
  22. package/astro/pages/index.astro +40 -0
  23. package/astro/pages/lesson.astro +103 -0
  24. package/astro/pages/library.astro +173 -0
  25. package/astro/pages/paths.astro +73 -0
  26. package/astro/pages/teach.astro +15 -0
  27. package/astro/quiz-drafts.ts +87 -0
  28. package/astro/quiz-player.ts +362 -0
  29. package/astro/styles.css +1300 -0
  30. package/dist/admin.d.ts +34 -0
  31. package/dist/admin.js +3231 -0
  32. package/dist/admin.js.map +1 -0
  33. package/dist/astro.d.ts +11 -0
  34. package/dist/astro.js +52 -0
  35. package/dist/astro.js.map +1 -0
  36. package/dist/commerce.d.ts +72 -0
  37. package/dist/commerce.js +0 -0
  38. package/dist/config-BX3beTpM.js +15 -0
  39. package/dist/config-BX3beTpM.js.map +1 -0
  40. package/dist/constants-Cnx7k82Z.js +161 -0
  41. package/dist/constants-Cnx7k82Z.js.map +1 -0
  42. package/dist/csv-DKq_4OWU.js +55 -0
  43. package/dist/csv-DKq_4OWU.js.map +1 -0
  44. package/dist/errors-ChzJS9al.js +16 -0
  45. package/dist/errors-ChzJS9al.js.map +1 -0
  46. package/dist/index.d.ts +13 -0
  47. package/dist/index.js +19 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/runtime.d.ts +773 -0
  50. package/dist/runtime.js +2840 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/schemas.d.ts +651 -0
  53. package/dist/schemas.js +347 -0
  54. package/dist/schemas.js.map +1 -0
  55. package/dist/server.d.ts +40 -0
  56. package/dist/server.js +65 -0
  57. package/dist/server.js.map +1 -0
  58. package/dist/types.d.ts +387 -0
  59. package/dist/types.js +0 -0
  60. package/package.json +83 -4
@@ -0,0 +1,302 @@
1
+ ---
2
+ import LessonContent from "./LessonContent.astro";
3
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
4
+ import type {
5
+ CourseDetail,
6
+ PublicLesson,
7
+ PublicEnrollment,
8
+ ProgressSummary,
9
+ } from "@anantagnanam/lms/types";
10
+ import { courseUrl, lessonUrl, certificateUrl } from "@anantagnanam/lms/server";
11
+ import { basePath } from "virtual:anantagnanam-lms/config";
12
+ interface Props {
13
+ course: CourseDetail;
14
+ lesson: PublicLesson;
15
+ enrollment?: PublicEnrollment | null;
16
+ progress?: ProgressSummary;
17
+ preview?: boolean;
18
+ }
19
+ const { course, lesson, enrollment = null, progress, preview = false } = Astro.props;
20
+ const all = course.modules.flatMap((module) => module.lessons);
21
+ const index = all.findIndex((item) => item.id === lesson.id);
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));
28
+ const current = enrollment?.progress[lesson.id];
29
+ const complete = Boolean(current?.completedAt);
30
+ const attempt = current?.quizAttempts.at(-1);
31
+ const assignment = current?.assignments.at(-1);
32
+ ---
33
+
34
+ <nav class="lms-breadcrumbs" aria-label="Breadcrumb">
35
+ <a href={basePath}>Courses</a>
36
+ <span>/</span>
37
+ <a href={courseUrl(basePath, course.slug)}>{course.title}</a>
38
+ <span>/</span>
39
+ <span>{lesson.title}</span>
40
+ </nav>
41
+ <div
42
+ class="lms-reader-grid"
43
+ data-lms-lesson
44
+ data-course-id={course.id}
45
+ data-lesson-id={lesson.id}
46
+ data-preview={preview ? "true" : "false"}
47
+ data-playback={current?.playback ? JSON.stringify(current.playback) : undefined}
48
+ >
49
+ <aside class="lms-course-sidebar">
50
+ <a href={courseUrl(basePath, course.slug)} class="lms-sidebar-course">
51
+ {course.title}
52
+ </a>
53
+ {progress && (
54
+ <>
55
+ <p>{progress.percent}% complete</p>
56
+ <progress max="100" value={progress.percent}>
57
+ {progress.percent}%
58
+ </progress>
59
+ </>
60
+ )}
61
+ {course.modules.map((module, moduleIndex) => (
62
+ <details open={module.lessons.some((entry) => entry.id === lesson.id)}>
63
+ <summary>
64
+ <small>MODULE {moduleIndex + 1}</small>
65
+ {module.title}
66
+ </summary>
67
+ <ol>
68
+ {module.lessons.map((entry) => (
69
+ <li>
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
+ )}
92
+ </li>
93
+ ))}
94
+ </ol>
95
+ </details>
96
+ ))}
97
+ </aside>
98
+ <article class="lms-lesson">
99
+ <header>
100
+ <p class="lms-eyebrow">
101
+ LESSON {index + 1} OF {all.length} · {lesson.kind.toUpperCase()}
102
+ {preview ? " · FREE PREVIEW" : ""}
103
+ </p>
104
+ <h1>{lesson.title}</h1>
105
+ {lesson.summary && <p class="lms-lead">{lesson.summary}</p>}
106
+ </header>
107
+ {preview && (
108
+ <p class="lms-notice">
109
+ You're viewing a free preview.{" "}
110
+ <a href={courseUrl(basePath, course.slug)}>Enroll to save your progress →</a>
111
+ </p>
112
+ )}
113
+ <LessonContent lesson={lesson} />
114
+ {!preview && lesson.kind === "quiz" && lesson.quiz && (
115
+ <section class="lms-assessment">
116
+ <p class="lms-eyebrow">CHECK YOUR UNDERSTANDING</p>
117
+ <h2>Lesson quiz</h2>
118
+ <p>
119
+ {lesson.quiz.questionCount} questions · Pass with {lesson.quiz.passingPercent}% ·{" "}
120
+ {lesson.quiz.maxAttempts} attempts
121
+ {lesson.quiz.timeLimitMinutes
122
+ ? ` · ${lesson.quiz.timeLimitMinutes} minutes per attempt`
123
+ : ""}
124
+ </p>
125
+ {complete ? (
126
+ <p class="lms-notice">✓ Quiz passed. {attempt?.percent}%</p>
127
+ ) : (
128
+ <div
129
+ data-lms-quiz
130
+ data-course-id={course.id}
131
+ data-lesson-id={lesson.id}
132
+ data-course-url={courseUrl(basePath, course.slug)}
133
+ >
134
+ <button class="lms-btn" type="button" data-lms-quiz-start>
135
+ {attempt?.status === "in_progress" ? "Resume quiz" : "Start quiz"}
136
+ </button>
137
+ <p data-lms-feedback aria-live="polite"></p>
138
+ <div data-lms-quiz-content></div>
139
+ </div>
140
+ )}
141
+ {current?.quizAttempts.length ? (
142
+ <details>
143
+ <summary>Your quiz attempts</summary>
144
+ <ol>
145
+ {current.quizAttempts.map((item) => (
146
+ <li>
147
+ {new Date(item.startedAt).toLocaleString("en-IN")} ·{" "}
148
+ {item.status === "expired"
149
+ ? "Time expired"
150
+ : item.status === "in_progress"
151
+ ? "In progress"
152
+ : `${item.percent}% · ${item.passed ? "Passed" : "Try again"}`}
153
+ </li>
154
+ ))}
155
+ </ol>
156
+ </details>
157
+ ) : null}
158
+ </section>
159
+ )}
160
+ {!preview && lesson.kind === "assignment" && lesson.assignment && (
161
+ <section class="lms-assessment">
162
+ <p class="lms-eyebrow">PUT LEARNING INTO PRACTICE</p>
163
+ <h2>Your assignment</h2>
164
+ <p class="lms-text">{lesson.assignment.instructions}</p>
165
+ <p>
166
+ {lesson.assignment.passingPoints}/{lesson.assignment.maxPoints} points to pass
167
+ {lesson.assignment.dueAt
168
+ ? ` · Due ${new Date(lesson.assignment.dueAt).toLocaleString("en-IN")}`
169
+ : ""}
170
+ </p>
171
+ {assignment && (
172
+ <div class="lms-notice">
173
+ <strong>
174
+ {assignment.status === "submitted"
175
+ ? "Submitted · awaiting feedback"
176
+ : `Graded · ${assignment.points}/${assignment.maxPoints}`}
177
+ </strong>
178
+ <p class="lms-text">{assignment.feedback}</p>
179
+ <details>
180
+ <summary>Your submission</summary>
181
+ <p class="lms-text">{assignment.body}</p>
182
+ {assignment.attachmentUrl && (
183
+ <a href={assignment.attachmentUrl} target="_blank" rel="noopener noreferrer">
184
+ Open attachment ↗
185
+ </a>
186
+ )}
187
+ </details>
188
+ </div>
189
+ )}
190
+ {!complete &&
191
+ (!assignment ||
192
+ (assignment.status === "graded" && lesson.assignment.allowResubmission)) && (
193
+ <form class="lms-form" data-lms-route="learner/assignment" data-lms-reload>
194
+ <input type="hidden" name="courseId" value={course.id} />
195
+ <input type="hidden" name="lessonId" value={lesson.id} />
196
+ <label>
197
+ <span>Your work</span>
198
+ <textarea name="body" required rows={8} maxlength={20_000}></textarea>
199
+ </label>
200
+ <label>
201
+ <span>Attachment link (optional)</span>
202
+ <input name="attachmentUrl" type="url" placeholder="https://…" />
203
+ </label>
204
+ <button class="lms-btn" type="submit">
205
+ Submit assignment
206
+ </button>
207
+ <p data-lms-feedback aria-live="polite"></p>
208
+ </form>
209
+ )}
210
+ </section>
211
+ )}
212
+ {!preview && !["quiz", "assignment"].includes(lesson.kind) && (
213
+ <section class="lms-lesson-completion">
214
+ {complete ? (
215
+ <p class="lms-notice">✓ You've completed this lesson.</p>
216
+ ) : (
217
+ <form data-lms-route="learner/complete" data-lms-reload>
218
+ <input type="hidden" name="courseId" value={course.id} />
219
+ <input type="hidden" name="lessonId" value={lesson.id} />
220
+ <button class="lms-btn" type="submit" data-lms-complete>
221
+ Mark lesson complete ✓
222
+ </button>
223
+ {lesson.minStudySeconds > 0 && (
224
+ <p class="lms-muted">
225
+ Study this lesson for at least {lesson.minStudySeconds} seconds before marking it
226
+ complete.
227
+ </p>
228
+ )}
229
+ <p data-lms-feedback aria-live="polite"></p>
230
+ </form>
231
+ )}
232
+ </section>
233
+ )}
234
+ {enrollment?.certificate && (
235
+ <div class="lms-achievement">
236
+ <span aria-hidden="true">✧</span>
237
+ <div>
238
+ <h2>Look how far you've come</h2>
239
+ <p>You've completed this learning path.</p>
240
+ <a class="lms-btn" href={certificateUrl(basePath, enrollment.certificate.id)}>
241
+ View your certificate →
242
+ </a>
243
+ </div>
244
+ </div>
245
+ )}
246
+ <nav class="lms-lesson-navigation" aria-label="Lesson navigation">
247
+ {previous ? (
248
+ <a href={lessonUrl(basePath, course.slug, previous.id)}>← Previous available lesson</a>
249
+ ) : (
250
+ <a href={courseUrl(basePath, course.slug)}>← Course overview</a>
251
+ )}
252
+ {nextLesson && (
253
+ <a href={lessonUrl(basePath, course.slug, nextLesson.id)}>Next available lesson →</a>
254
+ )}
255
+ </nav>
256
+ {!nextLesson && progress?.blockedLessonId && (
257
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
258
+ )}
259
+ {!preview && (
260
+ <>
261
+ <section class="lms-content-section">
262
+ <h2>A note to your future self</h2>
263
+ <p class="lms-muted">Only you can see these notes.</p>
264
+ <form class="lms-form" data-lms-route="learner/note">
265
+ <input type="hidden" name="courseId" value={course.id} />
266
+ <input type="hidden" name="lessonId" value={lesson.id} />
267
+ <label>
268
+ <span>Your lesson notes</span>
269
+ <textarea name="note" rows={5} maxlength={10_000}>{current?.note ?? ""}</textarea>
270
+ </label>
271
+ <button class="lms-btn lms-btn-outline" type="submit">
272
+ Save notes
273
+ </button>
274
+ <p data-lms-feedback aria-live="polite"></p>
275
+ </form>
276
+ </section>
277
+ <section
278
+ class="lms-content-section"
279
+ data-lms-discussions
280
+ data-course-id={course.id}
281
+ data-lesson-id={lesson.id}
282
+ >
283
+ <h2>Learn together</h2>
284
+ <p class="lms-muted">Ask a question, share an insight, or help another learner.</p>
285
+ <div data-lms-discussion-list></div>
286
+ <form class="lms-form" data-lms-route="learner/discussion">
287
+ <input type="hidden" name="courseId" value={course.id} />
288
+ <input type="hidden" name="lessonId" value={lesson.id} />
289
+ <label>
290
+ <span>Your contribution</span>
291
+ <textarea name="body" rows={4} required maxlength={5000}></textarea>
292
+ </label>
293
+ <button class="lms-btn lms-btn-outline" type="submit">
294
+ Post to discussion
295
+ </button>
296
+ <p data-lms-feedback aria-live="polite"></p>
297
+ </form>
298
+ </section>
299
+ </>
300
+ )}
301
+ </article>
302
+ </div>
@@ -0,0 +1,111 @@
1
+ ---
2
+ interface Props {
3
+ url: string;
4
+ kind: "video" | "audio" | "live";
5
+ title: string;
6
+ startsAt?: string | null;
7
+ captions?: { src: string; language: string; label: string }[];
8
+ }
9
+ const { url, kind, title, startsAt, captions = [] } = Astro.props;
10
+ let embed: string | null = null;
11
+ try {
12
+ const parsed = new URL(url, Astro.url.origin);
13
+ if (["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes(parsed.hostname)) {
14
+ const id =
15
+ parsed.hostname === "youtu.be"
16
+ ? parsed.pathname.slice(1)
17
+ : (parsed.searchParams.get("v") ??
18
+ parsed.pathname.match(/^\/(?:embed|shorts)\/([^/]+)/)?.[1]);
19
+ if (id && /^[a-zA-Z0-9_-]{11}$/.test(id))
20
+ embed = `https://www.youtube-nocookie.com/embed/${id}`;
21
+ } else if (["vimeo.com", "www.vimeo.com", "player.vimeo.com"].includes(parsed.hostname)) {
22
+ const id = parsed.pathname.match(/\/(?:video\/)?(\d+)$/)?.[1];
23
+ if (id) embed = `https://player.vimeo.com/video/${id}`;
24
+ }
25
+ } catch {}
26
+ ---
27
+
28
+ <div class="lms-media" data-lms-media>
29
+ {kind === "live" ? (
30
+ <div class="lms-live-card">
31
+ <p class="lms-eyebrow">LIVE LEARNING SESSION</p>
32
+ <h2>{title}</h2>
33
+ {startsAt && (
34
+ <p>
35
+ Starts{" "}
36
+ {new Date(startsAt).toLocaleString("en-IN", { dateStyle: "long", timeStyle: "short" })}
37
+ </p>
38
+ )}
39
+ <a class="lms-btn" href={url} target="_blank" rel="noopener noreferrer">
40
+ Open meeting ↗
41
+ </a>
42
+ </div>
43
+ ) : kind === "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>
61
+ ) : embed ? (
62
+ <iframe
63
+ src={embed}
64
+ title={title}
65
+ allow="fullscreen; picture-in-picture; encrypted-media"
66
+ allowfullscreen
67
+ referrerpolicy="strict-origin-when-cross-origin"
68
+ ></iframe>
69
+ ) : (
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>
105
+ )}
106
+ {kind !== "live" && (
107
+ <a class="lms-media-link" href={url} target="_blank" rel="noopener noreferrer">
108
+ Open media separately ↗
109
+ </a>
110
+ )}
111
+ </div>
@@ -0,0 +1,7 @@
1
+ export { default as LearningShell } from "./LearningShell.astro";
2
+ export { default as CourseCatalog } from "./CourseCatalog.astro";
3
+ export { default as CourseOverview } from "./CourseOverview.astro";
4
+ export { default as LessonPlayer } from "./LessonPlayer.astro";
5
+ export { default as LearnerDashboard } from "./LearnerDashboard.astro";
6
+ export { default as CertificateCard } from "./CertificateCard.astro";
7
+ export { default as CourseCard } from "./CourseCard.astro";
@@ -0,0 +1,84 @@
1
+ ---
2
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
3
+ import { loginUrl } from "@anantagnanam/lms/server";
4
+ import "../styles.css";
5
+ interface Props {
6
+ title: string;
7
+ description?: string;
8
+ robots?: string;
9
+ }
10
+ const {
11
+ title,
12
+ description = "Courses and guided learning from Ananta Gnanam.",
13
+ robots,
14
+ } = Astro.props;
15
+ ---
16
+
17
+ <!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <title>{title} · Ananta Gnanam</title>
23
+ <meta name="description" content={description} />
24
+ {robots && <meta name="robots" content={robots} />}
25
+ <style is:global>
26
+ body {
27
+ margin: 0;
28
+ background: #f7faf8;
29
+ color: #18271e;
30
+ font-family:
31
+ ui-sans-serif,
32
+ system-ui,
33
+ -apple-system,
34
+ BlinkMacSystemFont,
35
+ "Segoe UI",
36
+ sans-serif;
37
+ }
38
+ a {
39
+ color: inherit;
40
+ }
41
+ html {
42
+ scroll-behavior: smooth;
43
+ }
44
+ @media (prefers-reduced-motion: reduce) {
45
+ html {
46
+ scroll-behavior: auto;
47
+ }
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="lms-root">
53
+ <a class="lms-skip" href="#lms-main">
54
+ Skip to learning content
55
+ </a>
56
+ <header class="lms-site-header">
57
+ <a class="lms-brand" href={basePath}>
58
+ <span class="lms-brand-mark" aria-hidden="true">
59
+ ∞
60
+ </span>
61
+ <span>
62
+ Ananta Gnanam<small>ACADEMY</small>
63
+ </span>
64
+ </a>
65
+ <nav aria-label="Academy navigation">
66
+ <a href={basePath}>Explore courses</a>
67
+ <a href={`${basePath}/my-learning`}>My learning</a>
68
+ <a href="/">Main site ↗</a>
69
+ {!Astro.locals.user && (
70
+ <a class="lms-btn lms-btn-small" href={loginUrl(loginPath, Astro.url.pathname)}>
71
+ Sign in
72
+ </a>
73
+ )}
74
+ </nav>
75
+ </header>
76
+ <slot />
77
+ <footer class="lms-site-footer">
78
+ <a href="/">Ananta Gnanam</a>
79
+ <p>Infinite knowledge. Lifelong learning.</p>
80
+ <a href={basePath}>Keep exploring →</a>
81
+ </footer>
82
+ </div>
83
+ </body>
84
+ </html>
@@ -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>