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