education-playlists 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -12,6 +12,10 @@ the answers into searches and the searches into a playlist.
12
12
  - **Preset, public and private playlists.** Presets ship with the catalog.
13
13
  Save a copy to make it yours; keep it private and invite people by email, or
14
14
  make it public and share a link.
15
+ - **Saved to the account when signed in.** Give the widget a
16
+ `playlistsEndpoint` and a `userId` and "My playlists" live server-side: they
17
+ follow the user across devices, invites reach the people invited, and
18
+ playlists made on the device while signed out move into the account.
15
19
  - **Progress by time.** Checkmarks persist per browser; progress and "weeks
16
20
  left at your pace" are measured in minutes, not item counts.
17
21
  - **Provenance on every item.** Seed record, official API, dataset snapshot,
@@ -32,8 +36,9 @@ import { EducationPlaylists } from 'education-playlists';
32
36
  // Homepage card: one row of playlists that expands in place.
33
37
  <EducationPlaylists compact planEndpoint="/api/learn" openHref="/learn" />
34
38
 
35
- // Full page, which also opens playlists shared by link (#playlist=…).
36
- <EducationPlaylists planEndpoint="/api/learn" importFromHash />
39
+ // Full page, which also opens playlists shared by link (#playlist=…) and
40
+ // invite links (#invite=…), and keeps a signed-in user's playlists server-side.
41
+ <EducationPlaylists planEndpoint="/api/learn" playlistsEndpoint="/api/learn/playlists" userId={user?.id} importFromHash />
37
42
  ```
38
43
 
39
44
  Without `planEndpoint` the planner runs in the browser: standard follow-up
@@ -62,6 +67,37 @@ If the model fails or answers with something unusable, the handler plans
62
67
  offline rather than erroring; `mode` says which happened. Rate limiting and
63
68
  auth are the host's job.
64
69
 
70
+ ### Playlist storage
71
+
72
+ `handlePlaylistStoreRequest` stores playlists and enforces who may see and
73
+ change them. The host supplies who is signed in and where playlists live:
74
+
75
+ ```ts
76
+ import { handlePlaylistStoreRequest, type PlaylistRepository } from 'education-playlists/server';
77
+
78
+ const handler = (request: Request) =>
79
+ handlePlaylistStoreRequest(request, {
80
+ repository: myRepository, // get / put / delete / listForUser / findByInviteToken
81
+ getUser: async (req) => (session ? { id, email, emailVerified } : null),
82
+ notifyInvite: async ({ to, playlist, token }) => sendMail(to, `${origin}/learn#invite=${token}`), // optional
83
+ });
84
+ ```
85
+
86
+ - `GET <base>/playlists` → `{ playlists, invites }` (owned, shared with the user, and invites to their address)
87
+ - `GET <base>/playlists/:id` → `{ playlist }` — anyone for a public one, owner and members for a private one
88
+ - `PUT <base>/playlists/:id` → create (the caller becomes owner) or update (owner or editor)
89
+ - `DELETE <base>/playlists/:id` → owner only
90
+ - `POST <base>/playlists/accept-invite` `{ token }` → `{ playlist }`
91
+
92
+ The rules: only the owner changes visibility and members (an editor's copy of
93
+ them is ignored); members are reconciled against the stored list, so a client
94
+ can never set its own status, user id or invite token; an invite is accepted
95
+ only by a signed-in user whose verified address it was sent to, so a forwarded
96
+ link is useless; only the owner sees invite tokens; a private playlist someone
97
+ cannot view answers 404, not 403. Without `notifyInvite`, invitees see the
98
+ invite when they sign in, and the owner can copy an invite link.
99
+ `createMemoryPlaylistRepository` is an in-memory repository for tests.
100
+
65
101
  ## Data sources
66
102
 
67
103
  Planned ingestion, in order of authority:
@@ -74,9 +110,10 @@ Planned ingestion, in order of authority:
74
110
 
75
111
  ## Not built yet
76
112
 
77
- - Server-side storage for playlists, so invites actually reach invitees and
78
- private playlists follow a signed-in user across devices. The access rules
79
- (`canView`, `canEdit`, `invite`, `acceptInvite`) are in place for it.
113
+ - Invite email delivery in qwksearch-web: the app has no mail sender, so it
114
+ does not pass `notifyInvite`; invitees see invites when they sign in.
115
+ - Checkmark progress is still per browser; only playlists are in the account.
116
+ - Concurrent edits are last-write-wins.
80
117
  - The ingestion adapters above.
81
118
  - A concrete quiz generator (the hook and prompt are here).
82
119
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @fileoverview Browser side of server-side playlist storage: a signed-in
3
+ * learner's playlists and pending invites, read from and written to the
4
+ * host's `<base>/playlists` endpoint (see `education-playlists/server`).
5
+ *
6
+ * Answers are re-validated like any other outside data; only the fields a
7
+ * server owns (owner, members, timestamps) are carried over as it sent them,
8
+ * type-checked.
9
+ */
10
+ import type { Playlist, PlaylistRole } from '../types';
11
+ /** An invitation waiting for the signed-in user. */
12
+ export interface PlaylistInvite {
13
+ playlistId: string;
14
+ title: string;
15
+ role: PlaylistRole;
16
+ token: string;
17
+ }
18
+ export interface RemotePlaylistStore {
19
+ /** The user's playlists (owned and shared with them) and pending invites. */
20
+ list(signal?: AbortSignal): Promise<{
21
+ playlists: Playlist[];
22
+ invites: PlaylistInvite[];
23
+ }>;
24
+ /** Saves a playlist; resolves to the server's copy (with invite tokens for new members). */
25
+ save(playlist: Playlist): Promise<Playlist>;
26
+ remove(id: string): Promise<void>;
27
+ acceptInvite(token: string): Promise<Playlist>;
28
+ }
29
+ /** Hash parameter an invite link carries its token in. */
30
+ export declare const INVITE_HASH_PARAM = "invite";
31
+ /** The invite token in a page fragment (with or without the leading `#`), or `null`. */
32
+ export declare function readInviteFragment(fragment: string): string | null;
33
+ /** An invite link for `token`, opening `baseHref`. */
34
+ export declare function inviteLink(baseHref: string, token: string): string;
35
+ /** A playlist from the store, or `null` if it is unusable. */
36
+ export declare function readStoredPlaylist(raw: unknown): Playlist | null;
37
+ /**
38
+ * A store over the host's playlist endpoint, e.g. `/api/learn/playlists`.
39
+ * Requests carry the page's cookies, which is how the server knows who is
40
+ * signed in. Every failure rejects with the server's message.
41
+ */
42
+ export declare function createRemotePlaylistStore(endpoint: string, fetchImpl?: typeof fetch): RemotePlaylistStore;
@@ -30,7 +30,15 @@ export interface EducationPlaylistsProps {
30
30
  importFromHash?: boolean;
31
31
  /** Signed-in user, recorded as the owner of playlists they create. */
32
32
  userId?: string;
33
+ /**
34
+ * The host's playlist store (see `handlePlaylistStoreRequest`), e.g.
35
+ * `/api/learn/playlists`. With it and a `userId`, "My playlists" live in the
36
+ * account: they follow the user across devices, invites reach invitees, and
37
+ * playlists made on this device while signed out move into the account.
38
+ * Without it, playlists stay in this browser.
39
+ */
40
+ playlistsEndpoint?: string;
33
41
  /** Turns on "Quiz me" for each item (NotebookLM or another quiz source). */
34
42
  quizGenerator?: QuizGenerator;
35
43
  }
