Compare commits

..

2 Commits

3 changed files with 163 additions and 25 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
/target
/app
.superpowers/
.git/
.claude/

View File

@@ -6,14 +6,15 @@ COPY . .
# Build the release binary # Build the release binary
RUN cargo build --release -p api RUN cargo build --release -p api
FROM debian:bookworm-slim FROM debian:trixie-slim
WORKDIR /app WORKDIR /app
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning) # Install OpenSSL, CA certs
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \ libssl3 \
ca-certificates \ ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/api . COPY --from=builder /app/target/release/api .

View File

@@ -11,6 +11,7 @@ import { Button } from "~/components/ui/button";
import type { SongSummary } from "~/lib/types"; import type { SongSummary } from "~/lib/types";
import { createSong } from "~/lib/api"; import { createSong } from "~/lib/api";
import { previewChords } from "~/lib/song-utils"; import { previewChords } from "~/lib/song-utils";
import { cn } from "~/lib/utils";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -18,11 +19,29 @@ interface Props {
onSongAdded: (summary: SongSummary) => void; onSongAdded: (summary: SongSummary) => void;
} }
type FileStatus = "pending" | "importing" | "done" | "error";
interface FileItem {
id: string;
name: string;
html: string | null;
status: FileStatus;
error: string | null;
}
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (ev) => resolve(ev.target?.result as string);
reader.onerror = () => reject(new Error("Failed to read file."));
reader.readAsText(file);
});
}
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 [fileItems, setFileItems] = useState<FileItem[]>([]);
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);
@@ -31,41 +50,107 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
if (!open) reset(); if (!open) reset();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const files = Array.from(e.target.files ?? []);
if (!file) return; if (!files.length) return;
setFileName(file.name);
setError(null); setError(null);
const reader = new FileReader();
reader.onload = (ev) => { const stubs: FileItem[] = files.map((f) => ({
setFileHtml(ev.target?.result as string); id: crypto.randomUUID(),
}; name: f.name,
reader.onerror = () => setError("Failed to read file."); html: null,
reader.readAsText(file); status: "pending",
error: null,
}));
setFileItems((prev) => [...prev, ...stubs]);
if (fileRef.current) fileRef.current.value = "";
for (let i = 0; i < files.length; i++) {
const stub = stubs[i];
try {
const html = await readFileAsText(files[i]);
setFileItems((prev) =>
prev.map((item) => item.id === stub.id ? { ...item, html } : item)
);
} catch {
setFileItems((prev) =>
prev.map((item) =>
item.id === stub.id
? { ...item, status: "error", error: "Failed to read file." }
: item
)
);
}
}
} }
function reset() { function reset() {
setUrl(""); setUrl("");
setFileName(null); setFileItems([]);
setFileHtml(null);
setError(null); setError(null);
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
} }
async function handleBulkImport() {
const toProcess = fileItems.filter((f) => f.status === "pending" && f.html !== null);
if (!toProcess.length) return;
setLoading(true);
const successes: SongSummary[] = [];
let hasError = false;
for (const item of toProcess) {
setFileItems((prev) =>
prev.map((f) => f.id === item.id ? { ...f, status: "importing" } : f)
);
try {
const stored = await createSong({ html: item.html! });
successes.push({
id: stored.id,
meta: stored.song.meta,
preview_chords: previewChords(stored.song),
});
setFileItems((prev) =>
prev.map((f) => f.id === item.id ? { ...f, status: "done" } : f)
);
} catch (err) {
hasError = true;
setFileItems((prev) =>
prev.map((f) =>
f.id === item.id
? { ...f, status: "error", error: err instanceof Error ? err.message : "Import failed." }
: f
)
);
}
}
setLoading(false);
for (const s of successes) onSongAdded(s);
if (!hasError) {
onOpenChange(false);
reset();
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!url.trim() && !fileHtml) { if (fileItems.length > 0) {
await handleBulkImport();
return;
}
if (!url.trim()) {
setError("Provide a URL or pick a file."); setError("Provide a URL or pick a file.");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const stored = await createSong( const stored = await createSong({ source: url.trim() });
fileHtml ? { html: fileHtml } : { source: url.trim() },
);
onSongAdded({ onSongAdded({
id: stored.id, id: stored.id,
meta: stored.song.meta, meta: stored.song.meta,
@@ -81,6 +166,10 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
} }
} }
const pendingCount = fileItems.filter((f) => f.status === "pending" && f.html !== null).length;
const doneCount = fileItems.filter((f) => f.status === "done").length;
const readableCount = fileItems.filter((f) => f.html !== null).length;
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl"> <SheetContent side="bottom" className="rounded-t-xl">
@@ -96,7 +185,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 || !!fileHtml} disabled={loading || fileItems.length > 0}
/> />
</div> </div>
@@ -115,19 +204,54 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
variant="outline" variant="outline"
className="w-full border-dashed" className="w-full border-dashed"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={loading} disabled={loading || !!url.trim()}
> >
{fileName ? `📄 ${fileName}` : "📂 Choose saved UG page (.html)"} {fileItems.length > 0 ? "📂 Add more files" : "📂 Choose saved UG pages (.html)"}
</Button> </Button>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".html,.htm" accept=".html,.htm"
multiple
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
{fileItems.length > 0 && (
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto mt-1 rounded border border-border p-2">
{fileItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-sm py-0.5">
<span
className={cn("shrink-0 w-4 text-center font-mono text-xs", {
"text-muted-foreground": item.status === "pending",
"text-blue-500": item.status === "importing",
"text-green-600": item.status === "done",
"text-destructive": item.status === "error",
})}
>
{item.status === "pending" && "•"}
{item.status === "importing" && "↻"}
{item.status === "done" && "✓"}
{item.status === "error" && "✗"}
</span>
<span className="flex-1 truncate text-foreground">{item.name}</span>
{item.error && (
<span className="text-xs text-destructive shrink-0 max-w-[140px] truncate">
{item.error}
</span>
)}
</div>
))}
</div>
)}
</div> </div>
{loading && fileItems.length > 0 && (
<p className="text-xs text-muted-foreground text-center">
Importing {doneCount} of {readableCount}...
</p>
)}
{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"> <div className="flex gap-2 pt-1">
@@ -146,9 +270,17 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
<Button <Button
type="submit" type="submit"
className="flex-1" className="flex-1"
disabled={loading || (!url.trim() && !fileHtml)} disabled={
loading ||
(fileItems.length === 0 && !url.trim()) ||
(fileItems.length > 0 && pendingCount === 0)
}
> >
{loading ? "Importing..." : "Import"} {loading
? "Importing..."
: fileItems.length > 1
? `Import ${pendingCount} Song${pendingCount !== 1 ? "s" : ""}`
: "Import"}
</Button> </Button>
</div> </div>
</form> </form>