feat(app): add AddSongSheet component
This commit is contained in:
155
app/app/components/add-song-sheet.tsx
Normal file
155
app/app/components/add-song-sheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user