mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-02 13:58:39 -05:00
Add BaseRomErrorModal on bad rom selections
This commit is contained in:
214
src/components/Hack/BaseRomErrorModal.tsx
Normal file
214
src/components/Hack/BaseRomErrorModal.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { FiX } from "react-icons/fi";
|
||||
import { FaCircleCheck, FaCircleXmark } from "react-icons/fa6";
|
||||
|
||||
export type BaseRomErrorModalState =
|
||||
| { kind: "archive"; fileName: string; requiredExtensionPhrase: string; requiredRomName: string }
|
||||
| { kind: "unrecognized"; fileName: string; requiredExtensionPhrase: string; requiredRomName: string }
|
||||
| {
|
||||
kind: "hash_mismatch";
|
||||
fileName: string;
|
||||
selectedHash: string;
|
||||
requiredHash: string;
|
||||
requiredRomName: string;
|
||||
matchedRomName?: string;
|
||||
};
|
||||
|
||||
interface BaseRomErrorModalProps {
|
||||
state: BaseRomErrorModalState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getTitle(state: BaseRomErrorModalState): string {
|
||||
switch (state.kind) {
|
||||
case "archive":
|
||||
return "Extract your ROM first";
|
||||
case "unrecognized":
|
||||
return "Unrecognized file type";
|
||||
case "hash_mismatch":
|
||||
return "This isn't the correct base ROM";
|
||||
}
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/60">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function HashBlock({
|
||||
label,
|
||||
hash,
|
||||
variant,
|
||||
}: {
|
||||
label: string;
|
||||
hash: string;
|
||||
variant: "wrong" | "correct";
|
||||
}) {
|
||||
const isWrong = variant === "wrong";
|
||||
const Icon = isWrong ? FaCircleXmark : FaCircleCheck;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-foreground/70">{label}</span>
|
||||
<div
|
||||
className={`flex items-center gap-2.5 rounded-md bg-[var(--surface-1)] px-3 py-2 ring-1 ring-inset ${
|
||||
isWrong ? "ring-red-500/30" : "ring-emerald-500/30"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`shrink-0 ${isWrong ? "text-red-500" : "text-emerald-500"}`}
|
||||
size={14}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<code className="whitespace-nowrap text-xs">{hash}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalBody({ state }: { state: BaseRomErrorModalState }) {
|
||||
switch (state.kind) {
|
||||
case "archive":
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground/80">
|
||||
Archive files like <code>.zip</code> need to be extracted first. After extracting, upload{" "}
|
||||
<strong>{state.requiredExtensionPhrase}</strong> for <strong>{state.requiredRomName}</strong>.
|
||||
</p>
|
||||
);
|
||||
case "unrecognized":
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground/80">
|
||||
<code>{state.fileName}</code> isn't a ROM file. This hack requires{" "}
|
||||
<strong>{state.requiredRomName}</strong> — please select{" "}
|
||||
<strong>{state.requiredExtensionPhrase}</strong>.
|
||||
</p>
|
||||
);
|
||||
case "hash_mismatch":
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>What happened</SectionLabel>
|
||||
<p className="text-base leading-relaxed text-foreground/90">
|
||||
{state.matchedRomName ? (
|
||||
<>
|
||||
This file looks like <strong>{state.matchedRomName}</strong>, but this hack needs{" "}
|
||||
<strong>{state.requiredRomName}</strong>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This file doesn't match a known clean copy of <strong>{state.requiredRomName}</strong>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm leading-relaxed text-amber-900 dark:text-amber-100">
|
||||
Patching against a non-matching ROM (even if that ROM is fully playable) can cause crashes, glitches, or broken saves.
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>Compare checksums</SectionLabel>
|
||||
<div className="flex flex-col gap-3">
|
||||
<HashBlock label="Your file" hash={state.selectedHash} variant="wrong" />
|
||||
<HashBlock label={`Required — ${state.requiredRomName}`} hash={state.requiredHash} variant="correct" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>Common reasons</SectionLabel>
|
||||
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-foreground/80">
|
||||
<li>Wrong revision or region</li>
|
||||
<li>The ROM was modified or trimmed</li>
|
||||
<li>The file is corrupted or incomplete</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3 text-xs leading-relaxed text-foreground/60">
|
||||
Hackdex cannot advise on where to find ROMs. The only legal source is ripping from your own cartridge.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const BaseRomErrorModal: React.FC<BaseRomErrorModalProps> = ({ state, onClose }) => {
|
||||
useEffect(() => {
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const title = getTitle(state);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex md:items-center md:justify-center md:p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 dark:bg-black/60 md:backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="base-rom-error-title"
|
||||
className="relative z-[101] flex h-full w-full flex-col card backdrop-blur-lg dark:!bg-black/70 rounded-none md:h-auto md:max-h-[85vh] md:w-full md:max-w-lg md:rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
|
||||
<h2 id="base-rom-error-title" className="text-xl font-semibold pr-8">
|
||||
{title}
|
||||
</h2>
|
||||
{state.kind !== "unrecognized" && (
|
||||
<p className="mt-2 text-sm text-foreground/80">
|
||||
Selected file: <code>{state.fileName}</code>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<ModalBody state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] p-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:border-0 md:p-6 md:pt-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-14 md:h-11 w-full items-center justify-center rounded-md bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
|
||||
>
|
||||
Choose a different file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseRomErrorModal;
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import React from "react";
|
||||
import StickyActionBar from "@/components/Hack/StickyActionBar";
|
||||
import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hack/BaseRomErrorModal";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import { baseRoms } from "@/data/baseRoms";
|
||||
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 type { DownloadEventDetail } from "@/types/util";
|
||||
import { getSignedPatchUrl, updatePatchDownloadCount } from "@/app/hack/[slug]/actions";
|
||||
import { sha1Hex } from "@/utils/hash";
|
||||
import {
|
||||
formatRequiredRomExtension,
|
||||
isArchiveFile,
|
||||
isAnyRomExtension,
|
||||
} from "@/utils/romFile";
|
||||
|
||||
interface HackActionsProps {
|
||||
title: string;
|
||||
@@ -37,7 +44,12 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
const [patchBlob, setPatchBlob] = React.useState<Blob | null>(null);
|
||||
const [patchUrl, setPatchUrl] = React.useState<string | null>(null);
|
||||
const [termsAgreed, setTermsAgreed] = React.useState(false);
|
||||
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
|
||||
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],
|
||||
);
|
||||
|
||||
// Basic client-side bot detection
|
||||
React.useEffect(() => {
|
||||
@@ -95,23 +107,74 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
|
||||
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
setFile(f);
|
||||
if (f) {
|
||||
const id = await importUploadedBlob(f);
|
||||
if (!id) {
|
||||
setError("That ROM doesn't match any supported base ROM.");
|
||||
setStatus("idle");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (id !== baseRomId) {
|
||||
setError(`This ROM matches "${id}", but this hack requires "${baseRomName}".`);
|
||||
setStatus("idle");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
setStatus("ready");
|
||||
setFile(null);
|
||||
if (!f) return;
|
||||
|
||||
const requiredRomName = baseRomName ?? "this hack's base ROM";
|
||||
const requiredExtensionPhrase = effectivePlatform
|
||||
? formatRequiredRomExtension(effectivePlatform)
|
||||
: "a ROM file";
|
||||
|
||||
const resetInput = () => {
|
||||
e.target.value = "";
|
||||
setStatus("idle");
|
||||
};
|
||||
|
||||
if (isArchiveFile(f.name)) {
|
||||
setRomErrorModal({
|
||||
kind: "archive",
|
||||
fileName: f.name,
|
||||
requiredExtensionPhrase,
|
||||
requiredRomName,
|
||||
});
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAnyRomExtension(f.name)) {
|
||||
setRomErrorModal({
|
||||
kind: "unrecognized",
|
||||
fileName: f.name,
|
||||
requiredExtensionPhrase,
|
||||
requiredRomName,
|
||||
});
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedHash = await sha1Hex(f);
|
||||
const match = baseRoms.find((r) => r.sha1.toLowerCase() === selectedHash.toLowerCase());
|
||||
const requiredHash = baseRoms.find((r) => r.id === baseRomId)?.sha1.toLowerCase() ?? "";
|
||||
|
||||
if (!match) {
|
||||
setRomErrorModal({
|
||||
kind: "hash_mismatch",
|
||||
fileName: f.name,
|
||||
selectedHash,
|
||||
requiredHash,
|
||||
requiredRomName,
|
||||
});
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.id !== baseRomId) {
|
||||
await importUploadedBlob(f);
|
||||
setRomErrorModal({
|
||||
kind: "hash_mismatch",
|
||||
fileName: f.name,
|
||||
selectedHash,
|
||||
requiredHash,
|
||||
requiredRomName,
|
||||
matchedRomName: match.name,
|
||||
});
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
await importUploadedBlob(f);
|
||||
setFile(f);
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
async function onAgreeToTerms() {
|
||||
@@ -244,23 +307,31 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StickyActionBar
|
||||
title={title}
|
||||
version={version}
|
||||
author={author}
|
||||
filename={patchFilename}
|
||||
baseRomName={baseRomName}
|
||||
baseRomPlatform={platform}
|
||||
onPatch={onPatch}
|
||||
status={status}
|
||||
error={error}
|
||||
isLinked={isLinked(baseRomId)}
|
||||
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
|
||||
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
|
||||
supported={supported}
|
||||
onUploadChange={onSelectFile}
|
||||
termsAgreed={termsAgreed}
|
||||
/>
|
||||
<>
|
||||
<StickyActionBar
|
||||
title={title}
|
||||
version={version}
|
||||
author={author}
|
||||
filename={patchFilename}
|
||||
baseRomName={baseRomName}
|
||||
baseRomPlatform={platform}
|
||||
onPatch={onPatch}
|
||||
status={status}
|
||||
error={error}
|
||||
isLinked={isLinked(baseRomId)}
|
||||
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
|
||||
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
|
||||
supported={supported}
|
||||
onUploadChange={onSelectFile}
|
||||
termsAgreed={termsAgreed}
|
||||
/>
|
||||
{romErrorModal && (
|
||||
<BaseRomErrorModal
|
||||
state={romErrorModal}
|
||||
onClose={() => setRomErrorModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export async function sha1Hex(blob: Blob): Promise<string> {
|
||||
const buf = await blob.arrayBuffer();
|
||||
// @ts-ignore - subtle may be undefined in non-browser
|
||||
const digest = await (crypto.subtle as SubtleCrypto).digest("SHA-1", buf);
|
||||
import { sha1 } from "js-sha1";
|
||||
|
||||
async function subtleSha1Hex(buf: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-1", buf);
|
||||
const bytes = new Uint8Array(digest);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
@@ -10,4 +10,16 @@ export async function sha1Hex(blob: Blob): Promise<string> {
|
||||
return hex;
|
||||
}
|
||||
|
||||
export async function sha1Hex(blob: Blob): Promise<string> {
|
||||
const buf = await blob.arrayBuffer();
|
||||
|
||||
if (typeof crypto !== "undefined" && typeof crypto.subtle?.digest === "function") {
|
||||
try {
|
||||
return await subtleSha1Hex(buf);
|
||||
} catch {
|
||||
// crypto.subtle can fail outside a secure context (e.g. http dev)
|
||||
}
|
||||
}
|
||||
|
||||
return sha1(buf);
|
||||
}
|
||||
|
||||
50
src/utils/romFile.ts
Normal file
50
src/utils/romFile.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Platform } from "@/data/baseRoms";
|
||||
import { platformAccept, platformAcceptAll } from "@/utils/idb";
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
".zip",
|
||||
".7z",
|
||||
".rar",
|
||||
".tar",
|
||||
".gz",
|
||||
".gzip",
|
||||
".bz2",
|
||||
".xz",
|
||||
".cab",
|
||||
]);
|
||||
|
||||
export function getFileExtension(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
const lastDot = lower.lastIndexOf(".");
|
||||
if (lastDot <= 0) return "";
|
||||
return lower.slice(lastDot);
|
||||
}
|
||||
|
||||
export function isArchiveFile(name: string): boolean {
|
||||
return ARCHIVE_EXTENSIONS.has(getFileExtension(name));
|
||||
}
|
||||
|
||||
function platformExtensions(platform: Platform): string[] {
|
||||
return platformAccept(platform)
|
||||
.split(",")
|
||||
.map((ext) => ext.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Any extension Hackdex accepts across all platforms (.gb, .gbc, .gba, .nds). */
|
||||
export function isAnyRomExtension(name: string): boolean {
|
||||
const ext = getFileExtension(name);
|
||||
if (!ext) return false;
|
||||
return platformAcceptAll()
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.includes(ext);
|
||||
}
|
||||
|
||||
export function formatRequiredRomExtension(platform: Platform): string {
|
||||
const exts = platformExtensions(platform);
|
||||
if (exts.length === 0) return "a ROM file";
|
||||
if (exts.length === 1) return `a ${exts[0]} file`;
|
||||
const formatted = exts.map((ext) => ext).join(" or ");
|
||||
return `a ${formatted} file`;
|
||||
}
|
||||
Reference in New Issue
Block a user