@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
✕
|
|
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
|
+
}
|