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) {
|
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>
|
||||||
|
|||||||
@@ -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() }))
|
||||||
|
|||||||
Reference in New Issue
Block a user