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