"use client"; import React, { useState, useEffect, useRef } from "react"; import { FiMoreVertical } from "react-icons/fi"; import { FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, } from "react-icons/fa6"; import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react"; import Modal from "@/components/Primitives/Modal"; import { archivePatchVersion, restorePatchVersion, rollbackToVersion, publishPatchVersion, getPatchDownloadUrl, reuploadPatchVersion, confirmReuploadPatchVersion, } from "@/app/hack/[slug]/actions"; 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 { sha1Hex } from "@/utils/hash"; import { baseRoms, type BaseRom } from "@/data/baseRoms"; import { platformAccept } from "@/utils/idb"; import { useBaseRoms } from "@/contexts/BaseRomContext"; interface Patch { id: number; version: string; created_at: string; changelog: string | null; published: boolean; archived: boolean; } interface VersionActionsProps { patch: Patch; isCurrent: boolean; hackSlug: string; baseRom: string; currentPatchCreatedAt: string | null; isCustomPatcherActive?: boolean; isInCustomPatcherList?: boolean; customPatcherPatchCount?: number; onActionComplete: () => void; } export default function VersionActions({ patch, isCurrent, hackSlug, baseRom, currentPatchCreatedAt, isCustomPatcherActive = false, isInCustomPatcherList = false, customPatcherPatchCount = 0, onActionComplete, }: VersionActionsProps) { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showRestoreModal, setShowRestoreModal] = useState(false); const [showRollbackModal, setShowRollbackModal] = useState(false); const [showPublishModal, setShowPublishModal] = useState(false); const [showReuploadModal, setShowReuploadModal] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [patchMode, setPatchMode] = useState<"bps" | "rom">("bps"); const [reuploadFile, setReuploadFile] = useState(null); const [reuploadError, setReuploadError] = useState(null); const [checksumStatus, setChecksumStatus] = useState<"idle" | "validating" | "valid" | "invalid" | "unknown">("idle"); const [checksumError, setChecksumError] = useState(""); const [genStatus, setGenStatus] = useState<"idle" | "generating" | "ready" | "error">("idle"); const [genError, setGenError] = useState(""); const [baseRomFile, setBaseRomFile] = useState(null); const patchInputRef = useRef(null); const modifiedRomInputRef = useRef(null); const baseRomInputRef = useRef(null); const baseRomEntry = baseRoms.find((r) => r.id === baseRom); const baseRomPlatform = baseRomEntry?.platform; const baseRomReady = baseRom && (hasPermission(baseRom) || hasCached(baseRom)); const baseRomNeedsPermission = baseRom && isLinked(baseRom) && !baseRomReady; const baseRomMissing = baseRom && !isLinked(baseRom) && !hasCached(baseRom); // Determine if this patch is newer than the current patch const isNewerThanCurrent = currentPatchCreatedAt ? new Date(patch.created_at).getTime() > new Date(currentPatchCreatedAt).getTime() : false; // Don't show Rollback if the patch is unpublished and newer than the current version const shouldShowRollback = !isCustomPatcherActive && !isCurrent && !(!patch.published && isNewerThanCurrent); const archiveWouldRemoveLastCustomPatch = isInCustomPatcherList && customPatcherPatchCount <= 2; useEffect(() => { if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) { const html = document.documentElement; const body = document.body; const previousHtmlOverflow = html.style.overflow; const previousBodyOverflow = body.style.overflow; const previousBodyPaddingRight = body.style.paddingRight; const scrollBarWidth = window.innerWidth - html.clientWidth; html.style.overflow = "hidden"; body.style.overflow = "hidden"; if (scrollBarWidth > 0) { body.style.paddingRight = `${scrollBarWidth}px`; } return () => { html.style.overflow = previousHtmlOverflow; body.style.overflow = previousBodyOverflow; body.style.paddingRight = previousBodyPaddingRight; }; } }, [showDeleteModal, showRestoreModal, showRollbackModal, showPublishModal, showReuploadModal]); const handleDownload = async () => { try { const result = await getPatchDownloadUrl(patch.id); if (result.ok) { window.open(result.url, "_blank"); } else { alert(result.error || "Failed to generate download URL"); } } catch (error) { alert("Failed to download patch"); } }; const handleDelete = async () => { setActionLoading(true); try { const result = await archivePatchVersion(hackSlug, patch.id); if (result.ok) { setShowDeleteModal(false); onActionComplete(); } else { alert(result.error || "Failed to archive version"); } } catch (error) { alert("Failed to archive version"); } finally { setActionLoading(false); } }; const handleRestore = async () => { setActionLoading(true); try { const result = await restorePatchVersion(hackSlug, patch.id); if (result.ok) { setShowRestoreModal(false); onActionComplete(); } else { alert(result.error || "Failed to restore version"); } } catch (error) { alert("Failed to restore version"); } finally { setActionLoading(false); } }; const handleRollback = async () => { setActionLoading(true); try { const result = await rollbackToVersion(hackSlug, patch.id); if (result.ok) { setShowRollbackModal(false); onActionComplete(); } else { alert(result.error || "Failed to rollback version"); } } catch (error) { alert("Failed to rollback version"); } finally { setActionLoading(false); } }; const handlePublish = async () => { setActionLoading(true); try { const result = await publishPatchVersion(hackSlug, patch.id); if (result.ok) { setShowPublishModal(false); onActionComplete(); } else { alert(result.error || "Failed to publish version"); } } catch (error) { alert("Failed to publish version"); } finally { setActionLoading(false); } }; async function onUploadPatch(e: React.ChangeEvent) { try { setChecksumStatus("validating"); setChecksumError(""); const patchFile = e.target.files?.[0] || null; if (!patchFile) { setChecksumStatus("idle"); setChecksumError(""); setReuploadFile(null); return; } if (!baseRomEntry) { setChecksumStatus("unknown"); setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead."); setReuploadFile(patchFile); return; } // Verify that the patch is a valid BPS file for the selected base ROM const bps = BPS.fromFile(new BinFile(await patchFile.arrayBuffer())); if (bps.sourceChecksum === 0 || bps.sourceChecksum === undefined) { setChecksumStatus("unknown"); setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead."); setReuploadFile(patchFile); return; } const baseRomChecksum = parseInt(baseRomEntry.crc32, 16); if (bps.sourceChecksum !== baseRomChecksum) { setChecksumStatus("invalid"); setChecksumError("Checksum validation failed. The patch file is not compatible with the selected base ROM."); setReuploadFile(null); return; } // All checks passed, set the checksum status to valid setChecksumStatus("valid"); setChecksumError(""); setReuploadFile(patchFile); } catch (err: any) { setChecksumStatus("unknown"); setChecksumError(err?.message || "Failed to validate patch file."); setReuploadFile(null); } } async function onUploadBaseRom(e: React.ChangeEvent) { try { setGenError(""); const f = e.target.files?.[0]; if (!f || !baseRom) return; const matchedId = await importUploadedBlob(f); if (!matchedId) { setGenError("That ROM doesn't match any supported base ROM."); return; } if (matchedId !== baseRom) { const matchedName = baseRoms.find(r => r.id === matchedId)?.name; const baseRomName = baseRomEntry?.name || baseRom; setGenError(`This ROM matches "${matchedName ?? matchedId}", but the form requires "${baseRomName}".`); return; } setBaseRomFile(f); } catch { setGenError("Failed to import base ROM."); setBaseRomFile(null); } } async function onGrantPermission() { if (!baseRom) return; await ensurePermission(baseRom, true); } async function onUploadModifiedRom(e: React.ChangeEvent) { try { setGenStatus("generating"); setGenError(""); const mod = e.target.files?.[0] || null; if (!mod || !baseRom) { setGenStatus("idle"); return; } let baseFile = baseRomFile; if (!baseFile) { baseFile = await getFileBlob(baseRom); } if (!baseFile) { setGenStatus("error"); setGenError("Base ROM not available. Please upload the base ROM first."); 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 fileName = hackSlug || "patch"; const patchBin = patch.export(fileName); const out = new File([patchBin._u8array], `${fileName}.bps`, { type: 'application/octet-stream' }); setReuploadFile(out); setGenStatus("ready"); } catch (err: any) { setGenStatus("error"); setGenError(err?.message || "Failed to generate patch."); } } const handleReupload = async () => { if (!reuploadFile) { setReuploadError("Please select a file"); return; } setActionLoading(true); setReuploadError(null); try { const safeVersion = patch.version.replace(/[^a-zA-Z0-9._-]+/g, "-"); const objectKey = `${hackSlug}-${safeVersion}-reupload-${Date.now()}.bps`; const presignResult = await reuploadPatchVersion(hackSlug, patch.id, objectKey); if (!presignResult.ok) { throw new Error(presignResult.error || "Failed to get upload URL"); } // Upload file const uploadResponse = await fetch(presignResult.presignedUrl, { method: "PUT", body: reuploadFile, headers: { "Content-Type": "application/octet-stream" }, }); if (!uploadResponse.ok) { throw new Error("Failed to upload file"); } // Confirm upload const confirmResult = await confirmReuploadPatchVersion(hackSlug, patch.id, objectKey); if (confirmResult.ok) { setShowReuploadModal(false); setReuploadFile(null); onActionComplete(); } else { throw new Error(confirmResult.error || "Failed to confirm upload"); } } catch (error: any) { setReuploadError(error.message || "Failed to re-upload patch"); } finally { setActionLoading(false); } }; // Mobile: Use dropdown menu, Desktop: Show buttons // If archived, only show Download and Restore if (patch.archived) { return ( <> {/* Desktop: Show buttons */}
{/* Mobile: Use dropdown menu */} Download setShowRestoreModal(true)} className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10" > Restore {/* Restore Modal */} !actionLoading && setShowRestoreModal(false)} >

