@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.
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/astro/client.ts +355 -0
- package/astro/components/CertificateCard.astro +83 -0
- package/astro/components/CourseCard.astro +61 -0
- package/astro/components/CourseCatalog.astro +96 -0
- package/astro/components/CourseOverview.astro +308 -0
- package/astro/components/LearnerDashboard.astro +127 -0
- package/astro/components/LearningShell.astro +37 -0
- package/astro/components/LessonAvailabilityNote.astro +26 -0
- package/astro/components/LessonContent.astro +53 -0
- package/astro/components/LessonPlayer.astro +302 -0
- package/astro/components/MediaPlayer.astro +111 -0
- package/astro/components/index.ts +7 -0
- package/astro/layouts/AcademyLayout.astro +84 -0
- package/astro/media-player.ts +99 -0
- package/astro/pages/author-preview.astro +71 -0
- package/astro/pages/certificate.astro +57 -0
- package/astro/pages/cohort.astro +102 -0
- package/astro/pages/course.astro +90 -0
- package/astro/pages/dashboard.astro +34 -0
- package/astro/pages/index.astro +40 -0
- package/astro/pages/lesson.astro +103 -0
- package/astro/pages/library.astro +173 -0
- package/astro/pages/paths.astro +73 -0
- package/astro/pages/teach.astro +15 -0
- package/astro/quiz-drafts.ts +87 -0
- package/astro/quiz-player.ts +362 -0
- package/astro/styles.css +1300 -0
- package/dist/admin.d.ts +34 -0
- package/dist/admin.js +3231 -0
- package/dist/admin.js.map +1 -0
- package/dist/astro.d.ts +11 -0
- package/dist/astro.js +52 -0
- package/dist/astro.js.map +1 -0
- package/dist/commerce.d.ts +72 -0
- package/dist/commerce.js +0 -0
- package/dist/config-BX3beTpM.js +15 -0
- package/dist/config-BX3beTpM.js.map +1 -0
- package/dist/constants-Cnx7k82Z.js +161 -0
- package/dist/constants-Cnx7k82Z.js.map +1 -0
- package/dist/csv-DKq_4OWU.js +55 -0
- package/dist/csv-DKq_4OWU.js.map +1 -0
- package/dist/errors-ChzJS9al.js +16 -0
- package/dist/errors-ChzJS9al.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +773 -0
- package/dist/runtime.js +2840 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schemas.d.ts +651 -0
- package/dist/schemas.js +347 -0
- package/dist/schemas.js.map +1 -0
- package/dist/server.d.ts +40 -0
- package/dist/server.js +65 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +387 -0
- package/dist/types.js +0 -0
- 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>
|