@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/astro/client.ts +355 -0
  4. package/astro/components/CertificateCard.astro +83 -0
  5. package/astro/components/CourseCard.astro +61 -0
  6. package/astro/components/CourseCatalog.astro +96 -0
  7. package/astro/components/CourseOverview.astro +308 -0
  8. package/astro/components/LearnerDashboard.astro +127 -0
  9. package/astro/components/LearningShell.astro +37 -0
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +53 -0
  12. package/astro/components/LessonPlayer.astro +302 -0
  13. package/astro/components/MediaPlayer.astro +111 -0
  14. package/astro/components/index.ts +7 -0
  15. package/astro/layouts/AcademyLayout.astro +84 -0
  16. package/astro/media-player.ts +99 -0
  17. package/astro/pages/author-preview.astro +71 -0
  18. package/astro/pages/certificate.astro +57 -0
  19. package/astro/pages/cohort.astro +102 -0
  20. package/astro/pages/course.astro +90 -0
  21. package/astro/pages/dashboard.astro +34 -0
  22. package/astro/pages/index.astro +40 -0
  23. package/astro/pages/lesson.astro +103 -0
  24. package/astro/pages/library.astro +173 -0
  25. package/astro/pages/paths.astro +73 -0
  26. package/astro/pages/teach.astro +15 -0
  27. package/astro/quiz-drafts.ts +87 -0
  28. package/astro/quiz-player.ts +362 -0
  29. package/astro/styles.css +1300 -0
  30. package/dist/admin.d.ts +34 -0
  31. package/dist/admin.js +3231 -0
  32. package/dist/admin.js.map +1 -0
  33. package/dist/astro.d.ts +11 -0
  34. package/dist/astro.js +52 -0
  35. package/dist/astro.js.map +1 -0
  36. package/dist/commerce.d.ts +72 -0
  37. package/dist/commerce.js +0 -0
  38. package/dist/config-BX3beTpM.js +15 -0
  39. package/dist/config-BX3beTpM.js.map +1 -0
  40. package/dist/constants-Cnx7k82Z.js +161 -0
  41. package/dist/constants-Cnx7k82Z.js.map +1 -0
  42. package/dist/csv-DKq_4OWU.js +55 -0
  43. package/dist/csv-DKq_4OWU.js.map +1 -0
  44. package/dist/errors-ChzJS9al.js +16 -0
  45. package/dist/errors-ChzJS9al.js.map +1 -0
  46. package/dist/index.d.ts +13 -0
  47. package/dist/index.js +19 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/runtime.d.ts +773 -0
  50. package/dist/runtime.js +2840 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/schemas.d.ts +651 -0
  53. package/dist/schemas.js +347 -0
  54. package/dist/schemas.js.map +1 -0
  55. package/dist/server.d.ts +40 -0
  56. package/dist/server.js +65 -0
  57. package/dist/server.js.map +1 -0
  58. package/dist/types.d.ts +387 -0
  59. package/dist/types.js +0 -0
  60. package/package.json +83 -4
