Fixes and improvements to hack action bar

This commit is contained in:
Jared Schoeny
2025-10-23 12:14:13 -10:00
parent 0ddd0f719e
commit 4f857cd7d1
3 changed files with 107 additions and 20 deletions

View File

@@ -29,15 +29,28 @@ const HackActions: React.FC<HackActionsProps> = ({
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle");
const [error, setError] = React.useState<string | null>(null);
const [patchBlob, setPatchBlob] = React.useState<Blob | null>(null);
const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]);
React.useEffect(() => {
if (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId))) {
if ((isLinked(baseRomId) && hasPermission(baseRomId)) || hasCached(baseRomId)) {
if (status !== "downloading" && status !== "patching" && status !== "done") {
setStatus("ready");
}
}
}, [baseRomId, isLinked, hasPermission, hasCached, status]);
React.useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (error) {
timeoutId = setTimeout(() => {
setError(null);
}, 3000);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
}
}, [error]);
// Pre-download patch on mount (or when patchUrl changes) and cache as Blob
React.useEffect(() => {
let aborted = false;
@@ -76,11 +89,25 @@ const HackActions: React.FC<HackActionsProps> = ({
};
}, [patchUrl]);
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setFile(f);
setStatus(f ? "ready" : "idle");
if (f) importUploadedBlob(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");
}
}
async function onPatch() {
@@ -145,10 +172,12 @@ const HackActions: React.FC<HackActionsProps> = ({
title={title}
version={version}
author={author}
baseRomPlatform={platform}
onPatch={onPatch}
status={status}
error={error}
isLinked={isLinked(baseRomId)}
ready={hasPermission(baseRomId) || hasCached(baseRomId)}
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
supported={supported}
onUploadChange={onSelectFile}

View File

@@ -1,24 +1,61 @@
"use client";
import React from "react";
import { platformAccept } from "@/utils/idb";
import type { Platform } from "@/data/baseRoms";
export default function StickyActionBar({ title, version, author, onPatch, status, isLinked, ready, onClickLink, supported, onUploadChange }: {
export default function StickyActionBar({ title, version, author, baseRomPlatform, onPatch, status, error, isLinked, romReady, onClickLink, supported, onUploadChange }: {
title: string;
version?: string;
author: string;
baseRomPlatform?: Platform;
onPatch: () => void;
status: "idle" | "ready" | "patching" | "done" | "downloading";
error: string | null;
isLinked: boolean;
ready: boolean;
romReady: boolean;
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const isDisabled = status === "patching" || (mounted && !ready && !isLinked && !supported);
const uploadInputRef = React.useRef<HTMLInputElement | null>(null);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [showError, setShowError] = React.useState(false);
const [patchAgainReady, setPatchAgainReady] = React.useState(true);
// Keep error mounted to allow fade-out when error becomes null
React.useEffect(() => {
let timeoutId: number | undefined;
if (error) {
setErrorMessage(error);
// next frame to ensure transition runs
requestAnimationFrame(() => setShowError(true));
} else if (errorMessage !== null) {
setShowError(false);
timeoutId = window.setTimeout(() => setErrorMessage(null), 300);
} else {
setShowError(false);
}
return () => {
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [error, errorMessage]);
React.useEffect(() => {
if (status === "done") {
setPatchAgainReady(false);
setTimeout(() => {
setPatchAgainReady(true);
}, 3000);
} else {
setPatchAgainReady(true);
}
}, [status]);
return (
<div className="sticky top-18 z-30">
<div className="sticky top-18 z-30 flex flex-col gap-2">
<div className="mx-auto flex w-full lg:max-w-screen-lg items-center justify-between gap-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -33,39 +70,58 @@ export default function StickyActionBar({ title, version, author, onPatch, statu
<span className={`rounded-full px-2 py-0.5 text-xs ring-1 ${
status === "downloading"
? "bg-[var(--surface-2)] text-foreground/85 ring-[var(--border)]"
: ready
: romReady
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{status === "downloading" ? "Downloading..." : ready ? "Ready" : isLinked ? "Permission needed" : "Base ROM needed"}
{status === "downloading" ? "Downloading..." : romReady ? "Ready" : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
{!ready && !isLinked && (
{!romReady && !isLinked && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input type="file" onChange={onUploadChange} className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]" />
<span>Upload base ROM</span>
<input ref={uploadInputRef} type="file" accept={platformAccept(baseRomPlatform)} onChange={onUploadChange} className="hidden" />
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-foreground text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
>
Select Base ROM
</button>
</label>
)}
{!ready && isLinked && (
{!romReady && isLinked && (
<button
type="button"
onClick={onClickLink}
disabled={!supported}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-60"
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
Grant permission
</button>
)}
<button
onClick={onPatch}
disabled={isDisabled}
className="shine-wrap btn-premium h-9 min-w-[7.5rem] text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-70"
disabled={!mounted || (status !== "ready" && status !== "done") || !patchAgainReady}
className="shine-wrap btn-premium h-9 min-w-[7.5rem] text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70"
>
<span>{status === "patching" ? "Patching…" : status === "done" ? "Patched" : "Patch Now"}</span>
<span>{status === "patching" ? "Patching…" : (
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : "Patch Now"
)}</span>
</button>
</div>
</div>
{errorMessage !== null && (
<div
className={`absolute -bottom-2 left-1/2 -translate-x-1/2 mx-auto flex lg:max-w-screen-lg rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)] text-sm text-red-400 transition-all duration-300 ${showError ? "opacity-100 translate-y-full" : "opacity-0 -translate-y-1/2 pointer-events-none"}`}
role="alert"
aria-live="polite"
>
{errorMessage}
</div>
)}
</div>
);
}

View File

@@ -115,7 +115,9 @@ export async function getAllBlobEntries(): Promise<Array<{ id: string; blob: Blo
});
}
export function platformAccept(p: Platform | Platform[]): string {
export function platformAccept(p?: Platform | Platform[] | null): string {
if (!p) return platformAcceptAll();
if (Array.isArray(p)) {
// Gather all individual extensions and dedupe
const extSet = new Set<string>();