From 3bc7ad4c7c40409131b63d257a2879c82285e408 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 02:43:27 +0200 Subject: [PATCH] fix: support raw HTML upload via FileReader, fix file import flow --- app/app/components/add-song-sheet.tsx | 60 +++++++++++++++++++-------- crates/api/src/routes/tabs.rs | 28 +++++++++---- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/app/app/components/add-song-sheet.tsx b/app/app/components/add-song-sheet.tsx index 12306cc..3e97be8 100644 --- a/app/app/components/add-song-sheet.tsx +++ b/app/app/components/add-song-sheet.tsx @@ -20,33 +20,53 @@ interface Props { export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { const navigate = useNavigate(); const [url, setUrl] = useState(""); + const [fileName, setFileName] = useState(null); + const [fileHtml, setFileHtml] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fileRef = useRef(null); + function handleFileChange(e: React.ChangeEvent) { + 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) { e.preventDefault(); setError(null); - const file = fileRef.current?.files?.[0]; - if (!url.trim() && !file) { + if (!url.trim() && !fileHtml) { 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 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`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source: url.trim() }), + body: JSON.stringify(body), }); if (!resp.ok) { @@ -65,7 +85,7 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { }; onSongAdded(summary); onOpenChange(false); - setUrl(""); + reset(); navigate(`/songs/${id}`); } catch (err) { 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/..." value={url} onChange={(e) => setUrl(e.target.value)} - disabled={loading} + disabled={loading || !!fileHtml} /> @@ -110,26 +130,30 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) { onClick={() => fileRef.current?.click()} disabled={loading} > - 📂 Choose HTML file + {fileName ? `📄 ${fileName}` : "📂 Choose saved UG page (.html)"} - + - {error && ( -

{error}

- )} + {error &&

{error}

}
-
diff --git a/crates/api/src/routes/tabs.rs b/crates/api/src/routes/tabs.rs index cf27a5e..fb2aa6e 100644 --- a/crates/api/src/routes/tabs.rs +++ b/crates/api/src/routes/tabs.rs @@ -10,7 +10,8 @@ pub struct AppState { #[derive(Deserialize)] pub struct ParseRequest { - pub source: String, + pub source: Option, + pub html: Option, } #[derive(Serialize)] @@ -22,17 +23,26 @@ pub async fn parse_tab( State(state): State>, Json(body): Json, ) -> Result, (StatusCode, Json)> { - let source = if body.source.starts_with("file://") { - let path = body.source.trim_start_matches("file://"); - TabSource::File(PathBuf::from(path)) + let html = if let Some(raw_html) = body.html { + // 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)) + } else { + TabSource::Url(source) + }; + state.fetcher.fetch(tab_source).await.map_err(|e| { + (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e.to_string() })) + })? } else { - TabSource::Url(body.source) + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: "Provide either 'source' or 'html'".into() }), + )); }; - let html = state.fetcher.fetch(source).await.map_err(|e| { - (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e.to_string() })) - })?; - let song = state.parser.parse(&html).map_err(|e| { (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() })) })?;