fix(web): make album-folder import robust + add a folder picker
A real folder drag silently did nothing for a user (the logic is fine — a simulated drop works — so a real drop was either erroring silently or not firing). Harden it and give a guaranteed alternative: - onDrop now captures the entries synchronously, wraps the async walk in try/catch, and TOASTS on any failure or empty result (no more silent no-op). - dragEnter preventDefault + dragover dropEffect="copy" for reliable drop-target registration. - New "Choose a folder" affordance (a webkitdirectory <input>) that sidesteps drag entirely — pick the album folder and it collects the files + prefills artist/album/year from the folder name. Plus "Choose files". tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -210,6 +210,8 @@ nav.contents .sep { flex: 1; }
|
|||||||
font-family: var(--mono); font-size: 0.8rem; color: var(--graphite); margin-bottom: 8px;
|
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); }
|
.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 ──────────── */
|
/* ── Phase 2: page headers, list rows, tools ──────────── */
|
||||||
.page-head { margin: 6px 0 26px; }
|
.page-head { margin: 6px 0 26px; }
|
||||||
|
|||||||
@@ -26,19 +26,6 @@ async function walkEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
/** Parse "Artist - Album (Year)" / "Album (Year)" from a dropped folder name. */
|
||||||
function parseFolder(name: string): { artist?: string; album: string; year?: string } {
|
function parseFolder(name: string): { artist?: string; album: string; year?: string } {
|
||||||
let s = name.trim();
|
let s = name.trim();
|
||||||
@@ -64,6 +51,7 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [drag, setDrag] = useState(false);
|
const [drag, setDrag] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
|
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
|
||||||
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
|
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
|
||||||
@@ -82,18 +70,50 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|||||||
if (list.length) setFiles((prev) => [...prev, ...list]);
|
if (list.length) setFiles((prev) => [...prev, ...list]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDrop(e: React.DragEvent) {
|
function prefillFolder(folder: string | null) {
|
||||||
e.preventDefault();
|
if (!folder) return;
|
||||||
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);
|
const p = parseFolder(folder);
|
||||||
setAlbum((a) => a || p.album);
|
setAlbum((a) => a || p.album);
|
||||||
if (p.artist) setArtist((a) => a || p.artist!);
|
if (p.artist) setArtist((a) => a || p.artist!);
|
||||||
if (p.year) setYear((y) => y || p.year!);
|
if (p.year) setYear((y) => y || p.year!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(false);
|
||||||
|
// 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<HTMLInputElement>) {
|
||||||
|
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) {
|
async function submit(e: React.FormEvent) {
|
||||||
@@ -131,8 +151,13 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|||||||
<form onSubmit={submit} className="import-form">
|
<form onSubmit={submit} className="import-form">
|
||||||
<div
|
<div
|
||||||
className={`dropzone${drag ? " over" : ""}`}
|
className={`dropzone${drag ? " over" : ""}`}
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(true);
|
||||||
|
}}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
setDrag(true);
|
setDrag(true);
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDrag(false)}
|
onDragLeave={() => setDrag(false)}
|
||||||
@@ -143,7 +168,7 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|||||||
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
|
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
|
||||||
files.length > audioCount ? " (+ cover)" : ""
|
files.length > audioCount ? " (+ cover)" : ""
|
||||||
}`
|
}`
|
||||||
: "Drop an album folder (or audio files) here, or click to choose files"}
|
: "Drop an album folder (or files) here"}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -152,7 +177,18 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|||||||
hidden
|
hidden
|
||||||
onChange={(e) => addFiles(Array.from(e.target.files ?? []))}
|
onChange={(e) => addFiles(Array.from(e.target.files ?? []))}
|
||||||
/>
|
/>
|
||||||
|
{/* @ts-expect-error webkitdirectory is a non-standard attribute for folder selection */}
|
||||||
|
<input ref={folderRef} type="file" webkitdirectory="" hidden onChange={onPickFolder} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="import-picks">
|
||||||
|
<button type="button" className="linkish" onClick={() => folderRef.current?.click()}>
|
||||||
|
Choose a folder
|
||||||
|
</button>
|
||||||
|
{" · "}
|
||||||
|
<button type="button" className="linkish" onClick={() => inputRef.current?.click()}>
|
||||||
|
Choose files
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Artist</span>
|
<span>Artist</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user