@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,578 @@
1
+ import { createSignal, Show, onMount, onCleanup } from "solid-js";
2
+ import {
3
+ processPlaylistCover,
4
+ validateImageFile,
5
+ createImageUrlFromData,
6
+ getImageUrlForContext,
7
+ } from "../services/imageService.js";
8
+ import type { Song } from "../types/playlist.js";
9
+ import { usePlaylistzManager } from "../context/PlaylistzContext.jsx";
10
+ import { formatDuration } from "../utils/timeUtils.js";
11
+
12
+ interface SongEditPanelProps {
13
+ song: Song;
14
+ index: number;
15
+ onClose: () => void;
16
+ onSave: (updatedSong: Song) => void;
17
+ prevSong?: Song;
18
+ nextSong?: Song;
19
+ onNavigate?: (song: Song) => void;
20
+ }
21
+
22
+ export function SongEditPanel(props: SongEditPanelProps) {
23
+ const [title, setTitle] = createSignal("");
24
+ const [artist, setArtist] = createSignal("");
25
+ const [album, setAlbum] = createSignal("");
26
+ const [imageData, setImageData] = createSignal<ArrayBuffer | undefined>();
27
+ const [thumbnailData, setThumbnailData] = createSignal<
28
+ ArrayBuffer | undefined
29
+ >();
30
+ const [imageType, setImageType] = createSignal<string | undefined>();
31
+ const [imageUrl, setImageUrl] = createSignal<string | undefined>();
32
+ const [isLoading, setIsLoading] = createSignal(false);
33
+ const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
34
+ const [error, setError] = createSignal<string | null>(null);
35
+
36
+ const playlistManager = usePlaylistzManager();
37
+ const { handleRemoveSong } = playlistManager;
38
+
39
+ // initialise (or reset) form state from the song's stored values
40
+ const initFromSong = () => {
41
+ setTitle(props.song.title);
42
+ setArtist(props.song.artist || "");
43
+ setAlbum(props.song.album || "");
44
+
45
+ setImageData(undefined);
46
+ setThumbnailData(undefined);
47
+ setImageType(undefined);
48
+ setImageUrl(undefined);
49
+
50
+ if (
51
+ (props.song.imageData || props.song.thumbnailData) &&
52
+ props.song.imageType
53
+ ) {
54
+ setImageData(props.song.imageData);
55
+ setThumbnailData(props.song.thumbnailData);
56
+ setImageType(props.song.imageType);
57
+ const displayData = props.song.imageData || props.song.thumbnailData;
58
+ if (displayData) {
59
+ setImageUrl(createImageUrlFromData(displayData, props.song.imageType));
60
+ }
61
+ } else if (props.song.imageFilePath) {
62
+ setImageType(props.song.imageType);
63
+ setImageUrl(props.song.imageFilePath);
64
+ }
65
+ };
66
+
67
+ onMount(initFromSong);
68
+
69
+ // true when the form has unsaved edits. image fields compare by reference -
70
+ // after a save props.song gets the same buffer instances, so this goes clean
71
+ const isDirty = () =>
72
+ title().trim() !== props.song.title ||
73
+ artist().trim() !== (props.song.artist || "") ||
74
+ album().trim() !== (props.song.album || "") ||
75
+ imageData() !== props.song.imageData ||
76
+ imageType() !== props.song.imageType;
77
+
78
+ const navDisabledTitle = "save or reset changes first";
79
+
80
+ // left/right arrow keys navigate between songs (when not typing in a field
81
+ // and there are no pending edits)
82
+ onMount(() => {
83
+ const onKeyDown = (e: KeyboardEvent) => {
84
+ const target = e.target as HTMLElement | null;
85
+ const tag = target?.tagName;
86
+ if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
87
+ return;
88
+ }
89
+ if (isDirty()) return;
90
+ if (e.key === "ArrowLeft" && props.prevSong) {
91
+ e.preventDefault();
92
+ props.onNavigate?.(props.prevSong);
93
+ } else if (e.key === "ArrowRight" && props.nextSong) {
94
+ e.preventDefault();
95
+ props.onNavigate?.(props.nextSong);
96
+ }
97
+ };
98
+ document.addEventListener("keydown", onKeyDown);
99
+ onCleanup(() => document.removeEventListener("keydown", onKeyDown));
100
+ });
101
+
102
+ const handleImageUpload = async (event: Event) => {
103
+ const input = event.target as HTMLInputElement;
104
+ const file = input.files?.[0];
105
+ if (!file) return;
106
+
107
+ const validation = validateImageFile(file);
108
+ if (!validation.valid) {
109
+ setError(validation.error || "invalid image file");
110
+ return;
111
+ }
112
+
113
+ try {
114
+ setIsLoading(true);
115
+ setError(null);
116
+
117
+ const result = await processPlaylistCover(file);
118
+ if (result.success && result.imageData && result.thumbnailData) {
119
+ const prevUrl = imageUrl();
120
+ if (prevUrl) URL.revokeObjectURL(prevUrl);
121
+
122
+ setImageData(result.imageData);
123
+ setThumbnailData(result.thumbnailData);
124
+ setImageType(file.type);
125
+ setImageUrl(createImageUrlFromData(result.imageData, file.type));
126
+ } else {
127
+ setError(result.error || "failed to process image");
128
+ }
129
+ } catch (err) {
130
+ setError("error uploading image");
131
+ console.error("image upload error:", err);
132
+ } finally {
133
+ setIsLoading(false);
134
+ }
135
+ };
136
+
137
+ const handleSave = async () => {
138
+ if (!title().trim()) {
139
+ setError("title is required");
140
+ return;
141
+ }
142
+
143
+ try {
144
+ setIsLoading(true);
145
+ setError(null);
146
+
147
+ const updatedSong: Song = {
148
+ ...props.song,
149
+ title: title().trim(),
150
+ artist: artist().trim() || "unknown artist",
151
+ album: album().trim() || "unknown album",
152
+ imageData: imageData(),
153
+ thumbnailData: thumbnailData(),
154
+ imageType: imageType(),
155
+ updatedAt: Date.now(),
156
+ };
157
+
158
+ // onSave handler (handleSongSaved in useSongState) persists to IDB.
159
+ // the panel stays open after saving
160
+ await props.onSave(updatedSong);
161
+ } catch (err) {
162
+ setError("failed to save");
163
+ console.error("save error:", err);
164
+ } finally {
165
+ setIsLoading(false);
166
+ }
167
+ };
168
+
169
+ const handleSaveAndNext = async () => {
170
+ await handleSave();
171
+ if (!error() && props.nextSong) {
172
+ props.onNavigate?.(props.nextSong);
173
+ }
174
+ };
175
+
176
+ const handleClose = () => {
177
+ const url = imageUrl();
178
+ if (url?.startsWith("blob:")) URL.revokeObjectURL(url);
179
+ setError(null);
180
+ props.onClose();
181
+ };
182
+
183
+ const handleRemoveImage = () => {
184
+ const url = imageUrl();
185
+ if (url) URL.revokeObjectURL(url);
186
+ setImageData(undefined);
187
+ setThumbnailData(undefined);
188
+ setImageType(undefined);
189
+ setImageUrl(undefined);
190
+ };
191
+
192
+ // preview thumbnail: shows current imageUrl (form state) or falls back to stored path
193
+ const previewImageUrl = () =>
194
+ imageUrl() ?? getImageUrlForContext(props.song, "thumbnail") ?? undefined;
195
+
196
+ return (
197
+ <div
198
+ data-testid="song-edit-panel"
199
+ class="bg-black/40 border-none overflow-hidden min-w-0 w-full"
200
+ >
201
+ {/* read-only song row preview - updates live as user edits */}
202
+ <div class="flex items-center gap-2 px-3 py-3 bg-black border-none select-none min-w-0">
203
+ {/* close button */}
204
+ <button
205
+ onClick={handleClose}
206
+ class="p-1 text-gray-400 hover:text-white transition-colors flex-shrink-0"
207
+ title="close"
208
+ >
209
+ <svg
210
+ class="w-4 h-4"
211
+ fill="none"
212
+ stroke="currentColor"
213
+ viewBox="0 0 24 24"
214
+ >
215
+ <path
216
+ stroke-linecap="round"
217
+ stroke-linejoin="round"
218
+ stroke-width="2"
219
+ d="M6 18L18 6M6 6l12 12"
220
+ />
221
+ </svg>
222
+ </button>
223
+
224
+ {/* thumbnail with index overlay (matches SongRow) */}
225
+ <div class="relative w-10 h-10 flex-shrink-0 bg-black overflow-hidden">
226
+ <Show
227
+ when={previewImageUrl()}
228
+ fallback={
229
+ <div class="w-full h-full flex items-center justify-center">
230
+ <svg
231
+ class="w-5 h-5 text-gray-500"
232
+ fill="none"
233
+ stroke="currentColor"
234
+ viewBox="0 0 24 24"
235
+ >
236
+ <path
237
+ stroke-linecap="round"
238
+ stroke-linejoin="round"
239
+ stroke-width="2"
240
+ 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"
241
+ />
242
+ </svg>
243
+ </div>
244
+ }
245
+ >
246
+ <img
247
+ src={previewImageUrl()}
248
+ alt={title()}
249
+ class="w-full h-full object-cover"
250
+ />
251
+ </Show>
252
+ {/* index number on a tight black background, centered on the thumbnail */}
253
+ <div class="absolute inset-0 flex justify-center items-center font-mono text-sm text-gray-300">
254
+ <span class="bg-black">
255
+ {props.index.toString().padStart(3, "0")}
256
+ </span>
257
+ </div>
258
+ </div>
259
+
260
+ {/* song metadata - live from form state */}
261
+ <div class="min-w-0">
262
+ <div class="text-white text-sm font-medium truncate">
263
+ {title() || "(no title)"}
264
+ </div>
265
+ <div class="text-gray-400 text-xs truncate">
266
+ {artist() || ""}
267
+ {artist() && album() ? " - " : ""}
268
+ {album() || ""}
269
+ </div>
270
+ </div>
271
+
272
+ {/* duration sits left, spacer pushes nav buttons to the right edge */}
273
+ <span class="text-gray-400 text-sm flex-shrink-0">
274
+ {formatDuration(props.song.duration)}
275
+ </span>
276
+
277
+ <div class="flex-1" />
278
+
279
+ {/* prev / next song navigation - disabled while there are unsaved
280
+ edits so they don't get silently lost */}
281
+ <Show when={props.prevSong}>
282
+ <button
283
+ onClick={() => props.onNavigate?.(props.prevSong!)}
284
+ disabled={isDirty()}
285
+ class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 disabled:text-gray-600 disabled:hover:bg-transparent transition-colors flex-shrink-0"
286
+ title={isDirty() ? navDisabledTitle : "edit previous song"}
287
+ >
288
+ <svg
289
+ class="w-4 h-4"
290
+ fill="none"
291
+ stroke="currentColor"
292
+ viewBox="0 0 24 24"
293
+ >
294
+ <path
295
+ stroke-linecap="round"
296
+ stroke-linejoin="round"
297
+ stroke-width="2"
298
+ d="M15 19l-7-7 7-7"
299
+ />
300
+ </svg>
301
+ </button>
302
+ </Show>
303
+ <Show when={props.nextSong}>
304
+ <button
305
+ onClick={() => props.onNavigate?.(props.nextSong!)}
306
+ disabled={isDirty()}
307
+ class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 disabled:text-gray-600 disabled:hover:bg-transparent transition-colors flex-shrink-0"
308
+ title={isDirty() ? navDisabledTitle : "edit next song"}
309
+ >
310
+ <svg
311
+ class="w-4 h-4"
312
+ fill="none"
313
+ stroke="currentColor"
314
+ viewBox="0 0 24 24"
315
+ >
316
+ <path
317
+ stroke-linecap="round"
318
+ stroke-linejoin="round"
319
+ stroke-width="2"
320
+ d="M9 5l7 7-7 7"
321
+ />
322
+ </svg>
323
+ </button>
324
+ </Show>
325
+ </div>
326
+
327
+ {/* edit form: on sm+ the text fields sit left (clamped to 500px), image
328
+ right. justify-between pushes the columns apart so spare width sits in
329
+ the middle. order utilities keep the image on top for mobile */}
330
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-[minmax(0,500px)_min(40%,24rem)] sm:justify-between gap-4">
331
+ {/* album art + image buttons */}
332
+ <div class="flex flex-col gap-2 sm:order-2">
333
+ {/* image sizes naturally at its own aspect ratio (no gray bars);
334
+ the gray square only shows as the no-image fallback */}
335
+ <Show
336
+ when={imageUrl()}
337
+ fallback={
338
+ <div class="w-full aspect-square bg-gray-700 flex items-center justify-center">
339
+ <svg
340
+ class="w-8 h-8 text-gray-400"
341
+ fill="none"
342
+ stroke="currentColor"
343
+ viewBox="0 0 24 24"
344
+ >
345
+ <path
346
+ stroke-linecap="round"
347
+ stroke-linejoin="round"
348
+ stroke-width="2"
349
+ 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"
350
+ />
351
+ </svg>
352
+ </div>
353
+ }
354
+ >
355
+ <img src={imageUrl()} alt="album art" class="w-full h-auto" />
356
+ </Show>
357
+ <input
358
+ type="file"
359
+ accept="image/*"
360
+ onChange={handleImageUpload}
361
+ disabled={isLoading()}
362
+ class="hidden"
363
+ id="song-image-upload-panel"
364
+ />
365
+ {/* mt-auto pushes the buttons to the column bottom so both columns
366
+ end at the same height regardless of image aspect ratio */}
367
+ <label
368
+ for="song-image-upload-panel"
369
+ 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"
370
+ >
371
+ choose image
372
+ </label>
373
+ <Show when={imageData()}>
374
+ <button
375
+ onClick={handleRemoveImage}
376
+ disabled={isLoading()}
377
+ 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"
378
+ >
379
+ remove image
380
+ </button>
381
+ </Show>
382
+ </div>
383
+
384
+ {/* text fields + file info */}
385
+ <div class="flex flex-col gap-3 sm:order-1">
386
+ <div>
387
+ <div class="flex items-center justify-between mb-1">
388
+ <label class="text-xs font-medium text-gray-400">title</label>
389
+ <Show when={title().trim() !== props.song.title}>
390
+ <button
391
+ onClick={() => setTitle(props.song.title)}
392
+ class="text-xs text-gray-500 hover:text-white transition-colors"
393
+ title="reset title"
394
+ >
395
+ reset
396
+ </button>
397
+ </Show>
398
+ </div>
399
+ <input
400
+ type="text"
401
+ value={title()}
402
+ onInput={(e) => setTitle(e.currentTarget.value)}
403
+ disabled={isLoading()}
404
+ class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
405
+ placeholder="song title"
406
+ />
407
+ </div>
408
+ <div>
409
+ <div class="flex items-center justify-between mb-1">
410
+ <label class="text-xs font-medium text-gray-400">artist</label>
411
+ <Show when={artist().trim() !== (props.song.artist || "")}>
412
+ <button
413
+ onClick={() => setArtist(props.song.artist || "")}
414
+ class="text-xs text-gray-500 hover:text-white transition-colors"
415
+ title="reset artist"
416
+ >
417
+ reset
418
+ </button>
419
+ </Show>
420
+ </div>
421
+ <input
422
+ type="text"
423
+ value={artist()}
424
+ onInput={(e) => setArtist(e.currentTarget.value)}
425
+ disabled={isLoading()}
426
+ class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
427
+ placeholder="artist name"
428
+ />
429
+ </div>
430
+ <div>
431
+ <div class="flex items-center justify-between mb-1">
432
+ <label class="text-xs font-medium text-gray-400">album</label>
433
+ <Show when={album().trim() !== (props.song.album || "")}>
434
+ <button
435
+ onClick={() => setAlbum(props.song.album || "")}
436
+ class="text-xs text-gray-500 hover:text-white transition-colors"
437
+ title="reset album"
438
+ >
439
+ reset
440
+ </button>
441
+ </Show>
442
+ </div>
443
+ <input
444
+ type="text"
445
+ value={album()}
446
+ onInput={(e) => setAlbum(e.currentTarget.value)}
447
+ disabled={isLoading()}
448
+ class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
449
+ placeholder="album name"
450
+ />
451
+ </div>
452
+ <div class="bg-black p-3 text-xs text-gray-400 space-y-1 mt-auto">
453
+ <div>filename: {props.song.originalFilename || "unknown"}</div>
454
+ <Show when={props.song.fileSize}>
455
+ <div>
456
+ size:{" "}
457
+ {Math.round((props.song.fileSize! / 1024 / 1024) * 100) / 100}{" "}
458
+ mb
459
+ </div>
460
+ </Show>
461
+ <div>duration: {formatDuration(props.song.duration)}</div>
462
+ <Show when={props.song.sha}>
463
+ <div class="break-all">sha: {props.song.sha}</div>
464
+ </Show>
465
+ </div>
466
+ </div>
467
+
468
+ {/* error - full width */}
469
+ <Show when={error()}>
470
+ <div class="sm:col-span-2 bg-red-900/30 border border-red-500 p-3">
471
+ <div class="text-red-400 text-sm">{error()}</div>
472
+ </div>
473
+ </Show>
474
+ </div>
475
+
476
+ {/* footer: delete on left, cancel+save on right */}
477
+ <Show
478
+ when={showDeleteConfirm()}
479
+ fallback={
480
+ <div class="flex items-center gap-3 px-4 py-3 border-none">
481
+ <button
482
+ onClick={() => setShowDeleteConfirm(true)}
483
+ disabled={isLoading()}
484
+ class="px-3 py-2 bg-red-700 hover:bg-red-800 disabled:bg-red-400 text-white text-sm font-medium transition-colors flex items-center gap-2"
485
+ title="delete song"
486
+ >
487
+ <svg
488
+ class="w-4 h-4 flex-shrink-0"
489
+ fill="none"
490
+ stroke="currentColor"
491
+ viewBox="0 0 24 24"
492
+ >
493
+ <path
494
+ stroke-linecap="round"
495
+ stroke-linejoin="round"
496
+ stroke-width="2"
497
+ 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"
498
+ />
499
+ </svg>
500
+ <span class="hidden sm:inline">delete</span>
501
+ </button>
502
+ <div class="flex-1" />
503
+ <button
504
+ onClick={handleSave}
505
+ disabled={isLoading()}
506
+ class="px-4 py-2 sm:px-6 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium transition-colors flex items-center gap-2"
507
+ >
508
+ <Show
509
+ when={!isLoading()}
510
+ fallback={
511
+ <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
512
+ }
513
+ >
514
+ <svg
515
+ class="w-4 h-4"
516
+ fill="none"
517
+ stroke="currentColor"
518
+ viewBox="0 0 24 24"
519
+ >
520
+ <path
521
+ stroke-linecap="round"
522
+ stroke-linejoin="round"
523
+ stroke-width="2"
524
+ d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2zM17 21v-8H7v8M7 3v5h8"
525
+ />
526
+ </svg>
527
+ </Show>
528
+ <span class="hidden sm:inline">save</span>
529
+ </button>
530
+ <Show when={props.nextSong}>
531
+ <button
532
+ onClick={handleSaveAndNext}
533
+ disabled={isLoading()}
534
+ class="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-500 text-white font-medium transition-colors flex items-center gap-2"
535
+ title="save and go to next song"
536
+ >
537
+ save
538
+ <svg
539
+ class="w-4 h-4"
540
+ fill="none"
541
+ stroke="currentColor"
542
+ viewBox="0 0 24 24"
543
+ >
544
+ <path
545
+ stroke-linecap="round"
546
+ stroke-linejoin="round"
547
+ stroke-width="2"
548
+ d="M14 5l7 7m0 0l-7 7m7-7H3"
549
+ />
550
+ </svg>
551
+ </button>
552
+ </Show>
553
+ </div>
554
+ }
555
+ >
556
+ <div class="bg-red-900/30 border-t border-red-500 px-4 py-3 space-y-2">
557
+ <p class="text-white text-sm">delete this song? cannot be undone.</p>
558
+ <div class="flex gap-2">
559
+ <button
560
+ onClick={() => handleRemoveSong(props.song.id, props.onClose)}
561
+ disabled={isLoading()}
562
+ class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors"
563
+ >
564
+ yes, delete
565
+ </button>
566
+ <button
567
+ onClick={() => setShowDeleteConfirm(false)}
568
+ disabled={isLoading()}
569
+ class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium transition-colors"
570
+ >
571
+ cancel
572
+ </button>
573
+ </div>
574
+ </div>
575
+ </Show>
576
+ </div>
577
+ );
578
+ }