fix: support raw HTML upload via FileReader, fix file import flow

This commit is contained in:
2026-04-08 02:43:27 +02:00
parent 90833c5b93
commit 3bc7ad4c7c
2 changed files with 61 additions and 27 deletions

View File

@@ -20,33 +20,53 @@ interface Props {
export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [fileName, setFileName] = useState<string | null>(null);
const [fileHtml, setFileHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
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);
}
function reset() {
setUrl("");
setFileName(null);
setFileHtml(null);
setError(null);
if (fileRef.current) fileRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
const file = fileRef.current?.files?.[0]; if (!url.trim() && !fileHtml) {
if (!url.trim() && !file) {
setError("Provide a URL or pick a file."); setError("Provide a URL or pick a file.");
return; return;
} }
setLoading(true); setLoading(true);
try { 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 apiBase = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; const apiBase = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const body = fileHtml
? { html: fileHtml }
: { source: url.trim() };
const resp = await fetch(`${apiBase}/tabs/parse`, { const resp = await fetch(`${apiBase}/tabs/parse`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: url.trim() }), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -65,7 +85,7 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
}; };
onSongAdded(summary); onSongAdded(summary);
onOpenChange(false); onOpenChange(false);
setUrl(""); reset();
navigate(`/songs/${id}`); navigate(`/songs/${id}`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong."); setError(err instanceof Error ? err.message : "Something went wrong.");
@@ -89,7 +109,7 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
placeholder="https://tabs.ultimate-guitar.com/..." placeholder="https://tabs.ultimate-guitar.com/..."
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
disabled={loading} disabled={loading || !!fileHtml}
/> />
</div> </div>
@@ -110,26 +130,30 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={loading} disabled={loading}
> >
📂 Choose HTML file {fileName ? `📄 ${fileName}` : "📂 Choose saved UG page (.html)"}
</Button> </Button>
<input ref={fileRef} type="file" accept=".html" className="hidden" /> <input
ref={fileRef}
type="file"
accept=".html,.htm"
className="hidden"
onChange={handleFileChange}
/>
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="flex-1" className="flex-1"
onClick={() => onOpenChange(false)} onClick={() => { onOpenChange(false); reset(); }}
disabled={loading} disabled={loading}
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" className="flex-1" disabled={loading}> <Button type="submit" className="flex-1" disabled={loading || (!url.trim() && !fileHtml)}>
{loading ? "Importing..." : "Import"} {loading ? "Importing..." : "Import"}
</Button> </Button>
</div> </div>

View File

@@ -10,7 +10,8 @@ pub struct AppState {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ParseRequest { pub struct ParseRequest {
pub source: String, pub source: Option<String>,
pub html: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -22,16 +23,25 @@ pub async fn parse_tab(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(body): Json<ParseRequest>, Json(body): Json<ParseRequest>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
let source = if body.source.starts_with("file://") { let html = if let Some(raw_html) = body.html {
let path = body.source.trim_start_matches("file://"); // Raw HTML provided directly (e.g. from browser file upload via FileReader)
raw_html
} else if let Some(source) = body.source {
let tab_source = if source.starts_with("file://") {
let path = source.trim_start_matches("file://");
TabSource::File(PathBuf::from(path)) TabSource::File(PathBuf::from(path))
} else { } else {
TabSource::Url(body.source) TabSource::Url(source)
}; };
state.fetcher.fetch(tab_source).await.map_err(|e| {
let html = state.fetcher.fetch(source).await.map_err(|e| {
(StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e.to_string() })) (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e.to_string() }))
})?; })?
} else {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: "Provide either 'source' or 'html'".into() }),
));
};
let song = state.parser.parse(&html).map_err(|e| { let song = state.parser.parse(&html).map_err(|e| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() })) (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))