"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({ kind: "idle" }); const [dragOver, setDragOver] = useState(false); const inputRef = useRef(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) => { 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 (
Tape Identifier
{state.kind === "results" ? ( <>

{state.fileName} matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:

{state.matches.map((m) => (
{m.entryTitle}
{m.innerPath}
{formatBytes(m.sizeBytes)}
{m.md5}
))}
) : (

No matching tape found in ZXDB for {state.fileName}.

)}
); } const isProcessing = state.kind === "hashing" || state.kind === "identifying"; return (
Tape Identifier
{ e.preventDefault(); if (!isProcessing) setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop} onClick={isProcessing ? undefined : () => inputRef.current?.click()} > {isProcessing ? (
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
) : ( <>

Drop a tape file to identify it

{SUPPORTED_EXTS.join(" ")} — or{" "} choose file

)}
{state.kind === "error" && (
{state.message}
)}
); }