36
- export declare function EducationPlaylists({ className, style, compact, catalog: catalogProp, planEndpoint, openHref, shareBaseHref, importFromHash, userId, quizGenerator, }: EducationPlaylistsProps): React.JSX.Element;
44
+ export declare function EducationPlaylists({ className, style, compact, catalog: catalogProp, planEndpoint, openHref, shareBaseHref, importFromHash, userId, playlistsEndpoint, quizGenerator, }: EducationPlaylistsProps): React.JSX.Element;
@@ -18,6 +18,10 @@ export interface PlaylistViewProps {
18
18
  minutesPerWeek?: number;
19
19
  /** Page a share link opens, e.g. `https://example.com/learn`. */
20
20
  shareBaseHref?: string;
21
+ /** Whether to show visibility and invite controls (the owner's alone). Defaults to `true`. */
22
+ canShare?: boolean;
23
+ /** Saved to a server-side account, so invites reach the people invited. */
24
+ inAccount?: boolean;
21
25
  quizGenerator?: QuizGenerator;
22
26
  }
23
- export declare function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesPerWeek, shareBaseHref, quizGenerator }: PlaylistViewProps): React.JSX.Element;
27
+ export declare function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesPerWeek, shareBaseHref, canShare, inAccount, quizGenerator }: PlaylistViewProps): React.JSX.Element;
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  DEFAULT_FOLLOW_UPS: () => DEFAULT_FOLLOW_UPS,
24
24
  EDUCATION_CATEGORIES: () => EDUCATION_CATEGORIES,
