diff --git a/app/app/components/add-song-sheet.tsx b/app/app/components/add-song-sheet.tsx index 2ee24db..e0903c9 100644 --- a/app/app/components/add-song-sheet.tsx +++ b/app/app/components/add-song-sheet.tsx @@ -11,6 +11,7 @@ import { Button } from "~/components/ui/button"; import type { SongSummary } from "~/lib/types"; import { createSong } from "~/lib/api"; import { previewChords } from "~/lib/song-utils"; +import { cn } from "~/lib/utils"; interface Props { open: boolean; @@ -18,11 +19,29 @@ interface Props { onSongAdded: (summary: SongSummary) => void; } +type FileStatus = "pending" | "importing" | "done" | "error"; + +interface FileItem { + id: string; + name: string; + html: string | null; + status: FileStatus; + error: string | null; +} + +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (ev) => resolve(ev.target?.result as string); + reader.onerror = () => reject(new Error("Failed to read file.")); + reader.readAsText(file); + }); +} + export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { const navigate = useNavigate(); const [url, setUrl] = useState(""); - const [fileName, setFileName] = useState(null); - const [fileHtml, setFileHtml] = useState(null); + const [fileItems, setFileItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fileRef = useRef(null); @@ -31,41 +50,107 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { if (!open) reset(); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - setFileName(file.name); + async function handleFileChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; setError(null); - const reader = new FileReader(); - reader.onload = (ev) => { - setFileHtml(ev.target?.result as string); - }; - reader.onerror = () => setError("Failed to read file."); - reader.readAsText(file); + + const stubs: FileItem[] = files.map((f) => ({ + id: crypto.randomUUID(), + name: f.name, + html: null, + status: "pending", + error: null, + })); + setFileItems((prev) => [...prev, ...stubs]); + if (fileRef.current) fileRef.current.value = ""; + + for (let i = 0; i < files.length; i++) { + const stub = stubs[i]; + try { + const html = await readFileAsText(files[i]); + setFileItems((prev) => + prev.map((item) => item.id === stub.id ? { ...item, html } : item) + ); + } catch { + setFileItems((prev) => + prev.map((item) => + item.id === stub.id + ? { ...item, status: "error", error: "Failed to read file." } + : item + ) + ); + } + } } function reset() { setUrl(""); - setFileName(null); - setFileHtml(null); + setFileItems([]); setError(null); if (fileRef.current) fileRef.current.value = ""; } + async function handleBulkImport() { + const toProcess = fileItems.filter((f) => f.status === "pending" && f.html !== null); + if (!toProcess.length) return; + + setLoading(true); + const successes: SongSummary[] = []; + let hasError = false; + + for (const item of toProcess) { + setFileItems((prev) => + prev.map((f) => f.id === item.id ? { ...f, status: "importing" } : f) + ); + try { + const stored = await createSong({ html: item.html! }); + successes.push({ + id: stored.id, + meta: stored.song.meta, + preview_chords: previewChords(stored.song), + }); + setFileItems((prev) => + prev.map((f) => f.id === item.id ? { ...f, status: "done" } : f) + ); + } catch (err) { + hasError = true; + setFileItems((prev) => + prev.map((f) => + f.id === item.id + ? { ...f, status: "error", error: err instanceof Error ? err.message : "Import failed." } + : f + ) + ); + } + } + + setLoading(false); + for (const s of successes) onSongAdded(s); + + if (!hasError) { + onOpenChange(false); + reset(); + } + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); - if (!url.trim() && !fileHtml) { + if (fileItems.length > 0) { + await handleBulkImport(); + return; + } + + if (!url.trim()) { setError("Provide a URL or pick a file."); return; } setLoading(true); try { - const stored = await createSong( - fileHtml ? { html: fileHtml } : { source: url.trim() }, - ); + const stored = await createSong({ source: url.trim() }); onSongAdded({ id: stored.id, meta: stored.song.meta, @@ -81,6 +166,10 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { } } + const pendingCount = fileItems.filter((f) => f.status === "pending" && f.html !== null).length; + const doneCount = fileItems.filter((f) => f.status === "done").length; + const readableCount = fileItems.filter((f) => f.html !== null).length; + return ( @@ -96,7 +185,7 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { placeholder="https://tabs.ultimate-guitar.com/..." value={url} onChange={(e) => setUrl(e.target.value)} - disabled={loading || !!fileHtml} + disabled={loading || fileItems.length > 0} /> @@ -115,19 +204,54 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { variant="outline" className="w-full border-dashed" onClick={() => fileRef.current?.click()} - disabled={loading} + disabled={loading || !!url.trim()} > - {fileName ? `📄 ${fileName}` : "📂 Choose saved UG page (.html)"} + {fileItems.length > 0 ? "📂 Add more files" : "📂 Choose saved UG pages (.html)"} + + {fileItems.length > 0 && ( +
+ {fileItems.map((item) => ( +
+ + {item.status === "pending" && "•"} + {item.status === "importing" && "↻"} + {item.status === "done" && "✓"} + {item.status === "error" && "✗"} + + {item.name} + {item.error && ( + + {item.error} + + )} +
+ ))} +
+ )} + {loading && fileItems.length > 0 && ( +

+ Importing {doneCount} of {readableCount}... +

+ )} + {error &&

{error}

}
@@ -146,9 +270,17 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {