@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.
- package/README.md +19 -2
- package/astro/assignment-player.ts +231 -0
- package/astro/client.ts +24 -1
- package/astro/components/AssignmentFeedback.astro +32 -0
- package/astro/components/AssignmentRubric.astro +27 -0
- package/astro/components/LearnerDashboard.astro +3 -0
- package/astro/components/LearningShell.astro +6 -0
- package/astro/components/LessonContent.astro +16 -2
- package/astro/components/LessonPlayer.astro +126 -38
- package/astro/draft-queue.ts +95 -0
- package/astro/media-player.ts +26 -1
- package/astro/pages/author-preview.astro +2 -0
- package/astro/pages/study.astro +180 -0
- package/astro/quiz-drafts.ts +12 -76
- package/astro/study-tools.ts +386 -0
- package/astro/styles.css +18 -0
- package/astro/transcript-search.ts +56 -0
- package/dist/admin.js +172 -12
- package/dist/admin.js.map +1 -1
- package/dist/assignments-CsuAjxGO.js +75 -0
- package/dist/assignments-CsuAjxGO.js.map +1 -0
- package/dist/astro.js +1 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-Cnx7k82Z.js → constants-DyQbwNbP.js} +2 -2
- package/dist/{constants-Cnx7k82Z.js.map → constants-DyQbwNbP.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +26 -5
- package/dist/runtime.js +307 -16
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +69 -3
- package/dist/schemas.js +47 -6
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.js +6 -3
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +67 -2
- package/package.json +1 -1
- package/dist/errors-ChzJS9al.js +0 -16
- package/dist/errors-ChzJS9al.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @anantagnanam/lms
|
|
2
2
|
|
|
3
|
-
A native LMS for EmDash 1.4 with Astro 7 and React 19. Version 0.
|
|
3
|
+
A native LMS for EmDash 1.4 with Astro 7 and React 19. Version 0.3.0 provides free courses, flexible enrollment, cohorts, lessons, quizzes, assignments, progress, certificates, discussions, moderation, reporting, notifications, published course editions, learning paths, a private library, native media resume, recoverable assignment drafts, rubric grading, private notebooks, course agendas and transcript search.
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -9,7 +9,7 @@ This is a free preview release. Before upgrading from 0.1.0, back up the full ho
|
|
|
9
9
|
Install the public npm release in an existing EmDash site:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install --save-exact @anantagnanam/lms@0.
|
|
12
|
+
npm install --save-exact @anantagnanam/lms@0.3.0
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
This is a trusted native dependency configured by the site developer. Local builds can also be installed from their release tarball.
|
|
@@ -44,6 +44,7 @@ After rebuilding the site, visit:
|
|
|
44
44
|
- `/academy`: course catalog
|
|
45
45
|
- `/academy/my-learning`: learner dashboard
|
|
46
46
|
- `/academy/my-learning/library`: saved courses, joined path progress, preferences and own-data downloads
|
|
47
|
+
- `/academy/my-learning/study`: private course notebooks and agendas
|
|
47
48
|
- `/academy/paths`: published learning paths
|
|
48
49
|
- `/academy/teach`: instructor course studio
|
|
49
50
|
- `/academy/teach/courses/<courseId>/lessons/<lessonId>`: private saved author preview
|
|
@@ -57,6 +58,22 @@ Existing courses now have separate saved drafts and published editions. Save a d
|
|
|
57
58
|
|
|
58
59
|
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
|
|
|
61
|
+
## Assignment workflow enhancements
|
|
62
|
+
|
|
63
|
+
Version 0.3.0 adds private assignment autosave/recovery, explicit conflict review, safe submission retries, optional grading rubrics, and feedback history across resubmissions. Rubrics support up to 20 criteria and 1000 total points; criteria and passing rules are frozen with each submission from the learner's enrolled course edition. Staff score each criterion and the server totals the grade. Existing submissions keep scalar grading.
|
|
64
|
+
|
|
65
|
+
Saved drafts are excluded from staff APIs and included in the learner's own data export. The browser must stay open for unacknowledged work; offline drafts, private file uploads, late policies and audited regrading are not included. Custom clients should send `expectedDraftRevision` for draft writes/submission and a stable `submissionId` for retryable submission. See the repository API and operator guides for recovery semantics.
|
|
66
|
+
|
|
67
|
+
## Study tools
|
|
68
|
+
|
|
69
|
+
Version 0.3.0 adds searchable course notebooks with multiple notes, stars, archive/restore, and playback bookmarks. Native audio/video can capture the current time and jump back from a saved note without autoplay. Transcript search highlights plain-text matches with previous/next controls. Embedded providers retain their own playback behavior.
|
|
70
|
+
|
|
71
|
+
Notebooks preserve existing lesson summaries, stay private from staff, and remain readable in the notebook and learner export after access expires. Creating, editing, archiving and restoring require current lesson access. Each course supports up to 100 notes of 5000 characters, with a 150,000-byte serialized notebook budget; archived entries count toward both limits. Saves require the last acknowledged revision and preserve local text after conflicts. Keep the page open until a save is confirmed; no offline note store is included.
|
|
72
|
+
|
|
73
|
+
The course agenda lists assignment deadlines, live sessions and future lesson releases from the enrolled edition, displays the selected timezone and downloads upcoming events as `.ics`. This is a private snapshot, not a subscription or automatic reminder. It contains authenticated lesson links, not meeting credentials. Calendar apps retain downloaded data independently of later course access changes.
|
|
74
|
+
|
|
75
|
+
Custom clients must send `expectedRevision` to the existing `learner/note` route; legacy summaries begin at revision zero. New notebook clients use stable note IDs and retry identical unacknowledged requests. See the API guide for the full contract.
|
|
76
|
+
|
|
60
77
|
## Enrollment and access
|
|
61
78
|
|
|
62
79
|
`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.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { DraftQueue, type DraftState } from "./draft-queue.js";
|
|
2
|
+
import type { AssignmentState } from "@anantagnanam/lms/types";
|
|
3
|
+
|
|
4
|
+
type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
|
|
5
|
+
type Values = { body: string; attachmentUrl: string | null };
|
|
6
|
+
|
|
7
|
+
export function initializeAssignments(call: Call) {
|
|
8
|
+
for (const form of document.querySelectorAll<HTMLFormElement>(
|
|
9
|
+
"form[data-lms-assignment]:not([data-lms-bound])",
|
|
10
|
+
)) {
|
|
11
|
+
form.dataset.lmsBound = "true";
|
|
12
|
+
bindAssignment(form, call);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function bindAssignment(form: HTMLFormElement, call: Call) {
|
|
17
|
+
const body = form.elements.namedItem("body") as HTMLTextAreaElement;
|
|
18
|
+
const attachment = form.elements.namedItem("attachmentUrl") as HTMLInputElement;
|
|
19
|
+
const save = form.querySelector<HTMLButtonElement>("[data-lms-save-draft]")!;
|
|
20
|
+
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
|
|
21
|
+
const status = form.querySelector<HTMLElement>("[data-lms-feedback]")!;
|
|
22
|
+
const conflict = form.querySelector<HTMLElement>("[data-lms-assignment-conflict]")!;
|
|
23
|
+
const savedText = conflict.querySelector<HTMLElement>("[data-lms-saved-draft]")!;
|
|
24
|
+
const review = form.querySelector<HTMLButtonElement>("[data-lms-review-draft]")!;
|
|
25
|
+
const useSaved = conflict.querySelector<HTMLButtonElement>("[data-lms-use-saved]")!;
|
|
26
|
+
const keepLocal = conflict.querySelector<HTMLButtonElement>("[data-lms-keep-local]")!;
|
|
27
|
+
const input = { courseId: form.dataset.courseId!, lessonId: form.dataset.lessonId! };
|
|
28
|
+
const values = (): Values => ({
|
|
29
|
+
body: body.value,
|
|
30
|
+
attachmentUrl: attachment.value.trim() || null,
|
|
31
|
+
});
|
|
32
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
let busy = false;
|
|
34
|
+
let closed = false;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
let submitted = false;
|
|
37
|
+
let remote: AssignmentState | undefined;
|
|
38
|
+
let reviewing: Promise<void> | undefined;
|
|
39
|
+
let request:
|
|
40
|
+
(Values & typeof input & { submissionId: string; expectedDraftRevision: number }) | undefined;
|
|
41
|
+
const tell = (text: string, error = false) => {
|
|
42
|
+
status.textContent = text;
|
|
43
|
+
status.setAttribute("role", error ? "alert" : "status");
|
|
44
|
+
};
|
|
45
|
+
const controls = () => {
|
|
46
|
+
body.readOnly = attachment.readOnly = busy || !!request;
|
|
47
|
+
save.disabled = busy || closed || !!request || queue.state === "conflict";
|
|
48
|
+
submit.disabled = busy || closed || queue.state === "conflict";
|
|
49
|
+
review.disabled = busy;
|
|
50
|
+
useSaved.disabled = keepLocal.disabled = busy || closed;
|
|
51
|
+
review.hidden = queue.state !== "conflict";
|
|
52
|
+
};
|
|
53
|
+
const changed = (state: DraftState) => {
|
|
54
|
+
const messages: Record<DraftState, string> = {
|
|
55
|
+
saved: "Draft saved. Only you can see it until submission.",
|
|
56
|
+
pending: "Unsaved changes…",
|
|
57
|
+
saving: "Saving draft…",
|
|
58
|
+
failed: "Draft not saved. Keep this page open and choose Retry save.",
|
|
59
|
+
conflict: "Another request changed this assignment. Review the saved work before continuing.",
|
|
60
|
+
};
|
|
61
|
+
tell(messages[state], state === "failed" || state === "conflict");
|
|
62
|
+
save.textContent = state === "failed" ? "Retry save" : "Save draft";
|
|
63
|
+
controls();
|
|
64
|
+
};
|
|
65
|
+
const queue = new DraftQueue<Values>(
|
|
66
|
+
values(),
|
|
67
|
+
Number(form.dataset.draftRevision ?? 0),
|
|
68
|
+
(write) =>
|
|
69
|
+
call("learner/assignment/draft", {
|
|
70
|
+
...input,
|
|
71
|
+
...write.value,
|
|
72
|
+
expectedDraftRevision: write.expectedDraftRevision,
|
|
73
|
+
}),
|
|
74
|
+
changed,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const refreshConflict = () =>
|
|
78
|
+
(reviewing ??= loadConflict().finally(() => {
|
|
79
|
+
reviewing = undefined;
|
|
80
|
+
}));
|
|
81
|
+
const loadConflict = async () => {
|
|
82
|
+
busy = true;
|
|
83
|
+
controls();
|
|
84
|
+
try {
|
|
85
|
+
remote = await call<AssignmentState>("learner/assignment/state", input, "GET");
|
|
86
|
+
if (disposed) return;
|
|
87
|
+
conflict.hidden = false;
|
|
88
|
+
closed = !remote.canSubmit;
|
|
89
|
+
savedText.textContent =
|
|
90
|
+
remote.submission && closed
|
|
91
|
+
? `Submitted work:\n${remote.submission.body}\n${remote.submission.attachmentUrl ?? ""}`
|
|
92
|
+
: `${remote.draft.body || "(Empty saved draft)"}\n${remote.draft.attachmentUrl ?? ""}`;
|
|
93
|
+
if (closed)
|
|
94
|
+
tell(
|
|
95
|
+
`${remote.closedReason} Your local text is still in the form; copy it before leaving.`,
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
else tell("Compare the saved draft below with your work, then choose which to keep.", true);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (disposed) return;
|
|
101
|
+
tell(
|
|
102
|
+
`Could not load the saved work. ${error instanceof Error ? error.message : "Try again."}`,
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
busy = false;
|
|
107
|
+
controls();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const saveDraft = async () => {
|
|
111
|
+
if (busy || closed || request || disposed) return;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
queue.update(values());
|
|
114
|
+
if (!attachment.checkValidity()) {
|
|
115
|
+
tell("Correct the attachment URL before saving.", true);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await queue.flush();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (disposed) return;
|
|
122
|
+
if (queue.state === "conflict") await refreshConflict();
|
|
123
|
+
else
|
|
124
|
+
tell(
|
|
125
|
+
`Draft not saved. ${error instanceof Error ? error.message : "Try again."} Use Retry save.`,
|
|
126
|
+
true,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const field of [body, attachment])
|
|
131
|
+
field.addEventListener("input", () => {
|
|
132
|
+
queue.update(values());
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
if (!["failed", "conflict"].includes(queue.state))
|
|
135
|
+
timer = setTimeout(() => void saveDraft(), 900);
|
|
136
|
+
});
|
|
137
|
+
save.addEventListener("click", () => void saveDraft());
|
|
138
|
+
review.addEventListener("click", () => void refreshConflict());
|
|
139
|
+
for (const [button, keep] of [
|
|
140
|
+
[useSaved, false],
|
|
141
|
+
[keepLocal, true],
|
|
142
|
+
] as const)
|
|
143
|
+
button.addEventListener("click", () => {
|
|
144
|
+
if (!remote || busy || closed) return;
|
|
145
|
+
if (!keep) {
|
|
146
|
+
body.value = remote.draft.body;
|
|
147
|
+
attachment.value = remote.draft.attachmentUrl ?? "";
|
|
148
|
+
}
|
|
149
|
+
queue.update(values());
|
|
150
|
+
queue.reconcile(
|
|
151
|
+
{ body: remote.draft.body, attachmentUrl: remote.draft.attachmentUrl },
|
|
152
|
+
remote.draft.revision,
|
|
153
|
+
keep,
|
|
154
|
+
);
|
|
155
|
+
conflict.hidden = true;
|
|
156
|
+
remote = undefined;
|
|
157
|
+
if (keep) void saveDraft();
|
|
158
|
+
});
|
|
159
|
+
form.addEventListener("submit", async (event) => {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
if (busy || closed || queue.state === "conflict") return;
|
|
162
|
+
if (!request && (!form.reportValidity() || !body.value.trim())) {
|
|
163
|
+
tell("Write your assignment before submitting.", true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
busy = true;
|
|
168
|
+
controls();
|
|
169
|
+
try {
|
|
170
|
+
if (!request) {
|
|
171
|
+
queue.update(values());
|
|
172
|
+
await queue.flush();
|
|
173
|
+
if (disposed) return;
|
|
174
|
+
request = {
|
|
175
|
+
...input,
|
|
176
|
+
...values(),
|
|
177
|
+
submissionId: crypto.randomUUID(),
|
|
178
|
+
expectedDraftRevision: queue.revision,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
tell("Submitting assignment…");
|
|
182
|
+
await call("learner/assignment", request);
|
|
183
|
+
if (disposed) return;
|
|
184
|
+
submitted = true;
|
|
185
|
+
queue.dispose();
|
|
186
|
+
window.location.reload();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (disposed) return;
|
|
189
|
+
const failure = error as { code?: string; status?: number };
|
|
190
|
+
if (failure.code === "DRAFT_CONFLICT") {
|
|
191
|
+
request = undefined;
|
|
192
|
+
queue.conflict();
|
|
193
|
+
await refreshConflict();
|
|
194
|
+
} else if (
|
|
195
|
+
!request ||
|
|
196
|
+
(failure.status && failure.status < 500 && failure.status !== 408 && failure.status !== 429)
|
|
197
|
+
) {
|
|
198
|
+
request = undefined;
|
|
199
|
+
tell(
|
|
200
|
+
error instanceof Error ? error.message : "Could not submit. Your work is kept here.",
|
|
201
|
+
true,
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
tell(
|
|
205
|
+
"Submission status is unconfirmed. Retry submission to check the same request. Your work is kept here.",
|
|
206
|
+
true,
|
|
207
|
+
);
|
|
208
|
+
submit.textContent = "Retry submission";
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
busy = false;
|
|
212
|
+
controls();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const warn = (event: BeforeUnloadEvent) => {
|
|
216
|
+
if (!submitted && (queue.dirty || busy || request)) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
window.addEventListener("beforeunload", warn);
|
|
221
|
+
document.addEventListener(
|
|
222
|
+
"astro:before-swap",
|
|
223
|
+
() => {
|
|
224
|
+
disposed = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
queue.dispose();
|
|
227
|
+
window.removeEventListener("beforeunload", warn);
|
|
228
|
+
},
|
|
229
|
+
{ once: true },
|
|
230
|
+
);
|
|
231
|
+
}
|
package/astro/client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { API_BASE } from "@anantagnanam/lms";
|
|
2
2
|
import { renderQuiz } from "./quiz-player.js";
|
|
3
3
|
import { initializeMedia } from "./media-player.js";
|
|
4
|
+
import { initializeAssignments } from "./assignment-player.js";
|
|
5
|
+
import { initializeStudyTools, protectStudyWork } from "./study-tools.js";
|
|
6
|
+
import { initializeTranscripts } from "./transcript-search.js";
|
|
4
7
|
import type { Discussion, Notification, Page, PublicQuizAttempt } from "@anantagnanam/lms/types";
|
|
5
8
|
|
|
6
9
|
async function call<T>(route: string, input: Record<string, unknown>, method = "POST"): Promise<T> {
|
|
@@ -58,6 +61,16 @@ function forms() {
|
|
|
58
61
|
"form[data-lms-route]:not([data-lms-bound])",
|
|
59
62
|
)) {
|
|
60
63
|
form.dataset.lmsBound = "true";
|
|
64
|
+
const noteField =
|
|
65
|
+
form.dataset.lmsRoute === "learner/note"
|
|
66
|
+
? (form.elements.namedItem("note") as HTMLTextAreaElement)
|
|
67
|
+
: null;
|
|
68
|
+
let savedNote = noteField?.value;
|
|
69
|
+
if (noteField)
|
|
70
|
+
protectStudyWork(
|
|
71
|
+
form,
|
|
72
|
+
() => noteField.value !== savedNote || form.dataset.lmsBusy === "true",
|
|
73
|
+
);
|
|
61
74
|
form.addEventListener("submit", async (event) => {
|
|
62
75
|
event.preventDefault();
|
|
63
76
|
if (form.dataset.lmsBusy === "true") return;
|
|
@@ -66,6 +79,8 @@ function forms() {
|
|
|
66
79
|
if ("rating" in input) input.rating = Number(input.rating);
|
|
67
80
|
if ("shared" in input) input.shared = input.shared === "true";
|
|
68
81
|
if ("saved" in input) input.saved = input.saved === "true";
|
|
82
|
+
if (form.dataset.lmsRoute === "learner/note")
|
|
83
|
+
input.expectedRevision = Number(input.expectedRevision);
|
|
69
84
|
if (form.dataset.lmsRoute === "learner/preferences/save") {
|
|
70
85
|
input.data = {
|
|
71
86
|
emailNotifications: input.emailNotifications === "true",
|
|
@@ -83,7 +98,12 @@ function forms() {
|
|
|
83
98
|
feedback(form, "Saving…");
|
|
84
99
|
try {
|
|
85
100
|
const route = form.dataset.lmsRoute!;
|
|
86
|
-
const result = await call<{ status?: string }>(route, input);
|
|
101
|
+
const result = await call<{ status?: string; revision?: number }>(route, input);
|
|
102
|
+
if (route === "learner/note" && result.revision !== undefined) {
|
|
103
|
+
const revision = form.elements.namedItem("expectedRevision") as HTMLInputElement;
|
|
104
|
+
revision.value = String(result.revision);
|
|
105
|
+
savedNote = String(input.note);
|
|
106
|
+
}
|
|
87
107
|
feedback(
|
|
88
108
|
form,
|
|
89
109
|
route === "learner/enroll"
|
|
@@ -249,9 +269,12 @@ async function updates(root: HTMLElement, cursor?: string, append = false) {
|
|
|
249
269
|
}
|
|
250
270
|
|
|
251
271
|
function initialize() {
|
|
272
|
+
initializeAssignments(call);
|
|
252
273
|
forms();
|
|
253
274
|
quizzes();
|
|
254
275
|
initializeMedia(call);
|
|
276
|
+
initializeStudyTools(call);
|
|
277
|
+
initializeTranscripts();
|
|
255
278
|
for (const root of document.querySelectorAll<HTMLElement>(
|
|
256
279
|
"[data-lms-export]:not([data-lms-bound])",
|
|
257
280
|
)) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AssignmentSubmission } from "@anantagnanam/lms/types";
|
|
3
|
+
import AssignmentRubric from "./AssignmentRubric.astro";
|
|
4
|
+
interface Props {
|
|
5
|
+
submission: AssignmentSubmission;
|
|
6
|
+
}
|
|
7
|
+
const { submission } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<div class="lms-notice">
|
|
11
|
+
<strong>
|
|
12
|
+
{submission.status === "submitted"
|
|
13
|
+
? "Submitted · awaiting feedback"
|
|
14
|
+
: `Graded · ${submission.points}/${submission.maxPoints} · ${submission.points! >= submission.passingPoints ? "Passed" : "Below passing grade"}`}
|
|
15
|
+
</strong>
|
|
16
|
+
<p>Submitted {new Date(submission.submittedAt).toLocaleString("en-IN")}</p>
|
|
17
|
+
{submission.gradedAt && <p>Graded {new Date(submission.gradedAt).toLocaleString("en-IN")}</p>}
|
|
18
|
+
{submission.feedback && <p class="lms-text">{submission.feedback}</p>}
|
|
19
|
+
<AssignmentRubric
|
|
20
|
+
rubric={submission.rubric}
|
|
21
|
+
scores={submission.status === "graded" ? submission.rubricScores : undefined}
|
|
22
|
+
/>
|
|
23
|
+
<details>
|
|
24
|
+
<summary>Your submitted work</summary>
|
|
25
|
+
<p class="lms-text">{submission.body}</p>
|
|
26
|
+
{submission.attachmentUrl && (
|
|
27
|
+
<a href={submission.attachmentUrl} target="_blank" rel="noopener noreferrer">
|
|
28
|
+
Open attachment ↗
|
|
29
|
+
</a>
|
|
30
|
+
)}
|
|
31
|
+
</details>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { RubricCriterion, RubricScore } from "@anantagnanam/lms/types";
|
|
3
|
+
interface Props {
|
|
4
|
+
rubric?: RubricCriterion[];
|
|
5
|
+
scores?: RubricScore[];
|
|
6
|
+
}
|
|
7
|
+
const { rubric = [], scores } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
{rubric.length > 0 && (
|
|
11
|
+
<div class="lms-rubric">
|
|
12
|
+
<h3>{scores ? "Criterion feedback" : "Grading rubric"}</h3>
|
|
13
|
+
<ul>
|
|
14
|
+
{rubric.map((criterion) => {
|
|
15
|
+
const score = scores?.find((item) => item.criterionId === criterion.id);
|
|
16
|
+
return (
|
|
17
|
+
<li>
|
|
18
|
+
<strong>{criterion.title}</strong> · {score ? `${score.points}/` : ""}
|
|
19
|
+
{criterion.maxPoints} points
|
|
20
|
+
{criterion.description && <p class="lms-text">{criterion.description}</p>}
|
|
21
|
+
{score?.feedback && <p class="lms-text">{score.feedback}</p>}
|
|
22
|
+
</li>
|
|
23
|
+
);
|
|
24
|
+
})}
|
|
25
|
+
</ul>
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
@@ -82,6 +82,9 @@ next.searchParams.set("cursor", page.cursor ?? "");
|
|
|
82
82
|
<a class="lms-btn lms-btn-outline" href={courseUrl(basePath, course.slug)}>
|
|
83
83
|
Course overview
|
|
84
84
|
</a>
|
|
85
|
+
<a href={`${basePath}/my-learning/study?courseId=${encodeURIComponent(course.id)}`}>
|
|
86
|
+
Notebook & agenda
|
|
87
|
+
</a>
|
|
85
88
|
{enrollment.certificate && (
|
|
86
89
|
<a
|
|
87
90
|
class="lms-btn lms-btn-outline"
|
|
@@ -27,6 +27,12 @@ const path = Astro.url.pathname;
|
|
|
27
27
|
{Astro.locals.user && Astro.locals.user.role >= 20 && (
|
|
28
28
|
<a href={`${basePath}/teach`}>Course studio</a>
|
|
29
29
|
)}
|
|
30
|
+
<a
|
|
31
|
+
href={`${basePath}/my-learning/study`}
|
|
32
|
+
aria-current={path === `${basePath}/my-learning/study` ? "page" : undefined}
|
|
33
|
+
>
|
|
34
|
+
Notebook & agenda
|
|
35
|
+
</a>
|
|
30
36
|
</nav>
|
|
31
37
|
<main id="lms-main" class="lms-container">
|
|
32
38
|
<slot />
|
|
@@ -18,9 +18,23 @@ const { lesson } = Astro.props;
|
|
|
18
18
|
/>
|
|
19
19
|
)}
|
|
20
20
|
{lesson.transcript && (
|
|
21
|
-
<details class="lms-resources">
|
|
21
|
+
<details class="lms-resources" data-lms-transcript>
|
|
22
22
|
<summary>Read transcript</summary>
|
|
23
|
-
<
|
|
23
|
+
<div class="lms-actions">
|
|
24
|
+
<label>
|
|
25
|
+
Search transcript <input type="search" maxlength={200} data-lms-transcript-search />
|
|
26
|
+
</label>
|
|
27
|
+
<button type="button" class="lms-btn lms-btn-outline" data-lms-transcript-previous disabled>
|
|
28
|
+
Previous match
|
|
29
|
+
</button>
|
|
30
|
+
<button type="button" class="lms-btn lms-btn-outline" data-lms-transcript-next disabled>
|
|
31
|
+
Next match
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<p data-lms-transcript-status aria-live="polite"></p>
|
|
35
|
+
<p class="lms-text" data-lms-transcript-text>
|
|
36
|
+
{lesson.transcript}
|
|
37
|
+
</p>
|
|
24
38
|
</details>
|
|
25
39
|
)}
|
|
26
40
|
<div class="lms-lesson-body">
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
import LessonContent from "./LessonContent.astro";
|
|
3
3
|
import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
|
|
4
|
+
import AssignmentRubric from "./AssignmentRubric.astro";
|
|
5
|
+
import AssignmentFeedback from "./AssignmentFeedback.astro";
|
|
4
6
|
import type {
|
|
5
7
|
CourseDetail,
|
|
6
8
|
PublicLesson,
|
|
7
9
|
PublicEnrollment,
|
|
8
10
|
ProgressSummary,
|
|
9
11
|
} from "@anantagnanam/lms/types";
|
|
10
|
-
import { courseUrl, lessonUrl, certificateUrl } from "@anantagnanam/lms/server";
|
|
12
|
+
import { courseUrl, lessonUrl, certificateUrl, assignmentBlock } from "@anantagnanam/lms/server";
|
|
11
13
|
import { basePath } from "virtual:anantagnanam-lms/config";
|
|
12
14
|
interface Props {
|
|
13
15
|
course: CourseDetail;
|
|
@@ -29,6 +31,14 @@ const current = enrollment?.progress[lesson.id];
|
|
|
29
31
|
const complete = Boolean(current?.completedAt);
|
|
30
32
|
const attempt = current?.quizAttempts.at(-1);
|
|
31
33
|
const assignment = current?.assignments.at(-1);
|
|
34
|
+
const draft = current?.assignmentDraft;
|
|
35
|
+
const assignmentClosed = lesson.assignment
|
|
36
|
+
? assignmentBlock(
|
|
37
|
+
lesson.assignment,
|
|
38
|
+
current ?? { completedAt: null, assignments: [] },
|
|
39
|
+
new Date(),
|
|
40
|
+
)
|
|
41
|
+
: null;
|
|
32
42
|
---
|
|
33
43
|
|
|
34
44
|
<nav class="lms-breadcrumbs" aria-label="Breadcrumb">
|
|
@@ -168,45 +178,90 @@ const assignment = current?.assignments.at(-1);
|
|
|
168
178
|
? ` · Due ${new Date(lesson.assignment.dueAt).toLocaleString("en-IN")}`
|
|
169
179
|
: ""}
|
|
170
180
|
</p>
|
|
171
|
-
{assignment
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<a href={assignment.attachmentUrl} target="_blank" rel="noopener noreferrer">
|
|
184
|
-
Open attachment ↗
|
|
185
|
-
</a>
|
|
186
|
-
)}
|
|
187
|
-
</details>
|
|
188
|
-
</div>
|
|
181
|
+
<AssignmentRubric rubric={lesson.assignment.rubric} />
|
|
182
|
+
{assignment && <AssignmentFeedback submission={assignment} />}
|
|
183
|
+
{(current?.assignments.length ?? 0) > 1 && (
|
|
184
|
+
<details>
|
|
185
|
+
<summary>Earlier submissions and feedback ({current!.assignments.length - 1})</summary>
|
|
186
|
+
{current!.assignments
|
|
187
|
+
.slice(0, -1)
|
|
188
|
+
.toReversed()
|
|
189
|
+
.map((item) => (
|
|
190
|
+
<AssignmentFeedback submission={item} />
|
|
191
|
+
))}
|
|
192
|
+
</details>
|
|
189
193
|
)}
|
|
190
|
-
{
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
{assignmentClosed ? (
|
|
195
|
+
<>
|
|
196
|
+
<p class="lms-notice">{assignmentClosed.message}</p>
|
|
197
|
+
{(draft?.body || draft?.attachmentUrl) && (
|
|
198
|
+
<details open>
|
|
199
|
+
<summary>Your saved draft (not submitted)</summary>
|
|
200
|
+
<p class="lms-text">{draft.body}</p>
|
|
201
|
+
{draft.attachmentUrl && (
|
|
202
|
+
<a href={draft.attachmentUrl} target="_blank" rel="noopener noreferrer">
|
|
203
|
+
Open draft attachment ↗
|
|
204
|
+
</a>
|
|
205
|
+
)}
|
|
206
|
+
</details>
|
|
207
|
+
)}
|
|
208
|
+
</>
|
|
209
|
+
) : (
|
|
210
|
+
<form
|
|
211
|
+
class="lms-form"
|
|
212
|
+
data-lms-assignment
|
|
213
|
+
data-course-id={course.id}
|
|
214
|
+
data-lesson-id={lesson.id}
|
|
215
|
+
data-draft-revision={draft?.revision ?? 0}
|
|
216
|
+
>
|
|
217
|
+
<p>Drafts save automatically. Only you can see your draft until you submit it.</p>
|
|
218
|
+
<label>
|
|
219
|
+
<span>Your work</span>
|
|
220
|
+
<textarea
|
|
221
|
+
name="body"
|
|
222
|
+
required
|
|
223
|
+
rows={8}
|
|
224
|
+
maxlength={20_000}
|
|
225
|
+
>{draft?.body ?? ""}</textarea>
|
|
226
|
+
</label>
|
|
227
|
+
<label>
|
|
228
|
+
<span>Attachment link (optional)</span>
|
|
229
|
+
<input
|
|
230
|
+
name="attachmentUrl"
|
|
231
|
+
type="url"
|
|
232
|
+
placeholder="https://…"
|
|
233
|
+
value={draft?.attachmentUrl ?? ""}
|
|
234
|
+
/>
|
|
235
|
+
</label>
|
|
236
|
+
<button class="lms-btn lms-btn-outline" type="button" data-lms-save-draft>
|
|
237
|
+
Save draft
|
|
238
|
+
</button>
|
|
239
|
+
<button class="lms-btn" type="submit">
|
|
240
|
+
Submit assignment
|
|
241
|
+
</button>
|
|
242
|
+
<p data-lms-feedback aria-live="polite">
|
|
243
|
+
{draft?.updatedAt ? "Saved draft restored." : "Your draft is empty."}
|
|
244
|
+
</p>
|
|
245
|
+
<button class="lms-btn lms-btn-outline" type="button" data-lms-review-draft hidden>
|
|
246
|
+
Review saved work
|
|
247
|
+
</button>
|
|
248
|
+
<section
|
|
249
|
+
class="lms-notice"
|
|
250
|
+
data-lms-assignment-conflict
|
|
251
|
+
hidden
|
|
252
|
+
aria-label="Review saved work"
|
|
253
|
+
>
|
|
254
|
+
<h3>Saved work</h3>
|
|
255
|
+
<p class="lms-text" data-lms-saved-draft></p>
|
|
256
|
+
<button class="lms-btn lms-btn-outline" type="button" data-lms-use-saved>
|
|
257
|
+
Use saved draft
|
|
206
258
|
</button>
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
<button class="lms-btn lms-btn-outline" type="button" data-lms-keep-local>
|
|
260
|
+
Keep my local draft
|
|
261
|
+
</button>
|
|
262
|
+
</section>
|
|
263
|
+
</form>
|
|
264
|
+
)}
|
|
210
265
|
</section>
|
|
211
266
|
)}
|
|
212
267
|
{!preview && !["quiz", "assignment"].includes(lesson.kind) && (
|
|
@@ -262,6 +317,7 @@ const assignment = current?.assignments.at(-1);
|
|
|
262
317
|
<h2>A note to your future self</h2>
|
|
263
318
|
<p class="lms-muted">Only you can see these notes.</p>
|
|
264
319
|
<form class="lms-form" data-lms-route="learner/note">
|
|
320
|
+
<input type="hidden" name="expectedRevision" value={current?.noteRevision ?? 0} />
|
|
265
321
|
<input type="hidden" name="courseId" value={course.id} />
|
|
266
322
|
<input type="hidden" name="lessonId" value={lesson.id} />
|
|
267
323
|
<label>
|
|
@@ -273,6 +329,38 @@ const assignment = current?.assignments.at(-1);
|
|
|
273
329
|
</button>
|
|
274
330
|
<p data-lms-feedback aria-live="polite"></p>
|
|
275
331
|
</form>
|
|
332
|
+
<p>
|
|
333
|
+
<a href={`${basePath}/my-learning/study?courseId=${encodeURIComponent(course.id)}`}>
|
|
334
|
+
Open course notebook & agenda →
|
|
335
|
+
</a>
|
|
336
|
+
</p>
|
|
337
|
+
<section data-lms-quick-note data-course-id={course.id} data-lesson-id={lesson.id}>
|
|
338
|
+
<h3>Notes & bookmarks</h3>
|
|
339
|
+
<p>
|
|
340
|
+
Save a separate thought or bookmark the current moment in native audio/video. Only you
|
|
341
|
+
can see it.
|
|
342
|
+
</p>
|
|
343
|
+
<form class="lms-form">
|
|
344
|
+
<label>
|
|
345
|
+
<span>New note</span>
|
|
346
|
+
<textarea name="body" rows={3} maxlength={5000}></textarea>
|
|
347
|
+
</label>
|
|
348
|
+
<label>
|
|
349
|
+
<input type="checkbox" name="starred" /> Star this note
|
|
350
|
+
</label>
|
|
351
|
+
<button type="button" class="lms-btn lms-btn-outline" data-lms-capture-time hidden>
|
|
352
|
+
Use current playback time
|
|
353
|
+
</button>
|
|
354
|
+
<p data-lms-note-time></p>
|
|
355
|
+
<button type="button" class="lms-text-button" data-lms-clear-time hidden>
|
|
356
|
+
Remove timestamp
|
|
357
|
+
</button>
|
|
358
|
+
<button type="submit" class="lms-btn lms-btn-outline">
|
|
359
|
+
Add to notebook
|
|
360
|
+
</button>
|
|
361
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
362
|
+
</form>
|
|
363
|
+
</section>
|
|
276
364
|
</section>
|
|
277
365
|
<section
|
|
278
366
|
class="lms-content-section"
|