@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,584 @@
1
+ // p2p sharing panel: endpoint setup, node status, share link paste,
2
+ // and the knock inbox. opened from the sidebar header.
3
+ import { createSignal, createEffect, onCleanup, Show, For } from "solid-js";
4
+ import {
5
+ getShareSettings,
6
+ saveShareSettings,
7
+ ensureSharingReady,
8
+ openShareLink,
9
+ getInboundKnocks,
10
+ acceptKnock,
11
+ denyKnock,
12
+ onKnocksChanged,
13
+ queryPeerPlaylists,
14
+ knockOnPeer,
15
+ type ShareSettings,
16
+ type PeerPlaylistListing,
17
+ } from "../services/sharingService.js";
18
+ import {
19
+ getIdentity,
20
+ isLeader,
21
+ onLeadershipChange,
22
+ onIdentityChange,
23
+ } from "../services/p2pService.js";
24
+ import { getIrohAdapter } from "../services/automergeRepo.js";
25
+ import type { KnockRecord } from "../services/indexedDBService.js";
26
+ import type { Playlist } from "../types/playlist.js";
27
+
28
+ interface SharePanelProps {
29
+ isOpen: boolean;
30
+ onClose: () => void;
31
+ playlists: Playlist[];
32
+ onPlaylistAdded?: (docId: string) => void;
33
+ }
34
+
35
+ export function SharePanel(props: SharePanelProps) {
36
+ const [settings, setSettings] = createSignal<ShareSettings>({
37
+ name: "",
38
+ mode: "knock",
39
+ });
40
+ const [nodeId, setNodeId] = createSignal<string>("");
41
+ const [leader, setLeader] = createSignal(false);
42
+ const [p2pEnabled, setP2pEnabled] = createSignal(false);
43
+ const [starting, setStarting] = createSignal(false);
44
+ const [connSummary, setConnSummary] = createSignal({
45
+ connected: 0,
46
+ reconnecting: 0,
47
+ failed: 0,
48
+ });
49
+ const [pasteValue, setPasteValue] = createSignal("");
50
+ const [pasteStatus, setPasteStatus] = createSignal<string | null>(null);
51
+ const [knocks, setKnocks] = createSignal<KnockRecord[]>([]);
52
+ const [grantSelection, setGrantSelection] = createSignal<
53
+ Record<string, Set<string>>
54
+ >({});
55
+ const [error, setError] = createSignal<string | null>(null);
56
+ const [copied, setCopied] = createSignal(false);
57
+ // browse a remote peer
58
+ const [browseNodeId, setBrowseNodeId] = createSignal("");
59
+ const [browseResult, setBrowseResult] =
60
+ createSignal<PeerPlaylistListing | null>(null);
61
+ const [browseStatus, setBrowseStatus] = createSignal<string | null>(null);
62
+
63
+ let unsubKnocks: (() => void) | null = null;
64
+ let unsubLeader: (() => void) | null = null;
65
+ let unsubIdentity: (() => void) | null = null;
66
+ let connTimer: ReturnType<typeof setInterval> | null = null;
67
+
68
+ async function refreshKnocks() {
69
+ setKnocks(await getInboundKnocks());
70
+ }
71
+
72
+ function refreshConnSummary() {
73
+ try {
74
+ setConnSummary(getIrohAdapter().getConnectionSummary());
75
+ } catch {
76
+ // repo not constructed yet
77
+ }
78
+ }
79
+
80
+ createEffect(() => {
81
+ if (!props.isOpen) return;
82
+
83
+ void (async () => {
84
+ setSettings(await getShareSettings());
85
+ await refreshKnocks();
86
+ const identity = getIdentity();
87
+ if (identity?.node_id) {
88
+ setNodeId(identity.node_id);
89
+ setP2pEnabled(true);
90
+ }
91
+ setLeader(isLeader());
92
+ refreshConnSummary();
93
+ })();
94
+
95
+ unsubKnocks = onKnocksChanged(() => void refreshKnocks());
96
+ unsubLeader = onLeadershipChange((l) => setLeader(l));
97
+ unsubIdentity = onIdentityChange((identity) => {
98
+ if (identity?.node_id) setNodeId(identity.node_id);
99
+ });
100
+ connTimer = setInterval(refreshConnSummary, 3000);
101
+
102
+ onCleanup(() => {
103
+ unsubKnocks?.();
104
+ unsubLeader?.();
105
+ unsubIdentity?.();
106
+ if (connTimer) clearInterval(connTimer);
107
+ });
108
+ });
109
+
110
+ const handleEnableP2P = async () => {
111
+ setStarting(true);
112
+ setError(null);
113
+ try {
114
+ await ensureSharingReady();
115
+ setP2pEnabled(true);
116
+ const identity = getIdentity();
117
+ if (identity?.node_id) setNodeId(identity.node_id);
118
+ setLeader(isLeader());
119
+ } catch (err) {
120
+ setError(err instanceof Error ? err.message : "failed to start p2p");
121
+ } finally {
122
+ setStarting(false);
123
+ }
124
+ };
125
+
126
+ const handleSaveSettings = async (update: Partial<ShareSettings>) => {
127
+ const next = { ...settings(), ...update };
128
+ setSettings(next);
129
+ await saveShareSettings(next);
130
+ };
131
+
132
+ const handleCopyNodeId = async () => {
133
+ try {
134
+ await navigator.clipboard.writeText(nodeId());
135
+ setCopied(true);
136
+ setTimeout(() => setCopied(false), 1500);
137
+ } catch {
138
+ // clipboard unavailable
139
+ }
140
+ };
141
+
142
+ const handleOpenLink = async () => {
143
+ const input = pasteValue().trim();
144
+ if (!input) return;
145
+ setPasteStatus("opening...");
146
+ setError(null);
147
+ try {
148
+ const result = await openShareLink(input);
149
+ if (result.status === "knock_required") {
150
+ setPasteStatus(
151
+ "this playlist requires a knock - the owner has enabled 'knock first' mode"
152
+ );
153
+ return;
154
+ }
155
+ setPasteStatus("playlist added!");
156
+ setPasteValue("");
157
+ props.onPlaylistAdded?.(result.docId);
158
+ setTimeout(() => setPasteStatus(null), 2000);
159
+ } catch (err) {
160
+ setPasteStatus(null);
161
+ setError(
162
+ err instanceof Error ? err.message : "could not open share link"
163
+ );
164
+ }
165
+ };
166
+
167
+ const handleBrowsePeer = async () => {
168
+ const target = browseNodeId().trim();
169
+ if (!target) return;
170
+ setBrowseStatus("connecting...");
171
+ setBrowseResult(null);
172
+ setError(null);
173
+ try {
174
+ const listing = await queryPeerPlaylists(target);
175
+ setBrowseResult(listing);
176
+ setBrowseStatus(null);
177
+ } catch (err) {
178
+ setBrowseStatus(null);
179
+ setError(err instanceof Error ? err.message : "could not reach peer");
180
+ }
181
+ };
182
+
183
+ const handleKnock = async () => {
184
+ const target = browseNodeId().trim();
185
+ if (!target) return;
186
+ setBrowseStatus("knocking...");
187
+ setError(null);
188
+ try {
189
+ const result = await knockOnPeer(target);
190
+ if (result.status === "accepted") {
191
+ setBrowseStatus(`accepted! ${result.docIds.length} playlistz shared`);
192
+ if (result.docIds.length > 0) {
193
+ props.onPlaylistAdded?.(result.docIds[0]!);
194
+ }
195
+ } else if (result.status === "pending") {
196
+ setBrowseStatus("knock sent - waiting for them to accept");
197
+ } else {
198
+ setBrowseStatus("knock denied");
199
+ }
200
+ } catch (err) {
201
+ setBrowseStatus(null);
202
+ setError(err instanceof Error ? err.message : "knock failed");
203
+ }
204
+ };
205
+
206
+ const toggleGrantDoc = (knockId: string, docId: string) => {
207
+ setGrantSelection((prev) => {
208
+ const next = { ...prev };
209
+ const set = new Set(next[knockId] ?? []);
210
+ if (set.has(docId)) {
211
+ set.delete(docId);
212
+ } else {
213
+ set.add(docId);
214
+ }
215
+ next[knockId] = set;
216
+ return next;
217
+ });
218
+ };
219
+
220
+ const handleAccept = async (knock: KnockRecord) => {
221
+ const selected = grantSelection()[knock.id];
222
+ const docIds =
223
+ selected && selected.size > 0
224
+ ? Array.from(selected)
225
+ : props.playlists.map((p) => p.id);
226
+ setError(null);
227
+ try {
228
+ await acceptKnock(knock.id, docIds);
229
+ await refreshKnocks();
230
+ } catch (err) {
231
+ setError(err instanceof Error ? err.message : "accept failed");
232
+ }
233
+ };
234
+
235
+ const handleDeny = async (knock: KnockRecord) => {
236
+ await denyKnock(knock.id);
237
+ await refreshKnocks();
238
+ };
239
+
240
+ const pendingKnocks = () => knocks().filter((k) => k.status === "pending");
241
+
242
+ return (
243
+ <Show when={props.isOpen}>
244
+ <div
245
+ class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4"
246
+ onClick={(e) => {
247
+ if (e.target === e.currentTarget) props.onClose();
248
+ }}
249
+ >
250
+ <div class="bg-black border border-magenta-500 w-full max-w-lg max-h-[85vh] overflow-y-auto p-6 font-mono text-white">
251
+ <div class="flex items-center justify-between mb-4">
252
+ <h2 class="text-xl font-bold">
253
+ share<span class="text-magenta-500">z</span>
254
+ </h2>
255
+ <button
256
+ onClick={props.onClose}
257
+ title="close share panel"
258
+ class="text-gray-400 hover:text-white"
259
+ >
260
+ <svg
261
+ class="w-6 h-6"
262
+ fill="none"
263
+ stroke="currentColor"
264
+ viewBox="0 0 24 24"
265
+ >
266
+ <path
267
+ stroke-linecap="round"
268
+ stroke-linejoin="round"
269
+ stroke-width="2"
270
+ d="M6 18L18 6M6 6l12 12"
271
+ />
272
+ </svg>
273
+ </button>
274
+ </div>
275
+
276
+ <Show when={error()}>
277
+ <div class="mb-4 p-2 border border-red-500 text-red-400 text-sm">
278
+ {error()}
279
+ </div>
280
+ </Show>
281
+
282
+ {/* p2p node status */}
283
+ <div class="mb-6">
284
+ <Show
285
+ when={p2pEnabled()}
286
+ fallback={
287
+ <button
288
+ onClick={handleEnableP2P}
289
+ disabled={starting()}
290
+ class="w-full px-4 py-3 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium"
291
+ >
292
+ {starting() ? "starting p2p node..." : "enable p2p sharing"}
293
+ </button>
294
+ }
295
+ >
296
+ <div class="text-sm space-y-2">
297
+ <div
298
+ class="flex items-center gap-2"
299
+ title={
300
+ leader()
301
+ ? "this tab runs the p2p node"
302
+ : "another tab holds the p2p node"
303
+ }
304
+ >
305
+ <span
306
+ class={`inline-block w-2 h-2 rounded-full ${leader() ? "bg-green-500" : "bg-yellow-500"}`}
307
+ />
308
+ <span class="text-gray-300">online</span>
309
+ </div>
310
+ <Show when={nodeId()}>
311
+ <div class="flex items-center gap-2">
312
+ <span class="text-gray-500">node id:</span>
313
+ <code class="text-xs text-magenta-400 truncate flex-1">
314
+ {nodeId()}
315
+ </code>
316
+ <button
317
+ onClick={handleCopyNodeId}
318
+ title="copy node id"
319
+ class="text-gray-400 hover:text-white text-xs border border-gray-600 px-2 py-1"
320
+ >
321
+ {copied() ? "copied!" : "copy"}
322
+ </button>
323
+ </div>
324
+ </Show>
325
+ <div class="text-gray-500 text-xs">
326
+ peers: {connSummary().connected} connected
327
+ <Show when={connSummary().reconnecting > 0}>
328
+ , {connSummary().reconnecting} reconnecting
329
+ </Show>
330
+ <Show when={connSummary().failed > 0}>
331
+ , {connSummary().failed} failed
332
+ </Show>
333
+ </div>
334
+ </div>
335
+ </Show>
336
+ </div>
337
+
338
+ {/* endpoint settings */}
339
+ <div class="mb-6 space-y-3">
340
+ <div>
341
+ <label class="block text-sm text-gray-400 mb-1">
342
+ display name
343
+ </label>
344
+ <input
345
+ type="text"
346
+ value={settings().name}
347
+ placeholder="anonymous"
348
+ onChange={(e) =>
349
+ void handleSaveSettings({ name: e.currentTarget.value })
350
+ }
351
+ class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
352
+ />
353
+ </div>
354
+ <div>
355
+ <label class="block text-sm text-gray-400 mb-1">
356
+ who can browse my playlistz?
357
+ </label>
358
+ <div class="flex gap-2">
359
+ <button
360
+ onClick={() => void handleSaveSettings({ mode: "public" })}
361
+ class={`flex-1 px-3 py-2 text-sm border ${settings().mode === "public" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400"}`}
362
+ >
363
+ anyone (public)
364
+ </button>
365
+ <button
366
+ onClick={() => void handleSaveSettings({ mode: "knock" })}
367
+ class={`flex-1 px-3 py-2 text-sm border ${settings().mode === "knock" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400"}`}
368
+ >
369
+ knock first
370
+ </button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ {/* open a share link */}
376
+ <div class="mb-6">
377
+ <label class="block text-sm text-gray-400 mb-1">
378
+ open a share link
379
+ </label>
380
+ <div class="flex flex-col gap-2">
381
+ <input
382
+ type="text"
383
+ value={pasteValue()}
384
+ placeholder="paste share link or token..."
385
+ onInput={(e) => setPasteValue(e.currentTarget.value)}
386
+ onKeyDown={(e) => {
387
+ if (e.key === "Enter") void handleOpenLink();
388
+ }}
389
+ class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
390
+ />
391
+ <div class="flex gap-2">
392
+ <button
393
+ onClick={() => void handleOpenLink()}
394
+ class="flex-1 px-4 py-2 bg-magenta-500 hover:bg-magenta-600 text-white text-sm"
395
+ >
396
+ open
397
+ </button>
398
+ </div>
399
+ </div>
400
+ <Show when={pasteStatus()}>
401
+ <div class="mt-1 text-xs text-magenta-400">{pasteStatus()}</div>
402
+ </Show>
403
+ </div>
404
+
405
+ {/* browse a peer */}
406
+ <div class="mb-6">
407
+ <label class="block text-sm text-gray-400 mb-1">
408
+ browse a peer's playlistz
409
+ </label>
410
+ <div class="flex flex-col gap-2">
411
+ <input
412
+ type="text"
413
+ value={browseNodeId()}
414
+ placeholder="peer node id..."
415
+ onInput={(e) => setBrowseNodeId(e.currentTarget.value)}
416
+ class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
417
+ />
418
+ <div class="flex gap-2">
419
+ <button
420
+ onClick={() => void handleBrowsePeer()}
421
+ class="flex-1 px-3 py-2 border border-magenta-500 text-magenta-400 hover:bg-magenta-500/20 text-sm"
422
+ >
423
+ browse
424
+ </button>
425
+ <button
426
+ onClick={() => void handleKnock()}
427
+ class="flex-1 px-3 py-2 border border-gray-600 text-gray-300 hover:bg-gray-800 text-sm"
428
+ title="ask this peer for access"
429
+ >
430
+ knock
431
+ </button>
432
+ </div>
433
+ </div>
434
+ <Show when={browseStatus()}>
435
+ <div class="mt-1 text-xs text-magenta-400">{browseStatus()}</div>
436
+ </Show>
437
+ <Show when={browseResult()}>
438
+ {(listing) => (
439
+ <div class="mt-2 text-sm">
440
+ <Show
441
+ when={listing().items.length > 0}
442
+ fallback={
443
+ <div class="text-gray-500 text-xs">
444
+ {listing().knockRequired
445
+ ? "this peer requires a knock"
446
+ : "no playlistz shared"}
447
+ </div>
448
+ }
449
+ >
450
+ <For each={listing().items}>
451
+ {(item) => (
452
+ <div class="flex items-center justify-between py-1 border-b border-gray-800">
453
+ <span>
454
+ {item.title}{" "}
455
+ <span class="text-gray-500 text-xs">
456
+ ({item.songCount} songz)
457
+ </span>
458
+ </span>
459
+ <button
460
+ onClick={() =>
461
+ void (async () => {
462
+ try {
463
+ const result = await openShareLink(
464
+ // build a minimal token from the listing
465
+ `#share/${btoa(
466
+ JSON.stringify({
467
+ v: 1,
468
+ n: listing().nodeId,
469
+ d: item.docId,
470
+ t: item.title,
471
+ })
472
+ )
473
+ .replace(/\+/g, "-")
474
+ .replace(/\//g, "_")
475
+ .replace(/=/g, "")}`
476
+ );
477
+ if (result.status === "synced") {
478
+ props.onPlaylistAdded?.(result.docId);
479
+ }
480
+ } catch (err) {
481
+ setError(
482
+ err instanceof Error
483
+ ? err.message
484
+ : "failed to add playlist"
485
+ );
486
+ }
487
+ })()
488
+ }
489
+ class="text-xs text-magenta-400 hover:text-magenta-300 border border-magenta-500 px-2 py-1"
490
+ >
491
+ add
492
+ </button>
493
+ </div>
494
+ )}
495
+ </For>
496
+ </Show>
497
+ </div>
498
+ )}
499
+ </Show>
500
+ </div>
501
+
502
+ {/* knock inbox */}
503
+ <div>
504
+ <label class="block text-sm text-gray-400 mb-1">
505
+ knock inbox
506
+ <Show when={pendingKnocks().length > 0}>
507
+ <span class="ml-2 text-magenta-400">
508
+ ({pendingKnocks().length} pending)
509
+ </span>
510
+ </Show>
511
+ </label>
512
+ <Show
513
+ when={pendingKnocks().length > 0}
514
+ fallback={
515
+ <div class="text-gray-600 text-xs">no pending knockz</div>
516
+ }
517
+ >
518
+ <For each={pendingKnocks()}>
519
+ {(knock) => (
520
+ <div class="border border-gray-700 p-3 mb-2 text-sm">
521
+ <div class="mb-1">
522
+ <span class="text-white">
523
+ {knock.name || "anonymous"}
524
+ </span>
525
+ <span class="text-gray-500 text-xs ml-2">
526
+ {knock.nodeId.slice(0, 16)}...
527
+ </span>
528
+ </div>
529
+ <Show when={knock.message}>
530
+ <div class="text-gray-400 text-xs mb-2">
531
+ "{knock.message}"
532
+ </div>
533
+ </Show>
534
+ <div class="text-xs text-gray-500 mb-2">
535
+ grant access to:
536
+ </div>
537
+ <div class="max-h-24 overflow-y-auto mb-2">
538
+ <For each={props.playlists}>
539
+ {(pl) => (
540
+ <label class="flex items-center gap-2 text-xs text-gray-300 py-0.5 cursor-pointer">
541
+ <input
542
+ type="checkbox"
543
+ checked={
544
+ grantSelection()[knock.id]?.has(pl.id) ?? false
545
+ }
546
+ onChange={() => toggleGrantDoc(knock.id, pl.id)}
547
+ />
548
+ {pl.title}
549
+ </label>
550
+ )}
551
+ </For>
552
+ </div>
553
+ <div class="flex gap-2">
554
+ <button
555
+ onClick={() => void handleAccept(knock)}
556
+ class="flex-1 px-3 py-1 bg-magenta-500 hover:bg-magenta-600 text-white text-xs"
557
+ title={
558
+ (grantSelection()[knock.id]?.size ?? 0) > 0
559
+ ? "grant selected playlistz"
560
+ : "grant all playlistz"
561
+ }
562
+ >
563
+ accept
564
+ {(grantSelection()[knock.id]?.size ?? 0) > 0
565
+ ? ` (${grantSelection()[knock.id]!.size})`
566
+ : " (all)"}
567
+ </button>
568
+ <button
569
+ onClick={() => void handleDeny(knock)}
570
+ class="flex-1 px-3 py-1 border border-gray-600 text-gray-300 hover:bg-gray-800 text-xs"
571
+ >
572
+ deny
573
+ </button>
574
+ </div>
575
+ </div>
576
+ )}
577
+ </For>
578
+ </Show>
579
+ </div>
580
+ </div>
581
+ </div>
582
+ </Show>
583
+ );
584
+ }