a94ba97eee
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>
226 lines
8.5 KiB
TypeScript
226 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Modal } from "../_ui/modal";
|
|
import { toast } from "../_ui/toast";
|
|
|
|
const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i;
|
|
|
|
// A dropped item may be a whole folder; the plain dataTransfer.files doesn't recurse, so walk
|
|
// the entry tree with webkitGetAsEntry to collect files inside a dropped album folder.
|
|
function readEntries(reader: {
|
|
readEntries: (cb: (e: FileSystemEntry[]) => void, err: (e: unknown) => void) => void;
|
|
}): Promise<FileSystemEntry[]> {
|
|
return new Promise((res, rej) => reader.readEntries(res, rej));
|
|
}
|
|
|
|
async function walkEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
|
if (entry.isFile) {
|
|
const file = await new Promise<File>((res, rej) => (entry as FileSystemFileEntry).file(res, rej));
|
|
out.push(file);
|
|
} else if (entry.isDirectory) {
|
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
|
for (let batch = await readEntries(reader); batch.length; batch = await readEntries(reader)) {
|
|
for (const e of batch) await walkEntry(e, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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();
|
|
let year: string | undefined;
|
|
const ym = s.match(/[([]?(\d{4})[)\]]?\s*$/);
|
|
if (ym) {
|
|
year = ym[1];
|
|
s = s.slice(0, ym.index).trim().replace(/[-–—([]\s*$/, "").trim();
|
|
}
|
|
const dash = s.split(/\s+[-–—]\s+/);
|
|
if (dash.length >= 2) return { artist: dash[0].trim(), album: dash.slice(1).join(" - ").trim(), year };
|
|
return { album: s, year };
|
|
}
|
|
|
|
/** Manually add an album (ripped CD / local files): drag-drop the folder (or audio files) +
|
|
* fill in artist/album/year, POST to /api/library/import. */
|
|
export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [artist, setArtist] = useState("");
|
|
const [album, setAlbum] = useState("");
|
|
const [year, setYear] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [drag, setDrag] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const folderRef = useRef<HTMLInputElement>(null);
|
|
|
|
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
|
|
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
|
|
|
|
function reset() {
|
|
setFiles([]);
|
|
setArtist("");
|
|
setAlbum("");
|
|
setYear("");
|
|
}
|
|
function close() {
|
|
setOpen(false);
|
|
reset();
|
|
}
|
|
function addFiles(list: File[]) {
|
|
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);
|
|
// 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) {
|
|
e.preventDefault();
|
|
if (!ready) return;
|
|
setBusy(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.set("artist", artist.trim());
|
|
fd.set("album", album.trim());
|
|
if (year.trim()) fd.set("year", year.trim());
|
|
for (const f of files) fd.append("files", f);
|
|
const res = await fetch("/api/library/import", { method: "POST", body: fd }).catch(() => null);
|
|
if (res?.ok) {
|
|
const d = (await res.json()) as { tracks: number };
|
|
toast(`Imported ${album.trim()} · ${d.tracks} tracks`);
|
|
onImported();
|
|
close();
|
|
} else {
|
|
const d = res ? ((await res.json().catch(() => ({}))) as { error?: string }) : {};
|
|
toast(d.error || "Import failed");
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button type="button" className="btn accent" onClick={() => setOpen(true)}>
|
|
Add album
|
|
</button>
|
|
{open ? (
|
|
<Modal open onClose={close} title="Add an album">
|
|
<form onSubmit={submit} className="import-form">
|
|
<div
|
|
className={`dropzone${drag ? " over" : ""}`}
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
setDrag(true);
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setDrag(true);
|
|
}}
|
|
onDragLeave={() => setDrag(false)}
|
|
onDrop={onDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
{audioCount > 0
|
|
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
|
|
files.length > audioCount ? " (+ cover)" : ""
|
|
}`
|
|
: "Drop an album folder (or files) here"}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
accept="audio/*,.flac,.m4a,.opus,image/*"
|
|
hidden
|
|
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>
|
|
<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">
|
|
<label className="field">
|
|
<span>Artist</span>
|
|
<input aria-label="import artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span>Album</span>
|
|
<input aria-label="import album" value={album} onChange={(e) => setAlbum(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span>Year (optional)</span>
|
|
<input aria-label="import year" value={year} onChange={(e) => setYear(e.target.value)} placeholder="2020" />
|
|
</label>
|
|
</div>
|
|
<p className="help">
|
|
Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "}
|
|
<b>Scan</b> in Settings afterwards to fetch cover art and match MusicBrainz.
|
|
</p>
|
|
<div className="save-row">
|
|
<button type="submit" className="btn accent" disabled={busy || !ready}>
|
|
{busy ? "Importing…" : "Import"}
|
|
</button>
|
|
{files.length > 0 ? (
|
|
<button type="button" className="btn ghost sm" onClick={reset}>
|
|
Clear files
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|