@@ -0,0 +1,96 @@
1
+ ---
2
+ import CourseCard from "./CourseCard.astro";
3
+ import type { CourseCard as CourseCardData, Page } from "@anantagnanam/lms/types";
4
+ interface Props {
5
+ page: Page<CourseCardData>;
6
+ title: string;
7
+ description: string;
8
+ error?: string;
9
+ }
10
+ const { page, title, description, error } = Astro.props;
11
+ const query = Astro.url.searchParams;
12
+ const nextUrl = new URL(Astro.url);
13
+ nextUrl.searchParams.set("cursor", page.cursor ?? "");
14
+ ---
15
+
16
+ <section class="lms-hero">
17
+ <p class="lms-eyebrow">LEARNING THAT STAYS WITH YOU</p>
18
+ <h1>{title}</h1>
19
+ <p class="lms-lead">{description}</p>
20
+ <div class="lms-hero-notes">
21
+ <span>◎ Free courses</span>
22
+ <span>✓ Guided progress</span>
23
+ <span>∞ Lifelong curiosity</span>
24
+ </div>
25
+ </section>
26
+ <section class="lms-catalog" aria-label="Course catalog">
27
+ <div class="lms-section-heading">
28
+ <div>
29
+ <p class="lms-eyebrow">CHOOSE YOUR NEXT STEP</p>
30
+ <h2>Find your learning path</h2>
31
+ </div>
32
+ </div>
33
+ <form class="lms-filters" method="get" action={Astro.url.pathname}>
34
+ <label>
35
+ <span>Course title</span>
36
+ <input
37
+ type="search"
38
+ name="q"
39
+ value={query.get("q") ?? ""}
40
+ placeholder="Start typing a course title…"
41
+ />
42
+ </label>
43
+ <label>
44
+ <span>Level</span>
45
+ <select name="level">
46
+ <option value="all" selected={!query.get("level") || query.get("level") === "all"}>
47
+ All levels
48
+ </option>
49
+ {["beginner", "intermediate", "advanced"].map((level) => (
50
+ <option value={level} selected={query.get("level") === level}>
51
+ {level}
52
+ </option>
53
+ ))}
54
+ </select>
55
+ </label>
56
+ <label>
57
+ <span>Category</span>
58
+ <input name="category" value={query.get("category") ?? ""} placeholder="Any category" />
59
+ </label>
60
+ <button class="lms-btn" type="submit">
61
+ Find courses
62
+ </button>
63
+ </form>
64
+ {error && (
65
+ <p class="lms-notice lms-notice-error" role="alert">
66
+ {error}
67
+ </p>
68
+ )}
69
+ <div class="lms-cards">
70
+ {page.items.map((course) => (
71
+ <CourseCard course={course} />
72
+ ))}
73
+ </div>
74
+ {!page.items.length && !error && (
75
+ <div class="lms-empty">
76
+ <span aria-hidden="true">◎</span>
77
+ <h3>
78
+ {query.get("q") || query.get("category")
79
+ ? "No matching courses"
80
+ : "A new chapter is coming"}
81
+ </h3>
82
+ <p>
83
+ {query.get("q") || query.get("category")
84
+ ? "Try another course title or category."
85
+ : "New courses will appear here when they are published."}
86
+ </p>
87
+ </div>
88
+ )}
89
+ {page.hasMore && page.cursor && (
90
+ <nav class="lms-pagination" aria-label="Catalog pagination">
91
+ <a class="lms-btn lms-btn-outline" href={nextUrl.href}>
92
+ More courses →
93
+ </a>
94
+ </nav>
95
+ )}
96
+ </section>
@@ -0,0 +1,308 @@
1
+ ---
2
+ import { Image } from "emdash/ui";
3
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
4
+ import type {
5
+ CourseDetail,
6
+ PublicEnrollment,
7
+ ProgressSummary,
8
+ Page,
9
+ Review,
10
+ CohortMembership,
11
+ } from "@anantagnanam/lms/types";
12
+ import { courseUrl, lessonUrl, certificateUrl, loginUrl } from "@anantagnanam/lms/server";
13
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
14
+ interface Props {
15
+ course: CourseDetail;
16
+ enrollment?: PublicEnrollment | null;
17
+ progress?: ProgressSummary;
18
+ reviews?: Page<Review>;
19
+ cohortId?: string;
20
+ cohortMembership?: CohortMembership | null;
21
+ }
22
+ const {
23
+ course,
24
+ enrollment = null,
25
+ progress,
26
+ reviews,
27
+ cohortId,
28
+ cohortMembership = null,
29
+ } = Astro.props;
30
+ const active =
31
+ enrollment &&
32
+ ["active", "completed"].includes(enrollment.status) &&
33
+ enrollment.effectiveAccess === "granted";
34
+ const next = progress?.nextLessonId;
35
+ const revisit =
36
+ !next && enrollment?.status === "completed"
37
+ ? course.modules
38
+ .flatMap((module) => module.lessons)
39
+ .find((lesson) => progress?.availability[lesson.id]?.available)
40
+ : undefined;
41
+ ---
42
+
43
+ <nav class="lms-breadcrumbs" aria-label="Breadcrumb">
44
+ <a href={basePath}>Courses</a>
45
+ <span aria-hidden="true">/</span>
46
+ <span>{course.title}</span>
47
+ </nav>
48
+ <header class="lms-course-hero">
49
+ <div>
50
+ <p class="lms-eyebrow">{course.category || "YOUR NEXT LEARNING JOURNEY"}</p>
51
+ <h1>{course.title}</h1>
52
+ <p class="lms-lead">{course.summary}</p>
53
+ <div class="lms-tags">
54
+ <span>{course.level === "all" ? "All levels" : course.level}</span>
55
+ <span>{course.lessonCount} lessons</span>
56
+ <span>{course.durationMinutes} minutes</span>
57
+ <span>{course.language}</span>
58
+ </div>
59
+ {course.instructorName && (
60
+ <p class="lms-instructor">
61
+ Guided by <strong>{course.instructorName}</strong>
62
+ </p>
63
+ )}
64
+ </div>
65
+ <aside class="lms-enroll-card">
66
+ {course.featuredImage && <Image image={course.featuredImage} width={600} height={360} />}
67
+ <span class="lms-eyebrow">FREE TO LEARN</span>
68
+ <h2>{active ? "Your next step awaits" : "Begin your learning journey"}</h2>
69
+ {active && progress ? (
70
+ <>
71
+ <label class="lms-progress-label" for="course-progress">
72
+ {progress.completed} of {progress.total} required lessons complete
73
+ </label>
74
+ <progress id="course-progress" max="100" value={progress.percent}>
75
+ {progress.percent}%
76
+ </progress>
77
+ {next && (
78
+ <a class="lms-btn" href={lessonUrl(basePath, course.slug, next)}>
79
+ {enrollment.status === "completed" ? "Revisit lessons" : "Continue learning"} →
80
+ </a>
81
+ )}
82
+ {revisit && (
83
+ <a class="lms-btn" href={lessonUrl(basePath, course.slug, revisit.id)}>
84
+ Revisit lessons →
85
+ </a>
86
+ )}
87
+ {!next && progress.blockedLessonId && (
88
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
89
+ )}
90
+ {enrollment.certificate && (
91
+ <a
92
+ class="lms-btn lms-btn-outline"
93
+ href={certificateUrl(basePath, enrollment.certificate.id)}
94
+ >
95
+ View certificate
96
+ </a>
97
+ )}
98
+ </>
99
+ ) : enrollment ? (
100
+ <div class="lms-notice">
101
+ <strong>
102
+ {enrollment.status === "pending"
103
+ ? "Your request is awaiting approval"
104
+ : enrollment.status === "active" || enrollment.status === "completed"
105
+ ? `Course access ${enrollment.effectiveAccess}`
106
+ : `Enrollment ${enrollment.status}`}
107
+ </strong>
108
+ <p>
109
+ {enrollment.status === "pending"
110
+ ? "An administrator will review your request. Check My Learning for updates."
111
+ : enrollment.decisionReason || "Contact an administrator to manage your access."}
112
+ </p>
113
+ <a href={`${basePath}/my-learning`}>Open My Learning →</a>
114
+ {progress?.blockedLessonId && (
115
+ <LessonAvailabilityNote availability={progress.availability[progress.blockedLessonId]} />
116
+ )}
117
+ </div>
118
+ ) : !Astro.locals.user ? (
119
+ <a class="lms-btn" href={loginUrl(loginPath, Astro.url.pathname + Astro.url.search)}>
120
+ Sign in to enroll →
121
+ </a>
122
+ ) : cohortId && cohortMembership?.status !== "active" ? (
123
+ <div class="lms-notice">
124
+ <p>
125
+ {cohortMembership
126
+ ? `Cohort membership ${cohortMembership.status}.`
127
+ : "This cohort requires administrator approval."}
128
+ </p>
129
+ <a class="lms-btn" href={`${basePath}/cohorts/${encodeURIComponent(cohortId)}`}>
130
+ {cohortMembership ? "View cohort membership" : "Request cohort membership"} →
131
+ </a>
132
+ </div>
133
+ ) : course.enrollmentMode === "invite" && !cohortId ? (
134
+ <p class="lms-notice">An administrator will enroll invited learners in this course.</p>
135
+ ) : (
136
+ <form data-lms-route="learner/enroll" data-lms-reload>
137
+ <input type="hidden" name="courseId" value={course.id} />
138
+ {cohortId && <input type="hidden" name="cohortId" value={cohortId} />}
139
+ <button class="lms-btn" type="submit">
140
+ {course.enrollmentMode === "approval" && !cohortId
141
+ ? "Request enrollment"
142
+ : "Enroll for free"}{" "}
143
+ →
144
+ </button>
145
+ <p data-lms-feedback aria-live="polite"></p>
146
+ </form>
147
+ )}
148
+ <ul class="lms-benefits">
149
+ <li>Learn at your own pace</li>
150
+ {course.sequential && <li>Follow a clear lesson sequence</li>}
151
+ {course.certificateEnabled && <li>Earn a completion certificate</li>}
152
+ {course.accessDays > 0 && <li>{course.accessDays} days of access</li>}
153
+ </ul>
154
+ </aside>
155
+ </header>
156
+ <div class="lms-course-content">
157
+ <section class="lms-content-section">
158
+ <p class="lms-eyebrow">A LITTLE CURIOSITY GOES A LONG WAY</p>
159
+ <h2>About this course</h2>
160
+ {course.description
161
+ .split(/\n\s*\n/)
162
+ .filter(Boolean)
163
+ .map((paragraph) => (
164
+ <p class="lms-text">{paragraph}</p>
165
+ ))}
166
+ </section>
167
+ {course.outcomes.length > 0 && (
168
+ <section class="lms-content-section lms-outcomes">
169
+ <h2>What you'll learn</h2>
170
+ <ul>
171
+ {course.outcomes.map((outcome) => (
172
+ <li>
173
+ <span aria-hidden="true">✓</span>
174
+ {outcome}
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ </section>
179
+ )}
180
+ {course.requirements.length > 0 && (
181
+ <section class="lms-content-section">
182
+ <h2>Before you begin</h2>
183
+ <ul>
184
+ {course.requirements.map((requirement) => (
185
+ <li>{requirement}</li>
186
+ ))}
187
+ </ul>
188
+ </section>
189
+ )}
190
+ {course.prerequisites.length > 0 && (
191
+ <section class="lms-content-section">
192
+ <h2>Complete these courses first</h2>
193
+ <ul>
194
+ {course.prerequisites.map((prerequisite) => (
195
+ <li>
196
+ {prerequisite.slug ? (
197
+ <a href={courseUrl(basePath, prerequisite.slug)}>{prerequisite.title}</a>
198
+ ) : (
199
+ prerequisite.title
200
+ )}
201
+ </li>
202
+ ))}
203
+ </ul>
204
+ </section>
205
+ )}
206
+ <section class="lms-content-section">
207
+ <div class="lms-section-heading">
208
+ <div>
209
+ <p class="lms-eyebrow">ONE STEP AT A TIME</p>
210
+ <h2>Your course curriculum</h2>
211
+ </div>
212
+ <span>{course.modules.length} modules</span>
213
+ </div>
214
+ <div class="lms-curriculum">
215
+ {course.modules.map((module, moduleIndex) => (
216
+ <details open={moduleIndex === 0}>
217
+ <summary>
218
+ <span>
219
+ <small>MODULE {moduleIndex + 1}</small>
220
+ <strong>{module.title}</strong>
221
+ </span>
222
+ <span>{module.lessons.length} lessons</span>
223
+ </summary>
224
+ {module.description && <p>{module.description}</p>}
225
+ {(module.releaseDays > 0 || module.unlockAt) && (
226
+ <p class="lms-muted">
227
+ {module.releaseDays > 0 ? `Unlocks ${module.releaseDays} days after approval` : ""}
228
+ {module.unlockAt
229
+ ? ` · Available from ${new Date(module.unlockAt).toLocaleDateString("en-IN")}`
230
+ : ""}
231
+ </p>
232
+ )}
233
+ <ol>
234
+ {module.lessons.map((lesson, index) => (
235
+ <li>
236
+ <span class="lms-lesson-number">
237
+ {enrollment?.progress[lesson.id]?.completedAt
238
+ ? "✓"
239
+ : String(index + 1).padStart(2, "0")}
240
+ </span>
241
+ <div>
242
+ {(
243
+ enrollment && ["active", "completed"].includes(enrollment.status)
244
+ ? progress?.availability[lesson.id]?.available
245
+ : lesson.preview
246
+ ) ? (
247
+ <a href={lessonUrl(basePath, course.slug, lesson.id)}>{lesson.title}</a>
248
+ ) : (
249
+ <strong>{lesson.title}</strong>
250
+ )}
251
+ <small>
252
+ {lesson.kind} · {lesson.durationMinutes} min
253
+ {!lesson.required ? " · optional" : ""}
254
+ </small>
255
+ <LessonAvailabilityNote availability={progress?.availability[lesson.id]} />
256
+ </div>
257
+ {lesson.preview && <span class="lms-badge">Preview</span>}
258
+ </li>
259
+ ))}
260
+ </ol>
261
+ </details>
262
+ ))}
263
+ </div>
264
+ </section>
265
+ {reviews && reviews.items.length > 0 && (
266
+ <section class="lms-content-section">
267
+ <h2>From fellow learners</h2>
268
+ <div class="lms-reviews">
269
+ {reviews.items.map((review) => (
270
+ <article>
271
+ <p class="lms-review-stars" aria-label={`${review.rating} out of 5 stars`}>
272
+ {"★".repeat(review.rating)}
273
+ {"☆".repeat(5 - review.rating)}
274
+ </p>
275
+ <p class="lms-text">{review.body}</p>
276
+ <small>{review.authorName}</small>
277
+ </article>
278
+ ))}
279
+ </div>
280
+ </section>
281
+ )}
282
+ {active && progress && progress.completed > 0 && (
283
+ <section class="lms-content-section">
284
+ <h2>Share your experience</h2>
285
+ <form class="lms-form" data-lms-route="learner/review">
286
+ <input type="hidden" name="courseId" value={course.id} />
287
+ <label>
288
+ <span>Your rating</span>
289
+ <select name="rating" required>
290
+ <option value="5">5 · Excellent</option>
291
+ <option value="4">4 · Very good</option>
292
+ <option value="3">3 · Good</option>
293
+ <option value="2">2 · Could improve</option>
294
+ <option value="1">1 · Needs work</option>
295
+ </select>
296
+ </label>
297
+ <label>
298
+ <span>Your review</span>
299
+ <textarea name="body" required rows={4} maxlength={3000}></textarea>
300
+ </label>
301
+ <button class="lms-btn" type="submit">
302
+ Submit review
303
+ </button>
304
+ <p data-lms-feedback aria-live="polite"></p>
305
+ </form>
306
+ </section>
307
+ )}
308
+ </div>
@@ -0,0 +1,127 @@
1
+ ---
2
+ import type { CourseCard, PublicEnrollment, ProgressSummary, Page } from "@anantagnanam/lms/types";
3
+ import { courseUrl, lessonUrl, certificateUrl } from "@anantagnanam/lms/server";
4
+ import { basePath } from "virtual:anantagnanam-lms/config";
5
+ import LessonAvailabilityNote from "./LessonAvailabilityNote.astro";
6
+ interface Props {
7
+ page: Page<{ course: CourseCard; enrollment: PublicEnrollment; progress: ProgressSummary }>;
8
+ error?: string;
9
+ }
10
+ const { page, error } = Astro.props;
11
+ const next = new URL(Astro.url);
12
+ next.searchParams.set("cursor", page.cursor ?? "");
13
+ ---
14
+
15
+ <header class="lms-dashboard-header">
16
+ <p class="lms-eyebrow">YOUR LEARNING JOURNEY</p>
17
+ <h1>Welcome back{Astro.locals.user?.name ? `, ${Astro.locals.user.name.split(" ")[0]}` : ""}.</h1>
18
+ <p class="lms-lead">A little progress today becomes lasting knowledge tomorrow.</p>
19
+ </header>
20
+ {error && (
21
+ <p role="alert" class="lms-notice lms-notice-error">
22
+ {error}
23
+ </p>
24
+ )}
25
+ <div class="lms-dashboard-grid">
26
+ <section>
27
+ <div class="lms-section-heading">
28
+ <h2>My courses</h2>
29
+ <a href={basePath}>Explore more →</a>
30
+ </div>
31
+ {!page.items.length && !error && (
32
+ <div class="lms-empty">
33
+ <span aria-hidden="true">◎</span>
34
+ <h3>Your first step starts here</h3>
35
+ <p>Choose a free course and make room for a little curiosity.</p>
36
+ <a class="lms-btn" href={basePath}>
37
+ Find a course →
38
+ </a>
39
+ </div>
40
+ )}
41
+ {page.items.map(({ course, enrollment, progress }) => (
42
+ <article class="lms-learning-card">
43
+ <div class="lms-learning-card-top">
44
+ <span class="lms-eyebrow">{course.category || "LEARNING PATH"}</span>
45
+ <span class="lms-badge">{enrollment.status}</span>
46
+ </div>
47
+ <h3>
48
+ <a href={courseUrl(basePath, course.slug)}>{course.title}</a>
49
+ </h3>
50
+ <p>{course.summary}</p>
51
+ <div class="lms-progress-label">
52
+ <span>
53
+ {progress.completed}/{progress.total} required lessons
54
+ </span>
55
+ <strong>{progress.percent}%</strong>
56
+ </div>
57
+ <progress value={progress.percent} max="100">
58
+ {progress.percent}%
59
+ </progress>
60
+ {enrollment.status === "pending" && (
61
+ <p class="lms-notice">Your request is awaiting administrator approval.</p>
62
+ )}
63
+ {enrollment.decisionReason && <p class="lms-notice">{enrollment.decisionReason}</p>}
64
+ {enrollment.status !== "pending" && enrollment.effectiveAccess !== "granted" && (
65
+ <p class="lms-notice">
66
+ Course access {enrollment.effectiveAccess}. Your learning progress is saved.
67
+ </p>
68
+ )}
69
+ <div class="lms-actions">
70
+ {!progress.nextLessonId && progress.blockedLessonId && (
71
+ <LessonAvailabilityNote
72
+ availability={progress.availability[progress.blockedLessonId]}
73
+ />
74
+ )}
75
+ {["active", "completed"].includes(enrollment.status) &&
76
+ enrollment.effectiveAccess === "granted" &&
77
+ progress.nextLessonId && (
78
+ <a class="lms-btn" href={lessonUrl(basePath, course.slug, progress.nextLessonId)}>
79
+ Continue learning →
80
+ </a>
81
+ )}
82
+ <a class="lms-btn lms-btn-outline" href={courseUrl(basePath, course.slug)}>
83
+ Course overview
84
+ </a>
85
+ {enrollment.certificate && (
86
+ <a
87
+ class="lms-btn lms-btn-outline"
88
+ href={certificateUrl(basePath, enrollment.certificate.id)}
89
+ >
90
+ Certificate ✧
91
+ </a>
92
+ )}
93
+ </div>
94
+ {enrollment.status === "pending" && (
95
+ <form
96
+ data-lms-route="learner/cancel"
97
+ data-lms-reload
98
+ data-lms-confirm="Cancel this enrollment request?"
99
+ >
100
+ <input type="hidden" name="courseId" value={course.id} />
101
+ <button class="lms-text-button" type="submit">
102
+ Cancel request
103
+ </button>
104
+ <p data-lms-feedback aria-live="polite"></p>
105
+ </form>
106
+ )}
107
+ </article>
108
+ ))}
109
+ {page.hasMore && page.cursor && (
110
+ <a class="lms-btn lms-btn-outline" href={next.href}>
111
+ More learning records →
112
+ </a>
113
+ )}
114
+ </section>
115
+ <aside class="lms-notifications">
116
+ <p class="lms-eyebrow">STAY IN THE LOOP</p>
117
+ <h2>Your updates</h2>
118
+ <div data-lms-notifications>
119
+ <p class="lms-muted">Loading your updates…</p>
120
+ </div>
121
+ <div class="lms-learning-tip">
122
+ <span aria-hidden="true">∞</span>
123
+ <h3>Small steps. Deep understanding.</h3>
124
+ <p>Return to a lesson, write down a question, or explain a new idea in your own words.</p>
125
+ </div>
126
+ </aside>
127
+ </div>
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { basePath } from "virtual:anantagnanam-lms/config";
3
+ import "../styles.css";
4
+ const path = Astro.url.pathname;
5
+ ---
6
+
7
+ <div class="lms-root">
8
+ <nav class="lms-subnav" aria-label="Learning">
9
+ <a href={basePath} aria-current={path === basePath ? "page" : undefined}>
10
+ Explore courses
11
+ </a>
12
+ <a href={`${basePath}/paths`} aria-current={path === `${basePath}/paths` ? "page" : undefined}>
13
+ Learning paths
14
+ </a>
15
+ <a
16
+ href={`${basePath}/my-learning/library`}
17
+ aria-current={path === `${basePath}/my-learning/library` ? "page" : undefined}
18
+ >
19
+ My library &amp; preferences
20
+ </a>
21
+ <a
22
+ href={`${basePath}/my-learning`}
23
+ aria-current={path === `${basePath}/my-learning` ? "page" : undefined}
24
+ >
25
+ My learning
26
+ </a>
27
+ {Astro.locals.user && Astro.locals.user.role >= 20 && (
28
+ <a href={`${basePath}/teach`}>Course studio</a>
29
+ )}
30
+ </nav>
31
+ <main id="lms-main" class="lms-container">
32
+ <slot />
33
+ </main>
34
+ </div>
35
+ <script>
36
+ import "../client";
37
+ </script>
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { LessonAvailability } from "@anantagnanam/lms/types";
3
+ interface Props {
4
+ availability?: LessonAvailability;
5
+ }
6
+ const { availability } = Astro.props;
7
+ ---
8
+
9
+ {availability && !availability.available && (
10
+ <small class="lms-lock-note">
11
+ {availability.reason}
12
+ {availability.releaseAt && (
13
+ <span>
14
+ {" "}
15
+ Available from{" "}
16
+ <time datetime={availability.releaseAt}>
17
+ {new Date(availability.releaseAt).toLocaleString("en-IN", {
18
+ timeZone: "UTC",
19
+ timeZoneName: "short",
20
+ })}
21
+ </time>
22
+ .
23
+ </span>
24
+ )}
25
+ </small>
26
+ )}
@@ -0,0 +1,53 @@
1
+ ---
2
+ import { PortableText, type TypedObject } from "emdash/ui";
3
+ import MediaPlayer from "./MediaPlayer.astro";
4
+ import type { PublicLesson } from "@anantagnanam/lms/types";
5
+ interface Props {
6
+ lesson: PublicLesson;
7
+ }
8
+ const { lesson } = Astro.props;
9
+ ---
10
+
11
+ {lesson.mediaUrl && ["video", "audio", "live"].includes(lesson.kind) && (
12
+ <MediaPlayer
13
+ url={lesson.mediaUrl}
14
+ kind={lesson.kind as "video" | "audio" | "live"}
15
+ title={lesson.title}
16
+ startsAt={lesson.liveStartsAt}
17
+ captions={lesson.captions ?? []}
18
+ />
19
+ )}
20
+ {lesson.transcript && (
21
+ <details class="lms-resources">
22
+ <summary>Read transcript</summary>
23
+ <p class="lms-text">{lesson.transcript}</p>
24
+ </details>
25
+ )}
26
+ <div class="lms-lesson-body">
27
+ {lesson.portableText.length > 0 ? (
28
+ <PortableText value={lesson.portableText as TypedObject[]} />
29
+ ) : (
30
+ lesson.body
31
+ .split(/\n\s*\n/)
32
+ .filter(Boolean)
33
+ .map((paragraph) => <p class="lms-text">{paragraph}</p>)
34
+ )}
35
+ </div>
36
+ {lesson.resources.length > 0 && (
37
+ <section class="lms-resources">
38
+ <h2>Keep exploring</h2>
39
+ <ul>
40
+ {lesson.resources.map((resource) => (
41
+ <li>
42
+ <a href={resource.url} target="_blank" rel="noopener noreferrer">
43
+ <span aria-hidden="true">↗</span>
44
+ <span>
45
+ {resource.title}
46
+ {resource.description && <small>{resource.description}</small>}
47
+ </span>
48
+ </a>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </section>
53
+ )}