education-playlists 0.1.0 → 0.1.1
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 +42 -5
- package/dist/api/playlists.d.ts +42 -0
- package/dist/components/EducationPlaylists.d.ts +9 -1
- package/dist/components/PlaylistView.d.ts +5 -1
- package/dist/index.cjs +191 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +186 -17
- package/dist/index.js.map +1 -1
- package/dist/lib/playlists.d.ts +4 -4
- package/dist/server/index.cjs +319 -39
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +312 -38
- package/dist/server/index.js.map +1 -1
- package/dist/server/playlists.d.ts +92 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
959
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1228
|
-
setMine(
|
|
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
|
|
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:
|
|
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 =
|
|
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,
|