25
25
  EducationPlaylists: () => EducationPlaylists,
26
+ INVITE_HASH_PARAM: () => INVITE_HASH_PARAM,
26
27
  MAX_ANSWERS: () => MAX_ANSWERS,
27
28
  MAX_ANSWER_LENGTH: () => MAX_ANSWER_LENGTH,
28
29
  MAX_GOAL_LENGTH: () => MAX_GOAL_LENGTH,
@@ -44,6 +45,7 @@ __export(index_exports, {
44
45
  createLocalPlaylistStore: () => createLocalPlaylistStore,
45
46
  createPlaylist: () => createPlaylist,
46
47
  createProgressStore: () => createProgressStore,
48
+ createRemotePlaylistStore: () => createRemotePlaylistStore,
47
49
  decodeShareFragment: () => decodeShareFragment,
48
50
  encodeShareFragment: () => encodeShareFragment,
49
51
  extractJson: () => extractJson,
@@ -53,12 +55,15 @@ __export(index_exports, {
53
55
  formatMinutes: () => formatMinutes,
54
56
  getDefaultCatalog: () => getDefaultCatalog,
55
57
  invite: () => invite,
58
+ inviteLink: () => inviteLink,
56
59
  moveItem: () => moveItem,
57
60
  normalizePlanRequest: () => normalizePlanRequest,
58
61
  parseQuiz: () => parseQuiz,
59
62
  planPlaylist: () => planPlaylist,
60
63
  playlistTime: () => playlistTime,
64
+ readInviteFragment: () => readInviteFragment,
61
65
  readPreferences: () => readPreferences,
66
+ readStoredPlaylist: () => readStoredPlaylist,
62
67
  removeItem: () => removeItem,
63
68
  removeMember: () => removeMember,
64
69
  safeUrl: () => safeUrl,
@@ -825,6 +830,91 @@ async function fetchPlan(goal, answers, endpoint, signal) {
825
830
  return planPlaylist({ goal, answers });
826
831
  }
827
832
 
833
+ // src/api/playlists.ts
834
+ var INVITE_HASH_PARAM = "invite";
835
+ function readInviteFragment(fragment) {
836
+ const token = new URLSearchParams(fragment.replace(/^#/, "")).get(INVITE_HASH_PARAM);
837
+ return token && token.length <= 200 ? token : null;
838
+ }
839
+ function inviteLink(baseHref, token) {
840
+ return `${baseHref.split("#")[0]}#${INVITE_HASH_PARAM}=${encodeURIComponent(token)}`;
841
+ }
842
+ var ROLES = ["owner", "editor", "viewer"];
843
+ var str = (value, max = 200) => typeof value === "string" && value ? value.slice(0, max) : void 0;
844
+ function readMember(raw) {
845
+ if (!raw || typeof raw !== "object") return [];
846
+ const r = raw;
847
+ if (!ROLES.includes(r.role)) return [];
848
+ return [
849
+ {
850
+ userId: str(r.userId),
851
+ email: str(r.email, 320),
852
+ role: r.role,
853
+ status: r.status === "active" ? "active" : "invited",
854
+ inviteToken: str(r.inviteToken)
855
+ }
856
+ ];
857
+ }
858
+ function readStoredPlaylist(raw) {
859
+ const clean = sanitizePlaylist(raw);
860
+ if (!clean) return null;
861
+ const r = raw;
862
+ const plannedFrom = r.plannedFrom;
863
+ return {
864
+ ...clean,
865
+ visibility: r.visibility === "public" ? "public" : "private",
866
+ ownerId: str(r.ownerId),
867
+ members: Array.isArray(r.members) ? r.members.flatMap(readMember) : [],
868
+ plannedFrom: plannedFrom && typeof plannedFrom.goal === "string" && Array.isArray(plannedFrom.answers) ? plannedFrom : void 0,
869
+ createdAt: str(r.createdAt, 40),
870
+ updatedAt: str(r.updatedAt, 40)
871
+ };
872
+ }
873
+ function readInvite(raw) {
874
+ if (!raw || typeof raw !== "object") return [];
875
+ const r = raw;
876
+ const playlistId = str(r.playlistId);
877
+ const token = str(r.token);
878
+ if (!playlistId || !token) return [];
879
+ return [{ playlistId, token, title: str(r.title) ?? "Shared playlist", role: r.role === "editor" ? "editor" : "viewer" }];
880
+ }
881
+ function createRemotePlaylistStore(endpoint, fetchImpl = (input, init) => fetch(input, init)) {
882
+ const base = endpoint.replace(/\/+$/, "");
883
+ async function call(path, init = {}) {
884
+ const res = await fetchImpl(`${base}${path}`, {
885
+ credentials: "same-origin",
886
+ ...init,
887
+ headers: init.body ? { "content-type": "application/json" } : void 0
888
+ });
889
+ const body = await res.json().catch(() => ({}));
890
+ if (!res.ok) throw new Error(typeof body.error === "string" ? body.error : `Playlist request failed (${res.status})`);
891
+ return body;
892
+ }
893
+ const one = (body) => {
894
+ const playlist = readStoredPlaylist(body.playlist);
895
+ if (!playlist) throw new Error("The server sent back an unusable playlist");
896
+ return playlist;
897
+ };
898
+ return {
899
+ async list(signal) {
900
+ const body = await call("", { signal });
901
+ return {
902
+ playlists: (Array.isArray(body.playlists) ? body.playlists : []).flatMap((raw) => readStoredPlaylist(raw) ?? []),
903
+ invites: (Array.isArray(body.invites) ? body.invites : []).flatMap(readInvite)
904
+ };
905
+ },
906
+ async save(playlist) {
907
+ return one(await call(`/${encodeURIComponent(playlist.id)}`, { method: "PUT", body: JSON.stringify(playlist) }));
908
+ },
909
+ async remove(id) {
910
+ await call(`/${encodeURIComponent(id)}`, { method: "DELETE" });
911
+ },
912
+ async acceptInvite(token) {
913
+ return one(await call("/accept-invite", { method: "POST", body: JSON.stringify({ token }) }));
914
+ }
915
+ };
916
+ }
917
+
828
918
  // src/components/EducationPlaylists.tsx
829
919
  var import_react3 = require("react");
830
920
 
@@ -952,19 +1042,19 @@ function ItemRow({
952
1042
  ] })
953
1043
  ] });
954
1044
  }
955
- function SharingControls({ playlist, onChange, shareBaseHref }) {
1045
+ function SharingControls({ playlist, onChange, shareBaseHref, inAccount }) {
956
1046
  const [email, setEmail] = (0, import_react.useState)("");
957
1047
  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)}`;
1048
+ const base = shareBaseHref ?? (typeof window !== "undefined" ? `${window.location.origin}${window.location.pathname}` : "");
1049
+ const copy = async (link, done) => {
961
1050
  try {
962
1051
  await navigator.clipboard.writeText(link);
963
- setMessage("Share link copied.");
1052
+ setMessage(done);
964
1053
  } catch {
965
1054
  setMessage(link);
966
1055
  }
967
1056
  };
1057
+ const copyShareLink = () => copy(`${base}#${encodeShareFragment(playlist)}`, "Share link copied.");
968
1058
  const addInvite = () => {
969
1059
  try {
970
1060
  onChange(invite(playlist, email, "viewer"));
@@ -1005,16 +1095,18 @@ function SharingControls({ playlist, onChange, shareBaseHref }) {
1005
1095
  ]
1006
1096
  }
1007
1097
  ),
1098
+ !inAccount && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: s.muted, children: "Invites are kept on this device until you sign in; nobody is notified yet." }),
1008
1099
  (playlist.members ?? []).map((member) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...s.row, ...s.muted }, children: [
1009
1100
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: member.email }),
1010
1101
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: s.badge, children: member.status === "invited" ? "invite pending" : member.role }),
1102
+ inAccount && member.status === "invited" && member.inviteToken && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...s.chip, padding: "0 8px" }, onClick: () => copy(inviteLink(base, member.inviteToken), `Invite link for ${member.email} copied.`), children: "Copy invite link" }),
1011
1103
  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
