291 lines
9.0 KiB
TypeScript
291 lines
9.0 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "~/components/ui/sheet";
|
|
import { Input } from "~/components/ui/input";
|
|
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;
|
|
onOpenChange: (open: boolean) => void;
|
|
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 [fileItems, setFileItems] = useState<FileItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) reset();
|
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (!files.length) return;
|
|
setError(null);
|
|
|
|
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("");
|
|
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 (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({ source: url.trim() });
|
|
onSongAdded({
|
|
id: stored.id,
|
|
meta: stored.song.meta,
|
|
preview_chords: previewChords(stored.song),
|
|
});
|
|
onOpenChange(false);
|
|
reset();
|
|
navigate(`/songs/${stored.id}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
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">
|
|
<SheetHeader className="mb-4">
|
|
<SheetTitle>Add Song</SheetTitle>
|
|
</SheetHeader>
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
From URL
|
|
</label>
|
|
<Input
|
|
placeholder="https://tabs.ultimate-guitar.com/..."
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
disabled={loading || fileItems.length > 0}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-border" />
|
|
<span className="text-xs text-muted-foreground">or</span>
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
From File
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full border-dashed"
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={loading || !!url.trim()}
|
|
>
|
|
{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">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
reset();
|
|
}}
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="flex-1"
|
|
disabled={
|
|
loading ||
|
|
(fileItems.length === 0 && !url.trim()) ||
|
|
(fileItems.length > 0 && pendingCount === 0)
|
|
}
|
|
>
|
|
{loading
|
|
? "Importing..."
|
|
: fileItems.length > 1
|
|
? `Import ${pendingCount} Song${pendingCount !== 1 ? "s" : ""}`
|
|
: "Import"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|