@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,453 @@
1
+
2
+ import { createSignal, Show, onMount, onCleanup, createEffect } from "solid-js";
3
+ import { updateSong } from "../services/indexedDBService.js";
4
+ import {
5
+ processPlaylistCover,
6
+ validateImageFile,
7
+ createImageUrlFromData,
8
+ } from "../services/imageService.js";
9
+ import type { Song } from "../types/playlist.js";
10
+ import { usePlaylistzManager } from "../context/PlaylistzContext.jsx";
11
+
12
+ interface SongEditModalProps {
13
+ song: Song;
14
+ isOpen: boolean;
15
+ onClose: () => void;
16
+ onSave: (updatedSong: Song) => void;
17
+ }
18
+
19
+ export function SongEditModal(props: SongEditModalProps) {
20
+ const [title, setTitle] = createSignal("");
21
+ const [artist, setArtist] = createSignal("");
22
+ const [album, setAlbum] = createSignal("");
23
+ const [imageData, setImageData] = createSignal<ArrayBuffer | undefined>();
24
+ const [thumbnailData, setThumbnailData] = createSignal<
25
+ ArrayBuffer | undefined
26
+ >();
27
+ const [imageType, setImageType] = createSignal<string | undefined>();
28
+ const [imageUrl, setImageUrl] = createSignal<string | undefined>();
29
+ const [isLoading, setIsLoading] = createSignal(false);
30
+ const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
31
+ const [error, setError] = createSignal<string | null>(null);
32
+
33
+ const playlistManager = usePlaylistzManager();
34
+
35
+ const { handleRemoveSong } = playlistManager;
36
+
37
+ onMount(() => {
38
+ if (props.isOpen && props.song) {
39
+ setTitle(props.song.title);
40
+ setArtist(props.song.artist || "");
41
+ setAlbum(props.song.album || "");
42
+ if (
43
+ (props.song.imageData || props.song.thumbnailData) &&
44
+ props.song.imageType
45
+ ) {
46
+ setImageData(props.song.imageData);
47
+ setThumbnailData(props.song.thumbnailData);
48
+ setImageType(props.song.imageType);
49
+ // imageData if available, fallback to thumbnailData for preview
50
+ const displayData = props.song.imageData || props.song.thumbnailData;
51
+ if (!displayData) return;
52
+ const url = createImageUrlFromData(displayData, props.song.imageType);
53
+ setImageUrl(url);
54
+ } else if (props.song.imageFilePath) {
55
+ // standalone mode: images are file paths, not in-memory buffers
56
+ setImageType(props.song.imageType);
57
+ setImageUrl(props.song.imageFilePath);
58
+ }
59
+ }
60
+ });
61
+
62
+ const handleImageUpload = async (event: Event) => {
63
+ const input = event.target as HTMLInputElement;
64
+ const file = input.files?.[0];
65
+
66
+ if (!file) return;
67
+
68
+ const validation = validateImageFile(file);
69
+ if (!validation.valid) {
70
+ setError(validation.error || "invalid image file");
71
+ return;
72
+ }
73
+
74
+ try {
75
+ setIsLoading(true);
76
+ setError(null);
77
+
78
+ const result = await processPlaylistCover(file);
79
+ if (result.success && result.imageData && result.thumbnailData) {
80
+ // trash previous URL
81
+ const prevUrl = imageUrl();
82
+ if (prevUrl) {
83
+ URL.revokeObjectURL(prevUrl);
84
+ }
85
+
86
+ setImageData(result.imageData);
87
+ setThumbnailData(result.thumbnailData);
88
+ setImageType(file.type);
89
+
90
+ // create new display URL using full-size image for preview
91
+ const newUrl = createImageUrlFromData(result.imageData, file.type);
92
+ setImageUrl(newUrl);
93
+ } else {
94
+ setError(result.error || "failed to process image");
95
+ }
96
+ } catch (err) {
97
+ setError("error uploading image");
98
+ console.error("image upload error:", err);
99
+ } finally {
100
+ setIsLoading(false);
101
+ }
102
+ };
103
+
104
+ const handleSave = async () => {
105
+ if (!title().trim()) {
106
+ setError("title is required");
107
+ return;
108
+ }
109
+
110
+ try {
111
+ setIsLoading(true);
112
+ setError(null);
113
+
114
+ const updates = {
115
+ title: title().trim(),
116
+ artist: artist().trim() || "unknown artist", // #TODO: don't default to this :/
117
+ album: album().trim() || "unknown album", // #TODO: don't default to this :/
118
+ imageData: imageData(),
119
+ thumbnailData: thumbnailData(),
120
+ imageType: imageType(),
121
+ updatedAt: Date.now(),
122
+ };
123
+
124
+ await updateSong(props.song.id, updates);
125
+
126
+ const updatedSong: Song = {
127
+ ...props.song,
128
+ ...updates,
129
+ };
130
+
131
+ props.onSave(updatedSong);
132
+ props.onClose();
133
+ } catch (err) {
134
+ setError("failed to save");
135
+ console.error("Save error:", err);
136
+ } finally {
137
+ setIsLoading(false);
138
+ }
139
+ };
140
+
141
+ const handleCancel = () => {
142
+ // trash any temporary URLz
143
+ const url = imageUrl();
144
+ if (url) {
145
+ URL.revokeObjectURL(url);
146
+ }
147
+ setError(null);
148
+ props.onClose();
149
+ };
150
+
151
+ const handleRemoveImage = () => {
152
+ const url = imageUrl();
153
+ if (url) {
154
+ URL.revokeObjectURL(url);
155
+ }
156
+ setImageData(undefined);
157
+ setThumbnailData(undefined);
158
+ setImageType(undefined);
159
+ setImageUrl(undefined);
160
+ };
161
+
162
+ // handle escape key
163
+ createEffect(() => {
164
+ if (props.isOpen) {
165
+ const handleKeyDown = (e: KeyboardEvent) => {
166
+ if (e.key === "Escape") {
167
+ handleCancel();
168
+ }
169
+ };
170
+
171
+ document.addEventListener("keydown", handleKeyDown);
172
+ onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
173
+ }
174
+ });
175
+
176
+ return (
177
+ <Show when={props.isOpen}>
178
+ <div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
179
+ <div class="bg-gray-900 shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto relative">
180
+ {/* header */}
181
+ <div class="flex items-center justify-between p-6 border-b border-gray-700 relative">
182
+ <h2 class="text-xl font-bold text-white font-mono">
183
+ song<span class="text-magenta-500">z</span>
184
+ </h2>
185
+ <button
186
+ onClick={handleCancel}
187
+ class="text-magenta-200 hover:text-magenta-500 p-1 rounded absolute top-4 right-4"
188
+ disabled={isLoading()}
189
+ >
190
+ <svg
191
+ class="w-6 h-6"
192
+ fill="none"
193
+ stroke="currentColor"
194
+ viewBox="0 0 24 24"
195
+ >
196
+ <path
197
+ stroke-linecap="round"
198
+ stroke-linejoin="round"
199
+ stroke-width="2"
200
+ d="M6 18L18 6M6 6l12 12"
201
+ />
202
+ </svg>
203
+ </button>
204
+ </div>
205
+
206
+ {/* content */}
207
+ <div class="p-6 space-y-6">
208
+ {/* album art */}
209
+ <div>
210
+ <label class="block text-sm font-medium text-gray-300 mb-3">
211
+ album art
212
+ </label>
213
+ <div class="flex items-center gap-4">
214
+ <div class="w-20 h-20 overflow-hidden bg-gray-700 flex items-center justify-center">
215
+ <Show
216
+ when={imageUrl()}
217
+ fallback={
218
+ <svg
219
+ class="w-8 h-8 text-gray-400"
220
+ fill="none"
221
+ stroke="currentColor"
222
+ viewBox="0 0 24 24"
223
+ >
224
+ <path
225
+ stroke-linecap="round"
226
+ stroke-linejoin="round"
227
+ stroke-width="2"
228
+ 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"
229
+ />
230
+ </svg>
231
+ }
232
+ >
233
+ <img
234
+ src={imageUrl()}
235
+ alt="album art"
236
+ class="w-full h-full object-cover"
237
+ />
238
+ </Show>
239
+ </div>
240
+
241
+ <div class="flex-1 space-y-2">
242
+ <input
243
+ type="file"
244
+ accept="image/*"
245
+ onChange={handleImageUpload}
246
+ disabled={isLoading()}
247
+ class="hidden"
248
+ id="image-upload"
249
+ />
250
+ <label
251
+ for="image-upload"
252
+ class="inline-block w-full px-4 py-2 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white cursor-pointer text-sm text-center font-medium transition-colors"
253
+ >
254
+ choose image
255
+ </label>
256
+
257
+ <Show when={imageData()}>
258
+ <button
259
+ onClick={handleRemoveImage}
260
+ disabled={isLoading()}
261
+ class="block w-full px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm text-center font-medium transition-colors"
262
+ >
263
+ remove image
264
+ </button>
265
+ </Show>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
270
+ {/* title */}
271
+ <div>
272
+ <label class="block text-sm font-medium text-gray-300 mb-2">
273
+ title
274
+ </label>
275
+ <input
276
+ type="text"
277
+ value={title()}
278
+ onInput={(e) => setTitle(e.currentTarget.value)}
279
+ disabled={isLoading()}
280
+ class="w-full bg-gray-800 text-white px-4 py-3 border border-gray-600 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors"
281
+ placeholder="song title"
282
+ />
283
+ </div>
284
+
285
+ {/* artist */}
286
+ <div>
287
+ <label class="block text-sm font-medium text-gray-300 mb-2">
288
+ artist
289
+ </label>
290
+ <input
291
+ type="text"
292
+ value={artist()}
293
+ onInput={(e) => setArtist(e.currentTarget.value)}
294
+ disabled={isLoading()}
295
+ class="w-full bg-gray-800 text-white px-4 py-3 border border-gray-600 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors"
296
+ placeholder="artist name"
297
+ />
298
+ </div>
299
+
300
+ {/* album */}
301
+ <div>
302
+ <label class="block text-sm font-medium text-gray-300 mb-2">
303
+ album
304
+ </label>
305
+ <input
306
+ type="text"
307
+ value={album()}
308
+ onInput={(e) => setAlbum(e.currentTarget.value)}
309
+ disabled={isLoading()}
310
+ class="w-full bg-gray-800 text-white px-4 py-3 border border-gray-600 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors"
311
+ placeholder="album name"
312
+ />
313
+ </div>
314
+
315
+ {/* file info */}
316
+ <div>
317
+ <label class="block text-sm font-medium text-gray-300 mb-3">
318
+ file information
319
+ </label>
320
+
321
+ <div class="bg-gray-800 p-4">
322
+ <div class="text-sm text-gray-400 space-y-1">
323
+ <div>
324
+ filename: {props.song.originalFilename || "Unknown"}
325
+ </div>
326
+ <Show when={props.song.fileSize}>
327
+ <div>
328
+ size:{" "}
329
+ {Math.round((props.song.fileSize! / 1024 / 1024) * 100) /
330
+ 100}{" "}
331
+ mb
332
+ </div>
333
+ </Show>
334
+ <div>duration: {formatDuration(props.song.duration)}</div>
335
+ <Show when={props.song.sha}>
336
+ <div class="break-all">sha: {props.song.sha}</div>
337
+ </Show>
338
+ </div>
339
+ </div>
340
+ </div>
341
+
342
+ {/* delete song */}
343
+ <div class="space-y-3">
344
+ <Show
345
+ when={!showDeleteConfirm()}
346
+ fallback={
347
+ <div class="bg-red-900 bg-opacity-30 border border-red-500 p-4 space-y-3">
348
+ <p class="text-white text-sm">
349
+ are you sure you want to delete this song? this action
350
+ cannot be undone.
351
+ </p>
352
+ <div class="flex gap-2">
353
+ <button
354
+ onClick={() =>
355
+ handleRemoveSong(props.song.id, props.onClose)
356
+ }
357
+ disabled={isLoading()}
358
+ class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-medium transition-colors"
359
+ >
360
+ yes, delete
361
+ </button>
362
+ <button
363
+ onClick={() => setShowDeleteConfirm(false)}
364
+ disabled={isLoading()}
365
+ class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-medium transition-colors"
366
+ >
367
+ cancel
368
+ </button>
369
+ </div>
370
+ </div>
371
+ }
372
+ >
373
+ <button
374
+ onClick={() => setShowDeleteConfirm(true)}
375
+ disabled={isLoading()}
376
+ class="w-full px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-medium transition-colors flex items-center justify-center gap-2"
377
+ >
378
+ <svg
379
+ class="w-4 h-4"
380
+ fill="none"
381
+ stroke="currentColor"
382
+ viewBox="0 0 24 24"
383
+ >
384
+ <path
385
+ stroke-linecap="round"
386
+ stroke-linejoin="round"
387
+ stroke-width="2"
388
+ 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"
389
+ />
390
+ </svg>
391
+ delete song
392
+ </button>
393
+ </Show>
394
+ </div>
395
+
396
+ {/* error message */}
397
+ <Show when={error()}>
398
+ <div class="bg-red-900 bg-opacity-30 border border-red-500 p-3">
399
+ <div class="text-red-400 text-sm">{error()}</div>
400
+ </div>
401
+ </Show>
402
+ </div>
403
+
404
+ {/* footer */}
405
+ <div class="flex items-center justify-end gap-3 p-6 border-t border-gray-700 sticky bottom-0 z-10 bg-gray-900">
406
+ <button
407
+ onClick={handleCancel}
408
+ disabled={isLoading()}
409
+ class="px-4 py-2 text-gray-400 hover:text-white disabled:text-gray-600 font-medium transition-colors"
410
+ >
411
+ cancel
412
+ </button>
413
+ <button
414
+ onClick={handleSave}
415
+ disabled={isLoading() || !title().trim()}
416
+ class="px-6 py-2 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium transition-colors flex items-center gap-2"
417
+ >
418
+ <Show
419
+ when={!isLoading()}
420
+ fallback={
421
+ <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
422
+ }
423
+ >
424
+ <svg
425
+ class="w-4 h-4"
426
+ fill="none"
427
+ stroke="currentColor"
428
+ viewBox="0 0 24 24"
429
+ >
430
+ <path
431
+ stroke-linecap="round"
432
+ stroke-linejoin="round"
433
+ stroke-width="2"
434
+ d="M5 13l4 4L19 7"
435
+ />
436
+ </svg>
437
+ </Show>
438
+ {isLoading() ? "saving..." : "save"}
439
+ </button>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </Show>
444
+ );
445
+ }
446
+
447
+ function formatDuration(seconds: number): string {
448
+ if (!seconds || isNaN(seconds)) return "0:00";
449
+
450
+ const minutes = Math.floor(seconds / 60);
451
+ const remainingSeconds = Math.floor(seconds % 60);
452
+ return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
453
+ }