Restore version {patch.version}? This will make it visible again.

); } // Non-archived patches: show all buttons return ( <> {/* Desktop: Show buttons */}
{!patch.published && ( )} {shouldShowRollback && ( )} {!isCurrent && ( )}
{/* Mobile: Use dropdown menu */} {!patch.published && ( setShowPublishModal(true)} className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10" > Publish )} Download setShowReuploadModal(true)} className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10" > Re-upload {(!isCurrent || shouldShowRollback) && ( <> {shouldShowRollback && ( setShowRollbackModal(true)} className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10" > Rollback )} {!isCurrent && ( setShowDeleteModal(true)} className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-red-600 data-focus:bg-red-600/10" > Archive )} )} {/* Delete Modal */} !actionLoading && setShowDeleteModal(false)} > {archiveWouldRemoveLastCustomPatch ? (

Version {patch.version} is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it.

) : ( <>

Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later.

{isInCustomPatcherList && (

This version will also be removed from the Custom patcher list.

)} )}
{!archiveWouldRemoveLastCustomPatch && ( )}
{/* Rollback Modal */} !actionLoading && setShowRollbackModal(false)} >

Rollback to version {patch.version}? This will set this version as the current patch and unpublish all newer versions.

{/* Publish Modal */} !actionLoading && setShowPublishModal(false)} >

Publish version {patch.version}? This will make it viewable to the public along with its changelog.

{isCustomPatcherActive ? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader." : "If this version is newer than the current patch, it will become the primary download used for all users."}

{/* Re-upload Modal */} { if (!actionLoading) { setShowReuploadModal(false); setReuploadFile(null); setReuploadError(null); setChecksumStatus("idle"); setChecksumError(""); setGenStatus("idle"); setGenError(""); setBaseRomFile(null); setPatchMode("bps"); } }} >
{patchMode === "bps" && (

Upload a BPS patch file.

{checksumStatus === "validating" &&
Validating checksum…
} {checksumStatus === "valid" &&
Checksum valid.
} {checksumStatus === "invalid" && !!checksumError &&
{checksumError}
} {checksumStatus === "unknown" && !!checksumError &&
{checksumError}
}
)} {patchMode === "rom" && (
Required base ROM
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}
{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" && reuploadFile &&
Patch ready: {reuploadFile.name}
} {genStatus === "error" && !!genError &&
{genError}
}
)}
{reuploadError && (

{reuploadError}

)}
); }