"use client"; import React from "react"; import { createClient } from "@/utils/supabase/client"; import { useBaseRoms } from "@/contexts/BaseRomContext"; import { baseRoms } from "@/data/baseRoms"; import { platformAccept } from "@/utils/idb"; import { sha1Hex } from "@/utils/hash"; import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js"; import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js"; import { presignNewPatchVersion } from "@/app/hack/actions"; import { confirmPatchUpload } from "@/app/submit/actions"; import { FaInfoCircle } from "react-icons/fa"; export interface HackPatchFormProps { slug: string; baseRomId: string; existingVersions: string[]; currentVersion?: string; } export default function HackPatchForm(props: HackPatchFormProps) { const { slug, baseRomId, existingVersions, currentVersion } = props; const [version, setVersion] = React.useState(""); const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps"); const [patchFile, setPatchFile] = React.useState(null); const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle"); const [genError, setGenError] = React.useState(""); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(""); const [publishAutomatically, setPublishAutomatically] = React.useState(false); const versionInputRef = React.useRef(null); const patchInputRef = React.useRef(null); const modifiedRomInputRef = React.useRef(null); const supabase = createClient(); const baseRomEntry = React.useMemo(() => baseRoms.find(r => r.id === baseRomId) || null, [baseRomId]); const baseRomPlatform = baseRomEntry?.platform; const baseRomName = baseRomEntry?.name; const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms(); const baseRomReady = !!baseRomId && (hasPermission(baseRomId) || hasCached(baseRomId)); const baseRomNeedsPermission = !!baseRomId && isLinked(baseRomId) && !baseRomReady; const baseRomMissing = !!baseRomId && !isLinked(baseRomId) && !hasCached(baseRomId); const isVersionTaken = version.trim() && existingVersions.includes(version.trim()); const canSubmit = React.useMemo(() => { return !!version.trim() && ((!!patchFile && patchMode === "bps") || (patchMode === "rom" && genStatus === "ready")) && !isVersionTaken && !submitting; }, [version, patchFile, patchMode, genStatus, isVersionTaken, submitting]); React.useEffect(() => { versionInputRef.current?.focus(); }, []); // Suggest next version based on currentVersion (supports: 1, 1.0, 1.0.0, v1, v1.0, v1.0.1) React.useEffect(() => { if (version.trim()) return; if (!currentVersion) return; const raw = String(currentVersion).trim(); // Capture prefix (non-digit), numeric core, and any trailing suffix const m = raw.match(/^([^0-9]*\s*)([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?([\s\S]*)$/); if (!m) return; const preservedPrefix = m[1] || ""; const major = parseInt(m[2] || "0", 10); const minor = parseInt(m[3] || "0", 10); const patch = parseInt(m[4] || "0", 10); const suffix = m[5] || ""; const next = `${major}.${minor}.${patch + 1}${suffix}`; setVersion(preservedPrefix + next); }, [currentVersion]); React.useEffect(() => { setPatchFile(null); setGenStatus("idle"); setGenError(""); patchInputRef.current && (patchInputRef.current.value = ""); modifiedRomInputRef.current && (modifiedRomInputRef.current.value = ""); }, [patchMode]); async function onGrantPermission() { if (!baseRomId) return; await ensurePermission(baseRomId, true); } async function onUploadBaseRom(e: React.ChangeEvent) { try { setGenError(""); const f = e.target.files?.[0]; if (!f) return; const matched = await importUploadedBlob(f); if (!matched) { setGenError("That ROM doesn't match any supported base ROM."); return; } if (matched !== baseRomId) { setGenError(`This ROM matches "${matched}", but this hack requires "${baseRomName}".`); return; } } catch { setGenError("Failed to import base ROM."); } } async function onUploadModifiedRom(e: React.ChangeEvent) { try { setGenStatus("generating"); setGenError(""); const mod = e.target.files?.[0] || null; if (!mod || !baseRomId) { setGenStatus("idle"); return; } let baseFile = await getFileBlob(baseRomId); if (!baseFile) { setGenStatus("idle"); setGenError("Base ROM not available."); return; } if (baseRomEntry?.sha1) { const hash = await sha1Hex(baseFile); if (hash.toLowerCase() !== baseRomEntry.sha1.toLowerCase()) { setGenStatus("error"); setGenError("Selected base ROM hash does not match the chosen base ROM."); return; } } const [origBuf, modBuf] = await Promise.all([baseFile.arrayBuffer(), mod.arrayBuffer()]); const origBin = new BinFile(origBuf); const modBin = new BinFile(modBuf); const deltaMode = origBin.fileSize <= 4194304; const patch = BPS.buildFromRoms(origBin, modBin, deltaMode); const fname = `${slug}-${(version || "patch").replace(/[^a-zA-Z0-9._-]+/g, "-")}`; const patchBin = patch.export(fname); const out = new File([patchBin._u8array], `${fname}.bps`, { type: 'application/octet-stream' }); setPatchFile(out); setGenStatus("ready"); } catch (err: any) { setGenStatus("error"); setGenError(err?.message || "Failed to generate patch."); } } const onSubmit = async () => { if (!canSubmit) return; setSubmitting(true); setError(""); try { const presigned = await presignNewPatchVersion({ slug, version: version.trim() }); if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign'); await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } }); const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim(), publishAutomatically }); if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize'); window.location.href = finalized.redirectTo!; } catch (e: any) { setError(e.message || 'Upload failed'); } finally { setSubmitting(false); } }; return (
{currentVersion !== undefined && (

Current version: {currentVersion || 'Not set'}

)}
setVersion(e.target.value)} placeholder="e.g. v1.2.0" className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ${isVersionTaken ? 'ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20' : 'ring-[var(--border)]'} focus:outline-none focus:ring-2 focus:ring-[var(--ring)]`} />
{isVersionTaken ? 'Already used by this hack.' : 'Use semantic versions like v1.2.0.'}
{existingVersions.length > 0 && (
Existing versions: {existingVersions.join(', ')}
)}
{patchMode === "bps" && (
setPatchFile(e.target.files?.[0] || null)} type="file" accept=".bps" className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer" />

Upload a BPS patch file.

)} {patchMode === "rom" && (
Required base ROM
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Select base ROM in main Edit page"}
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"} {baseRomNeedsPermission && ( )} {baseRomMissing && ( )}
{!!genError &&
{genError}
}

We'll generate a .bps patch on-device. No ROMs are uploaded.

{genStatus === "generating" &&
Generating patch…
} {genStatus === "ready" && patchFile &&
Patch ready: {patchFile.name}
} {genStatus === "error" && !!genError &&
{genError}
}
)}
{!!error &&
{error}
}
); }