fix: support raw HTML upload via FileReader, fix file import flow
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,17 +23,26 @@ 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://");
|
||||
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() }))
|
||||
})?;
|
||||
|
||||
Reference in New Issue
Block a user