mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-05-06 04:55:34 -05:00
Merge 2bfeec5ff2 into 99f2e05627
This commit is contained in:
commit
7dd0502dfa
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import StickyActionBar from "@/components/Hack/StickyActionBar";
|
||||
import PatchProgressBar from "@/components/Hack/PatchProgressBar";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import { baseRoms } from "@/data/baseRoms";
|
||||
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
|
||||
|
|
@ -9,6 +10,19 @@ 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";
|
||||
|
||||
const PROGRESS_MESSAGES = {
|
||||
PREPARING_DOWNLOAD: "Preparing download...",
|
||||
DOWNLOADING_PATCH: "Downloading patch file...",
|
||||
DOWNLOAD_COMPLETE: "Download complete!",
|
||||
PREPARING_PATCH: "Preparing to patch...",
|
||||
LOADING_BASE_ROM: "Loading base ROM...",
|
||||
READING_ROM_FILES: "Reading ROM files...",
|
||||
BUILDING_PATCH_FILES: "Building patch files...",
|
||||
APPLYING_PATCH: "Applying patch...",
|
||||
PREPARING_FINAL_DOWNLOAD: "Preparing download...",
|
||||
COMPLETE: "Complete!",
|
||||
} as const;
|
||||
|
||||
interface HackActionsProps {
|
||||
title: string;
|
||||
version: string;
|
||||
|
|
@ -20,7 +34,7 @@ interface HackActionsProps {
|
|||
hackSlug: string;
|
||||
}
|
||||
|
||||
const HackActions: React.FC<HackActionsProps> = ({
|
||||
export default function HackActions({
|
||||
title,
|
||||
version,
|
||||
author,
|
||||
|
|
@ -29,7 +43,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
patchFilename,
|
||||
patchId,
|
||||
hackSlug,
|
||||
}) => {
|
||||
}: HackActionsProps) {
|
||||
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle");
|
||||
|
|
@ -37,12 +51,20 @@ 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 [progress, setProgress] = React.useState(0);
|
||||
const [progressLabel, setProgressLabel] = React.useState("");
|
||||
const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]);
|
||||
|
||||
const progressBarHeight = React.useMemo(() => {
|
||||
const isVisible = status === "downloading" || status === "patching" || progress > 0;
|
||||
if (!isVisible) return 0;
|
||||
return progressLabel ? 64 : 44;
|
||||
}, [status, progress, progressLabel]);
|
||||
|
||||
// Basic client-side bot detection
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof localStorage === 'undefined') {
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof localStorage === "undefined") {
|
||||
setError("Browser features not available");
|
||||
return;
|
||||
}
|
||||
|
|
@ -74,7 +96,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [error]);
|
||||
|
||||
// When patch URL is fetched and terms are agreed, automatically proceed with patching if ROM is ready
|
||||
|
|
@ -118,23 +140,63 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
try {
|
||||
setError(null);
|
||||
setStatus("downloading");
|
||||
setProgress(10);
|
||||
setProgressLabel(PROGRESS_MESSAGES.PREPARING_DOWNLOAD);
|
||||
|
||||
// Fetch signed URL from server
|
||||
const result = await getSignedPatchUrl(hackSlug);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setStatus("idle");
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
return;
|
||||
}
|
||||
|
||||
setPatchUrl(result.url);
|
||||
setTermsAgreed(true);
|
||||
setProgress(30);
|
||||
setProgressLabel(PROGRESS_MESSAGES.DOWNLOADING_PATCH);
|
||||
|
||||
// Download patch blob
|
||||
const res = await fetch(result.url);
|
||||
if (!res.ok) throw new Error("Failed to fetch patch");
|
||||
const blob = await res.blob();
|
||||
setPatchBlob(blob);
|
||||
|
||||
const contentLength = res.headers.get("content-length");
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
if (total > 0 && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
let receivedLength = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
const downloadProgress = 30 + (receivedLength / total) * 60;
|
||||
setProgress(downloadProgress);
|
||||
}
|
||||
|
||||
setProgress(90);
|
||||
const blob = new Blob(chunks);
|
||||
setPatchBlob(blob);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
setPatchBlob(blob);
|
||||
setProgress(90);
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setProgressLabel(PROGRESS_MESSAGES.DOWNLOAD_COMPLETE);
|
||||
|
||||
setTimeout(() => {
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
}, 500);
|
||||
|
||||
// Update status based on ROM readiness
|
||||
const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
|
||||
|
|
@ -146,6 +208,8 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to fetch patch URL");
|
||||
setStatus("idle");
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,20 +228,55 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
setProgress(5);
|
||||
setProgressLabel(PROGRESS_MESSAGES.PREPARING_PATCH);
|
||||
setStatus("patching");
|
||||
|
||||
// this is to make sure it slides down properly
|
||||
await new Promise(resolve => {
|
||||
let count = 0;
|
||||
const waitFrames = () => {
|
||||
if (++count < 18) {
|
||||
requestAnimationFrame(waitFrames);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(waitFrames);
|
||||
});
|
||||
|
||||
let baseFile = file;
|
||||
if (!baseFile) {
|
||||
if (!isLinked(baseRomId) && !hasCached(baseRomId)) return;
|
||||
if (!hasCached(baseRomId)) {
|
||||
const perm = await ensurePermission(baseRomId, true);
|
||||
if (perm !== "granted") return;
|
||||
if (perm !== "granted") {
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setProgress(15);
|
||||
setProgressLabel(PROGRESS_MESSAGES.LOADING_BASE_ROM);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
const linkedFile = await getFileBlob(baseRomId);
|
||||
if (!linkedFile) return;
|
||||
if (!linkedFile) {
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
baseFile = linkedFile;
|
||||
}
|
||||
|
||||
if (!patchUrl) return;
|
||||
|
||||
setProgress(30);
|
||||
setProgressLabel(PROGRESS_MESSAGES.READING_ROM_FILES);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
setStatus("patching");
|
||||
|
||||
// Read inputs
|
||||
|
|
@ -195,21 +294,41 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
})(),
|
||||
]);
|
||||
|
||||
setProgress(50);
|
||||
setProgressLabel(PROGRESS_MESSAGES.BUILDING_PATCH_FILES);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Build BinFiles
|
||||
const romBin = new BinFile(romBuf);
|
||||
romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : "");
|
||||
const patchBin = new BinFile(patchBuf);
|
||||
|
||||
setProgress(65);
|
||||
setProgressLabel(PROGRESS_MESSAGES.APPLYING_PATCH);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Parse and apply BPS
|
||||
const patch = BPS.fromFile(patchBin);
|
||||
const patchedRom = patch.apply(romBin);
|
||||
|
||||
setProgress(85);
|
||||
setProgressLabel(PROGRESS_MESSAGES.PREPARING_FINAL_DOWNLOAD);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Name output and download
|
||||
const outExt = platform ? platform.toLowerCase() : 'bin';
|
||||
const outExt = platform ? platform.toLowerCase() : "bin";
|
||||
const outputName = `${title} (${version}).${outExt}`;
|
||||
patchedRom.fileName = outputName;
|
||||
patchedRom.save();
|
||||
|
||||
setProgress(100);
|
||||
setProgressLabel(PROGRESS_MESSAGES.COMPLETE);
|
||||
|
||||
setTimeout(() => {
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
}, 1000);
|
||||
|
||||
setStatus("done");
|
||||
|
||||
// Best-effort log applied event for counting and animate badge
|
||||
|
|
@ -221,9 +340,10 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
deviceId = crypto.randomUUID();
|
||||
localStorage.setItem(key, deviceId);
|
||||
}
|
||||
const finalDeviceId = deviceId;
|
||||
// Defer count update to avoid Safari cancelling the request
|
||||
setTimeout(async () => {
|
||||
const deviceIdObscured = deviceId.split("-");
|
||||
const deviceIdObscured = finalDeviceId.split("-");
|
||||
const result = await updatePatchDownloadCount(patchId, deviceIdObscured);
|
||||
if (!result.ok) {
|
||||
console.error(result.error);
|
||||
|
|
@ -238,31 +358,38 @@ const HackActions: React.FC<HackActionsProps> = ({
|
|||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to patch ROM");
|
||||
setStatus("idle");
|
||||
setProgress(0);
|
||||
setProgressLabel("");
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<>
|
||||
<PatchProgressBar
|
||||
progress={progress}
|
||||
visible={status === "downloading" || status === "patching" || progress > 0}
|
||||
label={progressLabel}
|
||||
/>
|
||||
<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}
|
||||
progressBarHeight={progressBarHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackActions;
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
48
src/components/Hack/PatchProgressBar.tsx
Normal file
48
src/components/Hack/PatchProgressBar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface PatchProgressBarProps {
|
||||
progress: number;
|
||||
visible: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function PatchProgressBar({ progress, visible, label }: PatchProgressBarProps) {
|
||||
const barHeight = label ? 64 : 44;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="hidden overflow-hidden transition-all duration-300 ease-out md:block"
|
||||
style={{
|
||||
height: visible ? `${barHeight}px` : "0px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`fixed inset-x-0 bottom-0 md:bottom-auto md:top-16 z-50 md:z-30 border-t md:border-t-0 md:border-b border-[var(--border)] bg-background/95 shadow-[0_-6px_24px_rgba(0,0,0,0.1)] md:shadow-lg backdrop-blur-sm transition-all duration-300 ease-out ${
|
||||
visible
|
||||
? "translate-y-0 opacity-100"
|
||||
: "pointer-events-none translate-y-full md:-translate-y-full opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto max-w-screen-lg px-3 py-3">
|
||||
{label && (
|
||||
<p className="mb-2 text-xs font-medium text-foreground/80">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-2)] ring-1 ring-[var(--border)]">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ interface StickyActionBarProps {
|
|||
supported: boolean;
|
||||
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
termsAgreed: boolean;
|
||||
progressBarHeight?: number;
|
||||
}
|
||||
|
||||
export default function StickyActionBar({
|
||||
|
|
@ -39,6 +40,7 @@ export default function StickyActionBar({
|
|||
supported,
|
||||
onUploadChange,
|
||||
termsAgreed,
|
||||
progressBarHeight = 0,
|
||||
}: StickyActionBarProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
|
@ -77,7 +79,12 @@ export default function StickyActionBar({
|
|||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 md:sticky md:top-18 md:z-30 flex flex-col gap-2 pb-safe">
|
||||
<div
|
||||
className="fixed inset-x-0 z-40 md:sticky md:top-18 md:z-30 flex flex-col gap-2 pb-safe transition-all duration-300 ease-out"
|
||||
style={{
|
||||
bottom: `${progressBarHeight - 8}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
|
||||
<div className="md:w-fit md:max-w-[40%] lg:max-w-[45%]">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -142,7 +149,7 @@ export default function StickyActionBar({
|
|||
onClick={onPatch}
|
||||
data-ready={romReady}
|
||||
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
|
||||
className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === "downloading" ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== "downloading" && status !== "ready" && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
>
|
||||
<span>{
|
||||
status === "patching" ? "Patching…" :
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user