feat: add tape identifier dropzone on /zxdb
Client computes MD5 + size in-browser, server action looks up software_hashes to identify tape files against ZXDB entries. - src/utils/md5.ts: pure-JS MD5 for browser (Web Crypto lacks MD5) - src/app/zxdb/actions.ts: server action identifyTape() - src/app/zxdb/TapeIdentifier.tsx: dropzone client component - src/server/repo/zxdb.ts: lookupByMd5() joins hashes→downloads→entries - src/app/zxdb/page.tsx: mount TapeIdentifier between hero and nav grid opus-4-6@McFiver
This commit is contained in:
195
src/app/zxdb/TapeIdentifier.tsx
Normal file
195
src/app/zxdb/TapeIdentifier.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { computeMd5 } from "@/utils/md5";
|
||||
import { identifyTape } from "./actions";
|
||||
import type { TapeMatch } from "@/server/repo/zxdb";
|
||||
|
||||
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||
|
||||
type State =
|
||||
| { kind: "idle" }
|
||||
| { kind: "hashing" }
|
||||
| { kind: "identifying" }
|
||||
| { kind: "results"; matches: TapeMatch[]; fileName: string }
|
||||
| { kind: "not-found"; fileName: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function TapeIdentifier() {
|
||||
const [state, setState] = useState<State>({ kind: "idle" });
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.includes(ext)) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ kind: "hashing" });
|
||||
try {
|
||||
const md5 = await computeMd5(file);
|
||||
setState({ kind: "identifying" });
|
||||
const matches = await identifyTape(md5, file.size);
|
||||
if (matches.length > 0) {
|
||||
setState({ kind: "results", matches, fileName: file.name });
|
||||
} else {
|
||||
setState({ kind: "not-found", fileName: file.name });
|
||||
}
|
||||
} catch {
|
||||
setState({ kind: "error", message: "Something went wrong. Please try again." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file);
|
||||
},
|
||||
[processFile]
|
||||
);
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) processFile(file);
|
||||
// Reset so re-selecting the same file triggers change
|
||||
e.target.value = "";
|
||||
},
|
||||
[processFile]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
// Dropzone view (idle, hashing, identifying, error)
|
||||
if (state.kind === "results" || state.kind === "not-found") {
|
||||
return (
|
||||
<div className="card border-0 shadow-sm">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||
Tape Identifier
|
||||
</h5>
|
||||
|
||||
{state.kind === "results" ? (
|
||||
<>
|
||||
<p className="text-secondary mb-2">
|
||||
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
|
||||
</p>
|
||||
<div className="list-group list-group-flush mb-3">
|
||||
{state.matches.map((m) => (
|
||||
<div key={m.downloadId} className="list-group-item px-0">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<Link href={`/zxdb/entries/${m.entryId}`} className="fw-semibold text-decoration-none">
|
||||
{m.entryTitle}
|
||||
</Link>
|
||||
<div className="text-secondary small mt-1">
|
||||
{m.innerPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end text-secondary small text-nowrap ms-3">
|
||||
<div>{formatBytes(m.sizeBytes)}</div>
|
||||
<div className="font-monospace" style={{ fontSize: "0.75rem" }}>{m.md5}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-secondary mb-3">
|
||||
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={reset}>
|
||||
Identify another tape
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
|
||||
|
||||
return (
|
||||
<div className="card border-0 shadow-sm">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||
Tape Identifier
|
||||
</h5>
|
||||
|
||||
<div
|
||||
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
|
||||
style={{
|
||||
border: "2px dashed",
|
||||
cursor: isProcessing ? "wait" : "pointer",
|
||||
transition: "background-color 0.15s, border-color 0.15s",
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isProcessing) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop}
|
||||
onClick={isProcessing ? undefined : () => inputRef.current?.click()}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div className="py-2">
|
||||
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
|
||||
<span className="text-secondary">
|
||||
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden />
|
||||
</div>
|
||||
<p className="mb-1 text-secondary">
|
||||
Drop a tape file to identify it
|
||||
</p>
|
||||
<p className="mb-0 small text-secondary">
|
||||
{SUPPORTED_EXTS.join(" ")} — or{" "}
|
||||
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
|
||||
choose file
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_EXTS.join(",")}
|
||||
className="d-none"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.kind === "error" && (
|
||||
<div className="alert alert-warning mt-3 mb-0 py-2 small">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user