1104
  ] }, member.email ?? member.userId))
1013
1105
  ] }),
1014
1106
  message && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...s.muted, wordBreak: "break-all" }, children: message })
1015
1107
  ] });
1016
1108
  }
1017
- function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesPerWeek, shareBaseHref, quizGenerator }) {
1109
+ function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesPerWeek, shareBaseHref, canShare = true, inAccount = false, quizGenerator }) {
1018
1110
  const time = playlistTime(playlist, done);
1019
1111
  const weeks = weeksToFinish(time.remainingMinutes, minutesPerWeek);
1020
1112
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: s.section, children: [
@@ -1056,7 +1148,7 @@ function PlaylistView({ playlist, done, onToggle, onChange, onSaveCopy, minutesP
1056
1148
  item.id
1057
1149
  )) }),
1058
1150
  /* @__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 })
1151
+ onChange && canShare && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SharingControls, { playlist, onChange, shareBaseHref, inAccount })
1060
1152
  ] });
1061
1153
  }
1062
1154
 
@@ -1189,11 +1281,13 @@ function EducationPlaylists({
1189
1281
  shareBaseHref,
1190
1282
  importFromHash = false,
1191
1283
  userId,
1284
+ playlistsEndpoint,
1192
1285
  quizGenerator
1193
1286
  }) {
1194
1287
  const catalog = (0, import_react3.useMemo)(() => catalogProp ?? getDefaultCatalog(), [catalogProp]);
1195
1288
  const progressStore = (0, import_react3.useMemo)(() => createProgressStore(), []);
1196
1289
  const playlistStore = (0, import_react3.useMemo)(() => createLocalPlaylistStore(), []);
1290
+ const remote = (0, import_react3.useMemo)(() => playlistsEndpoint && userId ? createRemotePlaylistStore(playlistsEndpoint) : null, [playlistsEndpoint, userId]);
1197
1291
  const [expanded, setExpanded] = (0, import_react3.useState)(!compact);
1198
1292
  const [tab, setTab] = (0, import_react3.useState)("browse");
1199
1293
  const [done, setDone] = (0, import_react3.useState)(() => /* @__PURE__ */ new Set());
