@freqhole/playlistz 0.0.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.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,1020 @@
1
+ // inline share panel: p2p status, share link for the current playlist,
2
+ // receive a shared playlist, endpoint settings, and knock inbox.
3
+ // rendered inside the playlist view (not as a floating modal).
4
+ import {
5
+ createSignal,
6
+ createEffect,
7
+ onCleanup,
8
+ Show,
9
+ For,
10
+ type Accessor,
11
+ } from "solid-js";
12
+ import {
13
+ getShareSettings,
14
+ saveShareSettings,
15
+ buildShareLink,
16
+ getInboundKnocks,
17
+ getOutboundKnocks,
18
+ acceptKnock,
19
+ denyKnock,
20
+ knockOnPeer,
21
+ knockForDocAccess,
22
+ onKnocksChanged,
23
+ type ShareSettings,
24
+ } from "../services/sharingService.js";
25
+ import { findPlaylistDoc, flushDoc } from "../services/automergeRepo.js";
26
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
27
+ import {
28
+ getIdentity,
29
+ isLeader,
30
+ onLeadershipChange,
31
+ onIdentityChange,
32
+ } from "../services/p2pService.js";
33
+ import { getIrohAdapter } from "../services/automergeRepo.js";
34
+ import {
35
+ sharingReady,
36
+ toggleEndpoint,
37
+ hasP2pIdentity,
38
+ endpointEnabled,
39
+ connectedPeerCount,
40
+ } from "../services/sharingState.js";
41
+ import type {
42
+ KnockRecord,
43
+ AccessGrantRecord,
44
+ } from "../services/indexedDBService.js";
45
+ import {
46
+ getAllAccessGrants,
47
+ upsertAccessGrant,
48
+ deleteAccessGrant,
49
+ } from "../services/docIndexService.js";
50
+ import type { Playlist } from "../types/playlist.js";
51
+ import { log } from "../utils/log.js";
52
+
53
+ interface PlaylistSharePanelProps {
54
+ playlist: Accessor<Playlist>;
55
+ playlists: Playlist[];
56
+ onClose: () => void;
57
+ onPlaylistAdded?: (docId: string) => void;
58
+ }
59
+
60
+ export function PlaylistSharePanel(props: PlaylistSharePanelProps) {
61
+ const [settings, setSettings] = createSignal<ShareSettings>({
62
+ name: "",
63
+ mode: "knock",
64
+ });
65
+ const [leader, setLeader] = createSignal(false);
66
+ // use sharingReady() from sharingState as the source of truth for whether
67
+ // the p2p node is running, falling back to local state for the "starting" phase
68
+ const [starting, setStarting] = createSignal(false);
69
+ const p2pEnabled = () => sharingReady();
70
+ const [connSummary, setConnSummary] = createSignal({
71
+ connected: 0,
72
+ reconnecting: 0,
73
+ failed: 0,
74
+ });
75
+ const [shareLink, setShareLink] = createSignal("");
76
+ const [copied, setCopied] = createSignal(false);
77
+ const [knocks, setKnocks] = createSignal<KnockRecord[]>([]);
78
+ const [outboundKnocks, setOutboundKnocks] = createSignal<KnockRecord[]>([]);
79
+ const [acceptingKnockId, setAcceptingKnockId] = createSignal<string | null>(
80
+ null
81
+ );
82
+ const [grants, setGrants] = createSignal<AccessGrantRecord[]>([]);
83
+ const [grantSelection, setGrantSelection] = createSignal<
84
+ Record<string, Set<string>>
85
+ >({});
86
+ const [retryingKnockId, setRetryingKnockId] = createSignal<string | null>(
87
+ null
88
+ );
89
+ const [retryStatusMap, setRetryStatusMap] = createSignal<
90
+ Record<string, string>
91
+ >({});
92
+ const [editingGrantId, setEditingGrantId] = createSignal<string | null>(null);
93
+ const [grantEditSelection, setGrantEditSelection] = createSignal<
94
+ Record<string, Set<string>>
95
+ >({});
96
+ const [error, setError] = createSignal<string | null>(null);
97
+ const [editingName, setEditingName] = createSignal(false);
98
+ // per-playlist collaborative editing flag (stored in the automerge doc)
99
+ const [collaborative, setCollaborative] = createSignal(false);
100
+ // whether this playlist is subscribed from a remote peer (not our own / not forked)
101
+ const isSubscribed = () =>
102
+ !!props.playlist().remoteNodeId && !props.playlist().isForked;
103
+ // collab access request state (only relevant when isSubscribed())
104
+ const [collabRequestMessage, setCollabRequestMessage] = createSignal("");
105
+ const [collabRequestStatus, setCollabRequestStatus] = createSignal<
106
+ string | null
107
+ >(null);
108
+ const [requestingCollab, setRequestingCollab] = createSignal(false);
109
+
110
+ // reactive flag for per-peer online status in the granted peers list.
111
+ // we mirror connSummary() changes by re-reading the adapter each time.
112
+ const isPeerOnline = (nodeId: string): boolean => {
113
+ // reading connSummary() creates a reactive dependency so this updates
114
+ // whenever connection state changes
115
+ void connSummary();
116
+ try {
117
+ return getIrohAdapter().isConnected(nodeId);
118
+ } catch {
119
+ return false;
120
+ }
121
+ };
122
+ let avatarFileInput!: HTMLInputElement;
123
+
124
+ let unsubKnocks: (() => void) | null = null;
125
+ let unsubLeader: (() => void) | null = null;
126
+ let unsubIdentity: (() => void) | null = null;
127
+ let connTimer: ReturnType<typeof setInterval> | null = null;
128
+
129
+ // avatar: hash name to a color for the fallback initial circle
130
+ const AVATAR_COLORS = [
131
+ "#e91e8c",
132
+ "#7c3aed",
133
+ "#0ea5e9",
134
+ "#10b981",
135
+ "#f59e0b",
136
+ "#ef4444",
137
+ ];
138
+ const avatarColor = (name: string) => {
139
+ const sum = name.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
140
+ return AVATAR_COLORS[sum % AVATAR_COLORS.length] ?? AVATAR_COLORS[0];
141
+ };
142
+
143
+ const handleAvatarUpload = (e: Event) => {
144
+ const file = (e.currentTarget as HTMLInputElement).files?.[0];
145
+ if (!file) return;
146
+ const reader = new FileReader();
147
+ reader.onload = () => {
148
+ if (typeof reader.result === "string") {
149
+ void handleSaveSettings({ avatarDataUrl: reader.result });
150
+ }
151
+ };
152
+ reader.readAsDataURL(file);
153
+ };
154
+
155
+ async function refreshKnocks() {
156
+ const loaded = await getInboundKnocks();
157
+ setKnocks(loaded);
158
+ setOutboundKnocks(await getOutboundKnocks());
159
+ setGrants(await getAllAccessGrants());
160
+ // pre-select the requested doc for doc_access knocks that have no selection yet
161
+ setGrantSelection((prev) => {
162
+ const updated = { ...prev };
163
+ for (const knock of loaded) {
164
+ if (
165
+ knock.status === "pending" &&
166
+ knock.knockType === "doc_access" &&
167
+ knock.requestedDocId &&
168
+ !updated[knock.id]
169
+ ) {
170
+ updated[knock.id] = new Set([knock.requestedDocId]);
171
+ }
172
+ }
173
+ return updated;
174
+ });
175
+ }
176
+
177
+ function refreshConnSummary() {
178
+ try {
179
+ setConnSummary(getIrohAdapter().getConnectionSummary());
180
+ } catch {
181
+ // repo not constructed yet
182
+ }
183
+ }
184
+
185
+ async function rebuildShareLink() {
186
+ try {
187
+ const result = await buildShareLink(
188
+ props.playlist().id,
189
+ props.playlist().title
190
+ );
191
+ setShareLink(result.url);
192
+ } catch (err) {
193
+ log.warn("share.panel", "could not build share link:", err);
194
+ }
195
+ }
196
+
197
+ // initialise on mount: load settings, check if p2p already enabled
198
+ createEffect(() => {
199
+ void (async () => {
200
+ const globalSettings = await getShareSettings();
201
+ // override mode from the playlist's own doc if it has one
202
+ try {
203
+ const handle = await findPlaylistDoc(
204
+ props.playlist().id as AutomergeUrl
205
+ );
206
+ const raw = handle.doc() as Record<string, unknown> | undefined;
207
+ const docMode = raw?.sharingMode as string | undefined;
208
+ if (docMode === "public" || docMode === "knock") {
209
+ globalSettings.mode = docMode;
210
+ }
211
+ setCollaborative(!!raw?.collaborative);
212
+ } catch {
213
+ /* doc not yet loaded - use global default */
214
+ }
215
+ setSettings(globalSettings);
216
+ await refreshKnocks();
217
+ const identity = getIdentity();
218
+ if (identity?.node_id) {
219
+ await rebuildShareLink();
220
+ }
221
+ setLeader(isLeader());
222
+ refreshConnSummary();
223
+ })();
224
+
225
+ unsubKnocks = onKnocksChanged(() => void refreshKnocks());
226
+ unsubLeader = onLeadershipChange((l) => setLeader(l));
227
+ unsubIdentity = onIdentityChange((identity) => {
228
+ if (identity?.node_id) void rebuildShareLink();
229
+ });
230
+ connTimer = setInterval(refreshConnSummary, 3000);
231
+
232
+ onCleanup(() => {
233
+ unsubKnocks?.();
234
+ unsubLeader?.();
235
+ unsubIdentity?.();
236
+ if (connTimer) clearInterval(connTimer);
237
+ });
238
+ });
239
+
240
+ // rebuild share link when p2p becomes enabled or the playlist changes
241
+ createEffect(() => {
242
+ const enabled = p2pEnabled();
243
+ const playlistId = props.playlist().id;
244
+ if (enabled && playlistId) {
245
+ void rebuildShareLink();
246
+ }
247
+ });
248
+
249
+ const handleEnableP2P = async () => {
250
+ setStarting(true);
251
+ setError(null);
252
+ try {
253
+ await toggleEndpoint();
254
+ setLeader(isLeader());
255
+ await rebuildShareLink();
256
+ } catch (err) {
257
+ setError(err instanceof Error ? err.message : "failed to start p2p");
258
+ } finally {
259
+ setStarting(false);
260
+ }
261
+ };
262
+
263
+ const handleSaveSettings = async (update: Partial<ShareSettings>) => {
264
+ const next = { ...settings(), ...update };
265
+ setSettings(next);
266
+ await saveShareSettings(next);
267
+ // also write sharingMode to the playlist's automerge doc when it changes
268
+ if (update.mode !== undefined) {
269
+ try {
270
+ const handle = await findPlaylistDoc(
271
+ props.playlist().id as AutomergeUrl
272
+ );
273
+ handle.change((d: Record<string, unknown>) => {
274
+ d.sharingMode = update.mode;
275
+ });
276
+ await flushDoc(props.playlist().id as AutomergeUrl);
277
+ } catch (err) {
278
+ log.warn("share.panel", "failed to write sharingMode to doc:", err);
279
+ }
280
+ }
281
+ };
282
+
283
+ const handleToggleCollaborative = async () => {
284
+ const next = !collaborative();
285
+ setCollaborative(next);
286
+ try {
287
+ const handle = await findPlaylistDoc(props.playlist().id as AutomergeUrl);
288
+ handle.change((d: Record<string, unknown>) => {
289
+ d.collaborative = next;
290
+ });
291
+ await flushDoc(props.playlist().id as AutomergeUrl);
292
+ } catch (err) {
293
+ log.warn("share.panel", "failed to write collaborative to doc:", err);
294
+ setCollaborative(!next); // revert on failure
295
+ }
296
+ };
297
+
298
+ const handleRequestCollabAccess = async () => {
299
+ if (requestingCollab()) return;
300
+ const ownerNodeId = props.playlist().remoteNodeId;
301
+ if (!ownerNodeId) return;
302
+ setRequestingCollab(true);
303
+ setCollabRequestStatus(null);
304
+ try {
305
+ const result = await knockForDocAccess(
306
+ ownerNodeId,
307
+ props.playlist().id,
308
+ collabRequestMessage(),
309
+ props.playlist().title
310
+ );
311
+ if (result.status === "accepted") {
312
+ setCollabRequestStatus("access granted - you can now collaborate");
313
+ } else if (result.status === "denied") {
314
+ setCollabRequestStatus("access denied");
315
+ } else {
316
+ setCollabRequestStatus("request sent - waiting for owner approval");
317
+ }
318
+ } catch (err) {
319
+ setCollabRequestStatus(
320
+ err instanceof Error ? err.message : "request failed"
321
+ );
322
+ } finally {
323
+ setRequestingCollab(false);
324
+ await refreshKnocks();
325
+ }
326
+ };
327
+
328
+ const handleCopyLink = async () => {
329
+ try {
330
+ await navigator.clipboard.writeText(shareLink());
331
+ setCopied(true);
332
+ setTimeout(() => setCopied(false), 1500);
333
+ } catch {
334
+ // clipboard unavailable in this context
335
+ }
336
+ };
337
+
338
+ function toggleGrantDoc(knockId: string, docId: string) {
339
+ setGrantSelection((prev) => {
340
+ const updated = { ...prev };
341
+ const set = new Set(updated[knockId] ?? []);
342
+ if (set.has(docId)) set.delete(docId);
343
+ else set.add(docId);
344
+ updated[knockId] = set;
345
+ return updated;
346
+ });
347
+ }
348
+
349
+ function selectAllGrantDocs(knockId: string) {
350
+ setGrantSelection((prev) => ({
351
+ ...prev,
352
+ [knockId]: new Set(props.playlists.map((p) => p.id)),
353
+ }));
354
+ }
355
+
356
+ function clearAllGrantDocs(knockId: string) {
357
+ setGrantSelection((prev) => ({ ...prev, [knockId]: new Set() }));
358
+ }
359
+
360
+ const handleAccept = async (knock: KnockRecord) => {
361
+ if (acceptingKnockId()) return;
362
+ setAcceptingKnockId(knock.id);
363
+ let docIds = [...(grantSelection()[knock.id] ?? [])];
364
+ // for doc_access knocks, always ensure the requested doc is included
365
+ if (
366
+ knock.knockType === "doc_access" &&
367
+ knock.requestedDocId &&
368
+ !docIds.includes(knock.requestedDocId)
369
+ ) {
370
+ docIds = [knock.requestedDocId, ...docIds];
371
+ }
372
+ try {
373
+ await acceptKnock(knock.id, docIds.length > 0 ? docIds : []);
374
+ } finally {
375
+ setAcceptingKnockId(null);
376
+ }
377
+ await refreshKnocks();
378
+ };
379
+
380
+ const handleDeny = async (knock: KnockRecord) => {
381
+ await denyKnock(knock.id);
382
+ await refreshKnocks();
383
+ };
384
+
385
+ const pendingKnocks = () => knocks().filter((k) => k.status === "pending");
386
+
387
+ return (
388
+ <div
389
+ data-testid="share-panel"
390
+ class="px-4 pb-6 pt-2 space-y-5 font-mono text-white overflow-x-hidden min-w-0"
391
+ >
392
+ <Show when={error()}>
393
+ <div
394
+ data-testid="share-link-error"
395
+ class="p-2 border border-red-500 text-red-400 text-sm"
396
+ >
397
+ <span class="bg-black/80 px-1">{error()}</span>
398
+ </div>
399
+ </Show>
400
+ <div>
401
+ <Show when={!hasP2pIdentity()}>
402
+ <button
403
+ data-testid="btn-enable-sharing"
404
+ onClick={() => void handleEnableP2P()}
405
+ disabled={starting()}
406
+ class="w-full px-4 py-3 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium"
407
+ >
408
+ {starting() ? "starting p2p node..." : "enable p2p sharing"}
409
+ </button>
410
+ </Show>
411
+ {/* display name + avatar - only shown once p2p identity exists */}
412
+ <Show when={hasP2pIdentity()}>
413
+ <div class="flex items-center gap-2 mt-2">
414
+ {/* avatar with inline status dot at bottom-right */}
415
+ <div class="relative flex-shrink-0 w-8 h-8">
416
+ <button
417
+ type="button"
418
+ class="w-8 h-8 rounded-full overflow-hidden border border-gray-700 hover:border-magenta-500 transition-colors focus:outline-none"
419
+ title={(() => {
420
+ if (!p2pEnabled()) return "click to change avatar";
421
+ const s = connSummary();
422
+ const parts: string[] = [
423
+ leader()
424
+ ? "this tab runs the p2p node"
425
+ : "another tab holds the p2p node",
426
+ ];
427
+ if (s.connected > 0) parts.push(`${s.connected} connected`);
428
+ if (s.reconnecting > 0)
429
+ parts.push(`${s.reconnecting} reconnecting`);
430
+ if (s.failed > 0) parts.push(`${s.failed} failed`);
431
+ return parts.join(" · ");
432
+ })()}
433
+ onClick={() => avatarFileInput.click()}
434
+ >
435
+ <Show
436
+ when={settings().avatarDataUrl}
437
+ fallback={
438
+ <div
439
+ class="w-full h-full flex items-center justify-center text-white text-sm font-bold"
440
+ style={{
441
+ "background-color": avatarColor(settings().name || "?"),
442
+ }}
443
+ >
444
+ {(settings().name?.[0] ?? "?").toUpperCase()}
445
+ </div>
446
+ }
447
+ >
448
+ <img
449
+ src={settings().avatarDataUrl}
450
+ alt="avatar"
451
+ class="w-full h-full object-cover"
452
+ />
453
+ </Show>
454
+ </button>
455
+ {/* status dot: green=online, yellow=connecting/reconnecting, red=failed, gray=offline */}
456
+ <Show when={hasP2pIdentity()}>
457
+ <span
458
+ data-testid="sharing-status"
459
+ class={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-black ${
460
+ !p2pEnabled()
461
+ ? "bg-gray-600"
462
+ : connSummary().failed > 0 &&
463
+ connSummary().connected === 0
464
+ ? "bg-red-500"
465
+ : connSummary().reconnecting > 0
466
+ ? "bg-yellow-400"
467
+ : "bg-green-500"
468
+ }`}
469
+ />
470
+ </Show>
471
+ </div>
472
+ <input
473
+ ref={avatarFileInput}
474
+ type="file"
475
+ accept="image/*"
476
+ class="hidden"
477
+ onChange={handleAvatarUpload}
478
+ />
479
+
480
+ {/* name pill / inline edit */}
481
+ <div class="flex-1 min-w-0">
482
+ <Show
483
+ when={editingName()}
484
+ fallback={
485
+ <button
486
+ type="button"
487
+ class="px-2 py-0.5 text-sm bg-black border border-gray-700 hover:border-magenta-500 text-white truncate max-w-[180px] transition-colors"
488
+ onClick={() => setEditingName(true)}
489
+ title="click to edit display name"
490
+ >
491
+ {settings().name || (
492
+ <span class="text-gray-500">anonymous</span>
493
+ )}
494
+ </button>
495
+ }
496
+ >
497
+ <div class="flex items-center gap-1 flex-1 min-w-0">
498
+ <input
499
+ data-testid="input-node-name"
500
+ type="text"
501
+ value={settings().name}
502
+ placeholder="anonymous"
503
+ autofocus
504
+ onInput={(e) =>
505
+ void handleSaveSettings({ name: e.currentTarget.value })
506
+ }
507
+ onKeyDown={(e) => {
508
+ if (e.key === "Enter" || e.key === "Escape")
509
+ setEditingName(false);
510
+ }}
511
+ class="flex-1 min-w-0 bg-black text-white px-2 py-0.5 text-sm border border-magenta-500 focus:outline-none"
512
+ />
513
+ <button
514
+ type="button"
515
+ class="flex-shrink-0 text-gray-400 hover:text-white px-1"
516
+ onClick={() => setEditingName(false)}
517
+ aria-label="close name editor"
518
+ >
519
+ &#x2715;
520
+ </button>
521
+ </div>
522
+ </Show>
523
+ {/* connected peer count + endpoint on/off toggle */}
524
+ <div class="flex items-center gap-2 mt-1">
525
+ <Show when={endpointEnabled() && connectedPeerCount() > 0}>
526
+ <span
527
+ data-testid="connected-peer-count"
528
+ class="text-xs text-green-400 bg-black/80 px-1"
529
+ >
530
+ {connectedPeerCount()} connected
531
+ </span>
532
+ </Show>
533
+ <button
534
+ data-testid="btn-toggle-endpoint"
535
+ type="button"
536
+ aria-pressed={endpointEnabled()}
537
+ onClick={() => void handleEnableP2P()}
538
+ disabled={starting()}
539
+ class={`text-xs px-2 py-0.5 border transition-colors disabled:opacity-50 ${
540
+ endpointEnabled()
541
+ ? "border-gray-600 text-gray-400 hover:text-red-400 hover:border-red-500"
542
+ : "border-magenta-500 text-magenta-400 hover:text-white hover:border-magenta-400"
543
+ }`}
544
+ title={endpointEnabled() ? "turn off p2p" : "turn on p2p"}
545
+ >
546
+ {starting() ? "..." : endpointEnabled() ? "on" : "off"}
547
+ </button>
548
+ </div>
549
+ </div>
550
+ </div>
551
+ </Show>
552
+ </div>
553
+
554
+ {/* request collaboration access - shown when viewing a subscribed playlist */}
555
+ <Show when={isSubscribed() && p2pEnabled()}>
556
+ <div>
557
+ <label class="block text-xs mb-1">
558
+ <span class="bg-black px-1 text-gray-400">
559
+ request collaboration access
560
+ </span>
561
+ </label>
562
+ <div class="space-y-2">
563
+ <input
564
+ data-testid="input-collab-request-message"
565
+ type="text"
566
+ placeholder="optional message to the owner"
567
+ value={collabRequestMessage()}
568
+ onInput={(e) => setCollabRequestMessage(e.currentTarget.value)}
569
+ class="w-full bg-black text-white px-2 py-1.5 text-xs border border-gray-700 hover:border-gray-500 focus:border-magenta-500 focus:outline-none transition-colors"
570
+ />
571
+ <button
572
+ data-testid="btn-request-collab-access"
573
+ onClick={() => void handleRequestCollabAccess()}
574
+ disabled={requestingCollab()}
575
+ class="w-full px-3 py-2 text-sm border border-gray-600 hover:border-magenta-500 text-gray-300 hover:text-white disabled:opacity-50 transition-colors"
576
+ >
577
+ {requestingCollab()
578
+ ? "sending request..."
579
+ : "request edit access"}
580
+ </button>
581
+ <Show when={collabRequestStatus()}>
582
+ <p
583
+ data-testid="collab-request-status"
584
+ class="text-xs px-1 text-magenta-400"
585
+ >
586
+ {collabRequestStatus()}
587
+ </p>
588
+ </Show>
589
+ </div>
590
+ </div>
591
+ </Show>
592
+
593
+ {/* share link for this playlist */}
594
+ <Show when={p2pEnabled()}>
595
+ <div>
596
+ <label class="block text-xs mb-1">
597
+ <span class="bg-black px-1 text-gray-400">share this playlist</span>
598
+ </label>
599
+ <Show
600
+ when={shareLink()}
601
+ fallback={
602
+ <div class="text-xs text-gray-600">
603
+ <span class="bg-black/80 px-1">building link...</span>
604
+ </div>
605
+ }
606
+ >
607
+ <div class="flex gap-2">
608
+ <input
609
+ data-testid="input-share-link"
610
+ type="text"
611
+ readOnly
612
+ value={shareLink()}
613
+ title="copy p2p share link"
614
+ onFocus={(e) => e.currentTarget.select()}
615
+ class="flex-1 bg-black text-white px-3 py-2 text-xs border border-magenta-200 hover:border-magenta-400 focus:outline-none truncate min-w-0 transition-colors"
616
+ />
617
+ <button
618
+ data-testid="btn-copy-share-link"
619
+ onClick={() => void handleCopyLink()}
620
+ title="copy share link"
621
+ class="px-4 py-2 bg-magenta-500 hover:bg-magenta-600 text-white text-sm whitespace-nowrap flex-shrink-0"
622
+ >
623
+ {copied() ? "copied!" : "copy"}
624
+ </button>
625
+ </div>
626
+ </Show>
627
+ </div>
628
+ </Show>
629
+
630
+ {/* receive a shared playlist - moved to all-playlists search bar */}
631
+
632
+ {/* endpoint settings: mode and visibility - only relevant when p2p is active */}
633
+ <Show when={p2pEnabled()}>
634
+ <div class="space-y-3">
635
+ <div>
636
+ <label class="block text-xs mb-1">
637
+ <span class="bg-black px-1 text-gray-400">
638
+ who can browse this playlist?
639
+ </span>
640
+ </label>
641
+ <div class="flex gap-2">
642
+ <button
643
+ data-testid="btn-mode-public"
644
+ aria-pressed={settings().mode === "public"}
645
+ onClick={() => void handleSaveSettings({ mode: "public" })}
646
+ class={`flex-1 px-3 py-2 text-sm border transition-colors ${settings().mode === "public" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400 hover:border-magenta-500 hover:text-gray-200 hover:bg-white/5"}`}
647
+ >
648
+ anyone (public)
649
+ </button>
650
+ <button
651
+ data-testid="btn-mode-knock"
652
+ aria-pressed={settings().mode === "knock"}
653
+ onClick={() => void handleSaveSettings({ mode: "knock" })}
654
+ class={`flex-1 px-3 py-2 text-sm border transition-colors ${settings().mode === "knock" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400 hover:border-magenta-500 hover:text-gray-200 hover:bg-white/5"}`}
655
+ >
656
+ knock first
657
+ </button>
658
+ </div>
659
+ <div class="mt-2">
660
+ <button
661
+ data-testid="btn-toggle-collaborative"
662
+ type="button"
663
+ aria-pressed={collaborative()}
664
+ onClick={() => void handleToggleCollaborative()}
665
+ class={`w-full px-3 py-2 text-sm border transition-colors ${
666
+ collaborative()
667
+ ? "border-magenta-500 bg-magenta-500/20 text-white"
668
+ : "border-gray-600 text-gray-400 hover:border-magenta-500 hover:text-gray-200 hover:bg-white/5"
669
+ }`}
670
+ title="when on, peers with access can edit without a separate approval"
671
+ >
672
+ collaborative editing {collaborative() ? "(on)" : "(off)"}
673
+ </button>
674
+ </div>
675
+ </div>
676
+ </div>
677
+ </Show>
678
+
679
+ {/* knock inbox - only shown when there are pending knocks */}
680
+ <Show when={pendingKnocks().length > 0}>
681
+ <div>
682
+ <label data-testid="knock-inbox" class="block text-xs mb-1">
683
+ <span class="bg-black px-1 text-gray-400">
684
+ knock inbox
685
+ <span class="ml-2 text-magenta-400">
686
+ ({pendingKnocks().length} pending)
687
+ </span>
688
+ </span>
689
+ </label>
690
+ <For each={pendingKnocks()}>
691
+ {(knock) => (
692
+ <div class="border border-gray-700 p-3 mb-2 text-sm">
693
+ <div class="mb-1">
694
+ <span class="text-white bg-black/80 px-1">
695
+ {knock.name || "anonymous"}
696
+ </span>
697
+ <span class="text-gray-500 text-xs ml-2 bg-black/80 px-1">
698
+ {knock.nodeId.slice(0, 16)}...
699
+ </span>
700
+ <span class="text-xs ml-2 bg-black/80 px-1 text-magenta-400">
701
+ {knock.knockType === "doc_access"
702
+ ? "wants playlist access"
703
+ : "wants to browse"}
704
+ </span>
705
+ </div>
706
+ <Show when={knock.message}>
707
+ <div class="text-gray-400 text-xs mb-2">
708
+ <span class="bg-black/80 px-1">"{knock.message}"</span>
709
+ </div>
710
+ </Show>
711
+ <div class="flex items-center justify-between text-xs text-gray-500 mb-1">
712
+ <span class="bg-black/80 px-1">grant access to:</span>
713
+ <div class="flex gap-2">
714
+ <button
715
+ type="button"
716
+ class="text-magenta-400 hover:text-magenta-300"
717
+ onClick={() => selectAllGrantDocs(knock.id)}
718
+ >
719
+ all
720
+ </button>
721
+ <button
722
+ type="button"
723
+ class="text-gray-500 hover:text-gray-300"
724
+ onClick={() => clearAllGrantDocs(knock.id)}
725
+ >
726
+ none
727
+ </button>
728
+ </div>
729
+ </div>
730
+ <div class="max-h-24 overflow-y-auto mb-2">
731
+ <For each={props.playlists}>
732
+ {(pl) => (
733
+ <label class="flex items-center gap-2 text-xs text-gray-300 py-0.5 cursor-pointer">
734
+ <input
735
+ type="checkbox"
736
+ checked={
737
+ grantSelection()[knock.id]?.has(pl.id) ?? false
738
+ }
739
+ onChange={() => toggleGrantDoc(knock.id, pl.id)}
740
+ />
741
+ <span class="bg-black/80 px-1">{pl.title}</span>
742
+ </label>
743
+ )}
744
+ </For>
745
+ </div>
746
+ <div class="flex gap-2">
747
+ <button
748
+ onClick={() => void handleAccept(knock)}
749
+ disabled={acceptingKnockId() === knock.id}
750
+ class="flex-1 px-3 py-1 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white text-xs"
751
+ title={
752
+ (grantSelection()[knock.id]?.size ?? 0) > 0
753
+ ? "grant selected playlistz"
754
+ : "grant all playlistz"
755
+ }
756
+ >
757
+ {acceptingKnockId() === knock.id
758
+ ? "accepting..."
759
+ : `accept${(grantSelection()[knock.id]?.size ?? 0) > 0 ? ` (${grantSelection()[knock.id]!.size})` : " (all)"}`}
760
+ </button>
761
+ <button
762
+ onClick={() => void handleDeny(knock)}
763
+ disabled={!!acceptingKnockId()}
764
+ class="flex-1 px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-400 hover:text-white hover:bg-white/5 disabled:opacity-50 text-xs transition-colors"
765
+ >
766
+ deny
767
+ </button>
768
+ </div>
769
+ </div>
770
+ )}
771
+ </For>
772
+ </div>
773
+ </Show>
774
+
775
+ {/* outbound pending knocks - playlists we've requested access to */}
776
+ <Show when={outboundKnocks().some((k) => k.status === "pending")}>
777
+ <div>
778
+ <label class="block text-xs mb-1">
779
+ <span class="bg-black px-1 text-gray-400">
780
+ waiting for access
781
+ <span class="ml-2 text-yellow-400">
782
+ ({outboundKnocks().filter((k) => k.status === "pending").length}{" "}
783
+ pending)
784
+ </span>
785
+ </span>
786
+ </label>
787
+ <For each={outboundKnocks().filter((k) => k.status === "pending")}>
788
+ {(knock) => {
789
+ const handleRetry = async () => {
790
+ if (retryingKnockId()) return;
791
+ setRetryingKnockId(knock.id);
792
+ setRetryStatusMap((m) => ({ ...m, [knock.id]: "" }));
793
+ try {
794
+ let result: { status: string };
795
+ if (
796
+ knock.knockType === "doc_access" &&
797
+ knock.requestedDocId
798
+ ) {
799
+ result = await knockForDocAccess(
800
+ knock.nodeId,
801
+ knock.requestedDocId,
802
+ knock.message
803
+ );
804
+ } else {
805
+ result = await knockOnPeer(
806
+ knock.nodeId,
807
+ knock.message || undefined
808
+ );
809
+ }
810
+ if (result.status === "accepted") {
811
+ setRetryStatusMap((m) => ({
812
+ ...m,
813
+ [knock.id]: "access granted!",
814
+ }));
815
+ await refreshKnocks();
816
+ if (knock.requestedDocId)
817
+ props.onPlaylistAdded?.(knock.requestedDocId);
818
+ } else if (result.status === "denied") {
819
+ setRetryStatusMap((m) => ({
820
+ ...m,
821
+ [knock.id]: "access denied",
822
+ }));
823
+ await refreshKnocks();
824
+ } else {
825
+ setRetryStatusMap((m) => ({
826
+ ...m,
827
+ [knock.id]: "still pending",
828
+ }));
829
+ }
830
+ } catch (err) {
831
+ setRetryStatusMap((m) => ({
832
+ ...m,
833
+ [knock.id]:
834
+ err instanceof Error ? err.message : "retry failed",
835
+ }));
836
+ } finally {
837
+ setRetryingKnockId(null);
838
+ }
839
+ };
840
+ return (
841
+ <div class="border border-gray-700 border-dashed p-3 mb-2 text-sm">
842
+ <div class="mb-1">
843
+ <span class="text-gray-300 bg-black/80 px-1 text-xs">
844
+ {knock.nodeId.slice(0, 20)}...
845
+ </span>
846
+ <span class="text-xs ml-2 text-yellow-400">waiting</span>
847
+ </div>
848
+ <Show when={retryStatusMap()[knock.id]}>
849
+ <p class="text-xs text-magenta-400 mb-1 bg-black/80 px-1">
850
+ {retryStatusMap()[knock.id]}
851
+ </p>
852
+ </Show>
853
+ <button
854
+ onClick={() => void handleRetry()}
855
+ disabled={retryingKnockId() === knock.id}
856
+ class="w-full px-3 py-1 border border-gray-600 hover:border-gray-400 text-gray-300 hover:text-white hover:bg-white/5 disabled:opacity-50 text-xs transition-colors"
857
+ >
858
+ {retryingKnockId() === knock.id
859
+ ? "checking..."
860
+ : "check if accepted"}
861
+ </button>
862
+ </div>
863
+ );
864
+ }}
865
+ </For>
866
+ </div>
867
+ </Show>
868
+
869
+ {/* granted peers - existing access grants with edit/revoke */}
870
+ <Show when={grants().length > 0}>
871
+ <div>
872
+ <label class="block text-xs mb-1">
873
+ <span class="bg-black px-1 text-gray-400">
874
+ granted peers ({grants().length})
875
+ </span>
876
+ </label>
877
+ <For each={grants()}>
878
+ {(grant) => {
879
+ const isEditing = () => editingGrantId() === grant.nodeId;
880
+ const currentSelection = () =>
881
+ grantEditSelection()[grant.nodeId] ??
882
+ new Set(grant.docIds ?? props.playlists.map((p) => p.id));
883
+ const startEdit = () => {
884
+ setGrantEditSelection((s) => ({
885
+ ...s,
886
+ [grant.nodeId]: new Set(
887
+ grant.docIds ?? props.playlists.map((p) => p.id)
888
+ ),
889
+ }));
890
+ setEditingGrantId(grant.nodeId);
891
+ };
892
+ const handleSaveGrant = async () => {
893
+ await upsertAccessGrant({
894
+ ...grant,
895
+ docIds: [...currentSelection()],
896
+ });
897
+ setEditingGrantId(null);
898
+ setGrants(await getAllAccessGrants());
899
+ };
900
+ const handleRevokeGrant = async () => {
901
+ await deleteAccessGrant(grant.nodeId);
902
+ setGrants(await getAllAccessGrants());
903
+ };
904
+ return (
905
+ <div class="border border-gray-700 p-3 mb-2 text-xs">
906
+ <div class="flex items-center justify-between mb-1">
907
+ <div class="flex items-center gap-2">
908
+ {/* avatar circle with online indicator dot */}
909
+ <div class="relative w-7 h-7 shrink-0">
910
+ <div class="w-7 h-7 rounded-full overflow-hidden bg-gray-700 flex items-center justify-center text-xs font-bold text-white">
911
+ <Show
912
+ when={grant.avatarDataUrl}
913
+ fallback={
914
+ <span>
915
+ {(grant.name || "?")[0]?.toUpperCase()}
916
+ </span>
917
+ }
918
+ >
919
+ <img
920
+ src={grant.avatarDataUrl}
921
+ alt={grant.name}
922
+ class="w-full h-full object-cover"
923
+ />
924
+ </Show>
925
+ </div>
926
+ {/* online status dot */}
927
+ <span
928
+ class={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-gray-900 ${isPeerOnline(grant.nodeId) ? "bg-green-400" : "bg-gray-500"}`}
929
+ title={
930
+ isPeerOnline(grant.nodeId) ? "online" : "offline"
931
+ }
932
+ />
933
+ </div>
934
+ <div>
935
+ <span class="text-gray-200 bg-black/80 px-1">
936
+ {grant.name || "anonymous"}
937
+ </span>
938
+ <span class="text-gray-600 ml-2">
939
+ {grant.nodeId.slice(0, 12)}...
940
+ </span>
941
+ </div>
942
+ </div>
943
+ <div class="flex gap-2">
944
+ <button
945
+ type="button"
946
+ class="text-gray-400 hover:text-white"
947
+ onClick={() =>
948
+ isEditing() ? setEditingGrantId(null) : startEdit()
949
+ }
950
+ >
951
+ {isEditing() ? "close" : "edit"}
952
+ </button>
953
+ <button
954
+ type="button"
955
+ class="text-red-500 hover:text-red-400"
956
+ onClick={() => void handleRevokeGrant()}
957
+ >
958
+ revoke
959
+ </button>
960
+ </div>
961
+ </div>
962
+ <Show when={isEditing()}>
963
+ <div class="mt-2 space-y-1">
964
+ <div class="flex items-center justify-between text-gray-500 mb-1">
965
+ <span>access to:</span>
966
+ <button
967
+ type="button"
968
+ class="text-magenta-400 hover:text-magenta-300"
969
+ onClick={() =>
970
+ setGrantEditSelection((s) => ({
971
+ ...s,
972
+ [grant.nodeId]: new Set(
973
+ props.playlists.map((p) => p.id)
974
+ ),
975
+ }))
976
+ }
977
+ >
978
+ select all
979
+ </button>
980
+ </div>
981
+ <div class="max-h-24 overflow-y-auto">
982
+ <For each={props.playlists}>
983
+ {(pl) => (
984
+ <label class="flex items-center gap-2 text-gray-300 py-0.5 cursor-pointer">
985
+ <input
986
+ type="checkbox"
987
+ checked={currentSelection().has(pl.id)}
988
+ onChange={() =>
989
+ setGrantEditSelection((s) => {
990
+ const next = new Set(
991
+ s[grant.nodeId] ?? currentSelection()
992
+ );
993
+ if (next.has(pl.id)) next.delete(pl.id);
994
+ else next.add(pl.id);
995
+ return { ...s, [grant.nodeId]: next };
996
+ })
997
+ }
998
+ />
999
+ <span class="bg-black/80 px-1">{pl.title}</span>
1000
+ </label>
1001
+ )}
1002
+ </For>
1003
+ </div>
1004
+ <button
1005
+ class="w-full mt-1 px-3 py-1 bg-magenta-500 hover:bg-magenta-600 text-white text-xs"
1006
+ onClick={() => void handleSaveGrant()}
1007
+ >
1008
+ save
1009
+ </button>
1010
+ </div>
1011
+ </Show>
1012
+ </div>
1013
+ );
1014
+ }}
1015
+ </For>
1016
+ </div>
1017
+ </Show>
1018
+ </div>
1019
+ );
1020
+ }