@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,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
|
+
}
|