feat: implement bulk file import functionality in AddSongSheet component
This commit is contained in:
@@ -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<string> {
|
||||
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<string | null>(null);
|
||||
const [fileHtml, setFileHtml] = useState<string | null>(null);
|
||||
const [fileItems, setFileItems] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="rounded-t-xl">
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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)"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".html,.htm"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{fileItems.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto mt-1 rounded border border-border p-2">
|
||||
{fileItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2 text-sm py-0.5">
|
||||
<span
|
||||
className={cn("shrink-0 w-4 text-center font-mono text-xs", {
|
||||
"text-muted-foreground": item.status === "pending",
|
||||
"text-blue-500": item.status === "importing",
|
||||
"text-green-600": item.status === "done",
|
||||
"text-destructive": item.status === "error",
|
||||
})}
|
||||
>
|
||||
{item.status === "pending" && "•"}
|
||||
{item.status === "importing" && "↻"}
|
||||
{item.status === "done" && "✓"}
|
||||
{item.status === "error" && "✗"}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-foreground">{item.name}</span>
|
||||
{item.error && (
|
||||
<span className="text-xs text-destructive shrink-0 max-w-[140px] truncate">
|
||||
{item.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && fileItems.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Importing {doneCount} of {readableCount}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
@@ -146,9 +270,17 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={loading || (!url.trim() && !fileHtml)}
|
||||
disabled={
|
||||
loading ||
|
||||
(fileItems.length === 0 && !url.trim()) ||
|
||||
(fileItems.length > 0 && pendingCount === 0)
|
||||
}
|
||||
>
|
||||
{loading ? "Importing..." : "Import"}
|
||||
{loading
|
||||
? "Importing..."
|
||||
: fileItems.length > 1
|
||||
? `Import ${pendingCount} Song${pendingCount !== 1 ? "s" : ""}`
|
||||
: "Import"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user