@anantagnanam/lms 0.1.0 → 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 -21
- package/README.md +23 -2
- package/astro/client.ts +73 -151
- package/astro/components/CourseOverview.astro +25 -3
- package/astro/components/LearnerDashboard.astro +6 -0
- package/astro/components/LearningShell.astro +10 -1
- package/astro/components/LessonAvailabilityNote.astro +26 -0
- package/astro/components/LessonContent.astro +53 -0
- package/astro/components/LessonPlayer.astro +40 -61
- package/astro/components/MediaPlayer.astro +55 -4
- package/astro/media-player.ts +99 -0
- package/astro/pages/author-preview.astro +71 -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/quiz-drafts.ts +87 -0
- package/astro/quiz-player.ts +362 -0
- package/astro/styles.css +12 -0
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +585 -116
- package/dist/admin.js.map +1 -1
- package/dist/astro.js +3 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-C0kT94tc.js → constants-Cnx7k82Z.js} +25 -3
- package/dist/constants-Cnx7k82Z.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +104 -12
- package/dist/runtime.js +896 -116
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +68 -1
- package/dist/schemas.js +34 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +11 -1
- package/dist/server.js +13 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +107 -4
- package/package.json +1 -1
- package/dist/constants-C0kT94tc.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import LessonContent from "./LessonContent.astro";
|
|
3
|
+
import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
|
|
4
4
|
import type {
|
|
5
5
|
CourseDetail,
|
|
6
6
|
PublicLesson,
|
|
@@ -19,10 +19,12 @@ interface Props {
|
|
|
19
19
|
const { course, lesson, enrollment = null, progress, preview = false } = Astro.props;
|
|
20
20
|
const all = course.modules.flatMap((module) => module.lessons);
|
|
21
21
|
const index = all.findIndex((item) => item.id === lesson.id);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const available = (id: string) =>
|
|
23
|
+
preview
|
|
24
|
+
? Boolean(all.find((item) => item.id === id)?.preview)
|
|
25
|
+
: progress?.availability[id]?.available === true;
|
|
26
|
+
const previous = all.slice(0, index).findLast((item) => available(item.id));
|
|
27
|
+
const nextLesson = all.slice(index + 1).find((item) => available(item.id));
|
|
26
28
|
const current = enrollment?.progress[lesson.id];
|
|
27
29
|
const complete = Boolean(current?.completedAt);
|
|
28
30
|
const attempt = current?.quizAttempts.at(-1);
|
|
@@ -42,6 +44,7 @@ const assignment = current?.assignments.at(-1);
|
|
|
42
44
|
data-course-id={course.id}
|
|
43
45
|
data-lesson-id={lesson.id}
|
|
44
46
|
data-preview={preview ? "true" : "false"}
|
|
47
|
+
data-playback={current?.playback ? JSON.stringify(current.playback) : undefined}
|
|
45
48
|
>
|
|
46
49
|
<aside class="lms-course-sidebar">
|
|
47
50
|
<a href={courseUrl(basePath, course.slug)} class="lms-sidebar-course">
|
|
@@ -64,20 +67,28 @@ const assignment = current?.assignments.at(-1);
|
|
|
64
67
|
<ol>
|
|
65
68
|
{module.lessons.map((entry) => (
|
|
66
69
|
<li>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
70
|
+
{available(entry.id) ? (
|
|
71
|
+
<a
|
|
72
|
+
href={lessonUrl(basePath, course.slug, entry.id)}
|
|
73
|
+
aria-current={entry.id === lesson.id ? "page" : undefined}
|
|
74
|
+
>
|
|
75
|
+
<span aria-hidden="true">
|
|
76
|
+
{enrollment?.progress[entry.id]?.completedAt ? "✓" : "○"}
|
|
77
|
+
</span>
|
|
78
|
+
<span>
|
|
79
|
+
{entry.title}
|
|
80
|
+
<small>
|
|
81
|
+
{entry.kind} · {entry.durationMinutes} min
|
|
82
|
+
</small>
|
|
83
|
+
</span>
|
|
84
|
+
</a>
|
|
85
|
+
) : (
|
|
86
|
+
<div class="lms-locked-lesson">
|
|
87
|
+
<span>{entry.title}</span>
|
|
88
|
+
<LessonAvailabilityNote availability={progress?.availability[entry.id]} />
|
|
89
|
+
{preview && <small>Enroll to access this lesson.</small>}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
81
92
|
</li>
|
|
82
93
|
))}
|
|
83
94
|
</ol>
|
|
@@ -99,42 +110,7 @@ const assignment = current?.assignments.at(-1);
|
|
|
99
110
|
<a href={courseUrl(basePath, course.slug)}>Enroll to save your progress →</a>
|
|
100
111
|
</p>
|
|
101
112
|
)}
|
|
102
|
-
{lesson
|
|
103
|
-
<MediaPlayer
|
|
104
|
-
url={lesson.mediaUrl}
|
|
105
|
-
kind={lesson.kind as "video" | "audio" | "live"}
|
|
106
|
-
title={lesson.title}
|
|
107
|
-
startsAt={lesson.liveStartsAt}
|
|
108
|
-
/>
|
|
109
|
-
)}
|
|
110
|
-
<div class="lms-lesson-body">
|
|
111
|
-
{lesson.portableText.length > 0 ? (
|
|
112
|
-
<PortableText value={lesson.portableText as TypedObject[]} />
|
|
113
|
-
) : (
|
|
114
|
-
lesson.body
|
|
115
|
-
.split(/\n\s*\n/)
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.map((paragraph) => <p class="lms-text">{paragraph}</p>)
|
|
118
|
-
)}
|
|
119
|
-
</div>
|
|
120
|
-
{lesson.resources.length > 0 && (
|
|
121
|
-
<section class="lms-resources">
|
|
122
|
-
<h2>Keep exploring</h2>
|
|
123
|
-
<ul>
|
|
124
|
-
{lesson.resources.map((resource) => (
|
|
125
|
-
<li>
|
|
126
|
-
<a href={resource.url} target="_blank" rel="noopener noreferrer">
|
|
127
|
-
<span aria-hidden="true">↗</span>
|
|
128
|
-
<span>
|
|
129
|
-
{resource.title}
|
|
130
|
-
{resource.description && <small>{resource.description}</small>}
|
|
131
|
-
</span>
|
|
132
|
-
</a>
|
|
133
|
-
</li>
|
|
134
|
-
))}
|
|
135
|
-
</ul>
|
|
136
|
-
</section>
|
|
137
|
-
)}
|
|
113
|
+
<LessonContent lesson={lesson} />
|
|
138
114
|
{!preview && lesson.kind === "quiz" && lesson.quiz && (
|
|
139
115
|
<section class="lms-assessment">
|
|
140
116
|
<p class="lms-eyebrow">CHECK YOUR UNDERSTANDING</p>
|
|
@@ -153,7 +129,7 @@ const assignment = current?.assignments.at(-1);
|
|
|
153
129
|
data-lms-quiz
|
|
154
130
|
data-course-id={course.id}
|
|
155
131
|
data-lesson-id={lesson.id}
|
|
156
|
-
data-
|
|
132
|
+
data-course-url={courseUrl(basePath, course.slug)}
|
|
157
133
|
>
|
|
158
134
|
<button class="lms-btn" type="button" data-lms-quiz-start>
|
|
159
135
|
{attempt?.status === "in_progress" ? "Resume quiz" : "Start quiz"}
|
|
@@ -268,15 +244,18 @@ const assignment = current?.assignments.at(-1);
|
|
|
268
244
|
</div>
|
|
269
245
|
)}
|
|
270
246
|
<nav class="lms-lesson-navigation" aria-label="Lesson navigation">
|
|
271
|
-
{
|
|
272
|
-
<a href={lessonUrl(basePath, course.slug,
|
|
247
|
+
{previous ? (
|
|
248
|
+
<a href={lessonUrl(basePath, course.slug, previous.id)}>← Previous available lesson</a>
|
|
273
249
|
) : (
|
|
274
250
|
<a href={courseUrl(basePath, course.slug)}>← Course overview</a>
|
|
275
251
|
)}
|
|
276
|
-
{
|
|
277
|
-
<a href={lessonUrl(basePath, course.slug,
|
|
252
|
+
{nextLesson && (
|
|
253
|
+
<a href={lessonUrl(basePath, course.slug, nextLesson.id)}>Next available lesson →</a>
|
|
278
254
|
)}
|
|
279
255
|
</nav>
|
|
256
|
+
{!nextLesson && progress?.blockedLessonId && (
|
|
257
|
+
<LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
|
|
258
|
+
)}
|
|
280
259
|
{!preview && (
|
|
281
260
|
<>
|
|
282
261
|
<section class="lms-content-section">
|
|
@@ -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,99 @@
|
|
|
1
|
+
import type { Playback } from "@anantagnanam/lms/types";
|
|
2
|
+
|
|
3
|
+
type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
|
|
4
|
+
|
|
5
|
+
/** Provider iframes own their controls. Only native media participates in resume. */
|
|
6
|
+
export function initializeMedia(call: Call) {
|
|
7
|
+
for (const root of document.querySelectorAll<HTMLElement>(
|
|
8
|
+
"[data-lms-media]:not([data-lms-bound])",
|
|
9
|
+
)) {
|
|
10
|
+
root.dataset.lmsBound = "true";
|
|
11
|
+
const media = root.querySelector<HTMLMediaElement>("video, audio");
|
|
12
|
+
if (!media) continue;
|
|
13
|
+
const lesson = root.closest<HTMLElement>("[data-lms-lesson]");
|
|
14
|
+
const speed = root.querySelector<HTMLSelectElement>("[data-lms-speed]")!;
|
|
15
|
+
const button = root.querySelector<HTMLButtonElement>("[data-lms-playback-save]")!;
|
|
16
|
+
const status = root.querySelector<HTMLElement>("[data-lms-feedback]")!;
|
|
17
|
+
const saved: Playback | undefined = lesson?.dataset.playback
|
|
18
|
+
? JSON.parse(lesson.dataset.playback)
|
|
19
|
+
: undefined;
|
|
20
|
+
let revision = saved?.revision ?? 0;
|
|
21
|
+
let ready = false;
|
|
22
|
+
let busy = false;
|
|
23
|
+
let pending = false;
|
|
24
|
+
let failed = false;
|
|
25
|
+
let lastSave = Date.now();
|
|
26
|
+
const enabled = Boolean(lesson && lesson.dataset.preview !== "true");
|
|
27
|
+
button.hidden = !enabled;
|
|
28
|
+
const restore = () => {
|
|
29
|
+
if (ready) return;
|
|
30
|
+
ready = true;
|
|
31
|
+
if (saved) {
|
|
32
|
+
const position = Number.isFinite(media.duration)
|
|
33
|
+
? Math.min(saved.positionSeconds, Math.max(0, media.duration - 0.1))
|
|
34
|
+
: saved.positionSeconds;
|
|
35
|
+
try {
|
|
36
|
+
media.currentTime = position;
|
|
37
|
+
media.playbackRate = saved.playbackRate;
|
|
38
|
+
} catch {
|
|
39
|
+
/* The browser may not have a seekable range yet. */
|
|
40
|
+
}
|
|
41
|
+
speed.value = String(media.playbackRate);
|
|
42
|
+
status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const save = async () => {
|
|
46
|
+
if (!enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
|
|
47
|
+
if (busy) {
|
|
48
|
+
pending = true;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
busy = true;
|
|
52
|
+
button.disabled = true;
|
|
53
|
+
pending = false;
|
|
54
|
+
status.textContent = "Saving playback position…";
|
|
55
|
+
try {
|
|
56
|
+
const result = await call<Playback>("learner/playback", {
|
|
57
|
+
courseId: lesson!.dataset.courseId,
|
|
58
|
+
lessonId: lesson!.dataset.lessonId,
|
|
59
|
+
positionSeconds: Math.min(media.currentTime, 86_400),
|
|
60
|
+
playbackRate: media.playbackRate,
|
|
61
|
+
expectedRevision: revision,
|
|
62
|
+
});
|
|
63
|
+
revision = result.revision;
|
|
64
|
+
lastSave = Date.now();
|
|
65
|
+
status.textContent = "Playback position saved.";
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// A lost response can follow a successful write. Stop instead of overwriting another tab.
|
|
68
|
+
failed = true;
|
|
69
|
+
status.textContent = `${error instanceof Error ? error.message : "Could not save playback."} Reload this lesson before saving again.`;
|
|
70
|
+
status.setAttribute("role", "alert");
|
|
71
|
+
} finally {
|
|
72
|
+
busy = false;
|
|
73
|
+
button.disabled = failed;
|
|
74
|
+
if (pending && !failed) void save();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
media.addEventListener("loadedmetadata", restore);
|
|
78
|
+
if (media.readyState >= 1) restore();
|
|
79
|
+
speed.addEventListener("change", () => {
|
|
80
|
+
media.playbackRate = Number(speed.value);
|
|
81
|
+
});
|
|
82
|
+
media.addEventListener("ratechange", () => {
|
|
83
|
+
speed.value = String(media.playbackRate);
|
|
84
|
+
void save();
|
|
85
|
+
});
|
|
86
|
+
media.addEventListener("timeupdate", () => {
|
|
87
|
+
if (!busy && Date.now() - lastSave >= 15_000) void save();
|
|
88
|
+
});
|
|
89
|
+
media.addEventListener("pause", () => {
|
|
90
|
+
void save();
|
|
91
|
+
});
|
|
92
|
+
media.addEventListener("ended", () => {
|
|
93
|
+
void save();
|
|
94
|
+
});
|
|
95
|
+
button.addEventListener("click", () => {
|
|
96
|
+
void save();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
|
+
import LearningShell from "../components/LearningShell.astro";
|
|
4
|
+
import LessonContent from "../components/LessonContent.astro";
|
|
5
|
+
import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
|
|
6
|
+
import { loginPath } from "virtual:anantagnanam-lms/config";
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
9
|
+
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
10
|
+
let result: Awaited<ReturnType<typeof lmsAuthorLessonPreview>> | undefined;
|
|
11
|
+
let error = "";
|
|
12
|
+
try {
|
|
13
|
+
result = await lmsAuthorLessonPreview(
|
|
14
|
+
Astro.locals,
|
|
15
|
+
{ courseId: Astro.params.courseId!, lessonId: Astro.params.lessonId! },
|
|
16
|
+
Astro.url.origin,
|
|
17
|
+
);
|
|
18
|
+
} catch (cause) {
|
|
19
|
+
Astro.response.status = cause instanceof LmsError ? cause.status : 503;
|
|
20
|
+
error = cause instanceof Error ? cause.message : "Preview is unavailable.";
|
|
21
|
+
}
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Layout
|
|
25
|
+
title={result ? `Preview: ${result.lesson.title}` : "Lesson preview"}
|
|
26
|
+
robots="noindex, nofollow"
|
|
27
|
+
>
|
|
28
|
+
<LearningShell>
|
|
29
|
+
{result ? (
|
|
30
|
+
<article class="lms-lesson">
|
|
31
|
+
<p class="lms-notice">
|
|
32
|
+
Saved author preview · {result.course.title}. Save changes in the course editor, then
|
|
33
|
+
refresh this page. Linked content uses its published version.
|
|
34
|
+
</p>
|
|
35
|
+
<h1>{result.lesson.title}</h1>
|
|
36
|
+
{result.lesson.summary && <p class="lms-lead">{result.lesson.summary}</p>}
|
|
37
|
+
<LessonContent lesson={result.lesson} />
|
|
38
|
+
{result.lesson.quiz && (
|
|
39
|
+
<section class="lms-assessment">
|
|
40
|
+
<h2>Lesson quiz</h2>
|
|
41
|
+
<p>
|
|
42
|
+
{result.lesson.quiz.questionCount} questions · Pass with{" "}
|
|
43
|
+
{result.lesson.quiz.passingPercent}% · {result.lesson.quiz.maxAttempts} attempts
|
|
44
|
+
</p>
|
|
45
|
+
<p>Assessment summary only. This preview does not create an attempt.</p>
|
|
46
|
+
</section>
|
|
47
|
+
)}
|
|
48
|
+
{result.lesson.assignment && (
|
|
49
|
+
<section class="lms-assessment">
|
|
50
|
+
<h2>Your assignment</h2>
|
|
51
|
+
<p class="lms-text">{result.lesson.assignment.instructions}</p>
|
|
52
|
+
<p>
|
|
53
|
+
{result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
|
|
54
|
+
to pass
|
|
55
|
+
</p>
|
|
56
|
+
</section>
|
|
57
|
+
)}
|
|
58
|
+
</article>
|
|
59
|
+
) : (
|
|
60
|
+
<section class="lms-gate">
|
|
61
|
+
<h1>Lesson preview</h1>
|
|
62
|
+
<p>{error}</p>
|
|
63
|
+
{!Astro.locals.user && (
|
|
64
|
+
<a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname)}>
|
|
65
|
+
Sign in →
|
|
66
|
+
</a>
|
|
67
|
+
)}
|
|
68
|
+
</section>
|
|
69
|
+
)}
|
|
70
|
+
</LearningShell>
|
|
71
|
+
</Layout>
|
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>
|