@@ -1202,10 +1296,31 @@ function EducationPlaylists({
1202
1296
  const [majorId, setMajorId] = (0, import_react3.useState)();
1203
1297
  const [programId, setProgramId] = (0, import_react3.useState)();
1204
1298
  const [open, setOpen] = (0, import_react3.useState)(null);
1299
+ const [invites, setInvites] = (0, import_react3.useState)([]);
1300
+ const [notice, setNotice] = (0, import_react3.useState)(null);
1205
1301
  (0, import_react3.useEffect)(() => {
1206
1302
  setDone(progressStore.load());
1207
1303
  setMine(playlistStore.list());
1208
1304
  }, [progressStore, playlistStore]);
1305
+ (0, import_react3.useEffect)(() => {
1306
+ if (!remote) return;
1307
+ const controller = new AbortController();
1308
+ (async () => {
1309
+ for (const playlist of playlistStore.list().filter((p) => !p.ownerId || p.ownerId === userId)) {
1310
+ try {
1311
+ await remote.save({ ...playlist, ownerId: userId });
1312
+ playlistStore.remove(playlist.id);
1313
+ } catch {
1314
+ }
1315
+ }
1316
+ const result = await remote.list(controller.signal);
1317
+ setMine(result.playlists);
1318
+ setInvites(result.invites);
1319
+ })().catch((error) => {
1320
+ if (error.name !== "AbortError") setNotice(`Couldn't load your saved playlists: ${error.message}`);
1321
+ });
1322
+ return () => controller.abort();
1323
+ }, [remote, playlistStore, userId]);
1209
1324
  (0, import_react3.useEffect)(() => {
1210
1325
  if (!importFromHash || typeof window === "undefined") return;
1211
1326
  const shared = decodeShareFragment(window.location.hash);
@@ -1213,7 +1328,21 @@ function EducationPlaylists({
1213
1328
  setExpanded(true);
1214
1329
  setOpen({ playlist: shared, source: "shared" });
1215
1330
  }
1216
- }, [importFromHash]);
1331
+ const token = readInviteFragment(window.location.hash);
1332
+ if (!token) return;
1333
+ setExpanded(true);
1334
+ if (!remote) {
1335
+ setNotice(playlistsEndpoint ? "Sign in with the invited email address to open this playlist." : "Invites need an account to open.");
1336
+ return;
1337
+ }
1338
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
1339
+ remote.acceptInvite(token).then((playlist) => {
1340
+ setMine((prev) => [playlist, ...prev.filter((p) => p.id !== playlist.id)]);
1341
+ setInvites((prev) => prev.filter((i) => i.token !== token));
1342
+ setOpen({ playlist, source: "mine" });
1343
+ setNotice(null);
1344
+ }).catch((error) => setNotice(error.message));
1345
+ }, [importFromHash, remote, playlistsEndpoint]);
1217
1346
  const category = catalog.categories.find((c2) => c2.id === categoryId);
1218
1347
  const major = category?.majors.find((m) => m.id === majorId);
1219
1348
  const visible = filterPlaylists(catalog.playlists, { categoryId, majorId, programId });
@@ -1224,10 +1353,22 @@ function EducationPlaylists({
1224
1353
  return next;
1225
1354
  });
1226
1355
  };
