education-playlists 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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1407 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_FOLLOW_UPS: () => DEFAULT_FOLLOW_UPS,
24
+ EDUCATION_CATEGORIES: () => EDUCATION_CATEGORIES,
25
+ EducationPlaylists: () => EducationPlaylists,
26
+ MAX_ANSWERS: () => MAX_ANSWERS,
27
+ MAX_ANSWER_LENGTH: () => MAX_ANSWER_LENGTH,
28
+ MAX_GOAL_LENGTH: () => MAX_GOAL_LENGTH,
29
+ MAX_PLAN_ITEMS: () => MAX_PLAN_ITEMS,
30
+ MAX_PLAYLIST_ITEMS: () => MAX_PLAYLIST_ITEMS,
31
+ MIT_OCW_PRESET_PLAYLISTS: () => MIT_OCW_PRESET_PLAYLISTS,
32
+ MIT_OCW_SEED_COURSES: () => MIT_OCW_SEED_COURSES,
33
+ PLAYLISTS_STORAGE_KEY: () => PLAYLISTS_STORAGE_KEY,
34
+ PROGRESS_STORAGE_KEY: () => PROGRESS_STORAGE_KEY,
35
+ PlaylistPlanner: () => PlaylistPlanner,
36
+ PlaylistView: () => PlaylistView,
37
+ SHARE_HASH_PARAM: () => SHARE_HASH_PARAM,
38
+ acceptInvite: () => acceptInvite,
39
+ addItem: () => addItem,
40
+ buildQuizPrompt: () => buildQuizPrompt,
41
+ canEdit: () => canEdit,
42
+ canView: () => canView,
43
+ catalogItems: () => catalogItems,
44
+ createLocalPlaylistStore: () => createLocalPlaylistStore,
45
+ createPlaylist: () => createPlaylist,
46
+ createProgressStore: () => createProgressStore,
47
+ decodeShareFragment: () => decodeShareFragment,
48
+ encodeShareFragment: () => encodeShareFragment,
49
+ extractJson: () => extractJson,
50
+ fetchFollowUps: () => fetchFollowUps,
51
+ fetchPlan: () => fetchPlan,
52
+ filterPlaylists: () => filterPlaylists,
53
+ formatMinutes: () => formatMinutes,
54
+ getDefaultCatalog: () => getDefaultCatalog,
55
+ invite: () => invite,
56
+ moveItem: () => moveItem,
57
+ normalizePlanRequest: () => normalizePlanRequest,
58
+ parseQuiz: () => parseQuiz,
59
+ planPlaylist: () => planPlaylist,
60
+ playlistTime: () => playlistTime,
61
+ readPreferences: () => readPreferences,
62
+ removeItem: () => removeItem,
63
+ removeMember: () => removeMember,
64
+ safeUrl: () => safeUrl,
65
+ sanitizeItem: () => sanitizeItem,
66
+ sanitizePlaylist: () => sanitizePlaylist,
67
+ searchCatalog: () => searchCatalog,
68
+ suggestFollowUps: () => suggestFollowUps,
69
+ toggleDone: () => toggleDone,
70
+ tokenize: () => tokenize,
71
+ weeksToFinish: () => weeksToFinish
72
+ });
73
+ module.exports = __toCommonJS(index_exports);
74
+
75
+ // src/catalog/taxonomy.ts
76
+ var EDUCATION_CATEGORIES = [
77
+ {
78
+ id: "engineering",
79
+ name: "Engineering",
80
+ majors: [
81
+ {
82
+ id: "computer-science",
83
+ name: "Computer Science",
84
+ department: "6",
85
+ programs: [
86
+ { id: "foundations", name: "Foundations", description: "Programming and discrete math" },
87
+ { id: "algorithms-theory", name: "Algorithms & Theory" },
88
+ { id: "ai-ml", name: "AI & Machine Learning" },
89
+ { id: "systems", name: "Systems" }
90
+ ]
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ id: "science",
96
+ name: "Science",
97
+ majors: [
98
+ {
99
+ id: "mathematics",
100
+ name: "Mathematics",
101
+ department: "18",
102
+ programs: [
103
+ { id: "calculus", name: "Calculus" },
104
+ { id: "linear-algebra-probability", name: "Linear Algebra & Probability" }
105
+ ]
106
+ },
107
+ {
108
+ id: "physics",
109
+ name: "Physics",
110
+ department: "8",
111
+ programs: [{ id: "core-physics", name: "Core Physics" }]
112
+ },
113
+ {
114
+ id: "biology-chemistry",
115
+ name: "Biology & Chemistry",
116
+ department: "7",
117
+ programs: [{ id: "life-sciences-core", name: "Life Sciences Core" }]
118
+ },
119
+ {
120
+ id: "brain-cognitive",
121
+ name: "Brain & Cognitive Sciences",
122
+ department: "9",
123
+ programs: [{ id: "brain-mind", name: "Brain & Mind" }]
124
+ }
125
+ ]
126
+ },
127
+ {
128
+ id: "social-science",
129
+ name: "Social Science & Management",
130
+ majors: [
131
+ {
132
+ id: "economics",
133
+ name: "Economics",
134
+ department: "14",
135
+ programs: [{ id: "principles", name: "Principles" }]
136
+ },
137
+ {
138
+ id: "finance",
139
+ name: "Finance",
140
+ department: "15",
141
+ programs: [{ id: "finance-theory", name: "Finance Theory" }]
142
+ }
143
+ ]
144
+ }
145
+ ];
146
+
147
+ // src/catalog/mit-ocw-seed.ts
148
+ var OCW = "https://ocw.mit.edu/courses/";
149
+ var OCW_YOUTUBE_SEARCH = "https://www.youtube.com/@mitocw/search?query=";
150
+ var seedProvenance = (note) => ({
151
+ provider: "mit_ocw",
152
+ method: "curated_seed",
153
+ verified: false,
154
+ note
155
+ });
156
+ function courseItems(course) {
157
+ const base = {
158
+ courseNumber: course.number,
159
+ institution: "MIT",
160
+ level: course.level
161
+ };
162
+ const id = course.slug;
163
+ const items = [];
164
+ if (course.lectures > 0) {
165
+ items.push({
166
+ ...base,
167
+ id: `${id}#videos`,
168
+ title: `${course.number} ${course.title}: lecture videos`,
169
+ url: `${OCW_YOUTUBE_SEARCH}${encodeURIComponent(`${course.number} ${course.title}`)}`,
170
+ kind: "video_playlist",
171
+ minutes: course.lectures * course.lectureMinutes,
172
+ estimate: "rough",
173
+ estimateBasis: `about ${course.lectures} lectures \xD7 ${course.lectureMinutes} min`,
174
+ description: course.description,
175
+ provenance: seedProvenance(
176
+ "Searches MIT OCW's YouTube channel; replaced by per-lecture URLs once the OCW YouTube dataset is ingested."
177
+ )
178
+ });
179
+ }
180
+ items.push({
181
+ ...base,
182
+ id: `${id}#courseware`,
183
+ title: `${course.number} ${course.title}: notes, problem sets and exams`,
184
+ url: `${OCW}${course.slug}/`,
185
+ kind: "courseware",
186
+ minutes: course.studyHours * 60,
187
+ estimate: "rough",
188
+ estimateBasis: `about ${course.studyHours} h of problem sets and readings`,
189
+ description: course.description,
190
+ provenance: seedProvenance("Canonical OCW course page, curated by hand.")
191
+ });
192
+ return items;
193
+ }
194
+ var c = (number, title, slug, level, lectures, lectureMinutes, studyHours, description) => ({ number, title, slug, level, lectures, lectureMinutes, studyHours, description });
195
+ var MIT_OCW_SEED_COURSES = Object.fromEntries(
196
+ [
197
+ // Computer science (Course 6)
198
+ c("6.0001", "Introduction to Computer Science and Programming in Python", "6-0001-introduction-to-computer-science-and-programming-in-python-fall-2016", "introductory", 12, 50, 30, "Programming in Python for people with little or no experience: computation, abstraction, recursion, objects, and algorithmic complexity."),
199
+ c("6.0002", "Introduction to Computational Thinking and Data Science", "6-0002-introduction-to-computational-thinking-and-data-science-fall-2016", "introductory", 15, 50, 30, "Using computation to solve problems: optimization, graphs, simulation, statistics and machine learning basics, in Python."),
200
+ c("6.042J", "Mathematics for Computer Science", "6-042j-mathematics-for-computer-science-spring-2015", "introductory", 25, 80, 60, "Discrete math for computer science: proofs, induction, number theory, graphs, counting and discrete probability."),
201
+ c("6.006", "Introduction to Algorithms", "6-006-introduction-to-algorithms-spring-2020", "intermediate", 21, 50, 60, "Data structures and algorithms: sorting, hashing, graph search, shortest paths and dynamic programming."),
202
+ c("6.046J", "Design and Analysis of Algorithms", "6-046j-design-and-analysis-of-algorithms-spring-2015", "advanced", 24, 80, 60, "Algorithm design techniques: divide and conquer, amortization, randomization, network flow, and approximation."),
203
+ c("18.404J", "Theory of Computation", "18-404j-theory-of-computation-fall-2020", "advanced", 25, 75, 50, "Automata, computability and complexity: what can be computed, and how efficiently."),
204
+ c("6.034", "Artificial Intelligence", "6-034-artificial-intelligence-fall-2010", "intermediate", 23, 50, 40, "Representations and methods of AI: search, constraints, learning, and knowledge representation."),
205
+ c("6.036", "Introduction to Machine Learning", "6-036-introduction-to-machine-learning-fall-2020", "intermediate", 13, 90, 40, "Principles and algorithms of machine learning: linear models, neural networks, and reinforcement learning."),
206
+ c("18.065", "Matrix Methods in Data Analysis, Signal Processing, and Machine Learning", "18-065-matrix-methods-in-data-analysis-signal-processing-and-machine-learning-spring-2018", "advanced", 36, 50, 40, "Linear algebra for deep learning and data analysis: SVD, low-rank approximation, optimization, and neural nets."),
207
+ c("6.004", "Computation Structures", "6-004-computation-structures-spring-2017", "intermediate", 25, 50, 50, "How computers work: digital logic, processor design, memory hierarchy, and operating-system support."),
208
+ c("6.033", "Computer System Engineering", "6-033-computer-system-engineering-spring-2018", "advanced", 26, 80, 50, "Designing large computer systems: networking, fault tolerance, distributed systems, and security."),
209
+ c("6.172", "Performance Engineering of Software Systems", "6-172-performance-engineering-of-software-systems-fall-2018", "advanced", 23, 80, 60, "Building fast, scalable software: work and span, caching, parallelism, and measurement."),
210
+ // Mathematics (Course 18)
211
+ c("18.01", "Single Variable Calculus", "18-01-single-variable-calculus-fall-2006", "introductory", 39, 50, 60, "Differentiation and integration of functions of one variable, with applications."),
212
+ c("18.02", "Multivariable Calculus", "18-02-multivariable-calculus-fall-2007", "introductory", 35, 50, 60, "Vectors, partial derivatives, multiple integrals, and vector calculus."),
213
+ c("18.03", "Differential Equations", "18-03-differential-equations-spring-2010", "intermediate", 33, 50, 50, "Ordinary differential equations: first-order and linear equations, Laplace transforms, and systems."),
214
+ c("18.06", "Linear Algebra", "18-06-linear-algebra-spring-2010", "introductory", 34, 45, 50, "Matrix theory and linear algebra: systems of equations, vector spaces, determinants, eigenvalues, and positive definite matrices."),
215
+ c("18.05", "Introduction to Probability and Statistics", "18-05-introduction-to-probability-and-statistics-spring-2014", "introductory", 0, 0, 60, "Probability, random variables, Bayesian and frequentist statistics, taught through readings and class problems."),
216
+ // Physics (Course 8)
217
+ c("8.01SC", "Classical Mechanics", "8-01sc-classical-mechanics-fall-2016", "introductory", 35, 45, 60, "Newtonian mechanics: kinematics, forces, energy, momentum, rotation, and gravitation."),
218
+ c("8.02", "Physics II: Electricity and Magnetism", "8-02-physics-ii-electricity-and-magnetism-spring-2007", "introductory", 36, 50, 50, "Electric and magnetic fields, circuits, induction, and electromagnetic waves."),
219
+ c("8.04", "Quantum Physics I", "8-04-quantum-physics-i-spring-2013", "advanced", 24, 80, 60, "Wave mechanics: the Schr\xF6dinger equation, one-dimensional potentials, and the hydrogen atom."),
220
+ // Biology (Course 7), chemistry (Course 5), brain and cognitive sciences (Course 9)
221
+ c("7.016", "Introductory Biology", "7-016-introductory-biology-fall-2018", "introductory", 35, 50, 40, "Biochemistry, genetics, molecular biology, and cell biology, with an emphasis on how they connect."),
222
+ c("7.012", "Introduction to Biology", "7-012-introduction-to-biology-fall-2004", "introductory", 35, 50, 30, "Fundamental principles of biochemistry, genetics, molecular biology, and cell biology."),
223
+ c("5.111SC", "Principles of Chemical Science", "5-111sc-principles-of-chemical-science-fall-2014", "introductory", 36, 50, 40, "Atomic structure, bonding, thermodynamics, kinetics, and chemical equilibrium."),
224
+ c("9.13", "The Human Brain", "9-13-the-human-brain-spring-2019", "introductory", 23, 80, 30, "How the brain gives rise to perception, language, and thought, and how we know."),
225
+ c("9.00SC", "Introduction to Psychology", "9-00sc-introduction-to-psychology-fall-2011", "introductory", 24, 50, 30, "Survey of the scientific study of human nature: perception, memory, emotion, and social behavior."),
226
+ // Economics (Course 14) and management (Course 15)
227
+ c("14.01SC", "Principles of Microeconomics", "14-01sc-principles-of-microeconomics-fall-2011", "introductory", 26, 50, 40, "How consumers and firms make choices, and how markets set prices."),
228
+ c("14.02", "Principles of Macroeconomics", "14-02-principles-of-macroeconomics-spring-2014", "introductory", 25, 80, 40, "Output, unemployment, inflation, interest rates, and the policies that move them."),
229
+ c("15.401", "Finance Theory I", "15-401-finance-theory-i-fall-2008", "intermediate", 24, 80, 40, "Present value, fixed income, equities, risk and return, and capital budgeting.")
230
+ ].map((course) => [course.number, course])
231
+ );
232
+ function preset(id, title, categoryId, majorId, programId, level, description, courseNumbers) {
233
+ return {
234
+ id,
235
+ title,
236
+ description,
237
+ categoryId,
238
+ majorId,
239
+ programId,
240
+ level,
241
+ visibility: "preset",
242
+ items: courseNumbers.flatMap((number) => {
243
+ const course = MIT_OCW_SEED_COURSES[number];
244
+ if (!course) throw new Error(`Unknown seed course ${number}`);
245
+ return courseItems(course);
246
+ })
247
+ };
248
+ }
249
+ var MIT_OCW_PRESET_PLAYLISTS = [
250
+ preset("cs-foundations", "CS Foundations", "engineering", "computer-science", "foundations", "introductory", "Start programming and pick up the math computer science leans on.", ["6.0001", "6.0002", "6.042J"]),
251
+ preset("cs-algorithms-theory", "Algorithms and Theory", "engineering", "computer-science", "algorithms-theory", "intermediate", "From data structures to what can be computed at all.", ["6.006", "6.046J", "18.404J"]),
252
+ preset("cs-ai-ml", "AI and Machine Learning", "engineering", "computer-science", "ai-ml", "intermediate", "Classical AI, modern machine learning, and the linear algebra underneath.", ["6.034", "6.036", "18.065"]),
253
+ preset("cs-systems", "Computer Systems", "engineering", "computer-science", "systems", "intermediate", "How hardware runs software, and how to build systems that are fast and reliable.", ["6.004", "6.033", "6.172"]),
254
+ preset("math-calculus", "The Calculus Sequence", "science", "mathematics", "calculus", "introductory", "Single-variable, multivariable, and differential equations, in that order.", ["18.01", "18.02", "18.03"]),
255
+ preset("math-linear-algebra-probability", "Linear Algebra and Probability", "science", "mathematics", "linear-algebra-probability", "introductory", "The two subjects every quantitative field assumes.", ["18.06", "18.05"]),
256
+ preset("physics-core", "Core Physics", "science", "physics", "core-physics", "introductory", "Mechanics, electromagnetism, then a first look at quantum.", ["8.01SC", "8.02", "8.04"]),
257
+ preset("life-sciences-core", "Life Sciences Core", "science", "biology-chemistry", "life-sciences-core", "introductory", "Biology and the chemistry it is built on.", ["5.111SC", "7.016", "7.012"]),
258
+ preset("brain-mind", "Brain and Mind", "science", "brain-cognitive", "brain-mind", "introductory", "Psychology and neuroscience for the curious.", ["9.00SC", "9.13"]),
259
+ preset("econ-principles", "Economics Principles", "social-science", "economics", "principles", "introductory", "Micro then macro: how markets and economies work.", ["14.01SC", "14.02"]),
260
+ preset("finance-theory", "Finance Theory", "social-science", "finance", "finance-theory", "intermediate", "Valuation, risk, and return.", ["15.401"])
261
+ ];
262
+
263
+ // src/catalog/index.ts
264
+ function getDefaultCatalog() {
265
+ return { categories: EDUCATION_CATEGORIES, playlists: MIT_OCW_PRESET_PLAYLISTS };
266
+ }
267
+ function filterPlaylists(playlists, filter) {
268
+ return playlists.filter(
269
+ (playlist) => (!filter.categoryId || playlist.categoryId === filter.categoryId) && (!filter.majorId || playlist.majorId === filter.majorId) && (!filter.programId || playlist.programId === filter.programId)
270
+ );
271
+ }
272
+ function catalogItems(catalog) {
273
+ const seen = /* @__PURE__ */ new Map();
274
+ for (const playlist of catalog.playlists) {
275
+ for (const item of playlist.items) if (!seen.has(item.id)) seen.set(item.id, item);
276
+ }
277
+ return [...seen.values()];
278
+ }
279
+
280
+ // src/lib/time.ts
281
+ function formatMinutes(minutes) {
282
+ const total = Math.max(0, Math.round(minutes));
283
+ if (total < 60) return `${total} min`;
284
+ if (total >= 600) return `${Math.round(total / 60)} h`;
285
+ const hours = Math.floor(total / 60);
286
+ const rest = total % 60;
287
+ return rest ? `${hours} h ${rest} min` : `${hours} h`;
288
+ }
289
+ function playlistTime(playlist, done) {
290
+ let totalMinutes = 0;
291
+ let doneMinutes = 0;
292
+ let doneCount = 0;
293
+ for (const item of playlist.items) {
294
+ totalMinutes += item.minutes;
295
+ if (done.has(item.id)) {
296
+ doneMinutes += item.minutes;
297
+ doneCount += 1;
298
+ }
299
+ }
300
+ return {
301
+ totalMinutes,
302
+ doneMinutes,
303
+ remainingMinutes: totalMinutes - doneMinutes,
304
+ percent: totalMinutes ? Math.round(doneMinutes / totalMinutes * 100) : 0,
305
+ doneCount,
306
+ itemCount: playlist.items.length
307
+ };
308
+ }
309
+ function weeksToFinish(remainingMinutes, minutesPerWeek) {
310
+ if (!minutesPerWeek || minutesPerWeek <= 0) return null;
311
+ return Math.ceil(remainingMinutes / minutesPerWeek);
312
+ }
313
+
314
+ // src/lib/progress.ts
315
+ var PROGRESS_STORAGE_KEY = "educationPlaylists.progress";
316
+ function createProgressStore(key = PROGRESS_STORAGE_KEY) {
317
+ let memory = /* @__PURE__ */ new Set();
318
+ const storage = () => {
319
+ try {
320
+ return typeof localStorage === "undefined" ? null : localStorage;
321
+ } catch {
322
+ return null;
323
+ }
324
+ };
325
+ return {
326
+ load() {
327
+ try {
328
+ const raw = storage()?.getItem(key);
329
+ if (!raw) return new Set(memory);
330
+ const parsed = JSON.parse(raw);
331
+ return Array.isArray(parsed) ? new Set(parsed.filter((id) => typeof id === "string")) : /* @__PURE__ */ new Set();
332
+ } catch {
333
+ return new Set(memory);
334
+ }
335
+ },
336
+ save(done) {
337
+ memory = new Set(done);
338
+ try {
339
+ storage()?.setItem(key, JSON.stringify([...done]));
340
+ } catch {
341
+ }
342
+ }
343
+ };
344
+ }
345
+ function toggleDone(done, id) {
346
+ const next = new Set(done);
347
+ if (next.has(id)) next.delete(id);
348
+ else next.add(id);
349
+ return next;
350
+ }
351
+
352
+ // src/lib/sanitize.ts
353
+ var MAX_PLAYLIST_ITEMS = 60;
354
+ var MAX_TITLE = 200;
355
+ var MAX_TEXT = 600;
356
+ var MAX_ITEM_MINUTES = 500 * 60;
357
+ var KINDS = ["video_playlist", "video", "courseware", "lecture_notes", "assignment", "reading", "textbook"];
358
+ var PROVIDERS = ["mit_ocw", "edx", "coursera", "khan", "openstax", "youtube", "web"];
359
+ var METHODS = ["curated_seed", "official_api", "snapshot_dataset", "community_list", "llm_search", "user"];
360
+ var LEVELS = ["introductory", "intermediate", "advanced"];
361
+ var VISIBILITIES = ["preset", "public", "private"];
362
+ var text = (value, max) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : void 0;
363
+ var oneOf = (value, allowed, fallback) => allowed.includes(value) ? value : fallback;
364
+ function safeUrl(value) {
365
+ if (typeof value !== "string") return null;
366
+ try {
367
+ const url = new URL(value.trim());
368
+ return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+ function sanitizeItem(raw, index = 0) {
374
+ if (!raw || typeof raw !== "object") return null;
375
+ const r = raw;
376
+ const url = safeUrl(r.url);
377
+ const title = text(r.title, MAX_TITLE);
378
+ if (!url || !title) return null;
379
+ const minutes = Number(r.minutes);
380
+ const p = r.provenance && typeof r.provenance === "object" ? r.provenance : {};
381
+ return {
382
+ id: text(r.id, MAX_TITLE) ?? `item-${index}-${url}`,
383
+ title,
384
+ url,
385
+ kind: oneOf(r.kind, KINDS, "reading"),
386
+ minutes: Number.isFinite(minutes) ? Math.min(Math.max(0, Math.round(minutes)), MAX_ITEM_MINUTES) : 0,
387
+ estimate: r.estimate === "exact" ? "exact" : "rough",
388
+ estimateBasis: text(r.estimateBasis, MAX_TITLE),
389
+ courseNumber: text(r.courseNumber, 40),
390
+ institution: text(r.institution, 120),
391
+ level: LEVELS.includes(r.level) ? r.level : void 0,
392
+ description: text(r.description, MAX_TEXT),
393
+ provenance: {
394
+ provider: oneOf(p.provider, PROVIDERS, "web"),
395
+ method: oneOf(p.method, METHODS, "user"),
396
+ sourceUrl: safeUrl(p.sourceUrl) ?? void 0,
397
+ fetchedAt: text(p.fetchedAt, 40),
398
+ // Nothing that arrives from outside gets to call itself verified.
399
+ verified: false,
400
+ note: text(p.note, MAX_TEXT)
401
+ }
402
+ };
403
+ }
404
+ function sanitizePlaylist(raw) {
405
+ if (!raw || typeof raw !== "object") return null;
406
+ const r = raw;
407
+ const title = text(r.title, MAX_TITLE);
408
+ if (!title || !Array.isArray(r.items)) return null;
409
+ const items = r.items.slice(0, MAX_PLAYLIST_ITEMS).map((item, index) => sanitizeItem(item, index)).filter((item) => item !== null);
410
+ if (items.length === 0) return null;
411
+ const visibility = oneOf(r.visibility, VISIBILITIES, "public");
412
+ return {
413
+ id: text(r.id, 120) ?? `shared-${Date.now().toString(36)}`,
414
+ title,
415
+ description: text(r.description, MAX_TEXT),
416
+ categoryId: text(r.categoryId, 80) ?? "custom",
417
+ majorId: text(r.majorId, 80) ?? "custom",
418
+ programId: text(r.programId, 80),
419
+ level: LEVELS.includes(r.level) ? r.level : void 0,
420
+ // A link can only ever carry a public copy.
421
+ visibility: visibility === "preset" ? "public" : visibility,
422
+ items
423
+ };
424
+ }
425
+
426
+ // src/lib/playlists.ts
427
+ var PLAYLISTS_STORAGE_KEY = "educationPlaylists.mine";
428
+ var newId = () => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `pl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
429
+ function createPlaylist(input) {
430
+ const now = (/* @__PURE__ */ new Date()).toISOString();
431
+ return {
432
+ id: newId(),
433
+ title: input.title.trim() || "My playlist",
434
+ description: input.description,
435
+ categoryId: "custom",
436
+ majorId: "custom",
437
+ visibility: input.visibility ?? "private",
438
+ ownerId: input.ownerId,
439
+ members: [],
440
+ items: input.items ?? [],
441
+ plannedFrom: input.plannedFrom,
442
+ createdAt: now,
443
+ updatedAt: now
444
+ };
445
+ }
446
+ function addItem(playlist, item) {
447
+ if (playlist.items.some((existing) => existing.id === item.id || existing.url === item.url)) return playlist;
448
+ return { ...playlist, items: [...playlist.items, item], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
449
+ }
450
+ function removeItem(playlist, itemId) {
451
+ return { ...playlist, items: playlist.items.filter((item) => item.id !== itemId), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
452
+ }
453
+ function moveItem(playlist, from, to) {
454
+ const items = [...playlist.items];
455
+ if (from < 0 || from >= items.length) return playlist;
456
+ const [moved] = items.splice(from, 1);
457
+ items.splice(Math.max(0, Math.min(to, items.length)), 0, moved);
458
+ return { ...playlist, items, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
459
+ }
460
+ function createLocalPlaylistStore(key = PLAYLISTS_STORAGE_KEY) {
461
+ let memory = [];
462
+ const read = () => {
463
+ try {
464
+ const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
465
+ if (!raw) return memory;
466
+ const parsed = JSON.parse(raw);
467
+ if (!Array.isArray(parsed)) return [];
468
+ return parsed.flatMap((entry) => {
469
+ const clean = sanitizePlaylist(entry);
470
+ if (!clean) return [];
471
+ const original = entry;
472
+ return [{ ...clean, visibility: original.visibility === "public" ? "public" : "private", ownerId: original.ownerId, members: original.members ?? [], plannedFrom: original.plannedFrom, createdAt: original.createdAt, updatedAt: original.updatedAt }];
473
+ });
474
+ } catch {
475
+ return memory;
476
+ }
477
+ };
478
+ const write = (playlists) => {
479
+ memory = playlists;
480
+ try {
481
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, JSON.stringify(playlists));
482
+ } catch {
483
+ }
484
+ return playlists;
485
+ };
486
+ return {
487
+ list: read,
488
+ save(playlist) {
489
+ const others = read().filter((existing) => existing.id !== playlist.id);
490
+ return write([playlist, ...others]);
491
+ },
492
+ remove(id) {
493
+ return write(read().filter((existing) => existing.id !== id));
494
+ }
495
+ };
496
+ }
497
+
498
+ // src/lib/sharing.ts
499
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
500
+ function activeMember(playlist, userId) {
501
+ if (!userId) return void 0;
502
+ return playlist.members?.find((member) => member.status === "active" && member.userId === userId);
503
+ }
504
+ function canView(playlist, userId) {
505
+ if (playlist.visibility !== "private") return true;
506
+ if (userId && playlist.ownerId === userId) return true;
507
+ return Boolean(activeMember(playlist, userId));
508
+ }
509
+ function canEdit(playlist, userId) {
510
+ if (playlist.visibility === "preset") return false;
511
+ if (userId && playlist.ownerId === userId) return true;
512
+ const member = activeMember(playlist, userId);
513
+ return member?.role === "owner" || member?.role === "editor";
514
+ }
515
+ var defaultToken = () => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
516
+ function invite(playlist, email, role, makeToken = defaultToken) {
517
+ const address = email.trim().toLowerCase();
518
+ if (!EMAIL.test(address)) throw new Error(`Not an email address: ${email}`);
519
+ if (role === "owner") throw new Error("A playlist has exactly one owner");
520
+ const members = [...playlist.members ?? []];
521
+ const existing = members.findIndex((member) => member.email === address);
522
+ if (existing >= 0) {
523
+ members[existing] = { ...members[existing], role };
524
+ } else {
525
+ members.push({ email: address, role, status: "invited", inviteToken: makeToken() });
526
+ }
527
+ return { ...playlist, members };
528
+ }
529
+ function acceptInvite(playlist, token, userId) {
530
+ const members = playlist.members ?? [];
531
+ const index = members.findIndex((member) => member.inviteToken === token && member.status === "invited");
532
+ if (index < 0) return null;
533
+ const next = [...members];
534
+ next[index] = { ...next[index], userId, status: "active", inviteToken: void 0 };
535
+ return { ...playlist, members: next };
536
+ }
537
+ function removeMember(playlist, email) {
538
+ const address = email.trim().toLowerCase();
539
+ return { ...playlist, members: (playlist.members ?? []).filter((member) => member.email !== address) };
540
+ }
541
+ var toBase64Url = (value) => {
542
+ const bytes = new TextEncoder().encode(value);
543
+ let binary = "";
544
+ for (const byte of bytes) binary += String.fromCharCode(byte);
545
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
546
+ };
547
+ var fromBase64Url = (value) => {
548
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
549
+ const binary = atob(padded);
550
+ return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
551
+ };
552
+ var SHARE_HASH_PARAM = "playlist";
553
+ function encodeShareFragment(playlist) {
554
+ if (playlist.visibility === "private") throw new Error("Private playlists are shared by invitation");
555
+ const { members: _members, ownerId: _ownerId, plannedFrom: _plannedFrom, ...shareable } = playlist;
556
+ return `${SHARE_HASH_PARAM}=${toBase64Url(JSON.stringify({ ...shareable, visibility: "public" }))}`;
557
+ }
558
+ function decodeShareFragment(fragment) {
559
+ const params = new URLSearchParams(fragment.replace(/^#/, ""));
560
+ const encoded = params.get(SHARE_HASH_PARAM);
561
+ if (!encoded) return null;
562
+ try {
563
+ return sanitizePlaylist(JSON.parse(fromBase64Url(encoded)));
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+
569
+ // src/lib/search.ts
570
+ var STOP_WORDS = new Set(
571
+ "a an and are as at be by for from how i in into is it learn learning me my of on or the to want what with about understand study become get better".split(" ")
572
+ );
573
+ function tokenize(text2) {
574
+ return text2.toLowerCase().split(/[^a-z0-9.]+/).map((token) => token.replace(/^\.+|\.+$/g, "")).filter((token) => token.length > 1 && !STOP_WORDS.has(token));
575
+ }
576
+ function searchCatalog(items, query, limit = 10) {
577
+ const terms = tokenize(query);
578
+ if (terms.length === 0) return [];
579
+ return items.map((item, order) => {
580
+ const title = item.title.toLowerCase();
581
+ const body = `${item.description ?? ""} ${item.courseNumber ?? ""}`.toLowerCase();
582
+ let score = 0;
583
+ for (const term of terms) {
584
+ if (title.includes(term)) score += 2;
585
+ else if (body.includes(term)) score += 1;
586
+ }
587
+ return { item, score, order };
588
+ }).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score || a.order - b.order).slice(0, limit).map((entry) => entry.item);
589
+ }
590
+
591
+ // src/planner/index.ts
592
+ var MAX_GOAL_LENGTH = 300;
593
+ var MAX_ANSWERS = 8;
594
+ var MAX_ANSWER_LENGTH = 300;
595
+ var MAX_PLAN_ITEMS = 12;
596
+ var MAX_SEARCHES = 5;
597
+ var WEB_RESULTS_PER_SEARCH = 3;
598
+ var WEB_ITEM_MINUTES = 60;
599
+ var DEFAULT_FOLLOW_UPS = [
600
+ { id: "level", question: "Where are you starting from?", options: ["New to this", "Some background", "Comfortable, want depth"] },
601
+ { id: "time", question: "How much time can you give it each week?", options: ["2 hours", "5 hours", "10 hours"] },
602
+ { id: "format", question: "What do you learn best from?", options: ["Lecture videos", "Problem sets and notes", "A mix"] },
603
+ { id: "outcome", question: "What should you be able to do at the end?", options: ["Pass a course or exam", "Build a project", "Switch careers", "Just curious"] }
604
+ ];
605
+ function normalizePlanRequest(raw) {
606
+ const r = raw && typeof raw === "object" ? raw : {};
607
+ const goal = typeof r.goal === "string" ? r.goal.trim().slice(0, MAX_GOAL_LENGTH) : "";
608
+ if (!goal) throw new Error("A learning goal is required");
609
+ const answers = Array.isArray(r.answers) ? r.answers.slice(0, MAX_ANSWERS).flatMap((entry) => {
610
+ const e = entry && typeof entry === "object" ? entry : {};
611
+ const question = typeof e.question === "string" ? e.question.trim().slice(0, MAX_ANSWER_LENGTH) : "";
612
+ const answer = typeof e.answer === "string" ? e.answer.trim().slice(0, MAX_ANSWER_LENGTH) : "";
613
+ return question && answer ? [{ question, answer }] : [];
614
+ }) : [];
615
+ return { goal, answers };
616
+ }
617
+ function extractJson(text2) {
618
+ const start = text2.indexOf("{");
619
+ const end = text2.lastIndexOf("}");
620
+ if (start < 0 || end <= start) return null;
621
+ try {
622
+ return JSON.parse(text2.slice(start, end + 1));
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ var FOLLOW_UP_SYSTEM = `You help a self-directed learner plan a study playlist of free university courses and lecture videos.
628
+ Ask 3 or 4 short follow-up questions that would most change which courses to recommend (their starting level, weekly time, preferred format, and what they want to be able to do are usually the most useful).
629
+ Give each question 2 to 4 short answer options. The learner can also type their own answer.
630
+ Reply with JSON only, in this shape: {"questions":[{"question":"...","options":["...","..."]}]}`;
631
+ function parseFollowUps(text2) {
632
+ const json = extractJson(text2);
633
+ if (!json || !Array.isArray(json.questions)) return null;
634
+ const questions = json.questions.slice(0, 4).flatMap((entry, index) => {
635
+ const e = entry && typeof entry === "object" ? entry : {};
636
+ const question = typeof e.question === "string" ? e.question.trim().slice(0, 200) : "";
637
+ if (!question) return [];
638
+ const options = Array.isArray(e.options) ? e.options.filter((o) => typeof o === "string" && o.trim() !== "").map((o) => o.trim().slice(0, 80)).slice(0, 4) : [];
639
+ return [{ id: `q${index + 1}`, question, options }];
640
+ });
641
+ return questions.length ? questions : null;
642
+ }
643
+ async function suggestFollowUps(goal, deps = {}) {
644
+ const { goal: cleanGoal } = normalizePlanRequest({ goal });
645
+ if (deps.generate) {
646
+ try {
647
+ const text2 = await deps.generate({ system: FOLLOW_UP_SYSTEM, prompt: `Learning goal: ${cleanGoal}` });
648
+ const questions = parseFollowUps(text2);
649
+ if (questions) return { questions, mode: "llm" };
650
+ } catch {
651
+ }
652
+ }
653
+ return { questions: DEFAULT_FOLLOW_UPS, mode: "offline" };
654
+ }
655
+ var PLAN_SYSTEM = `You plan a self-paced study playlist from free university courseware (MIT OpenCourseWare first) and lecture videos.
656
+ From the learner's goal and answers, write 3 to 5 short search queries (a few words each, the way you would search a course catalog) that together cover what they need, in study order.
657
+ If you know MIT course numbers that fit (for example 6.0001 or 18.06), list them too.
658
+ Reply with JSON only, in this shape: {"title":"...","searches":["..."],"courses":["6.0001"]}`;
659
+ function parseModelPlan(text2) {
660
+ const json = extractJson(text2);
661
+ if (!json || !Array.isArray(json.searches)) return null;
662
+ const strings = (value, max) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim().slice(0, max)) : [];
663
+ const searches = strings(json.searches, 120).slice(0, MAX_SEARCHES);
664
+ if (searches.length === 0) return null;
665
+ return {
666
+ title: typeof json.title === "string" && json.title.trim() ? json.title.trim().slice(0, 120) : void 0,
667
+ searches,
668
+ courses: strings(json.courses, 20).slice(0, 10)
669
+ };
670
+ }
671
+ function readPreferences(answers = []) {
672
+ const text2 = answers.map((a) => a.answer.toLowerCase()).join(" | ");
673
+ const beginner = /\b(new|beginner|none|no background|never|starting out|from scratch)\b/.test(text2);
674
+ const advanced = /\b(comfortable|depth|advanced|expert|graduate|deep)\b/.test(text2);
675
+ const wantsVideos = /\bvideo|lecture|watch/.test(text2);
676
+ const wantsCourseware = /\bproblem|notes|reading|exercise|courseware|text/.test(text2);
677
+ const hours = text2.match(/(\d+(?:\.\d+)?)\s*(?:\+\s*)?(?:h|hr|hrs|hour|hours)\b/);
678
+ return {
679
+ maxLevel: beginner && !advanced ? "intermediate" : void 0,
680
+ preferAdvanced: advanced && !beginner,
681
+ format: wantsVideos && !wantsCourseware ? "videos" : wantsCourseware && !wantsVideos ? "courseware" : "mix",
682
+ minutesPerWeek: hours ? Math.round(Number(hours[1]) * 60) : void 0
683
+ };
684
+ }
685
+ var LEVEL_ORDER = { introductory: 0, intermediate: 1, advanced: 2 };
686
+ function webItem(hit, query, index) {
687
+ const url = safeUrl(hit.url);
688
+ const title = typeof hit.title === "string" ? hit.title.trim().slice(0, 200) : "";
689
+ if (!url || !title) return null;
690
+ const isVideo = /youtube\.com|youtu\.be|vimeo\.com/.test(url);
691
+ return {
692
+ id: `web:${url}`,
693
+ title,
694
+ url,
695
+ kind: isVideo ? "video" : "reading",
696
+ minutes: WEB_ITEM_MINUTES,
697
+ estimate: "rough",
698
+ estimateBasis: "length unknown; budgeted at 1 h",
699
+ description: hit.snippet?.slice(0, 300),
700
+ provenance: {
701
+ provider: isVideo ? "youtube" : "web",
702
+ method: "llm_search",
703
+ verified: false,
704
+ note: `Found by the planner's web search for "${query}" (result ${index + 1}).`
705
+ }
706
+ };
707
+ }
708
+ async function planPlaylist(rawRequest, deps = {}) {
709
+ const { goal, answers = [] } = normalizePlanRequest(rawRequest);
710
+ const catalog = deps.catalog ?? getDefaultCatalog();
711
+ const items = catalogItems(catalog);
712
+ const prefs = readPreferences(answers);
713
+ let mode = "offline";
714
+ let plan = null;
715
+ if (deps.generate) {
716
+ try {
717
+ const prompt = [`Learning goal: ${goal}`, ...answers.map((a) => `Q: ${a.question}
718
+ A: ${a.answer}`)].join("\n\n");
719
+ plan = parseModelPlan(await deps.generate({ system: PLAN_SYSTEM, prompt }));
720
+ if (plan) mode = "llm";
721
+ } catch {
722
+ plan = null;
723
+ }
724
+ }
725
+ const searches = plan?.searches ?? [
726
+ goal,
727
+ ...answers.map((a) => a.answer).filter((answer) => tokenize(answer).length >= 2 && !DEFAULT_FOLLOW_UPS.some((q) => q.options.includes(answer)))
728
+ ].slice(0, MAX_SEARCHES);
729
+ const picked = /* @__PURE__ */ new Map();
730
+ const take = (item) => {
731
+ if (!picked.has(item.id) && ![...picked.values()].some((p) => p.url === item.url)) picked.set(item.id, item);
732
+ };
733
+ for (const number of plan?.courses ?? []) {
734
+ for (const item of items) if (item.courseNumber?.toLowerCase() === number.toLowerCase()) take(item);
735
+ }
736
+ for (const query of searches) for (const item of searchCatalog(items, query, 6)) take(item);
737
+ if (deps.search) {
738
+ for (const query of searches.slice(0, 3)) {
739
+ try {
740
+ const hits = await deps.search(`${query} free course lecture videos`);
741
+ hits.slice(0, WEB_RESULTS_PER_SEARCH).forEach((hit, index) => {
742
+ const item = webItem(hit, query, index);
743
+ if (item) take(item);
744
+ });
745
+ } catch {
746
+ }
747
+ }
748
+ }
749
+ const chosen = [...picked.values()].filter((item) => !prefs.maxLevel || !item.level || LEVEL_ORDER[item.level] <= LEVEL_ORDER[prefs.maxLevel]).filter((item) => {
750
+ if (prefs.format === "videos") return item.kind === "video" || item.kind === "video_playlist";
751
+ if (prefs.format === "courseware") return item.kind !== "video" && item.kind !== "video_playlist";
752
+ return true;
753
+ }).map((item, order) => ({ item, order })).sort((a, b) => {
754
+ const la = a.item.level ? LEVEL_ORDER[a.item.level] : 1;
755
+ const lb = b.item.level ? LEVEL_ORDER[b.item.level] : 1;
756
+ return (prefs.preferAdvanced ? lb - la : la - lb) || a.order - b.order;
757
+ }).slice(0, MAX_PLAN_ITEMS).map((entry) => entry.item);
758
+ const playlist = createPlaylist({
759
+ title: plan?.title ?? `My path: ${goal.slice(0, 80)}`,
760
+ description: `Planned from: ${goal}`,
761
+ items: chosen,
762
+ plannedFrom: { goal, answers }
763
+ });
764
+ return { playlist, searches, mode, minutesPerWeek: prefs.minutesPerWeek };
765
+ }
766
+
767
+ // src/quiz.ts
768
+ function buildQuizPrompt(item, questionCount = 5) {
769
+ return [
770
+ `Write a ${questionCount}-question multiple-choice quiz on "${item.title}"${item.courseNumber ? ` (${[item.institution, item.courseNumber].filter(Boolean).join(" ")})` : ""}.`,
771
+ "Use only the attached source material. Each question has 4 choices and exactly one correct answer, with a one-sentence explanation citing the material.",
772
+ 'Reply with JSON only: {"questions":[{"prompt":"...","choices":["...","...","...","..."],"answerIndex":0,"explanation":"..."}]}'
773
+ ].join("\n");
774
+ }
775
+ function parseQuiz(item, raw, source) {
776
+ const r = raw && typeof raw === "object" ? raw : {};
777
+ if (!Array.isArray(r.questions)) return null;
778
+ const questions = r.questions.flatMap((entry) => {
779
+ const q = entry && typeof entry === "object" ? entry : {};
780
+ const choices = Array.isArray(q.choices) ? q.choices.filter((c2) => typeof c2 === "string") : [];
781
+ const answerIndex = Number(q.answerIndex);
782
+ if (typeof q.prompt !== "string" || choices.length < 2 || !Number.isInteger(answerIndex) || answerIndex < 0 || answerIndex >= choices.length) return [];
783
+ return [{ prompt: q.prompt, choices, answerIndex, explanation: typeof q.explanation === "string" ? q.explanation : void 0 }];
784
+ });
785
+ return questions.length ? { itemId: item.id, title: `Quiz: ${item.title}`, questions, source } : null;
786
+ }
787
+
788
+ // src/api/client.ts
789
+ async function post(url, body, signal) {
790
+ const res = await fetch(url, {
791
+ method: "POST",
792
+ headers: { "content-type": "application/json" },
793
+ body: JSON.stringify(body),
794
+ signal
795
+ });
796
+ if (!res.ok) throw new Error(`Planner request failed (${res.status})`);
797
+ return await res.json();
798
+ }
799
+ async function fetchFollowUps(goal, endpoint, signal) {
800
+ if (endpoint) {
801
+ try {
802
+ const result = await post(`${endpoint.replace(/\/+$/, "")}/questions`, { goal }, signal);
803
+ if (Array.isArray(result?.questions) && result.questions.length) return result;
804
+ } catch (error) {
805
+ if (error.name === "AbortError") throw error;
806
+ }
807
+ }
808
+ return suggestFollowUps(goal);
809
+ }
810
+ async function fetchPlan(goal, answers, endpoint, signal) {
811
+ if (endpoint) {
812
+ try {
813
+ const result = await post(endpoint.replace(/\/+$/, ""), { goal, answers }, signal);
814
+ const playlist = sanitizePlaylist({ ...result?.playlist, visibility: "private" });
815
+ if (playlist) {
816
+ return {
817
+ ...result,
818
+ playlist: { ...playlist, visibility: "private", members: [], plannedFrom: { goal, answers } }
819
+ };
820
+ }
821
+ } catch (error) {
822
+ if (error.name === "AbortError") throw error;
823
+ }
824
+ }
825
+ return planPlaylist({ goal, answers });
826
+ }
827
+
828
+ // src/components/EducationPlaylists.tsx
829
+ var import_react3 = require("react");
830
+
831
+ // src/components/PlaylistView.tsx
832
+ var import_react = require("react");
833
+
834
+ // src/components/styles.ts
835
+ var line = "1px solid rgba(127,127,127,0.3)";
836
+ var s = {
837
+ root: { fontFamily: "system-ui, sans-serif", display: "flex", flexDirection: "column", gap: 8, padding: "10px 14px", borderRadius: 8, fontSize: 13, lineHeight: 1.4, minWidth: 0 },
838
+ headerRow: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 },
839
+ label: { fontSize: 11, fontWeight: 600, opacity: 0.75, letterSpacing: 0.3, textTransform: "uppercase" },
840
+ muted: { opacity: 0.7, fontSize: 12 },
841
+ row: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
842
+ scrollRow: { display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2, scrollbarWidth: "thin" },
843
+ chip: { border: line, borderRadius: 999, padding: "2px 10px", fontSize: 12, background: "transparent", color: "inherit", cursor: "pointer", whiteSpace: "nowrap" },
844
+ chipActive: { background: "rgba(127,127,127,0.22)", borderColor: "rgba(127,127,127,0.6)" },
845
+ button: { border: line, borderRadius: 6, padding: "4px 10px", fontSize: 12, background: "rgba(127,127,127,0.12)", color: "inherit", cursor: "pointer" },
846
+ primary: { border: "1px solid rgba(99,102,241,0.7)", borderRadius: 6, padding: "5px 12px", fontSize: 12, fontWeight: 600, background: "rgba(99,102,241,0.25)", color: "inherit", cursor: "pointer" },
847
+ iconButton: { border: "none", background: "transparent", color: "inherit", cursor: "pointer", padding: 2, opacity: 0.75, display: "flex" },
848
+ input: { flex: 1, minWidth: 0, border: line, borderRadius: 6, padding: "5px 8px", fontSize: 13, background: "rgba(127,127,127,0.08)", color: "inherit" },
849
+ card: { border: line, borderRadius: 8, padding: "8px 10px", background: "rgba(127,127,127,0.06)", textAlign: "left", color: "inherit", cursor: "pointer", display: "flex", flexDirection: "column", gap: 4, minWidth: 0 },
850
+ compactCard: { border: line, borderRadius: 8, padding: "6px 10px", background: "rgba(127,127,127,0.06)", textAlign: "left", color: "inherit", cursor: "pointer", display: "flex", flexDirection: "column", gap: 2, minWidth: 150, maxWidth: 190, flex: "0 0 auto" },
851
+ title: { fontWeight: 600, fontSize: 13 },
852
+ bar: { height: 4, borderRadius: 2, background: "rgba(127,127,127,0.25)", overflow: "hidden" },
853
+ barFill: { height: "100%", background: "rgba(34,197,94,0.85)" },
854
+ list: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 },
855
+ item: { display: "flex", alignItems: "flex-start", gap: 8, padding: "6px 4px", borderBottom: "1px solid rgba(127,127,127,0.15)" },
856
+ link: { color: "inherit", textDecoration: "none", fontWeight: 500 },
857
+ badge: { fontSize: 10, border: line, borderRadius: 4, padding: "0 5px", opacity: 0.8, whiteSpace: "nowrap" },
858
+ section: { display: "flex", flexDirection: "column", gap: 6 }
859
+ };
860
+
861
+ // src/components/PlaylistView.tsx
862
+ var import_jsx_runtime = require("react/jsx-runtime");
863
+ var KIND_LABEL = {
864
+ video_playlist: "Videos",
865
+ video: "Video",
866
+ courseware: "Courseware",
867
+ lecture_notes: "Notes",
868
+ assignment: "Assignment",
869
+ reading: "Reading",
870
+ textbook: "Textbook"
871
+ };
872
+ var PROVIDER_LABEL = {
873
+ mit_ocw: "MIT OCW",
874
+ youtube: "YouTube",
875
+ edx: "edX",
876
+ coursera: "Coursera",
877
+ khan: "Khan Academy",
878
+ openstax: "OpenStax",
879
+ web: "Web"
880
+ };
881
+ var METHOD_LABEL = {
882
+ curated_seed: "curated",
883
+ official_api: "official",
884
+ snapshot_dataset: "dataset",
885
+ community_list: "community",
886
+ llm_search: "AI search",
887
+ user: "added"
888
+ };
889
+ function ItemRow({
890
+ item,
891
+ checked,
892
+ onToggle,
893
+ onRemove,
894
+ quizGenerator
895
+ }) {
896
+ const [quiz, setQuiz] = (0, import_react.useState)(null);
897
+ const [quizState, setQuizState] = (0, import_react.useState)("idle");
898
+ const generateQuiz = async () => {
899
+ if (!quizGenerator) return;
900
+ setQuizState("loading");
901
+ try {
902
+ setQuiz(await quizGenerator(item));
903
+ setQuizState("idle");
904
+ } catch {
905
+ setQuizState("error");
906
+ }
907
+ };
908
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: s.item, children: [
909
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
910
+ "input",
911
+ {
912
+ type: "checkbox",
913
+ checked,
914
+ onChange: onToggle,
915
+ "aria-label": `Mark "${item.title}" ${checked ? "not done" : "done"}`,
916
+ style: { marginTop: 3, cursor: "pointer" }
917
+ }
918
+ ),
919
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }, children: [
920
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
921
+ "a",
922
+ {
923
+ href: item.url,
924
+ target: "_blank",
925
+ rel: "noopener noreferrer",
926
+ style: { ...s.link, textDecoration: checked ? "line-through" : "none", opacity: checked ? 0.6 : 1 },
927
+ children: item.title
928
+ }
929
+ ),
930
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...s.row, ...s.muted }, children: [
931
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: s.badge, children: KIND_LABEL[item.kind] }),
932
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { title: item.estimateBasis, children: [
933
+ item.estimate === "rough" ? "~" : "",
934
+ formatMinutes(item.minutes)
935
+ ] }),
936
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: s.badge, title: item.provenance.note, children: [
937
+ PROVIDER_LABEL[item.provenance.provider] ?? item.provenance.provider,
938
+ " \xB7 ",
939
+ METHOD_LABEL[item.provenance.method] ?? item.provenance.method
940
+ ] }),
941
+ quizGenerator && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...s.chip, padding: "0 8px" }, onClick: generateQuiz, disabled: quizState === "loading", children: quizState === "loading" ? "Writing quiz\u2026" : quizState === "error" ? "Quiz failed, retry" : "Quiz me" }),
942
+ onRemove && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...s.chip, padding: "0 8px" }, onClick: onRemove, "aria-label": `Remove "${item.title}"`, children: "Remove" })
943
+ ] }),
944
+ quiz && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ol", { style: { margin: "4px 0 0", paddingLeft: 18, fontSize: 12 }, children: quiz.questions.map((question, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [
945
+ question.prompt,
946
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("details", { children: [
947
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("summary", { style: { cursor: "pointer", opacity: 0.7 }, children: "Answer" }),
948
+ question.choices[question.answerIndex],
949
+ question.explanation ? ` \u2014 ${question.explanation}` : ""
950
+ ] })
951
+ ] }, index)) })
952
+ ] })
953
+ ] });
954
+ }
955
+ function SharingControls({ playlist, onChange, shareBaseHref }) {
956
+ const [email, setEmail] = (0, import_react.useState)("");
957
+ const [message, setMessage] = (0, import_react.useState)(null);
958
+ const copyShareLink = async () => {
959
+ const base = shareBaseHref ?? (typeof window !== "undefined" ? `${window.location.origin}${window.location.pathname}` : "");
960
+ const link = `${base}#${encodeShareFragment(playlist)}`;
961
+ try {
962
+ await navigator.clipboard.writeText(link);
963
+ setMessage("Share link copied.");
964
+ } catch {
965
+ setMessage(link);
966
+ }
967
+ };
968
+ const addInvite = () => {
969
+ try {
970
+ onChange(invite(playlist, email, "viewer"));
971
+ setEmail("");
972
+ setMessage(null);
973
+ } catch (error) {
974
+ setMessage(error.message);
975
+ }
976
+ };
977
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: s.section, children: [
978
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: s.row, children: [
979
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: s.muted, children: "Visibility:" }),
980
+ ["private", "public"].map((visibility) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
981
+ "button",
982
+ {
983
+ type: "button",
984
+ style: { ...s.chip, ...playlist.visibility === visibility ? s.chipActive : null },
985
+ onClick: () => onChange({ ...playlist, visibility }),
986
+ "aria-pressed": playlist.visibility === visibility,
987
+ children: visibility === "private" ? "Private" : "Public"
988
+ },
989
+ visibility
990
+ )),
991
+ playlist.visibility === "public" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: s.button, onClick: copyShareLink, children: "Copy share link" })
992
+ ] }),
993
+ playlist.visibility === "private" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
994
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
995
+ "form",
996
+ {
997
+ style: s.row,
998
+ onSubmit: (event) => {
999
+ event.preventDefault();
1000
+ addInvite();
1001
+ },
1002
+ children: [
1003
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { style: s.input, type: "email", placeholder: "Invite by email", value: email, onChange: (event) => setEmail(event.target.value), "aria-label": "Invite by email" }),
1004
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "submit", style: s.button, disabled: !email.trim(), children: "Invite" })
1005
+ ]
1006
+ }
1007
+ ),
1008
+ (playlist.members ?? []).map((member) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...s.row, ...s.muted }, children: [
1009
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: member.email }),
1010
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: s.badge, children: member.status === "invited" ? "invite pending" : member.role }),
1011
+ member.email && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...s.chip, padding: "0 8px" }, onClick: () => onChange(removeMember(playlist, member.email)), children: "Remove" })
1012
+ ] }, member.email ?? member.userId))
1013
+ ] }),
1014
+ message && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...s.muted, wordBreak: "break-all" }, children: message })
1015
+ ] });
1016
+ }
1017
+ function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesPerWeek, shareBaseHref, quizGenerator }) {
1018
+ const time = playlistTime(playlist, done);
1019
+ const weeks = weeksToFinish(time.remainingMinutes, minutesPerWeek);
1020
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: s.section, children: [
1021
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
1022
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.title, children: playlist.title }),
1023
+ playlist.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.muted, children: playlist.description })
1024
+ ] }),
1025
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...s.row, ...s.muted }, children: [
1026
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
1027
+ time.doneCount,
1028
+ "/",
1029
+ time.itemCount,
1030
+ " done"
1031
+ ] }),
1032
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
1033
+ "\xB7 ~",
1034
+ formatMinutes(time.remainingMinutes),
1035
+ " left of ~",
1036
+ formatMinutes(time.totalMinutes)
1037
+ ] }),
1038
+ weeks !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
1039
+ "\xB7 about ",
1040
+ weeks,
1041
+ " week",
1042
+ weeks === 1 ? "" : "s",
1043
+ " at your pace"
1044
+ ] })
1045
+ ] }),
1046
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.bar, role: "progressbar", "aria-valuenow": time.percent, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": "Progress by time", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...s.barFill, width: `${time.percent}%` } }) }),
1047
+ playlist.items.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.muted, children: "Nothing matched yet. Try different wording, or answer the questions in your own words." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: s.list, children: playlist.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1048
+ ItemRow,
1049
+ {
1050
+ item,
1051
+ checked: done.has(item.id),
1052
+ onToggle: () => onToggle(item.id),
1053
+ onRemove: onChange ? () => onChange(removeItem(playlist, item.id)) : void 0,
1054
+ quizGenerator
1055
+ },
1056
+ item.id
1057
+ )) }),
1058
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.row, children: onSaveCopy && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: s.primary, onClick: () => onSaveCopy(playlist), children: "Save to my playlists" }) }),
1059
+ onChange && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SharingControls, { playlist, onChange, shareBaseHref })
1060
+ ] });
1061
+ }
1062
+
1063
+ // src/components/PlaylistPlanner.tsx
1064
+ var import_react2 = require("react");
1065
+ var import_jsx_runtime2 = require("react/jsx-runtime");
1066
+ var MODE_NOTE = {
1067
+ llm: "Questions written by AI for your goal.",
1068
+ offline: "AI is not connected here, so these are the standard questions."
1069
+ };
1070
+ function PlaylistPlanner({ planEndpoint, onPlanned, initialGoal = "" }) {
1071
+ const [goal, setGoal] = (0, import_react2.useState)(initialGoal);
1072
+ const [questions, setQuestions] = (0, import_react2.useState)(null);
1073
+ const [mode, setMode] = (0, import_react2.useState)(null);
1074
+ const [answers, setAnswers] = (0, import_react2.useState)({});
1075
+ const [busy, setBusy] = (0, import_react2.useState)(null);
1076
+ const [error, setError] = (0, import_react2.useState)(null);
1077
+ const abort = (0, import_react2.useRef)(null);
1078
+ const run = async (kind, task) => {
1079
+ abort.current?.abort();
1080
+ const controller = new AbortController();
1081
+ abort.current = controller;
1082
+ setBusy(kind);
1083
+ setError(null);
1084
+ try {
1085
+ return await task(controller.signal);
1086
+ } catch (err) {
1087
+ if (err.name !== "AbortError") setError(err.message);
1088
+ return null;
1089
+ } finally {
1090
+ if (abort.current === controller) setBusy(null);
1091
+ }
1092
+ };
1093
+ const askQuestions = async () => {
1094
+ const result = await run("questions", (signal) => fetchFollowUps(goal, planEndpoint, signal));
1095
+ if (!result) return;
1096
+ setQuestions(result.questions);
1097
+ setMode(result.mode);
1098
+ setAnswers({});
1099
+ };
1100
+ const buildPlan = async () => {
1101
+ const answered = (questions ?? []).map((question) => ({ question: question.question, answer: (answers[question.id] ?? "").trim() })).filter((entry) => entry.answer);
1102
+ const result = await run("plan", (signal) => fetchPlan(goal, answered, planEndpoint, signal));
1103
+ if (result) onPlanned(result);
1104
+ };
1105
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.section, children: [
1106
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1107
+ "form",
1108
+ {
1109
+ style: s.row,
1110
+ onSubmit: (event) => {
1111
+ event.preventDefault();
1112
+ if (goal.trim()) void askQuestions();
1113
+ },
1114
+ children: [
1115
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1116
+ "input",
1117
+ {
1118
+ style: s.input,
1119
+ value: goal,
1120
+ maxLength: MAX_GOAL_LENGTH,
1121
+ onChange: (event) => setGoal(event.target.value),
1122
+ placeholder: "What do you want to learn? e.g. machine learning from scratch",
1123
+ "aria-label": "What do you want to learn?"
1124
+ }
1125
+ ),
1126
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "submit", style: s.primary, disabled: !goal.trim() || busy !== null, children: busy === "questions" ? "Thinking\u2026" : questions ? "Ask again" : "Start" })
1127
+ ]
1128
+ }
1129
+ ),
1130
+ questions && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.section, children: [
1131
+ mode && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.muted, children: MODE_NOTE[mode] }),
1132
+ questions.map((question) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { style: { border: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 4 }, children: [
1133
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: { fontWeight: 500, padding: 0, marginBottom: 4 }, children: question.question }),
1134
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.row, children: question.options.map((option) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1135
+ "button",
1136
+ {
1137
+ type: "button",
1138
+ style: { ...s.chip, ...answers[question.id] === option ? s.chipActive : null },
1139
+ "aria-pressed": answers[question.id] === option,
1140
+ onClick: () => setAnswers((prev) => ({ ...prev, [question.id]: prev[question.id] === option ? "" : option })),
1141
+ children: option
1142
+ },
1143
+ option
1144
+ )) }),
1145
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1146
+ "input",
1147
+ {
1148
+ style: s.input,
1149
+ value: answers[question.id] ?? "",
1150
+ maxLength: MAX_ANSWER_LENGTH,
1151
+ onChange: (event) => setAnswers((prev) => ({ ...prev, [question.id]: event.target.value })),
1152
+ placeholder: "Or say it in your own words",
1153
+ "aria-label": `Your answer: ${question.question}`
1154
+ }
1155
+ )
1156
+ ] }, question.id)),
1157
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.row, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", style: s.primary, onClick: () => void buildPlan(), disabled: busy !== null, children: busy === "plan" ? "Planning\u2026" : "Build my playlist" }) })
1158
+ ] }),
1159
+ error && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { ...s.muted, color: "#ef4444" }, children: error })
1160
+ ] });
1161
+ }
1162
+
1163
+ // src/components/EducationPlaylists.tsx
1164
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1165
+ function ChevronIcon({ up }) {
1166
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", style: { transform: up ? "rotate(180deg)" : void 0, transition: "transform 150ms ease" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("polyline", { points: "6 9 12 15 18 9" }) });
1167
+ }
1168
+ function PlaylistCard({ playlist, done, onOpen, compact }) {
1169
+ const time = playlistTime(playlist, done);
1170
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("button", { type: "button", style: compact ? s.compactCard : s.card, onClick: onOpen, children: [
1171
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: { ...s.title, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: compact ? "nowrap" : void 0 }, children: playlist.title }),
1172
+ !compact && playlist.description && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.muted, children: playlist.description }),
1173
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: s.muted, children: [
1174
+ time.itemCount,
1175
+ " items \xB7 ~",
1176
+ formatMinutes(time.totalMinutes),
1177
+ time.percent > 0 ? ` \xB7 ${time.percent}% done` : ""
1178
+ ] }),
1179
+ time.percent > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.bar, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: { ...s.barFill, display: "block", width: `${time.percent}%` } }) })
1180
+ ] });
1181
+ }
1182
+ function EducationPlaylists({
1183
+ className,
1184
+ style,
1185
+ compact = false,
1186
+ catalog: catalogProp,
1187
+ planEndpoint,
1188
+ openHref,
1189
+ shareBaseHref,
1190
+ importFromHash = false,
1191
+ userId,
1192
+ quizGenerator
1193
+ }) {
1194
+ const catalog = (0, import_react3.useMemo)(() => catalogProp ?? getDefaultCatalog(), [catalogProp]);
1195
+ const progressStore = (0, import_react3.useMemo)(() => createProgressStore(), []);
1196
+ const playlistStore = (0, import_react3.useMemo)(() => createLocalPlaylistStore(), []);
1197
+ const [expanded, setExpanded] = (0, import_react3.useState)(!compact);
1198
+ const [tab, setTab] = (0, import_react3.useState)("browse");
1199
+ const [done, setDone] = (0, import_react3.useState)(() => /* @__PURE__ */ new Set());
1200
+ const [mine, setMine] = (0, import_react3.useState)([]);
1201
+ const [categoryId, setCategoryId] = (0, import_react3.useState)();
1202
+ const [majorId, setMajorId] = (0, import_react3.useState)();
1203
+ const [programId, setProgramId] = (0, import_react3.useState)();
1204
+ const [open, setOpen] = (0, import_react3.useState)(null);
1205
+ (0, import_react3.useEffect)(() => {
1206
+ setDone(progressStore.load());
1207
+ setMine(playlistStore.list());
1208
+ }, [progressStore, playlistStore]);
1209
+ (0, import_react3.useEffect)(() => {
1210
+ if (!importFromHash || typeof window === "undefined") return;
1211
+ const shared = decodeShareFragment(window.location.hash);
1212
+ if (shared) {
1213
+ setExpanded(true);
1214
+ setOpen({ playlist: shared, source: "shared" });
1215
+ }
1216
+ }, [importFromHash]);
1217
+ const category = catalog.categories.find((c2) => c2.id === categoryId);
1218
+ const major = category?.majors.find((m) => m.id === majorId);
1219
+ const visible = filterPlaylists(catalog.playlists, { categoryId, majorId, programId });
1220
+ const toggle = (itemId) => {
1221
+ setDone((prev) => {
1222
+ const next = toggleDone(prev, itemId);
1223
+ progressStore.save(next);
1224
+ return next;
1225
+ });
1226
+ };
1227
+ const saveMine = (playlist) => {
1228
+ setMine(playlistStore.save({ ...playlist, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1229
+ setOpen((current) => current?.playlist.id === playlist.id ? { ...current, playlist } : current);
1230
+ };
1231
+ const saveCopy = (playlist) => {
1232
+ const copy = createPlaylist({
1233
+ title: playlist.title,
1234
+ description: playlist.description,
1235
+ items: playlist.items,
1236
+ ownerId: userId,
1237
+ plannedFrom: playlist.plannedFrom
1238
+ });
1239
+ setMine(playlistStore.save(copy));
1240
+ setOpen({ playlist: copy, source: "mine" });
1241
+ setTab("mine");
1242
+ };
1243
+ const deleteMine = (id) => {
1244
+ setMine(playlistStore.remove(id));
1245
+ setOpen(null);
1246
+ };
1247
+ const openPlaylist = (playlist, source) => {
1248
+ setExpanded(true);
1249
+ setOpen({ playlist, source });
1250
+ };
1251
+ const shareBase = shareBaseHref ?? (openHref && typeof window !== "undefined" ? new URL(openHref, window.location.href).toString() : void 0);
1252
+ const header = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.headerRow, children: [
1253
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { display: "flex", alignItems: "baseline", gap: 8, minWidth: 0 }, children: [
1254
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.label, children: "Learn" }),
1255
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { ...s.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: [
1256
+ catalog.playlists.length,
1257
+ " playlists \xB7 MIT OpenCourseWare"
1258
+ ] })
1259
+ ] }),
1260
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
1261
+ compact && !expanded && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1262
+ "button",
1263
+ {
1264
+ type: "button",
1265
+ style: s.chip,
1266
+ onClick: () => {
1267
+ setExpanded(true);
1268
+ setTab("plan");
1269
+ setOpen(null);
1270
+ },
1271
+ children: "Plan my own"
1272
+ }
1273
+ ),
1274
+ compact && openHref && expanded && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("a", { href: openHref, style: { ...s.chip, textDecoration: "none" }, children: "Open full page" }),
1275
+ compact && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.iconButton, onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-label": expanded ? "Collapse education playlists" : "Expand education playlists", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ChevronIcon, { up: expanded }) })
1276
+ ] })
1277
+ ] });
1278
+ if (compact && !expanded) {
1279
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("section", { className, style: { ...s.root, ...style }, "aria-label": "Education playlists", children: [
1280
+ header,
1281
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: s.scrollRow, children: [...mine, ...catalog.playlists].map((playlist) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlaylistCard, { playlist, done, compact: true, onOpen: () => openPlaylist(playlist, playlist.visibility === "preset" ? "preset" : "mine") }, playlist.id)) })
1282
+ ] });
1283
+ }
1284
+ const tabs = [
1285
+ { id: "browse", label: "Browse" },
1286
+ { id: "mine", label: `My playlists${mine.length ? ` (${mine.length})` : ""}` },
1287
+ { id: "plan", label: "Plan with AI" }
1288
+ ];
1289
+ let body;
1290
+ if (open) {
1291
+ const editable = open.source === "mine";
1292
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.section, children: [
1293
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1294
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.button, onClick: () => setOpen(null), children: "\u2190 Back" }),
1295
+ open.source === "shared" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.muted, children: "Shared with you by link" }),
1296
+ open.source === "planned" && open.plan && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: s.muted, children: [
1297
+ open.plan.mode === "llm" ? "Planned by AI" : "Planned from the catalog (AI not connected)",
1298
+ " from: ",
1299
+ open.plan.searches.join(" \xB7 ")
1300
+ ] }),
1301
+ editable && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.button, onClick: () => deleteMine(open.playlist.id), children: "Delete" })
1302
+ ] }),
1303
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1304
+ PlaylistView,
1305
+ {
1306
+ playlist: open.playlist,
1307
+ done,
1308
+ onToggle: toggle,
1309
+ onChange: editable ? saveMine : void 0,
1310
+ onSaveCopy: editable ? void 0 : saveCopy,
1311
+ minutesPerWeek: open.plan?.minutesPerWeek,
1312
+ shareBaseHref: shareBase,
1313
+ quizGenerator
1314
+ }
1315
+ )
1316
+ ] });
1317
+ } else if (tab === "browse") {
1318
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.section, children: [
1319
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1320
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: { ...s.chip, ...!categoryId ? s.chipActive : null }, onClick: () => {
1321
+ setCategoryId(void 0);
1322
+ setMajorId(void 0);
1323
+ setProgramId(void 0);
1324
+ }, children: "All" }),
1325
+ catalog.categories.map((c2) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: { ...s.chip, ...categoryId === c2.id ? s.chipActive : null }, "aria-pressed": categoryId === c2.id, onClick: () => {
1326
+ setCategoryId(c2.id);
1327
+ setMajorId(void 0);
1328
+ setProgramId(void 0);
1329
+ }, children: c2.name }, c2.id))
1330
+ ] }),
1331
+ category && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1332
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.muted, children: "Major:" }),
1333
+ category.majors.map((m) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: { ...s.chip, ...majorId === m.id ? s.chipActive : null }, "aria-pressed": majorId === m.id, onClick: () => {
1334
+ setMajorId(majorId === m.id ? void 0 : m.id);
1335
+ setProgramId(void 0);
1336
+ }, children: m.name }, m.id))
1337
+ ] }),
1338
+ major && major.programs.length > 1 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1339
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: s.muted, children: "Program:" }),
1340
+ major.programs.map((p) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: { ...s.chip, ...programId === p.id ? s.chipActive : null }, "aria-pressed": programId === p.id, onClick: () => setProgramId(programId === p.id ? void 0 : p.id), children: p.name }, p.id))
1341
+ ] }),
1342
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 6 }, children: visible.map((playlist) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlaylistCard, { playlist, done, onOpen: () => openPlaylist(playlist, "preset") }, playlist.id)) })
1343
+ ] });
1344
+ } else if (tab === "mine") {
1345
+ body = mine.length ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 6 }, children: mine.map((playlist) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlaylistCard, { playlist, done, onOpen: () => openPlaylist(playlist, "mine") }, playlist.id)) }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: s.muted, children: "Nothing saved yet. Save a preset playlist, or plan your own with AI." });
1346
+ } else {
1347
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlaylistPlanner, { planEndpoint, onPlanned: (result) => setOpen({ playlist: result.playlist, source: "planned", plan: result }) });
1348
+ }
1349
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("section", { className, style: { ...s.root, ...style }, "aria-label": "Education playlists", children: [
1350
+ header,
1351
+ !open && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: s.row, role: "tablist", children: tabs.map((t) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", role: "tab", "aria-selected": tab === t.id, style: { ...s.chip, ...tab === t.id ? s.chipActive : null }, onClick: () => setTab(t.id), children: t.label }, t.id)) }),
1352
+ body
1353
+ ] });
1354
+ }
1355
+ // Annotate the CommonJS export names for ESM import in node:
1356
+ 0 && (module.exports = {
1357
+ DEFAULT_FOLLOW_UPS,
1358
+ EDUCATION_CATEGORIES,
1359
+ EducationPlaylists,
1360
+ MAX_ANSWERS,
1361
+ MAX_ANSWER_LENGTH,
1362
+ MAX_GOAL_LENGTH,
1363
+ MAX_PLAN_ITEMS,
1364
+ MAX_PLAYLIST_ITEMS,
1365
+ MIT_OCW_PRESET_PLAYLISTS,
1366
+ MIT_OCW_SEED_COURSES,
1367
+ PLAYLISTS_STORAGE_KEY,
1368
+ PROGRESS_STORAGE_KEY,
1369
+ PlaylistPlanner,
1370
+ PlaylistView,
1371
+ SHARE_HASH_PARAM,
1372
+ acceptInvite,
1373
+ addItem,
1374
+ buildQuizPrompt,
1375
+ canEdit,
1376
+ canView,
1377
+ catalogItems,
1378
+ createLocalPlaylistStore,
1379
+ createPlaylist,
1380
+ createProgressStore,
1381
+ decodeShareFragment,
1382
+ encodeShareFragment,
1383
+ extractJson,
1384
+ fetchFollowUps,
1385
+ fetchPlan,
1386
+ filterPlaylists,
1387
+ formatMinutes,
1388
+ getDefaultCatalog,
1389
+ invite,
1390
+ moveItem,
1391
+ normalizePlanRequest,
1392
+ parseQuiz,
1393
+ planPlaylist,
1394
+ playlistTime,
1395
+ readPreferences,
1396
+ removeItem,
1397
+ removeMember,
1398
+ safeUrl,
1399
+ sanitizeItem,
1400
+ sanitizePlaylist,
1401
+ searchCatalog,
1402
+ suggestFollowUps,
1403
+ toggleDone,
1404
+ tokenize,
1405
+ weeksToFinish
1406
+ });
1407
+ //# sourceMappingURL=index.cjs.map