diff --git a/web/src/app/design.css b/web/src/app/design.css index 23a40e0..0f3b8ca 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -210,6 +210,8 @@ nav.contents .sep { flex: 1; } font-family: var(--mono); font-size: 0.8rem; color: var(--graphite); margin-bottom: 8px; } .dropzone:hover, .dropzone.over { border-color: var(--accent); color: var(--accent); } +.import-picks { text-align: center; font-size: 0.78rem; color: var(--graphite); margin: 6px 0 10px; } +.import-picks .linkish { font-size: inherit; } /* ── Phase 2: page headers, list rows, tools ──────────── */ .page-head { margin: 6px 0 26px; } diff --git a/web/src/app/library/import-album.tsx b/web/src/app/library/import-album.tsx index 6eaeb18..755f56f 100644 --- a/web/src/app/library/import-album.tsx +++ b/web/src/app/library/import-album.tsx @@ -26,19 +26,6 @@ async function walkEntry(entry: FileSystemEntry, out: File[]): Promise { } } -/** Returns the dropped files (recursing into a dropped folder) + the top-level folder name if - * a single folder was dropped (used to prefill artist/album). */ -async function collectDropped(dt: DataTransfer): Promise<{ files: File[]; folder: string | null }> { - const entries = Array.from(dt.items) - .map((it) => it.webkitGetAsEntry?.()) - .filter((e): e is FileSystemEntry => !!e); - if (entries.length === 0) return { files: Array.from(dt.files), folder: null }; - const out: File[] = []; - for (const e of entries) await walkEntry(e, out); - const dirs = entries.filter((e) => e.isDirectory); - return { files: out, folder: dirs.length === 1 && entries.length === 1 ? dirs[0].name : null }; -} - /** Parse "Artist - Album (Year)" / "Album (Year)" from a dropped folder name. */ function parseFolder(name: string): { artist?: string; album: string; year?: string } { let s = name.trim(); @@ -64,6 +51,7 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) { const [busy, setBusy] = useState(false); const [drag, setDrag] = useState(false); const inputRef = useRef(null); + const folderRef = useRef(null); const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length; const ready = !!artist.trim() && !!album.trim() && audioCount > 0; @@ -82,20 +70,52 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) { if (list.length) setFiles((prev) => [...prev, ...list]); } + function prefillFolder(folder: string | null) { + if (!folder) return; + const p = parseFolder(folder); + setAlbum((a) => a || p.album); + if (p.artist) setArtist((a) => a || p.artist!); + if (p.year) setYear((y) => y || p.year!); + } + async function onDrop(e: React.DragEvent) { e.preventDefault(); setDrag(false); - const { files: dropped, folder } = await collectDropped(e.dataTransfer); - addFiles(dropped); - // prefill from a dropped album folder's name (only fields the user hasn't typed into) - if (folder) { - const p = parseFolder(folder); - setAlbum((a) => a || p.album); - if (p.artist) setArtist((a) => a || p.artist!); - if (p.year) setYear((y) => y || p.year!); + // Capture the entries SYNCHRONOUSLY — the DataTransfer is emptied once this handler yields. + const dt = e.dataTransfer; + const entries = Array.from(dt.items || []) + .map((it) => it.webkitGetAsEntry?.()) + .filter((x): x is FileSystemEntry => !!x); + const plain = Array.from(dt.files || []); + try { + if (entries.length) { + const out: File[] = []; + for (const en of entries) await walkEntry(en, out); + const dirs = entries.filter((en) => en.isDirectory); + addFiles(out); + prefillFolder(dirs.length === 1 && entries.length === 1 ? dirs[0].name : null); + if (out.length === 0) toast("That folder had no files Lyra could read"); + } else if (plain.length) { + addFiles(plain); + } else { + toast("Couldn't read the drop — try the “choose a folder” link"); + } + } catch (err) { + console.error("import drop failed:", err); + toast("Couldn't read the dropped folder — try the “choose a folder” link"); } } + // Reliable fallback: a folder picker (webkitdirectory). Files carry webkitRelativePath, whose + // first segment is the folder name — use it to prefill. + function onPickFolder(e: React.ChangeEvent) { + const picked = Array.from(e.target.files ?? []); + addFiles(picked); + const rel = picked[0]?.webkitRelativePath ?? ""; + prefillFolder(rel.includes("/") ? rel.split("/")[0] : null); + e.target.value = ""; + } + async function submit(e: React.FormEvent) { e.preventDefault(); if (!ready) return; @@ -131,8 +151,13 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
{ + e.preventDefault(); + setDrag(true); + }} onDragOver={(e) => { e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; setDrag(true); }} onDragLeave={() => setDrag(false)} @@ -143,7 +168,7 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) { ? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${ files.length > audioCount ? " (+ cover)" : "" }` - : "Drop an album folder (or audio files) here, or click to choose files"} + : "Drop an album folder (or files) here"} void }) { hidden onChange={(e) => addFiles(Array.from(e.target.files ?? []))} /> + {/* @ts-expect-error webkitdirectory is a non-standard attribute for folder selection */} +
+

+ + {" · "} + +