1227
- const saveMine = (playlist) => {
1228
- setMine(playlistStore.save({ ...playlist, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1356
+ const showSaved = (playlist) => {
1357
+ setMine((prev) => [playlist, ...prev.filter((p) => p.id !== playlist.id)]);
1229
1358
  setOpen((current) => current?.playlist.id === playlist.id ? { ...current, playlist } : current);
1230
1359
  };
1360
+ const saveMine = (playlist) => {
1361
+ if (!remote) {
1362
+ setMine(playlistStore.save({ ...playlist, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1363
+ setOpen((current) => current?.playlist.id === playlist.id ? { ...current, playlist } : current);
1364
+ return;
1365
+ }
1366
+ showSaved(playlist);
1367
+ remote.save(playlist).then((saved) => {
1368
+ showSaved(saved);
1369
+ setNotice(null);
1370
+ }).catch((error) => setNotice(`Couldn't save to your account: ${error.message}`));
1371
+ };
1231
1372
  const saveCopy = (playlist) => {
1232
1373
  const copy = createPlaylist({
1233
1374
  title: playlist.title,
@@ -1236,13 +1377,25 @@ function EducationPlaylists({
1236
1377
  ownerId: userId,
1237
1378
  plannedFrom: playlist.plannedFrom
1238
1379
  });
1239
- setMine(playlistStore.save(copy));
1240
1380
  setOpen({ playlist: copy, source: "mine" });
1241
1381
  setTab("mine");
1382
+ saveMine(copy);
1242
1383
  };
1243
1384
  const deleteMine = (id) => {
1244
- setMine(playlistStore.remove(id));
1245
1385
  setOpen(null);
1386
+ if (!remote) {
1387
+ setMine(playlistStore.remove(id));
1388
+ return;
1389
+ }
1390
+ setMine((prev) => prev.filter((p) => p.id !== id));
1391
+ remote.remove(id).catch((error) => setNotice(`Couldn't delete it from your account: ${error.message}`));
1392
+ };
1393
+ const acceptInvite2 = (pending) => {
1394
+ remote?.acceptInvite(pending.token).then((playlist) => {
1395
+ setInvites((prev) => prev.filter((i) => i.token !== pending.token));
1396
+ showSaved(playlist);
1397
+ openPlaylist(playlist, "mine");
1398
+ }).catch((error) => setNotice(error.message));
1246
1399
  };
1247
1400
  const openPlaylist = (playlist, source) => {
1248
1401
  setExpanded(true);
@@ -1283,12 +1436,13 @@ function EducationPlaylists({
1283
1436
  }
1284
1437
  const tabs = [
1285
1438
  { id: "browse", label: "Browse" },
1286
- { id: "mine", label: `My playlists${mine.length ? ` (${mine.length})` : ""}` },
1439
+ { id: "mine", label: `My playlists${mine.length ? ` (${mine.length})` : ""}${invites.length ? ` \xB7 ${invites.length} invite${invites.length === 1 ? "" : "s"}` : ""}` },
1287
1440
  { id: "plan", label: "Plan with AI" }
1288
1441
  ];
1289
1442
  let body;
1290
1443
  if (open) {
1291
- const editable = open.source === "mine";
1444
+ const owned = !remote || open.playlist.ownerId === userId;
1445
+ const editable = open.source === "mine" && (owned || canEdit(open.playlist, userId));
1292
1446
  body = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.section, children: [
1293
1447
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1294
1448
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.button, onClick: () => setOpen(null), children: "\u2190 Back" }),
@@ -1298,7 +1452,7 @@ function EducationPlaylists({
1298
1452
  " from: ",
1299
1453
  open.plan.searches.join(" \xB7 ")
1300
1454
  ] }),
1301
- editable && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.button, onClick: () => deleteMine(open.playlist.id), children: "Delete" })
1455
+ editable && owned && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.button, onClick: () => deleteMine(open.playlist.id), children: "Delete" })
1302
1456
  ] }),
1303
1457
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1304
1458
  PlaylistView,
@@ -1307,7 +1461,9 @@ function EducationPlaylists({
1307
1461
  done,
1308
1462
  onToggle: toggle,
1309
1463
  onChange: editable ? saveMine : void 0,
1310
- onSaveCopy: editable ? void 0 : saveCopy,
1464
+ onSaveCopy: open.source === "mine" ? void 0 : saveCopy,
1465
+ canShare: owned,
1466
+ inAccount: Boolean(remote),
1311
1467
  minutesPerWeek: open.plan?.minutesPerWeek,
1312
1468
  shareBaseHref: shareBase,
1313
1469
  quizGenerator
@@ -1342,13 +1498,26 @@ function EducationPlaylists({
1342
1498
  /* @__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
1499
  ] });
1344
1500
  } 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." });
1501
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.section, children: [
1502
+ invites.map((pending) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: s.row, children: [
1503
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: s.muted, children: [
1504
+ "You're invited to ",
1505
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("strong", { children: pending.title }),
1506
+ " as ",
1507
+ pending.role === "editor" ? "an editor" : "a viewer",
1508
+ "."
1509
+ ] }),
1510
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", style: s.primary, onClick: () => acceptInvite2(pending), children: "Accept" })
1511
+ ] }, pending.token)),
1512
+ 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." })
1513
+ ] });
1346
1514
  } else {
1347
1515
  body = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlaylistPlanner, { planEndpoint, onPlanned: (result) => setOpen({ playlist: result.playlist, source: "planned", plan: result }) });
1348
1516
  }
1349
1517
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("section", { className, style: { ...s.root, ...style }, "aria-label": "Education playlists", children: [
1350
1518
  header,
1351
1519
  !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)) }),
1520
+ notice && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "status", style: s.muted, children: notice }),
1352
1521
  body
1353
1522
  ] });
1354
1523
  }
@@ -1357,6 +1526,7 @@ function EducationPlaylists({
1357
1526
  DEFAULT_FOLLOW_UPS,
1358
1527
  EDUCATION_CATEGORIES,
1359
1528
  EducationPlaylists,
1529
+ INVITE_HASH_PARAM,
1360
1530
  MAX_ANSWERS,
1361
1531
  MAX_ANSWER_LENGTH,
1362
1532
  MAX_GOAL_LENGTH,
@@ -1378,6 +1548,7 @@ function EducationPlaylists({
1378
1548
  createLocalPlaylistStore,
1379
1549
  createPlaylist,
1380
1550
  createProgressStore,
1551
+ createRemotePlaylistStore,
1381
1552
  decodeShareFragment,
1382
1553
  encodeShareFragment,
1383
1554
  extractJson,
@@ -1387,12 +1558,15 @@ function EducationPlaylists({
1387
1558
  formatMinutes,
1388
1559
  getDefaultCatalog,
1389
1560
  invite,
1561
+ inviteLink,
1390
1562
  moveItem,
1391
1563
  normalizePlanRequest,
1392
1564
  parseQuiz,
1393
1565
  planPlaylist,
1394
1566
  playlistTime,
1567
+ readInviteFragment,
1395
1568
  readPreferences,
1569
+ readStoredPlaylist,
1396
1570
  removeItem,
1397
1571
  removeMember,
1398
1572
  safeUrl,