@anantagnanam/lms 0.1.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/LICENSE +21 -21
- package/README.md +40 -2
- package/astro/assignment-player.ts +231 -0
- package/astro/client.ts +97 -152
- package/astro/components/AssignmentFeedback.astro +32 -0
- package/astro/components/AssignmentRubric.astro +27 -0
- package/astro/components/CourseOverview.astro +25 -3
- package/astro/components/LearnerDashboard.astro +9 -0
- package/astro/components/LearningShell.astro +16 -1
- package/astro/components/LessonAvailabilityNote.astro +26 -0
- package/astro/components/LessonContent.astro +67 -0
- package/astro/components/LessonPlayer.astro +166 -99
- package/astro/components/MediaPlayer.astro +55 -4
- package/astro/draft-queue.ts +95 -0
- package/astro/media-player.ts +124 -0
- package/astro/pages/author-preview.astro +73 -0
- package/astro/pages/course.astro +10 -0
- package/astro/pages/lesson.astro +3 -0
- package/astro/pages/library.astro +173 -0
- package/astro/pages/paths.astro +73 -0
- package/astro/pages/study.astro +180 -0
- package/astro/quiz-drafts.ts +23 -0
- package/astro/quiz-player.ts +362 -0
- package/astro/study-tools.ts +386 -0
- package/astro/styles.css +30 -0
- package/astro/transcript-search.ts +56 -0
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +756 -127
- 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 +4 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
- package/dist/constants-DyQbwNbP.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +129 -16
- package/dist/runtime.js +1201 -130
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +136 -3
- package/dist/schemas.js +80 -7
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +18 -1
- package/dist/server.js +17 -3
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +172 -4
- package/package.json +1 -1
- package/dist/constants-C0kT94tc.js.map +0 -1
- package/dist/errors-ChzJS9al.js +0 -16
- package/dist/errors-ChzJS9al.js.map +0 -1
|
@@ -4,8 +4,9 @@ interface Props {
|
|
|
4
4
|
kind: "video" | "audio" | "live";
|
|
5
5
|
title: string;
|
|
6
6
|
startsAt?: string | null;
|
|
7
|
+
captions?: { src: string; language: string; label: string }[];
|
|
7
8
|
}
|
|
8
|
-
const { url, kind, title, startsAt } = Astro.props;
|
|
9
|
+
const { url, kind, title, startsAt, captions = [] } = Astro.props;
|
|
9
10
|
let embed: string | null = null;
|
|
10
11
|
try {
|
|
11
12
|
const parsed = new URL(url, Astro.url.origin);
|
|
@@ -24,7 +25,7 @@ try {
|
|
|
24
25
|
} catch {}
|
|
25
26
|
---
|
|
26
27
|
|
|
27
|
-
<div class="lms-media">
|
|
28
|
+
<div class="lms-media" data-lms-media>
|
|
28
29
|
{kind === "live" ? (
|
|
29
30
|
<div class="lms-live-card">
|
|
30
31
|
<p class="lms-eyebrow">LIVE LEARNING SESSION</p>
|
|
@@ -40,7 +41,23 @@ try {
|
|
|
40
41
|
</a>
|
|
41
42
|
</div>
|
|
42
43
|
) : kind === "audio" ? (
|
|
43
|
-
<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>
|
|
44
61
|
) : embed ? (
|
|
45
62
|
<iframe
|
|
46
63
|
src={embed}
|
|
@@ -50,7 +67,41 @@ try {
|
|
|
50
67
|
referrerpolicy="strict-origin-when-cross-origin"
|
|
51
68
|
></iframe>
|
|
52
69
|
) : (
|
|
53
|
-
<video
|
|
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>
|
|
54
105
|
)}
|
|
55
106
|
{kind !== "live" && (
|
|
56
107
|
<a class="lms-media-link" href={url} target="_blank" rel="noopener noreferrer">
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type DraftState = "saved" | "pending" | "saving" | "failed" | "conflict";
|
|
2
|
+
export interface DraftWrite<T> {
|
|
3
|
+
value: T;
|
|
4
|
+
expectedDraftRevision: number;
|
|
5
|
+
}
|
|
6
|
+
const fingerprint = (value: object) =>
|
|
7
|
+
JSON.stringify(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
8
|
+
|
|
9
|
+
/** Serialize writes and preserve the exact unacknowledged request for retries. */
|
|
10
|
+
export class DraftQueue<T extends object> {
|
|
11
|
+
revision: number;
|
|
12
|
+
state: DraftState = "saved";
|
|
13
|
+
private latest: T;
|
|
14
|
+
private acknowledged: string;
|
|
15
|
+
private retry?: DraftWrite<T>;
|
|
16
|
+
private running?: Promise<void>;
|
|
17
|
+
private disposed = false;
|
|
18
|
+
constructor(
|
|
19
|
+
value: T,
|
|
20
|
+
revision: number,
|
|
21
|
+
private send: (write: DraftWrite<T>) => Promise<{ draftRevision: number }>,
|
|
22
|
+
private changed: (state: DraftState) => void,
|
|
23
|
+
) {
|
|
24
|
+
this.latest = structuredClone(value);
|
|
25
|
+
this.acknowledged = fingerprint(value);
|
|
26
|
+
this.revision = revision;
|
|
27
|
+
}
|
|
28
|
+
get dirty() {
|
|
29
|
+
return !!this.retry || fingerprint(this.latest) !== this.acknowledged;
|
|
30
|
+
}
|
|
31
|
+
private emit(state: DraftState) {
|
|
32
|
+
this.state = state;
|
|
33
|
+
if (!this.disposed) this.changed(state);
|
|
34
|
+
}
|
|
35
|
+
update(value: T) {
|
|
36
|
+
this.latest = structuredClone(value);
|
|
37
|
+
if (!["failed", "conflict", "saving"].includes(this.state))
|
|
38
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
39
|
+
}
|
|
40
|
+
flush(): Promise<void> {
|
|
41
|
+
if (this.disposed) return Promise.reject(new Error("This form has closed."));
|
|
42
|
+
if (this.state === "conflict")
|
|
43
|
+
return Promise.reject(new Error("Review the saved draft before continuing."));
|
|
44
|
+
if (this.running) return this.running;
|
|
45
|
+
this.running = this.drain().finally(() => {
|
|
46
|
+
this.running = undefined;
|
|
47
|
+
});
|
|
48
|
+
return this.running;
|
|
49
|
+
}
|
|
50
|
+
private async drain() {
|
|
51
|
+
while (this.dirty && !this.disposed) {
|
|
52
|
+
const write = this.retry ?? {
|
|
53
|
+
value: structuredClone(this.latest),
|
|
54
|
+
expectedDraftRevision: this.revision,
|
|
55
|
+
};
|
|
56
|
+
this.retry = write;
|
|
57
|
+
this.emit("saving");
|
|
58
|
+
try {
|
|
59
|
+
const result = await this.send(structuredClone(write));
|
|
60
|
+
this.revision = result.draftRevision;
|
|
61
|
+
this.acknowledged = fingerprint(write.value);
|
|
62
|
+
this.retry = undefined;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const failure = error as { code?: string; status?: number };
|
|
65
|
+
// A definite rejection did not persist the write. Allow corrected values on retry.
|
|
66
|
+
if (
|
|
67
|
+
failure.status &&
|
|
68
|
+
failure.status < 500 &&
|
|
69
|
+
failure.status !== 408 &&
|
|
70
|
+
failure.status !== 429 &&
|
|
71
|
+
failure.code !== "DRAFT_CONFLICT"
|
|
72
|
+
)
|
|
73
|
+
this.retry = undefined;
|
|
74
|
+
this.emit((error as { code?: string })?.code === "DRAFT_CONFLICT" ? "conflict" : "failed");
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
79
|
+
}
|
|
80
|
+
conflict() {
|
|
81
|
+
this.emit("conflict");
|
|
82
|
+
}
|
|
83
|
+
/** Only after the learner has reviewed the current saved draft. */
|
|
84
|
+
reconcile(value: T, revision: number, keepLocal: boolean) {
|
|
85
|
+
if (this.running) throw new Error("Wait for the current save to finish.");
|
|
86
|
+
this.revision = revision;
|
|
87
|
+
this.acknowledged = fingerprint(value);
|
|
88
|
+
this.retry = undefined;
|
|
89
|
+
if (!keepLocal) this.latest = structuredClone(value);
|
|
90
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
91
|
+
}
|
|
92
|
+
dispose() {
|
|
93
|
+
this.disposed = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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 disposed = false;
|
|
26
|
+
let lastSave = Date.now();
|
|
27
|
+
const enabled = Boolean(lesson && lesson.dataset.preview !== "true");
|
|
28
|
+
const seekBookmark = () => {
|
|
29
|
+
const raw = new URLSearchParams(window.location.hash.slice(1)).get("t");
|
|
30
|
+
if (!ready || disposed || raw === null || !/^\d+(?:\.\d+)?$/.test(raw)) return;
|
|
31
|
+
const position = Number(raw);
|
|
32
|
+
if (!Number.isFinite(position) || position > 86_400) return;
|
|
33
|
+
try {
|
|
34
|
+
media.currentTime = Number.isFinite(media.duration)
|
|
35
|
+
? Math.min(position, Math.max(0, media.duration - 0.1))
|
|
36
|
+
: position;
|
|
37
|
+
status.textContent = `Bookmark at ${Math.floor(media.currentTime / 60)}:${String(Math.floor(media.currentTime % 60)).padStart(2, "0")}. Press play when ready.`;
|
|
38
|
+
} catch {
|
|
39
|
+
status.textContent = "This media is not seekable yet.";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
button.hidden = !enabled;
|
|
43
|
+
const restore = () => {
|
|
44
|
+
if (ready) return;
|
|
45
|
+
ready = true;
|
|
46
|
+
if (saved) {
|
|
47
|
+
const position = Number.isFinite(media.duration)
|
|
48
|
+
? Math.min(saved.positionSeconds, Math.max(0, media.duration - 0.1))
|
|
49
|
+
: saved.positionSeconds;
|
|
50
|
+
try {
|
|
51
|
+
media.currentTime = position;
|
|
52
|
+
media.playbackRate = saved.playbackRate;
|
|
53
|
+
} catch {
|
|
54
|
+
/* The browser may not have a seekable range yet. */
|
|
55
|
+
}
|
|
56
|
+
speed.value = String(media.playbackRate);
|
|
57
|
+
status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
|
|
58
|
+
}
|
|
59
|
+
seekBookmark();
|
|
60
|
+
};
|
|
61
|
+
const save = async () => {
|
|
62
|
+
if (disposed || !enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
|
|
63
|
+
if (busy) {
|
|
64
|
+
pending = true;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
busy = true;
|
|
68
|
+
button.disabled = true;
|
|
69
|
+
pending = false;
|
|
70
|
+
status.textContent = "Saving playback position…";
|
|
71
|
+
try {
|
|
72
|
+
const result = await call<Playback>("learner/playback", {
|
|
73
|
+
courseId: lesson!.dataset.courseId,
|
|
74
|
+
lessonId: lesson!.dataset.lessonId,
|
|
75
|
+
positionSeconds: Math.min(media.currentTime, 86_400),
|
|
76
|
+
playbackRate: media.playbackRate,
|
|
77
|
+
expectedRevision: revision,
|
|
78
|
+
});
|
|
79
|
+
revision = result.revision;
|
|
80
|
+
lastSave = Date.now();
|
|
81
|
+
status.textContent = "Playback position saved.";
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A lost response can follow a successful write. Stop instead of overwriting another tab.
|
|
84
|
+
failed = true;
|
|
85
|
+
status.textContent = `${error instanceof Error ? error.message : "Could not save playback."} Reload this lesson before saving again.`;
|
|
86
|
+
status.setAttribute("role", "alert");
|
|
87
|
+
} finally {
|
|
88
|
+
busy = false;
|
|
89
|
+
button.disabled = failed;
|
|
90
|
+
if (pending && !failed) void save();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
media.addEventListener("loadedmetadata", restore);
|
|
94
|
+
if (media.readyState >= 1) restore();
|
|
95
|
+
speed.addEventListener("change", () => {
|
|
96
|
+
media.playbackRate = Number(speed.value);
|
|
97
|
+
});
|
|
98
|
+
media.addEventListener("ratechange", () => {
|
|
99
|
+
speed.value = String(media.playbackRate);
|
|
100
|
+
void save();
|
|
101
|
+
});
|
|
102
|
+
media.addEventListener("timeupdate", () => {
|
|
103
|
+
if (!busy && Date.now() - lastSave >= 15_000) void save();
|
|
104
|
+
});
|
|
105
|
+
media.addEventListener("pause", () => {
|
|
106
|
+
void save();
|
|
107
|
+
});
|
|
108
|
+
media.addEventListener("ended", () => {
|
|
109
|
+
void save();
|
|
110
|
+
});
|
|
111
|
+
button.addEventListener("click", () => {
|
|
112
|
+
void save();
|
|
113
|
+
});
|
|
114
|
+
window.addEventListener("hashchange", seekBookmark);
|
|
115
|
+
document.addEventListener(
|
|
116
|
+
"astro:before-swap",
|
|
117
|
+
() => {
|
|
118
|
+
disposed = true;
|
|
119
|
+
window.removeEventListener("hashchange", seekBookmark);
|
|
120
|
+
},
|
|
121
|
+
{ once: true },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 AssignmentRubric from "../components/AssignmentRubric.astro";
|
|
6
|
+
import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
|
|
7
|
+
import { loginPath } from "virtual:anantagnanam-lms/config";
|
|
8
|
+
export const prerender = false;
|
|
9
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
10
|
+
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
11
|
+
let result: Awaited<ReturnType<typeof lmsAuthorLessonPreview>> | undefined;
|
|
12
|
+
let error = "";
|
|
13
|
+
try {
|
|
14
|
+
result = await lmsAuthorLessonPreview(
|
|
15
|
+
Astro.locals,
|
|
16
|
+
{ courseId: Astro.params.courseId!, lessonId: Astro.params.lessonId! },
|
|
17
|
+
Astro.url.origin,
|
|
18
|
+
);
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
Astro.response.status = cause instanceof LmsError ? cause.status : 503;
|
|
21
|
+
error = cause instanceof Error ? cause.message : "Preview is unavailable.";
|
|
22
|
+
}
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<Layout
|
|
26
|
+
title={result ? `Preview: ${result.lesson.title}` : "Lesson preview"}
|
|
27
|
+
robots="noindex, nofollow"
|
|
28
|
+
>
|
|
29
|
+
<LearningShell>
|
|
30
|
+
{result ? (
|
|
31
|
+
<article class="lms-lesson">
|
|
32
|
+
<p class="lms-notice">
|
|
33
|
+
Saved author preview · {result.course.title}. Save changes in the course editor, then
|
|
34
|
+
refresh this page. Linked content uses its published version.
|
|
35
|
+
</p>
|
|
36
|
+
<h1>{result.lesson.title}</h1>
|
|
37
|
+
{result.lesson.summary && <p class="lms-lead">{result.lesson.summary}</p>}
|
|
38
|
+
<LessonContent lesson={result.lesson} />
|
|
39
|
+
{result.lesson.quiz && (
|
|
40
|
+
<section class="lms-assessment">
|
|
41
|
+
<h2>Lesson quiz</h2>
|
|
42
|
+
<p>
|
|
43
|
+
{result.lesson.quiz.questionCount} questions · Pass with{" "}
|
|
44
|
+
{result.lesson.quiz.passingPercent}% · {result.lesson.quiz.maxAttempts} attempts
|
|
45
|
+
</p>
|
|
46
|
+
<p>Assessment summary only. This preview does not create an attempt.</p>
|
|
47
|
+
</section>
|
|
48
|
+
)}
|
|
49
|
+
{result.lesson.assignment && (
|
|
50
|
+
<section class="lms-assessment">
|
|
51
|
+
<h2>Your assignment</h2>
|
|
52
|
+
<p class="lms-text">{result.lesson.assignment.instructions}</p>
|
|
53
|
+
<AssignmentRubric rubric={result.lesson.assignment.rubric} />
|
|
54
|
+
<p>
|
|
55
|
+
{result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
|
|
56
|
+
to pass
|
|
57
|
+
</p>
|
|
58
|
+
</section>
|
|
59
|
+
)}
|
|
60
|
+
</article>
|
|
61
|
+
) : (
|
|
62
|
+
<section class="lms-gate">
|
|
63
|
+
<h1>Lesson preview</h1>
|
|
64
|
+
<p>{error}</p>
|
|
65
|
+
{!Astro.locals.user && (
|
|
66
|
+
<a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
|
|
67
|
+
Sign in →
|
|
68
|
+
</a>
|
|
69
|
+
)}
|
|
70
|
+
</section>
|
|
71
|
+
)}
|
|
72
|
+
</LearningShell>
|
|
73
|
+
</Layout>
|
package/astro/pages/course.astro
CHANGED
|
@@ -60,6 +60,16 @@ try {
|
|
|
60
60
|
robots={course ? undefined : "noindex, follow"}
|
|
61
61
|
>
|
|
62
62
|
<LearningShell>
|
|
63
|
+
{course && Astro.locals.user && (
|
|
64
|
+
<form class="lms-actions" data-lms-route="learner/saved/save">
|
|
65
|
+
<input type="hidden" name="courseId" value={course.id} />
|
|
66
|
+
<input type="hidden" name="saved" value="true" />
|
|
67
|
+
<button class="lms-btn lms-btn-outline" type="submit">
|
|
68
|
+
Save course to my library
|
|
69
|
+
</button>
|
|
70
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
71
|
+
</form>
|
|
72
|
+
)}
|
|
63
73
|
{course ? (
|
|
64
74
|
<CourseOverview
|
|
65
75
|
course={course}
|
package/astro/pages/lesson.astro
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
3
|
import LearningShell from "../components/LearningShell.astro";
|
|
4
4
|
import LessonPlayer from "../components/LessonPlayer.astro";
|
|
5
|
+
import LessonAvailabilityNote from "../components/LessonAvailabilityNote.astro";
|
|
5
6
|
import { lmsPublic, lmsLearner, courseUrl, loginUrl, LmsError } from "@anantagnanam/lms/server";
|
|
6
7
|
import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
|
|
7
8
|
import type {
|
|
@@ -27,6 +28,7 @@ try {
|
|
|
27
28
|
}>(Astro.locals, "learner/course-by-slug", { slug: Astro.params.slug }, Astro.url.origin);
|
|
28
29
|
course = result.course;
|
|
29
30
|
enrollment = result.enrollment;
|
|
31
|
+
progress = result.progress;
|
|
30
32
|
} else
|
|
31
33
|
course = await lmsPublic<CourseDetail>(
|
|
32
34
|
Astro.locals,
|
|
@@ -84,6 +86,7 @@ try {
|
|
|
84
86
|
<p class="lms-eyebrow">ONE STEP AT A TIME</p>
|
|
85
87
|
<h1>Continue your learning</h1>
|
|
86
88
|
<p>{error}</p>
|
|
89
|
+
<LessonAvailabilityNote availability={progress?.availability[Astro.params.lessonId!]} />
|
|
87
90
|
{!Astro.locals.user && (
|
|
88
91
|
<a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
|
|
89
92
|
Sign in →
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
|
+
import LearningShell from "../components/LearningShell.astro";
|
|
4
|
+
import { lmsLearner, loginUrl, courseUrl, LmsError } from "@anantagnanam/lms/server";
|
|
5
|
+
import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
|
|
6
|
+
import type { Page, CourseCard, LearnerPath, LearnerPreferences } from "@anantagnanam/lms/types";
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
9
|
+
if (!Astro.locals.user)
|
|
10
|
+
return Astro.redirect(loginUrl(loginPath, Astro.url.pathname + Astro.url.search));
|
|
11
|
+
let saved: Page<{ courseId: string; course: CourseCard | null }> = { items: [], hasMore: false };
|
|
12
|
+
let paths: Page<LearnerPath> = { items: [], hasMore: false };
|
|
13
|
+
let preferences: { value: LearnerPreferences; revision: string | null } | undefined;
|
|
14
|
+
let error = "";
|
|
15
|
+
try {
|
|
16
|
+
[saved, paths, preferences] = await Promise.all([
|
|
17
|
+
lmsLearner<typeof saved>(
|
|
18
|
+
Astro.locals,
|
|
19
|
+
"learner/saved",
|
|
20
|
+
{ cursor: Astro.url.searchParams.get("saved") ?? undefined },
|
|
21
|
+
Astro.url.origin,
|
|
22
|
+
),
|
|
23
|
+
lmsLearner<typeof paths>(
|
|
24
|
+
Astro.locals,
|
|
25
|
+
"learner/paths",
|
|
26
|
+
{ cursor: Astro.url.searchParams.get("paths") ?? undefined },
|
|
27
|
+
Astro.url.origin,
|
|
28
|
+
),
|
|
29
|
+
lmsLearner<NonNullable<typeof preferences>>(
|
|
30
|
+
Astro.locals,
|
|
31
|
+
"learner/preferences",
|
|
32
|
+
{},
|
|
33
|
+
Astro.url.origin,
|
|
34
|
+
),
|
|
35
|
+
]);
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
Astro.response.status = cause instanceof LmsError ? cause.status : 503;
|
|
38
|
+
error = cause instanceof Error ? cause.message : "Your library is unavailable.";
|
|
39
|
+
}
|
|
40
|
+
const more = (key: string, cursor: string) => {
|
|
41
|
+
const url = new URL(Astro.url);
|
|
42
|
+
url.searchParams.set(key, cursor);
|
|
43
|
+
return url.href;
|
|
44
|
+
};
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<Layout title="My library and preferences" robots="noindex, follow">
|
|
48
|
+
<LearningShell>
|
|
49
|
+
<h1>My library & preferences</h1>
|
|
50
|
+
{error && <p role="alert">{error}</p>}
|
|
51
|
+
<section>
|
|
52
|
+
<h2>Saved courses</h2>
|
|
53
|
+
{!error && !saved.items.length && (
|
|
54
|
+
<p>Save a course from its overview to find it here later.</p>
|
|
55
|
+
)}
|
|
56
|
+
{saved.items.map(({ courseId, course }) => (
|
|
57
|
+
<article class="lms-learning-card">
|
|
58
|
+
<h3>
|
|
59
|
+
{course ? (
|
|
60
|
+
<a href={courseUrl(basePath, course.slug)}>{course.title}</a>
|
|
61
|
+
) : (
|
|
62
|
+
"Course currently unavailable"
|
|
63
|
+
)}
|
|
64
|
+
</h3>
|
|
65
|
+
<form data-lms-route="learner/saved/save" data-lms-reload>
|
|
66
|
+
<input type="hidden" name="courseId" value={courseId} />
|
|
67
|
+
<input type="hidden" name="saved" value="false" />
|
|
68
|
+
<button class="lms-text-button" type="submit">
|
|
69
|
+
Remove from saved courses
|
|
70
|
+
</button>
|
|
71
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
72
|
+
</form>
|
|
73
|
+
</article>
|
|
74
|
+
))}
|
|
75
|
+
{saved.hasMore && saved.cursor && (
|
|
76
|
+
<a href={more("saved", saved.cursor)}>More saved courses →</a>
|
|
77
|
+
)}
|
|
78
|
+
</section>
|
|
79
|
+
<section>
|
|
80
|
+
<h2>My learning paths</h2>
|
|
81
|
+
<p>
|
|
82
|
+
Your joined paths keep their original course lists when administrators edit the program.
|
|
83
|
+
</p>
|
|
84
|
+
{!error && !paths.items.length && <a href={`${basePath}/paths`}>Explore learning paths</a>}
|
|
85
|
+
{paths.items.map((path) => (
|
|
86
|
+
<article class="lms-learning-card">
|
|
87
|
+
<h3>{path.title}</h3>
|
|
88
|
+
<p>
|
|
89
|
+
{path.completed} of {path.total} courses completed
|
|
90
|
+
</p>
|
|
91
|
+
<progress value={path.completed} max={path.total} aria-label={`${path.title} progress`}>
|
|
92
|
+
{path.completed}/{path.total}
|
|
93
|
+
</progress>
|
|
94
|
+
<ol>
|
|
95
|
+
{path.courses.map(({ course, completed }) => (
|
|
96
|
+
<li>
|
|
97
|
+
{course ? (
|
|
98
|
+
<a href={courseUrl(basePath, course.slug)}>{course.title}</a>
|
|
99
|
+
) : (
|
|
100
|
+
"Course currently unavailable"
|
|
101
|
+
)}
|
|
102
|
+
{completed && " · Completed"}
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ol>
|
|
106
|
+
</article>
|
|
107
|
+
))}
|
|
108
|
+
{paths.hasMore && paths.cursor && (
|
|
109
|
+
<a href={more("paths", paths.cursor)}>More joined paths →</a>
|
|
110
|
+
)}
|
|
111
|
+
</section>
|
|
112
|
+
{preferences && (
|
|
113
|
+
<section class="lms-learning-card">
|
|
114
|
+
<h2>Learning preferences</h2>
|
|
115
|
+
<form class="lms-form" data-lms-route="learner/preferences/save" data-lms-reload>
|
|
116
|
+
<input type="hidden" name="expectedRevision" value={preferences.revision ?? ""} />
|
|
117
|
+
<label>
|
|
118
|
+
<span>Email updates</span>
|
|
119
|
+
<select name="emailNotifications">
|
|
120
|
+
<option value="true" selected={preferences.value.emailNotifications}>
|
|
121
|
+
Enabled when the academy sends email
|
|
122
|
+
</option>
|
|
123
|
+
<option value="false" selected={!preferences.value.emailNotifications}>
|
|
124
|
+
Off
|
|
125
|
+
</option>
|
|
126
|
+
</select>
|
|
127
|
+
</label>
|
|
128
|
+
<label>
|
|
129
|
+
<span>Weekly lesson goal</span>
|
|
130
|
+
<input
|
|
131
|
+
type="number"
|
|
132
|
+
name="weeklyGoalLessons"
|
|
133
|
+
min="0"
|
|
134
|
+
max="100"
|
|
135
|
+
value={preferences.value.weeklyGoalLessons}
|
|
136
|
+
/>
|
|
137
|
+
</label>
|
|
138
|
+
<p>
|
|
139
|
+
Set 0 for no goal. Your goal is a personal target; it does not change deadlines or
|
|
140
|
+
completion requirements.
|
|
141
|
+
</p>
|
|
142
|
+
<button class="lms-btn" type="submit">
|
|
143
|
+
Save preferences
|
|
144
|
+
</button>
|
|
145
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
146
|
+
</form>
|
|
147
|
+
</section>
|
|
148
|
+
)}
|
|
149
|
+
<section class="lms-learning-card" data-lms-export>
|
|
150
|
+
<h2>Download my learning data</h2>
|
|
151
|
+
<p>
|
|
152
|
+
Download each section as JSON, five records per page. Continue until all pages are
|
|
153
|
+
downloaded. Exports include only your records and omit quiz answer keys.
|
|
154
|
+
</p>
|
|
155
|
+
<label>
|
|
156
|
+
Data section{" "}
|
|
157
|
+
<select data-lms-export-section>
|
|
158
|
+
<option value="enrollments">Learning history, notes and assessments</option>
|
|
159
|
+
<option value="discussions">Discussion contributions</option>
|
|
160
|
+
<option value="reviews">Course reviews</option>
|
|
161
|
+
<option value="notifications">Notifications</option>
|
|
162
|
+
<option value="saved_courses">Saved courses</option>
|
|
163
|
+
<option value="path_enrollments">Joined paths</option>
|
|
164
|
+
<option value="cohort_enrollments">Cohort membership</option>
|
|
165
|
+
</select>
|
|
166
|
+
</label>
|
|
167
|
+
<button type="button" class="lms-btn" data-lms-export-download>
|
|
168
|
+
Download first page
|
|
169
|
+
</button>
|
|
170
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
171
|
+
</section>
|
|
172
|
+
</LearningShell>
|
|
173
|
+
</Layout>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
|
+
import LearningShell from "../components/LearningShell.astro";
|
|
4
|
+
import { lmsPublic, courseUrl, loginUrl, LmsError } from "@anantagnanam/lms/server";
|
|
5
|
+
import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
|
|
6
|
+
import type { Page, PublicPath } from "@anantagnanam/lms/types";
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
9
|
+
let page: Page<PublicPath> = { items: [], hasMore: false };
|
|
10
|
+
let error = "";
|
|
11
|
+
try {
|
|
12
|
+
page = await lmsPublic<typeof page>(
|
|
13
|
+
Astro.locals,
|
|
14
|
+
"paths",
|
|
15
|
+
{ cursor: Astro.url.searchParams.get("cursor") ?? undefined },
|
|
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 : "Learning paths unavailable.";
|
|
21
|
+
}
|
|
22
|
+
const next = new URL(Astro.url);
|
|
23
|
+
next.searchParams.set("cursor", page.cursor ?? "");
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<Layout title="Learning paths">
|
|
27
|
+
<LearningShell>
|
|
28
|
+
<h1>Learning paths</h1>
|
|
29
|
+
<p class="lms-lead">
|
|
30
|
+
Follow an ordered program of courses and track your progress in your library.
|
|
31
|
+
</p>
|
|
32
|
+
<p>
|
|
33
|
+
Join a path to save its course list. Enroll in each course separately; approval and
|
|
34
|
+
prerequisites still apply.
|
|
35
|
+
</p>
|
|
36
|
+
{error && <p role="alert">{error}</p>}
|
|
37
|
+
{!error && !page.items.length && <p>No learning paths have been published yet.</p>}
|
|
38
|
+
{page.items.map((path) => (
|
|
39
|
+
<article class="lms-learning-card">
|
|
40
|
+
<h2>{path.title}</h2>
|
|
41
|
+
<p class="lms-text">{path.description}</p>
|
|
42
|
+
<ol>
|
|
43
|
+
{path.courses.map((course) => (
|
|
44
|
+
<li>
|
|
45
|
+
{course ? (
|
|
46
|
+
<a href={courseUrl(basePath, course.slug)}>{course.title}</a>
|
|
47
|
+
) : (
|
|
48
|
+
"Course currently unavailable"
|
|
49
|
+
)}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ol>
|
|
53
|
+
{Astro.locals.user ? (
|
|
54
|
+
<form data-lms-route="learner/path/join">
|
|
55
|
+
<input type="hidden" name="pathId" value={path.id} />
|
|
56
|
+
<button class="lms-btn" type="submit">
|
|
57
|
+
Join path
|
|
58
|
+
</button>
|
|
59
|
+
<a class="lms-btn lms-btn-outline" href={`${basePath}/my-learning/library`}>
|
|
60
|
+
View my paths
|
|
61
|
+
</a>
|
|
62
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
63
|
+
</form>
|
|
64
|
+
) : (
|
|
65
|
+
<a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
|
|
66
|
+
Sign in to join
|
|
67
|
+
</a>
|
|
68
|
+
)}
|
|
69
|
+
</article>
|
|
70
|
+
))}
|
|
71
|
+
{page.hasMore && page.cursor && <a href={next.href}>More learning paths →</a>}
|
|
72
|
+
</LearningShell>
|
|
73
|
+
</Layout>
|