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) {
const navigate = useNavigate();
const [url, setUrl] = useState("");
const [fileName, setFileName] = useState<string | null>(null);
const [fileHtml, setFileHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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) {
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}
/>
</div>
@@ -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)"}
</Button>
<input ref={fileRef} type="file" accept=".html" className="hidden" />
<input
ref={fileRef}
type="file"
accept=".html,.htm"
className="hidden"
onChange={handleFileChange}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{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)}
onClick={() => { onOpenChange(false); reset(); }}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
<Button type="submit" className="flex-1" disabled={loading || (!url.trim() && !fileHtml)}>
{loading ? "Importing..." : "Import"}
</Button>
</div>

View File

@@ -10,7 +10,8 @@ pub struct AppState {
#[derive(Deserialize)]
pub struct ParseRequest {
pub source: String,
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize)]
@@ -22,16 +23,25 @@ pub async fn parse_tab(
State(state): State<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
let source = if body.source.starts_with("file://") {
let path = body.source.trim_start_matches("file://");
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(body.source)
TabSource::Url(source)
};
let html = state.fetcher.fetch(source).await.map_err(|e| {
state.fetcher.fetch(tab_source).await.map_err(|e| {
(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| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))