feat(app): add AddSongSheet component

This commit is contained in:
2026-04-08 02:30:57 +02:00
parent 8ca80c38f1
commit cdb9c59d37

View File

@@ -0,0 +1,155 @@
import { 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 { Song, SongSummary } from "~/lib/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSongAdded: (summary: SongSummary) => void;
}
function previewChords(song: Song): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const section of song.sections) {
for (const line of section.lines) {
for (const cp of line.chords) {
if (!seen.has(cp.chord)) {
seen.add(cp.chord);
result.push(cp.chord);
}
}
}
if (result.length >= 5) break;
}
return result.slice(0, 5);
}
export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
const navigate = useNavigate();
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const file = fileRef.current?.files?.[0];
if (!url.trim() && !file) {
setError("Provide a URL or pick a file.");
return;
}
setLoading(true);
try {
if (file) {
// File paths can't be sent from the browser — the API expects a file:// URI
// which only works when running locally. Show a helpful error.
throw new Error("File upload requires running the app locally with direct file paths. Use a URL instead.");
}
const resp = await fetch("http://localhost:8000/tabs/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: url.trim() }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `HTTP ${resp.status}`);
}
const song: Song = await resp.json();
const id = `new-${Date.now()}`;
sessionStorage.setItem(id, JSON.stringify(song));
const summary: SongSummary = {
id,
meta: song.meta,
preview_chords: previewChords(song),
};
onSongAdded(summary);
onOpenChange(false);
setUrl("");
navigate(`/songs/${id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setLoading(false);
}
}
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}
/>
</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}
>
📂 Choose HTML file
</Button>
<input ref={fileRef} type="file" accept=".html" className="hidden" />
</div>
{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)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? "Importing..." : "Import"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}