@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ananta Gnanam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @anantagnanam/lms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A native LMS for EmDash 1.4 with Astro 7 and React 19. Version 0.2.0 provides free courses, flexible enrollment, cohorts, lessons, quizzes, assignments, progress, certificates, discussions, moderation, reporting, notifications, published course editions, learning paths, a private library, and native media resume.
|
|
4
|
+
|
|
5
|
+
This is a free preview release. Before upgrading from 0.1.0, back up the full host database, rebuild the site, and run **LMS settings → Course edition migration** until complete. Do not downgrade after migration begins. Production database/auth/email verification, backup restoration, accessibility and capacity checks remain operator release gates. Payment processing is deferred.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install the public npm release in an existing EmDash site:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --save-exact @anantagnanam/lms@0.2.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This is a trusted native dependency configured by the site developer. Local builds can also be installed from their release tarball.
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { defineConfig } from "astro/config";
|
|
19
|
+
import react from "@astrojs/react";
|
|
20
|
+
import emdash from "emdash/astro";
|
|
21
|
+
import { anantagnanamLmsPlugin } from "@anantagnanam/lms";
|
|
22
|
+
import { lmsAstro } from "@anantagnanam/lms/astro";
|
|
23
|
+
|
|
24
|
+
const learning = { basePath: "/academy", loginPath: "/login" };
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
output: "server",
|
|
28
|
+
// Keep the site's existing adapter and other options.
|
|
29
|
+
integrations: [
|
|
30
|
+
react(),
|
|
31
|
+
lmsAstro({ ...learning, layout: "./src/layouts/Base.astro" }),
|
|
32
|
+
emdash({
|
|
33
|
+
// Keep the site's existing database, storage, and auth provider.
|
|
34
|
+
plugins: [anantagnanamLmsPlugin(learning)],
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Keep existing plugin and integration registrations alongside these. Use the same `basePath` and `loginPath` for both registrations. Omit `layout` for the standalone academy layout. A custom layout receives `title`, `description`, and `robots` and must render a slot. The host supplies learner signup and sign-in through its EmDash authentication provider.
|
|
41
|
+
|
|
42
|
+
After rebuilding the site, visit:
|
|
43
|
+
|
|
44
|
+
- `/academy`: course catalog
|
|
45
|
+
- `/academy/my-learning`: learner dashboard
|
|
46
|
+
- `/academy/my-learning/library`: saved courses, joined path progress, preferences and own-data downloads
|
|
47
|
+
- `/academy/paths`: published learning paths
|
|
48
|
+
- `/academy/teach`: instructor course studio
|
|
49
|
+
- `/academy/teach/courses/<courseId>/lessons/<lessonId>`: private saved author preview
|
|
50
|
+
- `/_emdash/admin/plugins/anantagnanam-lms`: administration
|
|
51
|
+
|
|
52
|
+
The native plugin declares course, enrollment, cohort, certificate, discussion, review, notification, and audit storage. EmDash creates the plugin storage namespace. No LMS SQL migration or separate authentication store is needed.
|
|
53
|
+
|
|
54
|
+
The lesson editor identifies inline text, imported rich content, or linked published CMS content. Imported rich content is read-only in the studio; linked lessons can be edited and published in EmDash. Author previews show saved content. Quiz drafts expose save/retry/conflict state and use revisions to prevent stale overwrites. Older attempts are revision zero; integrations must send `expectedDraftRevision` after each acknowledged draft save.
|
|
55
|
+
|
|
56
|
+
Existing courses now have separate saved drafts and published editions. Save a draft, then use **Publish saved draft** to publish for new enrollments. **Saved revision history** can restore earlier material into a draft. Existing learners retain their original curriculum, grading rules, progress and certificates. Linked CMS lesson text is captured at course publication; media URLs remain references. Instructor assignments take effect when saved.
|
|
57
|
+
|
|
58
|
+
After upgrading, run **LMS settings → Course edition migration** batches until complete. Checkpoints survive interruption; courses also upgrade lazily before editing or enrollment. Keep a full database backup: course exports contain only draft curriculum, and this migration is not a full backup or cross-site import. Publication scheduling, editorial review and moving existing enrollments between editions remain future work.
|
|
59
|
+
|
|
60
|
+
## Enrollment and access
|
|
61
|
+
|
|
62
|
+
`open` courses enroll learners immediately. `approval` courses create pending requests. `invite` courses require administrator enrollment. Cohort membership always requires an administrator decision. Administrators can reject, suspend, restore, cancel, and date access without deleting progress.
|
|
63
|
+
|
|
64
|
+
An enrollment has separate lifecycle, administrative access, and individual grants. Current cohort membership and dates are authoritative for cohort access. A public free or manual grant can continue independently of a suspended or expired cohort.
|
|
65
|
+
|
|
66
|
+
## Commerce boundary
|
|
67
|
+
|
|
68
|
+
This release performs no payments. Paid publication and paid enrollment are blocked. `@anantagnanam/lms/commerce` exports only interfaces for money, orders, checkout, verified payment events, refunds, and purchase grants. Future payment providers can add their ledger and purchase grants without replacing enrollment or progress records.
|
|
69
|
+
|
|
70
|
+
## Exports
|
|
71
|
+
|
|
72
|
+
| Export | Purpose |
|
|
73
|
+
| ------------------- | ------------------------------------------------------- |
|
|
74
|
+
| `@anantagnanam/lms` | Descriptor factory and constants |
|
|
75
|
+
| `/runtime` | Native runtime factory |
|
|
76
|
+
| `/astro` | Astro integration and injected server routes |
|
|
77
|
+
| `/server` | Public/learner read helpers and URL builders |
|
|
78
|
+
| `/admin` | React admin pages, course studio, and widget |
|
|
79
|
+
| `/components` | Astro components for custom site composition |
|
|
80
|
+
| `/styles.css` | Scoped learner styles |
|
|
81
|
+
| `/types` | Course, enrollment, progress, assessment, and API types |
|
|
82
|
+
| `/schemas` | Validated authoring and request schemas |
|
|
83
|
+
| `/commerce` | Future provider-independent commerce interfaces |
|
|
84
|
+
|
|
85
|
+
Private HTTP calls use an EmDash session and `X-EmDash-Request: 1`. API routes begin at `/_emdash/api/plugins/anantagnanam-lms`. Learner rendering helpers accept only the read routes required by the included pages.
|
|
86
|
+
|
|
87
|
+
Notifications always appear in the learner dashboard. Email is optional and disabled by default. Configure an EmDash email transport and working cron scheduler before enabling email or inactivity reminders. Certificates use the browser's print/save-as-PDF flow.
|
|
88
|
+
|
|
89
|
+
Learners can opt out of email from their library; this also suppresses queued email when maintenance next runs. Personal weekly lesson goals are stored targets, not measured activity reports. The library exports the learner's own records in pages of up to five records per section, with quiz answer keys and scoring snapshots removed. Continue through every page and section for a complete download; it is not a full database backup.
|
|
90
|
+
|
|
91
|
+
Administrators can publish ordered learning paths. Joining saves the path's original course list but does not enroll the learner in its courses or bypass approval, prerequisites or access dates. Course completion contributes to path progress even when access later expires. Course enrollment still pins the actual curriculum edition.
|
|
92
|
+
|
|
93
|
+
Native audio/video supports resume and playback speed, revision-fenced saves, WebVTT caption tracks and readable transcripts. Captions and media hosted elsewhere require appropriate CORS. YouTube/Vimeo controls and resume remain provider-managed. Playback reports never complete lessons or verify attendance. New caption/transcript fields have compatible defaults; enrollment playback fields are optional for existing records.
|
|
94
|
+
|
|
95
|
+
The studio can duplicate a saved course as a new draft, duplicate lessons and move lessons between modules. Review copied access/date settings before publishing. **LMS settings → Operational health** shows maintenance freshness, notification backlog/failures and migration status. A healthy heartbeat does not certify the deployment.
|
|
96
|
+
|
|
97
|
+
See the [release-readiness assessment](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/release-readiness.md), [platform feature matrix](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/platform-feature-matrix.md), and [security policy](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/SECURITY.md) for remaining release gates and capabilities.
|
|
98
|
+
|
|
99
|
+
Full documentation: [repository](https://github.com/anantagnanam/anantagnanam-emdash-plugins), [installation](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/installation.md), [architecture](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/architecture.md), and [release scope](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/release-scope.md).
|
package/astro/client.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { API_BASE } from "@anantagnanam/lms";
|
|
2
|
+
import { renderQuiz } from "./quiz-player.js";
|
|
3
|
+
import { initializeMedia } from "./media-player.js";
|
|
4
|
+
import type { Discussion, Notification, Page, PublicQuizAttempt } from "@anantagnanam/lms/types";
|
|
5
|
+
|
|
6
|
+
async function call<T>(route: string, input: Record<string, unknown>, method = "POST"): Promise<T> {
|
|
7
|
+
let url = `${API_BASE}/${route}`;
|
|
8
|
+
const headers: Record<string, string> = { "X-EmDash-Request": "1", Accept: "application/json" };
|
|
9
|
+
if (method === "GET") {
|
|
10
|
+
const query = new URLSearchParams();
|
|
11
|
+
for (const [key, value] of Object.entries(input))
|
|
12
|
+
if (value !== undefined) query.set(key, String(value));
|
|
13
|
+
url += `?${query}`;
|
|
14
|
+
} else headers["Content-Type"] = "application/json";
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
method,
|
|
17
|
+
signal: AbortSignal.timeout(20_000),
|
|
18
|
+
credentials: "same-origin",
|
|
19
|
+
headers,
|
|
20
|
+
...(method === "POST" ? { body: JSON.stringify(input) } : {}),
|
|
21
|
+
});
|
|
22
|
+
const result = (await response.json()) as {
|
|
23
|
+
success: boolean;
|
|
24
|
+
data?: T;
|
|
25
|
+
error?: { message?: string; code?: string };
|
|
26
|
+
};
|
|
27
|
+
if (!response.ok || !result.success)
|
|
28
|
+
throw Object.assign(new Error(result.error?.message ?? "Please try again."), {
|
|
29
|
+
code: result.error?.code,
|
|
30
|
+
status: response.status,
|
|
31
|
+
});
|
|
32
|
+
return result.data as T;
|
|
33
|
+
}
|
|
34
|
+
function node<K extends keyof HTMLElementTagNameMap>(
|
|
35
|
+
tag: K,
|
|
36
|
+
text?: string,
|
|
37
|
+
className?: string,
|
|
38
|
+
): HTMLElementTagNameMap[K] {
|
|
39
|
+
const element = document.createElement(tag);
|
|
40
|
+
if (text !== undefined) element.textContent = text;
|
|
41
|
+
if (className) element.className = className;
|
|
42
|
+
return element;
|
|
43
|
+
}
|
|
44
|
+
function feedback(root: Element, message: string, error = false) {
|
|
45
|
+
const target = root.querySelector<HTMLElement>("[data-lms-feedback]");
|
|
46
|
+
if (target) {
|
|
47
|
+
target.textContent = message;
|
|
48
|
+
target.dataset.tone = error ? "error" : "success";
|
|
49
|
+
target.setAttribute("role", error ? "alert" : "status");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function data(element: HTMLElement): Record<string, string> {
|
|
53
|
+
return { courseId: element.dataset.courseId ?? "", lessonId: element.dataset.lessonId ?? "" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function forms() {
|
|
57
|
+
for (const form of document.querySelectorAll<HTMLFormElement>(
|
|
58
|
+
"form[data-lms-route]:not([data-lms-bound])",
|
|
59
|
+
)) {
|
|
60
|
+
form.dataset.lmsBound = "true";
|
|
61
|
+
form.addEventListener("submit", async (event) => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (form.dataset.lmsBusy === "true") return;
|
|
64
|
+
if (form.dataset.lmsConfirm && !window.confirm(form.dataset.lmsConfirm)) return;
|
|
65
|
+
const input: Record<string, unknown> = Object.fromEntries(new FormData(form));
|
|
66
|
+
if ("rating" in input) input.rating = Number(input.rating);
|
|
67
|
+
if ("shared" in input) input.shared = input.shared === "true";
|
|
68
|
+
if ("saved" in input) input.saved = input.saved === "true";
|
|
69
|
+
if (form.dataset.lmsRoute === "learner/preferences/save") {
|
|
70
|
+
input.data = {
|
|
71
|
+
emailNotifications: input.emailNotifications === "true",
|
|
72
|
+
weeklyGoalLessons: Number(input.weeklyGoalLessons),
|
|
73
|
+
};
|
|
74
|
+
input.expectedRevision = input.expectedRevision || null;
|
|
75
|
+
delete input.emailNotifications;
|
|
76
|
+
delete input.weeklyGoalLessons;
|
|
77
|
+
}
|
|
78
|
+
if ("attachmentUrl" in input && input.attachmentUrl === "") input.attachmentUrl = null;
|
|
79
|
+
if ("parentId" in input && input.parentId === "") input.parentId = null;
|
|
80
|
+
const buttons = [...form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')];
|
|
81
|
+
form.dataset.lmsBusy = "true";
|
|
82
|
+
buttons.forEach((button) => (button.disabled = true));
|
|
83
|
+
feedback(form, "Saving…");
|
|
84
|
+
try {
|
|
85
|
+
const route = form.dataset.lmsRoute!;
|
|
86
|
+
const result = await call<{ status?: string }>(route, input);
|
|
87
|
+
feedback(
|
|
88
|
+
form,
|
|
89
|
+
route === "learner/enroll"
|
|
90
|
+
? result.status === "pending"
|
|
91
|
+
? "Enrollment requested. An administrator will review it."
|
|
92
|
+
: "You're enrolled. Your learning journey is ready."
|
|
93
|
+
: route === "learner/cohort/request"
|
|
94
|
+
? "Cohort request received. An administrator will review it."
|
|
95
|
+
: route === "learner/assignment"
|
|
96
|
+
? "Assignment submitted for grading."
|
|
97
|
+
: route === "learner/review" || route === "learner/discussion"
|
|
98
|
+
? "Your contribution has been submitted."
|
|
99
|
+
: "Saved.",
|
|
100
|
+
);
|
|
101
|
+
if (route === "learner/discussion") {
|
|
102
|
+
const body = form.elements.namedItem("body");
|
|
103
|
+
if (body instanceof HTMLTextAreaElement) body.value = "";
|
|
104
|
+
const parent = form.elements.namedItem("parentId");
|
|
105
|
+
if (parent instanceof HTMLInputElement) parent.value = "";
|
|
106
|
+
const root = form.closest<HTMLElement>("[data-lms-discussions]");
|
|
107
|
+
if (root) await renderDiscussions(root);
|
|
108
|
+
}
|
|
109
|
+
if (form.hasAttribute("data-lms-reload")) window.location.reload();
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
feedback(
|
|
112
|
+
form,
|
|
113
|
+
cause instanceof Error ? cause.message : "Could not save. Please try again.",
|
|
114
|
+
true,
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
form.dataset.lmsBusy = "false";
|
|
118
|
+
buttons.forEach((button) => (button.disabled = false));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renderDiscussions(root: HTMLElement, cursor?: string, append = false) {
|
|
125
|
+
const list = root.querySelector<HTMLElement>("[data-lms-discussion-list]");
|
|
126
|
+
if (!list) return;
|
|
127
|
+
try {
|
|
128
|
+
const page = await call<Page<Discussion> & { pending: Discussion[] }>(
|
|
129
|
+
"learner/discussions",
|
|
130
|
+
{ ...data(root), cursor },
|
|
131
|
+
"GET",
|
|
132
|
+
);
|
|
133
|
+
if (!append) list.replaceChildren();
|
|
134
|
+
root.querySelector("[data-lms-discussion-more]")?.remove();
|
|
135
|
+
if (!page.items.length && !page.pending.length && !append)
|
|
136
|
+
list.append(node("p", "Start a thoughtful conversation.", "lms-muted"));
|
|
137
|
+
for (const item of [...page.items, ...(!append ? page.pending : [])]) {
|
|
138
|
+
const article = node("article", undefined, "lms-discussion");
|
|
139
|
+
if (item.parentId) article.style.marginInlineStart = "1.2rem";
|
|
140
|
+
article.append(
|
|
141
|
+
node("strong", item.authorName),
|
|
142
|
+
node("p", item.body),
|
|
143
|
+
node(
|
|
144
|
+
"small",
|
|
145
|
+
item.status === "pending"
|
|
146
|
+
? "Awaiting moderation · visible only to you"
|
|
147
|
+
: new Date(item.createdAt).toLocaleString(),
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
if (item.status === "published" && !item.parentId) {
|
|
151
|
+
const reply = node("button", "Reply", "lms-text-button");
|
|
152
|
+
reply.type = "button";
|
|
153
|
+
reply.addEventListener("click", () => {
|
|
154
|
+
const form = root.querySelector<HTMLFormElement>("form");
|
|
155
|
+
if (!form) return;
|
|
156
|
+
let parent = form.elements.namedItem("parentId");
|
|
157
|
+
if (!(parent instanceof HTMLInputElement)) {
|
|
158
|
+
parent = node("input");
|
|
159
|
+
(parent as HTMLInputElement).type = "hidden";
|
|
160
|
+
(parent as HTMLInputElement).name = "parentId";
|
|
161
|
+
form.append(parent as HTMLInputElement);
|
|
162
|
+
}
|
|
163
|
+
(parent as HTMLInputElement).value = item.id;
|
|
164
|
+
feedback(form, `Replying to ${item.authorName}.`);
|
|
165
|
+
form.querySelector("textarea")?.focus();
|
|
166
|
+
});
|
|
167
|
+
article.append(reply);
|
|
168
|
+
}
|
|
169
|
+
list.append(article);
|
|
170
|
+
}
|
|
171
|
+
if (page.hasMore && page.cursor) {
|
|
172
|
+
const more = node("button", "More discussion", "lms-btn lms-btn-outline");
|
|
173
|
+
more.type = "button";
|
|
174
|
+
more.dataset.lmsDiscussionMore = "true";
|
|
175
|
+
more.addEventListener("click", () => void renderDiscussions(root, page.cursor, true));
|
|
176
|
+
root.append(more);
|
|
177
|
+
}
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
list.replaceChildren(
|
|
180
|
+
node("p", cause instanceof Error ? cause.message : "Could not load discussion.", "lms-muted"),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function quizzes() {
|
|
186
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
187
|
+
"[data-lms-quiz]:not([data-lms-bound])",
|
|
188
|
+
)) {
|
|
189
|
+
root.dataset.lmsBound = "true";
|
|
190
|
+
const start = root.querySelector<HTMLButtonElement>("[data-lms-quiz-start]")!;
|
|
191
|
+
start.addEventListener("click", async () => {
|
|
192
|
+
if (start.disabled) return;
|
|
193
|
+
start.disabled = true;
|
|
194
|
+
feedback(root, "Preparing your quiz…");
|
|
195
|
+
try {
|
|
196
|
+
const attempt = await call<PublicQuizAttempt>("learner/quiz/start", data(root));
|
|
197
|
+
start.hidden = true;
|
|
198
|
+
feedback(root, "Your attempt has started.");
|
|
199
|
+
renderQuiz(root, attempt, call);
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
start.disabled = false;
|
|
202
|
+
feedback(root, cause instanceof Error ? cause.message : "Could not start quiz.", true);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function updates(root: HTMLElement, cursor?: string, append = false) {
|
|
208
|
+
try {
|
|
209
|
+
const page = await call<Page<Omit<Notification, "email">>>(
|
|
210
|
+
"learner/notifications",
|
|
211
|
+
{ cursor },
|
|
212
|
+
"GET",
|
|
213
|
+
);
|
|
214
|
+
if (!append) root.replaceChildren();
|
|
215
|
+
root.querySelector("[data-lms-more]")?.remove();
|
|
216
|
+
if (!page.items.length && !append)
|
|
217
|
+
root.append(node("p", "Your learning updates will appear here.", "lms-muted"));
|
|
218
|
+
for (const item of page.items) {
|
|
219
|
+
const article = node("article", undefined, "lms-notification");
|
|
220
|
+
article.append(node("strong", item.title), node("p", item.message));
|
|
221
|
+
if (!item.read) {
|
|
222
|
+
const button = node("button", "Mark as read");
|
|
223
|
+
button.type = "button";
|
|
224
|
+
button.addEventListener("click", async () => {
|
|
225
|
+
button.disabled = true;
|
|
226
|
+
try {
|
|
227
|
+
await call("learner/notification/read", { id: item.id });
|
|
228
|
+
button.remove();
|
|
229
|
+
} catch {
|
|
230
|
+
button.disabled = false;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
article.append(button);
|
|
234
|
+
}
|
|
235
|
+
root.append(article);
|
|
236
|
+
}
|
|
237
|
+
if (page.hasMore && page.cursor) {
|
|
238
|
+
const more = node("button", "More updates", "lms-text-button");
|
|
239
|
+
more.type = "button";
|
|
240
|
+
more.dataset.lmsMore = "true";
|
|
241
|
+
more.addEventListener("click", () => void updates(root, page.cursor, true));
|
|
242
|
+
root.append(more);
|
|
243
|
+
}
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
root.replaceChildren(
|
|
246
|
+
node("p", cause instanceof Error ? cause.message : "Could not load updates.", "lms-muted"),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function initialize() {
|
|
252
|
+
forms();
|
|
253
|
+
quizzes();
|
|
254
|
+
initializeMedia(call);
|
|
255
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
256
|
+
"[data-lms-export]:not([data-lms-bound])",
|
|
257
|
+
)) {
|
|
258
|
+
root.dataset.lmsBound = "true";
|
|
259
|
+
const select = root.querySelector<HTMLSelectElement>("[data-lms-export-section]")!;
|
|
260
|
+
const button = root.querySelector<HTMLButtonElement>("[data-lms-export-download]")!;
|
|
261
|
+
let cursor: string | undefined;
|
|
262
|
+
let pageNumber = 1;
|
|
263
|
+
select.addEventListener("change", () => {
|
|
264
|
+
cursor = undefined;
|
|
265
|
+
pageNumber = 1;
|
|
266
|
+
button.disabled = false;
|
|
267
|
+
button.textContent = "Download first page";
|
|
268
|
+
feedback(root, "");
|
|
269
|
+
});
|
|
270
|
+
button.addEventListener("click", async () => {
|
|
271
|
+
button.disabled = true;
|
|
272
|
+
select.disabled = true;
|
|
273
|
+
try {
|
|
274
|
+
const page = await call<Page<unknown>>(
|
|
275
|
+
"learner/export",
|
|
276
|
+
{ section: select.value, cursor, limit: 5 },
|
|
277
|
+
"GET",
|
|
278
|
+
);
|
|
279
|
+
const url = URL.createObjectURL(
|
|
280
|
+
new Blob([JSON.stringify(page, null, 2)], { type: "application/json" }),
|
|
281
|
+
);
|
|
282
|
+
const link = node("a");
|
|
283
|
+
link.href = url;
|
|
284
|
+
link.download = `lms-${select.value}-${pageNumber}.json`;
|
|
285
|
+
document.body.append(link);
|
|
286
|
+
link.click();
|
|
287
|
+
link.remove();
|
|
288
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
289
|
+
cursor = page.cursor;
|
|
290
|
+
pageNumber++;
|
|
291
|
+
button.disabled = !page.hasMore;
|
|
292
|
+
button.textContent = page.hasMore ? "Download next page" : "Section downloaded";
|
|
293
|
+
feedback(
|
|
294
|
+
root,
|
|
295
|
+
page.hasMore
|
|
296
|
+
? "Page downloaded. Download the next page to continue."
|
|
297
|
+
: "All pages in this section downloaded. Choose another section to continue.",
|
|
298
|
+
);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
button.disabled = false;
|
|
301
|
+
feedback(root, error instanceof Error ? error.message : "Export failed.", true);
|
|
302
|
+
} finally {
|
|
303
|
+
select.disabled = false;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
308
|
+
"[data-lms-lesson]:not([data-lms-started])",
|
|
309
|
+
)) {
|
|
310
|
+
root.dataset.lmsStarted = "true";
|
|
311
|
+
if (root.dataset.preview !== "true")
|
|
312
|
+
void call("learner/start", data(root)).catch((cause) => {
|
|
313
|
+
const button = root.querySelector<HTMLButtonElement>("[data-lms-complete]");
|
|
314
|
+
if (button) button.disabled = true;
|
|
315
|
+
const form = root.querySelector("form[data-lms-route='learner/complete']");
|
|
316
|
+
if (form)
|
|
317
|
+
feedback(form, cause instanceof Error ? cause.message : "Could not start lesson.", true);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
321
|
+
"[data-lms-discussions]:not([data-lms-loaded])",
|
|
322
|
+
)) {
|
|
323
|
+
root.dataset.lmsLoaded = "true";
|
|
324
|
+
void renderDiscussions(root);
|
|
325
|
+
}
|
|
326
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
327
|
+
"[data-lms-notifications]:not([data-lms-loaded])",
|
|
328
|
+
)) {
|
|
329
|
+
root.dataset.lmsLoaded = "true";
|
|
330
|
+
void updates(root);
|
|
331
|
+
}
|
|
332
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>(
|
|
333
|
+
"[data-lms-print]:not([data-lms-bound])",
|
|
334
|
+
)) {
|
|
335
|
+
button.dataset.lmsBound = "true";
|
|
336
|
+
button.addEventListener("click", () => window.print());
|
|
337
|
+
}
|
|
338
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>(
|
|
339
|
+
"[data-lms-copy-link]:not([data-lms-bound])",
|
|
340
|
+
)) {
|
|
341
|
+
button.dataset.lmsBound = "true";
|
|
342
|
+
button.addEventListener("click", async () => {
|
|
343
|
+
const status = document.querySelector("[data-lms-copy-status]");
|
|
344
|
+
const url = location.origin + location.pathname;
|
|
345
|
+
try {
|
|
346
|
+
await navigator.clipboard.writeText(url);
|
|
347
|
+
if (status) status.textContent = "Verification link copied.";
|
|
348
|
+
} catch {
|
|
349
|
+
if (status) status.textContent = `Verification link: ${url}`;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
initialize();
|
|
355
|
+
document.addEventListener("astro:page-load", initialize);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Certificate } from "@anantagnanam/lms/types";
|
|
3
|
+
interface Verification {
|
|
4
|
+
id: string;
|
|
5
|
+
valid: boolean;
|
|
6
|
+
shared: boolean;
|
|
7
|
+
learnerName: string | null;
|
|
8
|
+
courseTitle: string;
|
|
9
|
+
issuerName: string;
|
|
10
|
+
issuedAt: string;
|
|
11
|
+
}
|
|
12
|
+
interface Props {
|
|
13
|
+
verification: Verification;
|
|
14
|
+
certificate?: Certificate | null;
|
|
15
|
+
}
|
|
16
|
+
const { verification, certificate } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<section class="lms-certificate-page">
|
|
20
|
+
<header class="lms-certificate-intro">
|
|
21
|
+
<p class="lms-eyebrow">A MILESTONE WORTH REMEMBERING</p>
|
|
22
|
+
<h1>{certificate ? "Your learning, recognised" : "Certificate verification"}</h1>
|
|
23
|
+
<p class="lms-lead">
|
|
24
|
+
{verification.valid
|
|
25
|
+
? "This certificate was issued for a completed learning path."
|
|
26
|
+
: "This certificate has been revoked."}
|
|
27
|
+
</p>
|
|
28
|
+
</header>
|
|
29
|
+
<article class:list={["lms-certificate", { "lms-certificate-revoked": !verification.valid }]}>
|
|
30
|
+
<div class="lms-certificate-top">
|
|
31
|
+
<span>{verification.issuerName}</span>
|
|
32
|
+
<span class="lms-certificate-mark" aria-hidden="true">
|
|
33
|
+
∞
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
<p class="lms-eyebrow">CERTIFICATE OF COMPLETION</p>
|
|
37
|
+
<p>This recognises</p>
|
|
38
|
+
<h2>
|
|
39
|
+
{certificate?.learnerName ??
|
|
40
|
+
verification.learnerName ??
|
|
41
|
+
"A learner who keeps their name private"}
|
|
42
|
+
</h2>
|
|
43
|
+
<p>for successfully completing</p>
|
|
44
|
+
<h3>{verification.courseTitle}</h3>
|
|
45
|
+
<div class="lms-certificate-rule"></div>
|
|
46
|
+
<div class="lms-certificate-bottom">
|
|
47
|
+
<div>
|
|
48
|
+
<span class="lms-eyebrow">ISSUED</span>
|
|
49
|
+
<p>
|
|
50
|
+
{new Date(verification.issuedAt).toLocaleDateString("en-IN", {
|
|
51
|
+
year: "numeric",
|
|
52
|
+
month: "long",
|
|
53
|
+
day: "numeric",
|
|
54
|
+
})}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div>
|
|
58
|
+
<span class="lms-eyebrow">STATUS</span>
|
|
59
|
+
<p>{verification.valid ? "Verified completion ✓" : "Revoked"}</p>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<small>Certificate {verification.id}</small>
|
|
63
|
+
</article>
|
|
64
|
+
{certificate && (
|
|
65
|
+
<div class="lms-certificate-controls">
|
|
66
|
+
<button class="lms-btn" type="button" data-lms-print>
|
|
67
|
+
Print or save as PDF
|
|
68
|
+
</button>
|
|
69
|
+
<form data-lms-route="learner/certificate/share" data-lms-reload>
|
|
70
|
+
<input type="hidden" name="courseId" value={certificate.courseId} />
|
|
71
|
+
<input type="hidden" name="shared" value={certificate.shared ? "false" : "true"} />
|
|
72
|
+
<button class="lms-btn lms-btn-outline" type="submit">
|
|
73
|
+
{certificate.shared ? "Keep my name private" : "Share my name on verification"}
|
|
74
|
+
</button>
|
|
75
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
76
|
+
</form>
|
|
77
|
+
<button class="lms-btn lms-btn-outline" type="button" data-lms-copy-link>
|
|
78
|
+
Copy verification link
|
|
79
|
+
</button>
|
|
80
|
+
<p data-lms-copy-status aria-live="polite"></p>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</section>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Image } from "emdash/ui";
|
|
3
|
+
import { courseUrl } from "@anantagnanam/lms/server";
|
|
4
|
+
import { basePath } from "virtual:anantagnanam-lms/config";
|
|
5
|
+
import type { CourseCard } from "@anantagnanam/lms/types";
|
|
6
|
+
interface Props {
|
|
7
|
+
course: CourseCard;
|
|
8
|
+
}
|
|
9
|
+
const { course } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<article class="lms-course-card">
|
|
13
|
+
<a
|
|
14
|
+
class="lms-card-art"
|
|
15
|
+
href={courseUrl(basePath, course.slug)}
|
|
16
|
+
aria-label={`Explore ${course.title}`}
|
|
17
|
+
tabindex="-1"
|
|
18
|
+
>
|
|
19
|
+
{course.featuredImage ? (
|
|
20
|
+
<Image image={course.featuredImage} width={760} height={440} loading="lazy" />
|
|
21
|
+
) : (
|
|
22
|
+
<div class="lms-course-cover">
|
|
23
|
+
<svg viewBox="0 0 120 100" width="140" height="116" aria-hidden="true">
|
|
24
|
+
<path
|
|
25
|
+
d="M60 28C45 17 23 18 13 25v55c14-8 31-8 47 2 16-10 33-10 47-2V25c-10-7-32-8-47 3Z"
|
|
26
|
+
fill="none"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
stroke-width="3"
|
|
29
|
+
stroke-linejoin="round"
|
|
30
|
+
/>
|
|
31
|
+
<path
|
|
32
|
+
d="M60 29v53M25 35c9-2 16-1 24 3M25 46c9-2 16-1 24 3M25 57c9-2 16-1 24 3M71 38c9-4 16-5 24-3M71 49c9-4 16-5 24-3M71 60c9-4 16-5 24-3"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
stroke-width="2"
|
|
36
|
+
stroke-linecap="round"
|
|
37
|
+
/>
|
|
38
|
+
</svg>
|
|
39
|
+
<span>{course.category || "A new perspective"}</span>
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
<span class="lms-free-label">FREE</span>
|
|
43
|
+
</a>
|
|
44
|
+
<div class="lms-card-body">
|
|
45
|
+
<div class="lms-card-meta">
|
|
46
|
+
<span>{course.category || "Learning"}</span>
|
|
47
|
+
<span>{course.level === "all" ? "All levels" : course.level}</span>
|
|
48
|
+
</div>
|
|
49
|
+
<h2>
|
|
50
|
+
<a href={courseUrl(basePath, course.slug)}>{course.title}</a>
|
|
51
|
+
</h2>
|
|
52
|
+
<p>{course.summary}</p>
|
|
53
|
+
<div class="lms-card-footer">
|
|
54
|
+
<span>
|
|
55
|
+
{course.lessonCount} lessons · {course.durationMinutes} min
|
|
56
|
+
</span>
|
|
57
|
+
<span aria-hidden="true">↗</span>
|
|
58
|
+
</div>
|
|
59
|
+
{course.instructorName && <small>With {course.instructorName}</small>}
|
|
60
|
+
</div>
|
|
61
|
+
</article>
|