@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,803 @@
|
|
|
1
|
+
import { createSignal, createEffect, Show, onMount } from "solid-js";
|
|
2
|
+
import {
|
|
3
|
+
updatePlaylist,
|
|
4
|
+
deletePlaylist,
|
|
5
|
+
setPlaylistCoverImage,
|
|
6
|
+
clearPlaylistCoverImage,
|
|
7
|
+
forkPlaylist,
|
|
8
|
+
} from "../services/playlistDocService.js";
|
|
9
|
+
import {
|
|
10
|
+
processPlaylistCover,
|
|
11
|
+
validateImageFile,
|
|
12
|
+
createImageUrlFromData,
|
|
13
|
+
} from "../services/imageService.js";
|
|
14
|
+
import { ensureSharingReady } from "../services/sharingService.js";
|
|
15
|
+
import { initSharingState } from "../services/sharingState.js";
|
|
16
|
+
import { usePlaylistzManager } from "../context/PlaylistzContext.js";
|
|
17
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
18
|
+
|
|
19
|
+
interface PlaylistEditPanelProps {
|
|
20
|
+
playlist: Playlist;
|
|
21
|
+
playlistSongs: Song[];
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
onSave: (updatedPlaylist: Playlist) => void;
|
|
24
|
+
onDelete?: () => void;
|
|
25
|
+
onFork?: (newDocId: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PlaylistEditPanel(props: PlaylistEditPanelProps) {
|
|
29
|
+
const playlistManager = usePlaylistzManager();
|
|
30
|
+
const { backgroundSource, setBackgroundOverride } = playlistManager;
|
|
31
|
+
const { handleDownloadPlaylist, isDownloading } = playlistManager;
|
|
32
|
+
|
|
33
|
+
const [selectedImageUrl, setSelectedImageUrl] = createSignal<
|
|
34
|
+
string | undefined
|
|
35
|
+
>();
|
|
36
|
+
const [isLoading, setIsLoading] = createSignal(false);
|
|
37
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
38
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
|
39
|
+
|
|
40
|
+
onMount(() => {
|
|
41
|
+
initSharingState();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// update the displayed cover whenever the playlist's image resolves from
|
|
45
|
+
// the blob store. docToPlaylistAsync sets imageFilePath asynchronously after
|
|
46
|
+
// the initial sync, so onMount alone would miss it on a fresh page load.
|
|
47
|
+
createEffect(() => {
|
|
48
|
+
if (props.playlist.imageData && props.playlist.imageType) {
|
|
49
|
+
const displayData =
|
|
50
|
+
props.playlist.thumbnailData || props.playlist.imageData;
|
|
51
|
+
setSelectedImageUrl(
|
|
52
|
+
createImageUrlFromData(displayData, props.playlist.imageType)
|
|
53
|
+
);
|
|
54
|
+
} else if (props.playlist.imageFilePath) {
|
|
55
|
+
setSelectedImageUrl(props.playlist.imageFilePath);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const handleImageUpload = async (event: Event) => {
|
|
60
|
+
const input = event.target as HTMLInputElement;
|
|
61
|
+
const file = input.files?.[0];
|
|
62
|
+
if (!file) return;
|
|
63
|
+
|
|
64
|
+
const validation = validateImageFile(file);
|
|
65
|
+
if (!validation.valid) {
|
|
66
|
+
setError(validation.error || "invalid image file");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
setIsLoading(true);
|
|
72
|
+
setError(null);
|
|
73
|
+
|
|
74
|
+
const result = await processPlaylistCover(file);
|
|
75
|
+
|
|
76
|
+
if (result.success && result.thumbnailData && result.imageData) {
|
|
77
|
+
const prevUrl = selectedImageUrl();
|
|
78
|
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
|
79
|
+
|
|
80
|
+
setSelectedImageUrl(
|
|
81
|
+
createImageUrlFromData(result.thumbnailData, file.type)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// immediately persist - no save button needed
|
|
85
|
+
const updates = {
|
|
86
|
+
imageData: result.imageData,
|
|
87
|
+
thumbnailData: result.thumbnailData,
|
|
88
|
+
imageType: file.type,
|
|
89
|
+
updatedAt: Date.now(),
|
|
90
|
+
};
|
|
91
|
+
await setPlaylistCoverImage(
|
|
92
|
+
props.playlist.id,
|
|
93
|
+
result.imageData,
|
|
94
|
+
file.type
|
|
95
|
+
);
|
|
96
|
+
const { image: _image, ...rest } = props.playlist as Playlist & {
|
|
97
|
+
image?: unknown;
|
|
98
|
+
};
|
|
99
|
+
props.onSave({ ...rest, ...updates });
|
|
100
|
+
} else {
|
|
101
|
+
setError(result.error || "failed to process image");
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setError("error uploading image");
|
|
105
|
+
console.error("image upload error:", err);
|
|
106
|
+
} finally {
|
|
107
|
+
setIsLoading(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleRemoveImage = async () => {
|
|
112
|
+
const url = selectedImageUrl();
|
|
113
|
+
if (url) URL.revokeObjectURL(url);
|
|
114
|
+
setSelectedImageUrl(undefined);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
setIsLoading(true);
|
|
118
|
+
setError(null);
|
|
119
|
+
const updates = {
|
|
120
|
+
imageData: undefined,
|
|
121
|
+
thumbnailData: undefined,
|
|
122
|
+
imageType: undefined,
|
|
123
|
+
updatedAt: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
await clearPlaylistCoverImage(props.playlist.id);
|
|
126
|
+
const { image: _image, ...rest } = props.playlist as Playlist & {
|
|
127
|
+
image?: unknown;
|
|
128
|
+
};
|
|
129
|
+
props.onSave({ ...rest, ...updates });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
setError("failed to remove image");
|
|
132
|
+
} finally {
|
|
133
|
+
setIsLoading(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleDeletePlaylist = async () => {
|
|
138
|
+
try {
|
|
139
|
+
setIsLoading(true);
|
|
140
|
+
setError(null);
|
|
141
|
+
await deletePlaylist(props.playlist.id);
|
|
142
|
+
setShowDeleteConfirm(false);
|
|
143
|
+
props.onDelete?.();
|
|
144
|
+
props.onClose();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
setError("failed to delete playlist");
|
|
147
|
+
console.error("delete error:", err);
|
|
148
|
+
} finally {
|
|
149
|
+
setIsLoading(false);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// fork/collaborate state (only relevant for subscribed remote playlists)
|
|
154
|
+
const [isForkingOrCollab, setIsForkingOrCollab] = createSignal(false);
|
|
155
|
+
const [forkCollabStatus, setForkCollabStatus] = createSignal<string | null>(
|
|
156
|
+
null
|
|
157
|
+
);
|
|
158
|
+
const [collabMessage, setCollabMessage] = createSignal("");
|
|
159
|
+
|
|
160
|
+
const isSubscribed = () =>
|
|
161
|
+
!!props.playlist.remoteNodeId && !props.playlist.isForked;
|
|
162
|
+
|
|
163
|
+
const handleFork = async () => {
|
|
164
|
+
try {
|
|
165
|
+
setIsForkingOrCollab(true);
|
|
166
|
+
setForkCollabStatus(null);
|
|
167
|
+
const forked = await forkPlaylist(props.playlist.id);
|
|
168
|
+
props.onFork?.(forked.id);
|
|
169
|
+
props.onClose();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
setForkCollabStatus("fork failed");
|
|
172
|
+
console.error("fork error:", err);
|
|
173
|
+
} finally {
|
|
174
|
+
setIsForkingOrCollab(false);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const handleRequestCollaboration = async () => {
|
|
179
|
+
const nodeId = props.playlist.remoteNodeId;
|
|
180
|
+
if (!nodeId) return;
|
|
181
|
+
try {
|
|
182
|
+
setIsForkingOrCollab(true);
|
|
183
|
+
setForkCollabStatus(null);
|
|
184
|
+
await ensureSharingReady();
|
|
185
|
+
const { knockForDocAccess } = await import(
|
|
186
|
+
"../services/sharingService.js"
|
|
187
|
+
);
|
|
188
|
+
const result = await knockForDocAccess(
|
|
189
|
+
nodeId,
|
|
190
|
+
props.playlist.id,
|
|
191
|
+
collabMessage() ||
|
|
192
|
+
"requesting collaboration access to: " +
|
|
193
|
+
(props.playlist.title || "playlist"),
|
|
194
|
+
props.playlist.title
|
|
195
|
+
);
|
|
196
|
+
if (result.status === "accepted") {
|
|
197
|
+
setForkCollabStatus("access granted - you can now collaborate");
|
|
198
|
+
} else if (result.status === "pending") {
|
|
199
|
+
setForkCollabStatus("knock sent - waiting for owner to accept");
|
|
200
|
+
} else {
|
|
201
|
+
setForkCollabStatus("request denied by owner");
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
setForkCollabStatus("knock failed");
|
|
205
|
+
console.error("collab knock error:", err);
|
|
206
|
+
} finally {
|
|
207
|
+
setIsForkingOrCollab(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const songsWithArt = () =>
|
|
212
|
+
props.playlistSongs.filter((s) => s.imageType || s.imageFilePath);
|
|
213
|
+
|
|
214
|
+
// filter settings - initialise from playlist props
|
|
215
|
+
const [bgEnabled, setBgEnabled] = createSignal(
|
|
216
|
+
props.playlist.bgFilterEnabled ?? true
|
|
217
|
+
);
|
|
218
|
+
const [bgBlur, setBgBlur] = createSignal(props.playlist.bgFilterBlur ?? 3);
|
|
219
|
+
const [bgContrast, setBgContrast] = createSignal(
|
|
220
|
+
props.playlist.bgFilterContrast ?? 3
|
|
221
|
+
);
|
|
222
|
+
const [bgBrightness, setBgBrightness] = createSignal(
|
|
223
|
+
props.playlist.bgFilterBrightness ?? 0.4
|
|
224
|
+
);
|
|
225
|
+
const [coverEnabled, setCoverEnabled] = createSignal(
|
|
226
|
+
props.playlist.coverFilterEnabled ?? true
|
|
227
|
+
);
|
|
228
|
+
const [coverBlur, setCoverBlur] = createSignal(
|
|
229
|
+
props.playlist.coverFilterBlur ?? 3
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// background image layout settings
|
|
233
|
+
const [bgSize, setBgSize] = createSignal(props.playlist.bgSize ?? "cover");
|
|
234
|
+
const [bgPosition, setBgPosition] = createSignal(
|
|
235
|
+
props.playlist.bgPosition ?? "top"
|
|
236
|
+
);
|
|
237
|
+
const [bgRepeat, setBgRepeat] = createSignal(
|
|
238
|
+
props.playlist.bgRepeat ?? "no-repeat"
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const BG_SIZE_OPTIONS = [
|
|
242
|
+
"cover",
|
|
243
|
+
"contain",
|
|
244
|
+
"auto",
|
|
245
|
+
"100% 100%",
|
|
246
|
+
"50%",
|
|
247
|
+
] as const;
|
|
248
|
+
const BG_POSITION_OPTIONS = [
|
|
249
|
+
"top",
|
|
250
|
+
"center",
|
|
251
|
+
"bottom",
|
|
252
|
+
"left",
|
|
253
|
+
"right",
|
|
254
|
+
"left top",
|
|
255
|
+
"right top",
|
|
256
|
+
] as const;
|
|
257
|
+
const BG_REPEAT_OPTIONS = [
|
|
258
|
+
"no-repeat",
|
|
259
|
+
"repeat",
|
|
260
|
+
"repeat-x",
|
|
261
|
+
"repeat-y",
|
|
262
|
+
] as const;
|
|
263
|
+
|
|
264
|
+
const saveFilterUpdates = async (updates: Partial<typeof props.playlist>) => {
|
|
265
|
+
try {
|
|
266
|
+
await updatePlaylist(props.playlist.id, {
|
|
267
|
+
bgFilterEnabled: updates.bgFilterEnabled,
|
|
268
|
+
bgFilterBlur: updates.bgFilterBlur,
|
|
269
|
+
bgFilterContrast: updates.bgFilterContrast,
|
|
270
|
+
bgFilterBrightness: updates.bgFilterBrightness,
|
|
271
|
+
coverFilterEnabled: updates.coverFilterEnabled,
|
|
272
|
+
coverFilterBlur: updates.coverFilterBlur,
|
|
273
|
+
bgSize: updates.bgSize,
|
|
274
|
+
bgPosition: updates.bgPosition,
|
|
275
|
+
bgRepeat: updates.bgRepeat,
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
setError("failed to save filter settings");
|
|
279
|
+
console.error("filter save error:", err);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// updates the live preview immediately (no IDB write)
|
|
284
|
+
const previewFilter = (updates: Partial<typeof props.playlist>) => {
|
|
285
|
+
const { image: _image, ...rest } =
|
|
286
|
+
props.playlist as typeof props.playlist & { image?: unknown };
|
|
287
|
+
props.onSave({ ...rest, ...updates, updatedAt: Date.now() });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const resetBgFilter = () => {
|
|
291
|
+
setBgEnabled(true);
|
|
292
|
+
setBgBlur(3);
|
|
293
|
+
setBgContrast(3);
|
|
294
|
+
setBgBrightness(0.4);
|
|
295
|
+
const defaults = {
|
|
296
|
+
bgFilterEnabled: true,
|
|
297
|
+
bgFilterBlur: 3,
|
|
298
|
+
bgFilterContrast: 3,
|
|
299
|
+
bgFilterBrightness: 0.4,
|
|
300
|
+
};
|
|
301
|
+
previewFilter(defaults);
|
|
302
|
+
saveFilterUpdates(defaults);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const resetCoverFilter = () => {
|
|
306
|
+
setCoverEnabled(true);
|
|
307
|
+
setCoverBlur(3);
|
|
308
|
+
const defaults = { coverFilterEnabled: true, coverFilterBlur: 3 };
|
|
309
|
+
previewFilter(defaults);
|
|
310
|
+
saveFilterUpdates(defaults);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div data-testid="edit-panel" class="bg-black/40 overflow-hidden min-w-0">
|
|
315
|
+
{/* on sm+: form controls left (clamped to 500px), image right.
|
|
316
|
+
justify-between pushes the columns apart so spare width sits in the
|
|
317
|
+
middle. order utilities keep the image on top for the mobile column.
|
|
318
|
+
on lg+ a third share column appears on the right; below lg the share
|
|
319
|
+
section spans the full width under the other two columns */}
|
|
320
|
+
<div class="p-4 border-none grid grid-cols-1 min-w-0 sm:grid-cols-[minmax(0,500px)_min(40%,24rem)] sm:justify-between gap-6">
|
|
321
|
+
{/* image + upload buttons */}
|
|
322
|
+
<div class="flex flex-col gap-3 min-w-0 sm:order-2">
|
|
323
|
+
{/* image sizes naturally at its own aspect ratio (no gray bars);
|
|
324
|
+
the gray square only shows as the no-image fallback */}
|
|
325
|
+
<Show
|
|
326
|
+
when={selectedImageUrl()}
|
|
327
|
+
fallback={
|
|
328
|
+
<div class="w-full aspect-square bg-gray-700 flex items-center justify-center">
|
|
329
|
+
<svg
|
|
330
|
+
class="w-8 h-8 text-gray-400"
|
|
331
|
+
fill="none"
|
|
332
|
+
stroke="currentColor"
|
|
333
|
+
viewBox="0 0 24 24"
|
|
334
|
+
>
|
|
335
|
+
<path
|
|
336
|
+
stroke-linecap="round"
|
|
337
|
+
stroke-linejoin="round"
|
|
338
|
+
stroke-width="2"
|
|
339
|
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
340
|
+
/>
|
|
341
|
+
</svg>
|
|
342
|
+
</div>
|
|
343
|
+
}
|
|
344
|
+
>
|
|
345
|
+
{/* hovering reveals a bottom strip; clicking anywhere on the image
|
|
346
|
+
sets the page background to this cover (only when the background
|
|
347
|
+
is currently something else, e.g. the song being edited) */}
|
|
348
|
+
<Show
|
|
349
|
+
when={
|
|
350
|
+
props.playlist.imageType &&
|
|
351
|
+
backgroundSource() !== `playlist-${props.playlist.id}`
|
|
352
|
+
}
|
|
353
|
+
fallback={
|
|
354
|
+
<img
|
|
355
|
+
src={selectedImageUrl()}
|
|
356
|
+
alt="playlist cover"
|
|
357
|
+
class="w-full h-auto"
|
|
358
|
+
/>
|
|
359
|
+
}
|
|
360
|
+
>
|
|
361
|
+
<button
|
|
362
|
+
data-testid="btn-set-bg-cover"
|
|
363
|
+
onClick={() => setBackgroundOverride("cover")}
|
|
364
|
+
class="relative block w-full group cursor-pointer"
|
|
365
|
+
title="set the page background to this playlist's cover image"
|
|
366
|
+
>
|
|
367
|
+
<img
|
|
368
|
+
src={selectedImageUrl()}
|
|
369
|
+
alt="playlist cover"
|
|
370
|
+
class="w-full h-auto"
|
|
371
|
+
/>
|
|
372
|
+
<span class="absolute bottom-0 inset-x-0 px-3 py-2 bg-black/70 text-white text-sm font-medium text-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
373
|
+
show cover background preview
|
|
374
|
+
</span>
|
|
375
|
+
</button>
|
|
376
|
+
</Show>
|
|
377
|
+
</Show>
|
|
378
|
+
|
|
379
|
+
<input
|
|
380
|
+
type="file"
|
|
381
|
+
accept="image/*"
|
|
382
|
+
onChange={handleImageUpload}
|
|
383
|
+
disabled={isLoading()}
|
|
384
|
+
class="hidden"
|
|
385
|
+
id="cover-upload-panel"
|
|
386
|
+
/>
|
|
387
|
+
{/* mt-auto pushes the buttons to the column bottom so both columns
|
|
388
|
+
end at the same height regardless of image aspect ratio */}
|
|
389
|
+
<label
|
|
390
|
+
for="cover-upload-panel"
|
|
391
|
+
class="mt-auto block w-full px-3 py-1.5 bg-magenta-500 hover:bg-magenta-600 text-white cursor-pointer text-sm font-medium transition-colors text-center"
|
|
392
|
+
>
|
|
393
|
+
upload image
|
|
394
|
+
</label>
|
|
395
|
+
<Show when={selectedImageUrl()}>
|
|
396
|
+
<button
|
|
397
|
+
onClick={handleRemoveImage}
|
|
398
|
+
disabled={isLoading()}
|
|
399
|
+
class="block w-full px-3 py-1.5 bg-red-700 hover:bg-red-800 text-white text-sm font-medium transition-colors text-center"
|
|
400
|
+
>
|
|
401
|
+
remove cover image
|
|
402
|
+
</button>
|
|
403
|
+
</Show>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{/* filter controls + playlist info */}
|
|
407
|
+
<div class="flex flex-col gap-5 min-w-0 sm:order-1">
|
|
408
|
+
{/* subscribed playlist: fork or request collaboration */}
|
|
409
|
+
<Show when={isSubscribed()}>
|
|
410
|
+
<div class="space-y-2 border border-gray-700 p-3">
|
|
411
|
+
<p class="text-xs text-gray-400">
|
|
412
|
+
this is a subscribed playlist from{" "}
|
|
413
|
+
<span class="text-gray-200">
|
|
414
|
+
{props.playlist.remoteName ||
|
|
415
|
+
props.playlist.remoteNodeId?.slice(0, 8) + "..."}
|
|
416
|
+
</span>
|
|
417
|
+
. you can make your own editable copy or request edit access
|
|
418
|
+
from the owner.
|
|
419
|
+
</p>
|
|
420
|
+
<div class="flex gap-2">
|
|
421
|
+
<button
|
|
422
|
+
data-testid="btn-fork-playlist"
|
|
423
|
+
onClick={() => void handleFork()}
|
|
424
|
+
disabled={isForkingOrCollab()}
|
|
425
|
+
class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm transition-colors"
|
|
426
|
+
>
|
|
427
|
+
make my own copy
|
|
428
|
+
</button>
|
|
429
|
+
<button
|
|
430
|
+
data-testid="btn-request-collaboration"
|
|
431
|
+
onClick={() => void handleRequestCollaboration()}
|
|
432
|
+
disabled={isForkingOrCollab()}
|
|
433
|
+
class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm transition-colors"
|
|
434
|
+
>
|
|
435
|
+
request collaboration
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
<textarea
|
|
439
|
+
data-testid="input-collab-message"
|
|
440
|
+
value={collabMessage()}
|
|
441
|
+
onInput={(e) => setCollabMessage(e.currentTarget.value)}
|
|
442
|
+
placeholder="optional message to the owner..."
|
|
443
|
+
rows="2"
|
|
444
|
+
disabled={isForkingOrCollab()}
|
|
445
|
+
class="w-full bg-black text-white text-xs border border-gray-700 px-2 py-1.5 focus:outline-none focus:border-magenta-500 resize-none disabled:opacity-50"
|
|
446
|
+
/>
|
|
447
|
+
<Show when={forkCollabStatus()}>
|
|
448
|
+
<p class="text-xs text-gray-400">{forkCollabStatus()}</p>
|
|
449
|
+
</Show>
|
|
450
|
+
</div>
|
|
451
|
+
</Show>
|
|
452
|
+
|
|
453
|
+
{/* background image filter */}
|
|
454
|
+
<div class="space-y-3">
|
|
455
|
+
<div class="flex items-center gap-2">
|
|
456
|
+
<label class="text-sm font-medium text-gray-300">
|
|
457
|
+
background filter
|
|
458
|
+
</label>
|
|
459
|
+
<input
|
|
460
|
+
type="checkbox"
|
|
461
|
+
checked={bgEnabled()}
|
|
462
|
+
onChange={(e) => {
|
|
463
|
+
const v = e.currentTarget.checked;
|
|
464
|
+
setBgEnabled(v);
|
|
465
|
+
previewFilter({ bgFilterEnabled: v });
|
|
466
|
+
saveFilterUpdates({ bgFilterEnabled: v });
|
|
467
|
+
}}
|
|
468
|
+
class="accent-magenta-500"
|
|
469
|
+
/>
|
|
470
|
+
<button
|
|
471
|
+
onClick={resetBgFilter}
|
|
472
|
+
class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
|
|
473
|
+
>
|
|
474
|
+
reset
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
<div
|
|
478
|
+
class={`space-y-2 ${bgEnabled() ? "" : "opacity-40 pointer-events-none"}`}
|
|
479
|
+
>
|
|
480
|
+
<div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
|
|
481
|
+
<label class="text-xs text-gray-400">blur</label>
|
|
482
|
+
<input
|
|
483
|
+
type="range"
|
|
484
|
+
min="0"
|
|
485
|
+
max="20"
|
|
486
|
+
step="0.5"
|
|
487
|
+
value={bgBlur()}
|
|
488
|
+
onInput={(e) => {
|
|
489
|
+
const v = Number(e.currentTarget.value);
|
|
490
|
+
setBgBlur(v);
|
|
491
|
+
previewFilter({
|
|
492
|
+
bgFilterBlur: v,
|
|
493
|
+
bgFilterEnabled: bgEnabled(),
|
|
494
|
+
bgFilterContrast: bgContrast(),
|
|
495
|
+
bgFilterBrightness: bgBrightness(),
|
|
496
|
+
});
|
|
497
|
+
}}
|
|
498
|
+
onChange={(e) =>
|
|
499
|
+
saveFilterUpdates({
|
|
500
|
+
bgFilterBlur: Number(e.currentTarget.value),
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
class="accent-magenta-500"
|
|
504
|
+
/>
|
|
505
|
+
<span class="text-xs text-gray-400 tabular-nums">
|
|
506
|
+
{bgBlur()}px
|
|
507
|
+
</span>
|
|
508
|
+
</div>
|
|
509
|
+
<div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
|
|
510
|
+
<label class="text-xs text-gray-400">contrast</label>
|
|
511
|
+
<input
|
|
512
|
+
type="range"
|
|
513
|
+
min="0"
|
|
514
|
+
max="10"
|
|
515
|
+
step="0.1"
|
|
516
|
+
value={bgContrast()}
|
|
517
|
+
onInput={(e) => {
|
|
518
|
+
const v = Number(e.currentTarget.value);
|
|
519
|
+
setBgContrast(v);
|
|
520
|
+
previewFilter({
|
|
521
|
+
bgFilterContrast: v,
|
|
522
|
+
bgFilterEnabled: bgEnabled(),
|
|
523
|
+
bgFilterBlur: bgBlur(),
|
|
524
|
+
bgFilterBrightness: bgBrightness(),
|
|
525
|
+
});
|
|
526
|
+
}}
|
|
527
|
+
onChange={(e) =>
|
|
528
|
+
saveFilterUpdates({
|
|
529
|
+
bgFilterContrast: Number(e.currentTarget.value),
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
class="accent-magenta-500"
|
|
533
|
+
/>
|
|
534
|
+
<span class="text-xs text-gray-400 tabular-nums">
|
|
535
|
+
{bgContrast().toFixed(1)}
|
|
536
|
+
</span>
|
|
537
|
+
</div>
|
|
538
|
+
<div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
|
|
539
|
+
<label class="text-xs text-gray-400">brightness</label>
|
|
540
|
+
<input
|
|
541
|
+
type="range"
|
|
542
|
+
min="0"
|
|
543
|
+
max="2"
|
|
544
|
+
step="0.05"
|
|
545
|
+
value={bgBrightness()}
|
|
546
|
+
onInput={(e) => {
|
|
547
|
+
const v = Number(e.currentTarget.value);
|
|
548
|
+
setBgBrightness(v);
|
|
549
|
+
previewFilter({
|
|
550
|
+
bgFilterBrightness: v,
|
|
551
|
+
bgFilterEnabled: bgEnabled(),
|
|
552
|
+
bgFilterBlur: bgBlur(),
|
|
553
|
+
bgFilterContrast: bgContrast(),
|
|
554
|
+
});
|
|
555
|
+
}}
|
|
556
|
+
onChange={(e) =>
|
|
557
|
+
saveFilterUpdates({
|
|
558
|
+
bgFilterBrightness: Number(e.currentTarget.value),
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
class="accent-magenta-500"
|
|
562
|
+
/>
|
|
563
|
+
<span class="text-xs text-gray-400 tabular-nums">
|
|
564
|
+
{bgBrightness().toFixed(2)}
|
|
565
|
+
</span>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{/* cover image filter */}
|
|
571
|
+
<div class="space-y-3">
|
|
572
|
+
<div class="flex items-center gap-2">
|
|
573
|
+
<label class="text-sm font-medium text-gray-300">
|
|
574
|
+
cover blur
|
|
575
|
+
</label>
|
|
576
|
+
<input
|
|
577
|
+
type="checkbox"
|
|
578
|
+
checked={coverEnabled()}
|
|
579
|
+
onChange={(e) => {
|
|
580
|
+
const v = e.currentTarget.checked;
|
|
581
|
+
setCoverEnabled(v);
|
|
582
|
+
previewFilter({ coverFilterEnabled: v });
|
|
583
|
+
saveFilterUpdates({ coverFilterEnabled: v });
|
|
584
|
+
}}
|
|
585
|
+
class="accent-magenta-500"
|
|
586
|
+
/>
|
|
587
|
+
<button
|
|
588
|
+
onClick={resetCoverFilter}
|
|
589
|
+
class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
|
|
590
|
+
>
|
|
591
|
+
reset
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
<div
|
|
595
|
+
class={`grid grid-cols-[5rem_1fr_3rem] items-center gap-2 ${coverEnabled() ? "" : "opacity-40 pointer-events-none"}`}
|
|
596
|
+
>
|
|
597
|
+
<label class="text-xs text-gray-400">blur</label>
|
|
598
|
+
<input
|
|
599
|
+
type="range"
|
|
600
|
+
min="0"
|
|
601
|
+
max="20"
|
|
602
|
+
step="0.5"
|
|
603
|
+
value={coverBlur()}
|
|
604
|
+
onInput={(e) => {
|
|
605
|
+
const v = Number(e.currentTarget.value);
|
|
606
|
+
setCoverBlur(v);
|
|
607
|
+
previewFilter({
|
|
608
|
+
coverFilterBlur: v,
|
|
609
|
+
coverFilterEnabled: coverEnabled(),
|
|
610
|
+
});
|
|
611
|
+
}}
|
|
612
|
+
onChange={(e) =>
|
|
613
|
+
saveFilterUpdates({
|
|
614
|
+
coverFilterBlur: Number(e.currentTarget.value),
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
class="accent-magenta-500"
|
|
618
|
+
/>
|
|
619
|
+
<span class="text-xs text-gray-400 tabular-nums">
|
|
620
|
+
{coverBlur()}px
|
|
621
|
+
</span>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
{/* background image layout */}
|
|
626
|
+
<div class="space-y-3">
|
|
627
|
+
<div class="flex items-center gap-2">
|
|
628
|
+
<label class="text-sm font-medium text-gray-300">
|
|
629
|
+
background image
|
|
630
|
+
</label>
|
|
631
|
+
<button
|
|
632
|
+
onClick={() => {
|
|
633
|
+
setBgSize("cover");
|
|
634
|
+
setBgPosition("top");
|
|
635
|
+
setBgRepeat("no-repeat");
|
|
636
|
+
const defaults = {
|
|
637
|
+
bgSize: "cover",
|
|
638
|
+
bgPosition: "top",
|
|
639
|
+
bgRepeat: "no-repeat",
|
|
640
|
+
};
|
|
641
|
+
previewFilter(defaults);
|
|
642
|
+
void saveFilterUpdates(defaults);
|
|
643
|
+
}}
|
|
644
|
+
class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
|
|
645
|
+
>
|
|
646
|
+
reset
|
|
647
|
+
</button>
|
|
648
|
+
</div>
|
|
649
|
+
<div class="grid grid-cols-[5rem_1fr] items-center gap-2">
|
|
650
|
+
<label class="text-xs text-gray-400">size</label>
|
|
651
|
+
<select
|
|
652
|
+
value={bgSize()}
|
|
653
|
+
onChange={(e) => {
|
|
654
|
+
const v = e.currentTarget.value;
|
|
655
|
+
setBgSize(v);
|
|
656
|
+
previewFilter({ bgSize: v });
|
|
657
|
+
void saveFilterUpdates({ bgSize: v });
|
|
658
|
+
}}
|
|
659
|
+
class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
|
|
660
|
+
>
|
|
661
|
+
{BG_SIZE_OPTIONS.map((o) => (
|
|
662
|
+
<option value={o}>{o}</option>
|
|
663
|
+
))}
|
|
664
|
+
</select>
|
|
665
|
+
</div>
|
|
666
|
+
<div class="grid grid-cols-[5rem_1fr] items-center gap-2">
|
|
667
|
+
<label class="text-xs text-gray-400">position</label>
|
|
668
|
+
<select
|
|
669
|
+
value={bgPosition()}
|
|
670
|
+
onChange={(e) => {
|
|
671
|
+
const v = e.currentTarget.value;
|
|
672
|
+
setBgPosition(v);
|
|
673
|
+
previewFilter({ bgPosition: v });
|
|
674
|
+
void saveFilterUpdates({ bgPosition: v });
|
|
675
|
+
}}
|
|
676
|
+
class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
|
|
677
|
+
>
|
|
678
|
+
{BG_POSITION_OPTIONS.map((o) => (
|
|
679
|
+
<option value={o}>{o}</option>
|
|
680
|
+
))}
|
|
681
|
+
</select>
|
|
682
|
+
</div>
|
|
683
|
+
<div class="grid grid-cols-[5rem_1fr] items-center gap-2">
|
|
684
|
+
<label class="text-xs text-gray-400">repeat</label>
|
|
685
|
+
<select
|
|
686
|
+
value={bgRepeat()}
|
|
687
|
+
onChange={(e) => {
|
|
688
|
+
const v = e.currentTarget.value;
|
|
689
|
+
setBgRepeat(v);
|
|
690
|
+
previewFilter({ bgRepeat: v });
|
|
691
|
+
void saveFilterUpdates({ bgRepeat: v });
|
|
692
|
+
}}
|
|
693
|
+
class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
|
|
694
|
+
>
|
|
695
|
+
{BG_REPEAT_OPTIONS.map((o) => (
|
|
696
|
+
<option value={o}>{o}</option>
|
|
697
|
+
))}
|
|
698
|
+
</select>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
|
|
702
|
+
{/* playlist info - mt-auto pushes this (and everything after) to the
|
|
703
|
+
bottom so the column stretches to match the image column height */}
|
|
704
|
+
<div class="bg-black p-3 text-xs text-gray-400 space-y-1 mt-auto">
|
|
705
|
+
<div>title: {props.playlist.title}</div>
|
|
706
|
+
<div>id: {props.playlist.id}</div>
|
|
707
|
+
<div>rev: {props.playlist.rev || 0}</div>
|
|
708
|
+
<div>songz: {props.playlist.songIds.length}</div>
|
|
709
|
+
<div>with album art: {songsWithArt().length}</div>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
{/* actions: download + delete */}
|
|
713
|
+
<div class="flex flex-col gap-2">
|
|
714
|
+
<Show when={window.location.protocol !== "file:"}>
|
|
715
|
+
<button
|
|
716
|
+
data-testid="btn-download-zip"
|
|
717
|
+
onClick={handleDownloadPlaylist}
|
|
718
|
+
disabled={isDownloading() || isLoading()}
|
|
719
|
+
class="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-400 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
720
|
+
>
|
|
721
|
+
<Show
|
|
722
|
+
when={!isDownloading()}
|
|
723
|
+
fallback={
|
|
724
|
+
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
725
|
+
}
|
|
726
|
+
>
|
|
727
|
+
<svg
|
|
728
|
+
class="w-4 h-4"
|
|
729
|
+
fill="none"
|
|
730
|
+
stroke="currentColor"
|
|
731
|
+
viewBox="0 0 24 24"
|
|
732
|
+
>
|
|
733
|
+
<path
|
|
734
|
+
stroke-linecap="round"
|
|
735
|
+
stroke-linejoin="round"
|
|
736
|
+
stroke-width="2"
|
|
737
|
+
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
738
|
+
/>
|
|
739
|
+
</svg>
|
|
740
|
+
</Show>
|
|
741
|
+
{isDownloading() ? "downloading..." : "download playlist"}
|
|
742
|
+
</button>
|
|
743
|
+
</Show>
|
|
744
|
+
|
|
745
|
+
<Show
|
|
746
|
+
when={!showDeleteConfirm()}
|
|
747
|
+
fallback={
|
|
748
|
+
<div class="bg-red-900/30 border border-red-500 p-2 space-y-2">
|
|
749
|
+
<p class="text-white text-sm">delete this playlist?</p>
|
|
750
|
+
<div class="flex gap-2">
|
|
751
|
+
<button
|
|
752
|
+
onClick={handleDeletePlaylist}
|
|
753
|
+
disabled={isLoading()}
|
|
754
|
+
class="flex-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors"
|
|
755
|
+
>
|
|
756
|
+
yes, delete
|
|
757
|
+
</button>
|
|
758
|
+
<button
|
|
759
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
760
|
+
disabled={isLoading()}
|
|
761
|
+
class="flex-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium transition-colors"
|
|
762
|
+
>
|
|
763
|
+
cancel
|
|
764
|
+
</button>
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
}
|
|
768
|
+
>
|
|
769
|
+
<button
|
|
770
|
+
onClick={() => setShowDeleteConfirm(true)}
|
|
771
|
+
disabled={isLoading()}
|
|
772
|
+
class="w-full px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
773
|
+
>
|
|
774
|
+
<svg
|
|
775
|
+
class="w-4 h-4"
|
|
776
|
+
fill="none"
|
|
777
|
+
stroke="currentColor"
|
|
778
|
+
viewBox="0 0 24 24"
|
|
779
|
+
>
|
|
780
|
+
<path
|
|
781
|
+
stroke-linecap="round"
|
|
782
|
+
stroke-linejoin="round"
|
|
783
|
+
stroke-width="2"
|
|
784
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
785
|
+
/>
|
|
786
|
+
</svg>
|
|
787
|
+
delete playlist
|
|
788
|
+
</button>
|
|
789
|
+
</Show>
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
<Show when={error()}>
|
|
793
|
+
<div class="bg-red-900/30 border border-red-500 p-3">
|
|
794
|
+
<div class="text-red-400 text-sm">{error()}</div>
|
|
795
|
+
</div>
|
|
796
|
+
</Show>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* p2p share column removed - use the share panel (sidebar) instead */}
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
);
|
|
803
|
+
}
|