From 15b71ad12dcc7172d4bab0dd17c944f92704feda Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Sat, 20 Jun 2026 00:46:29 -0600 Subject: [PATCH] Add patch version picker to hack page downloader --- src/app/hack/[slug]/page.tsx | 1 + src/components/Hack/HackActions.tsx | 48 ++++++++- src/components/Hack/StickyActionBar.tsx | 123 +++++++++++++++++++++++- 3 files changed, 165 insertions(+), 7 deletions(-) diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index ef31225..3fe757a 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -228,6 +228,7 @@ export default async function HackDetail({ params }: HackDetailProps) { patchFilename={patchFilename} patchId={patchId ?? undefined} hackSlug={hack.slug} + patcherSelector={metadata.patcherSelector} /> )} diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 2cf6aff..55080fa 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -15,6 +15,7 @@ import { isArchiveFile, isAnyRomExtension, } from "@/utils/romFile"; +import type { SelectablePatch } from "@/types/patcher"; interface HackActionsProps { title: string; @@ -25,6 +26,10 @@ interface HackActionsProps { patchFilename: string | null; patchId?: number; hackSlug: string; + patcherSelector: { + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; + }; } const HackActions: React.FC = ({ @@ -36,6 +41,7 @@ const HackActions: React.FC = ({ patchFilename, patchId, hackSlug, + patcherSelector, }) => { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms(); const [file, setFile] = React.useState(null); @@ -46,11 +52,36 @@ const HackActions: React.FC = ({ const [termsAgreed, setTermsAgreed] = React.useState(false); const [romErrorModal, setRomErrorModal] = React.useState(null); const [isVerifyingRom, setIsVerifyingRom] = React.useState(false); + const [selectedPatchId, setSelectedPatchId] = React.useState(patcherSelector.defaultPatchId); const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]); const effectivePlatform = React.useMemo( () => platform ?? baseRoms.find(r => r.id === baseRomId)?.platform, [platform, baseRomId], ); + const selectedPatch = React.useMemo( + () => patcherSelector.selectablePatches.find((patch) => patch.id === selectedPatchId) + ?? patcherSelector.selectablePatches[0] + ?? null, + [patcherSelector.selectablePatches, selectedPatchId], + ); + const selectedVersion = selectedPatch?.version ?? version; + + React.useEffect(() => { + setSelectedPatchId(patcherSelector.defaultPatchId); + }, [patcherSelector.defaultPatchId]); + + function resetPatchSession() { + setTermsAgreed(false); + setPatchUrl(null); + setPatchBlob(null); + setStatus("idle"); + } + + function onVersionChange(nextPatchId: number) { + if (nextPatchId === selectedPatchId) return; + setSelectedPatchId(nextPatchId); + resetPatchSession(); + } // Basic client-side bot detection React.useEffect(() => { @@ -189,7 +220,10 @@ const HackActions: React.FC = ({ setStatus("downloading"); // Fetch signed URL from server - const result = await getSignedPatchUrl(hackSlug); + const result = await getSignedPatchUrl( + hackSlug, + selectedPatch ? { patchId: selectedPatch.id } : undefined, + ); if (!result.ok) { setError(result.error); setStatus("idle"); @@ -276,7 +310,7 @@ const HackActions: React.FC = ({ // Name output and download const outExt = platform ? platform.toLowerCase() : 'bin'; - const outputName = `${title} (${version}).${outExt}`; + const outputName = `${title} (${selectedVersion}).${outExt}`; patchedRom.fileName = outputName; patchedRom.save(); @@ -284,7 +318,8 @@ const HackActions: React.FC = ({ // Best-effort log applied event for counting and animate badge try { - if (patchId != null) { + const countPatchId = selectedPatch?.id ?? patchId; + if (countPatchId != null) { const key = "deviceId"; let deviceId = localStorage.getItem(key); if (!deviceId) { @@ -294,7 +329,7 @@ const HackActions: React.FC = ({ // Defer count update to avoid Safari cancelling the request setTimeout(async () => { const deviceIdObscured = deviceId.split("-"); - const result = await updatePatchDownloadCount(patchId, deviceIdObscured); + const result = await updatePatchDownloadCount(countPatchId, deviceIdObscured); if (!result.ok) { console.error(result.error); } else if (result.didIncrease) { @@ -316,7 +351,10 @@ const HackActions: React.FC = ({ <> void; author: string; filename: string | null; baseRomName?: string | null; @@ -27,6 +31,9 @@ interface StickyActionBarProps { export default function StickyActionBar({ title, version, + selectablePatches = [], + selectedPatchId, + onVersionChange, author, filename, baseRomName, @@ -48,6 +55,8 @@ export default function StickyActionBar({ const [errorMessage, setErrorMessage] = React.useState(null); const [showError, setShowError] = React.useState(false); const [patchAgainReady, setPatchAgainReady] = React.useState(true); + const [versionPickerOpen, setVersionPickerOpen] = React.useState(false); + const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange; // Keep error mounted to allow fade-out when error becomes null React.useEffect(() => { @@ -78,19 +87,54 @@ export default function StickyActionBar({ } }, [status]); + const handleVersionSelect = (patchId: number, closeAfterSelect: boolean) => { + onVersionChange?.(patchId); + if (closeAfterSelect) { + setVersionPickerOpen(false); + } + }; + return (
{title}
- {version && ( + {hasVersionPicker ? ( + + ) : version && ( {version} )}
By {author}
-
+ {hasVersionPicker && versionPickerOpen && ( +
+
+
Select version
+
+ handleVersionSelect(patchId, true)} + /> +
+ )} +
{!termsAgreed || status === "downloading" ? ( !romReady ? (

@@ -166,6 +210,35 @@ export default function StickyActionBar({

+ {hasVersionPicker && versionPickerOpen && ( +
+
setVersionPickerOpen(false)} + /> +
+ +

Select which version to download

+ handleVersionSelect(patchId, false)} + /> +
+
+ )} {errorMessage !== null && (
void; +}) { + return ( +
+ {patches.map((patch, index) => { + const selected = selectedPatchId === patch.id; + return ( + + ); + })} +
+ ); +} +