From 43aa0236e1ae0cc9e4a9729ff78cf9a48c5a5591 Mon Sep 17 00:00:00 2001
From: Jared Schoeny
Date: Fri, 10 Jul 2026 22:42:51 -0600
Subject: [PATCH] Add customizable patch version selector + related UX
improvements (#62)
* Add `hack_patcher_patches` table
* Implement patcher version server actions
* Use first curated patch as default when custom patcher list is active
* Allow saving custom patcher lists with unpublished patch auto-publish
* Keep custom patcher list consistent across archive and new uploads
* Add creator UI for Latest vs Custom patcher version settings
* Add patch version picker to hack page downloader
* Add custom public version names for Custom patcher mode
* Fix direct patch download consistency between Latest vs Custom modes
* Consolidate download and patch to one button press
* Fix rom ready for patching checks
* Fix Select ROM pop-in while base roms loading
* Tighten patcher patches db insertion
* Harden getSignedPatchUrl permission checks
* Fix not using selected patch's filename
* Acknowledge Custom patcher setting in HackPatchForm
* Update types/db.ts
* Refresh discover cache on patch published
* Add migration ordering check to ci.yaml
* Fix new migrations ordering
---
.github/workflows/ci.yaml | 11 +
src/app/discover/actions.ts | 36 +-
src/app/hack/[slug]/actions.ts | 196 ++++++++-
src/app/hack/[slug]/edit/patch/page.tsx | 10 +-
src/app/hack/[slug]/page.tsx | 7 +-
src/app/hack/[slug]/versions/page.tsx | 165 ++++---
src/app/page.tsx | 59 ++-
src/app/submit/actions.ts | 15 +-
.../Hack/DownloadPermissionSettings.tsx | 48 +-
src/components/Hack/HackActions.tsx | 138 +++---
src/components/Hack/HackPatchForm.tsx | 42 +-
src/components/Hack/PatcherVersionManager.tsx | 412 ++++++++++++++++++
.../Hack/PatcherVersionSettings.tsx | 317 ++++++++++++++
src/components/Hack/StickyActionBar.tsx | 141 +++++-
src/components/Hack/VersionActions.tsx | 48 +-
src/components/Hack/VersionList.tsx | 121 ++++-
src/contexts/BaseRomContext.tsx | 5 +
src/types/db.ts | 47 ++
src/types/patcher.ts | 12 +
src/utils/patches/hack-display-version.ts | 46 ++
.../patches/patcher-selectable-patches.ts | 62 +++
.../20260711041720_hack_patcher_patches.sql | 66 +++
...41816_replace_hack_patcher_patches_rpc.sql | 54 +++
.../20260711041848_custom_version_name.sql | 2 +
24 files changed, 1857 insertions(+), 203 deletions(-)
create mode 100644 src/components/Hack/PatcherVersionManager.tsx
create mode 100644 src/components/Hack/PatcherVersionSettings.tsx
create mode 100644 src/types/patcher.ts
create mode 100644 src/utils/patches/hack-display-version.ts
create mode 100644 src/utils/patches/patcher-selectable-patches.ts
create mode 100644 supabase/migrations/20260711041720_hack_patcher_patches.sql
create mode 100644 supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql
create mode 100644 supabase/migrations/20260711041848_custom_version_name.sql
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index d6fadcb..c1105f1 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -17,6 +17,17 @@ jobs:
with:
version: latest
+ - name: Check new migrations are after main
+ run: |
+ git fetch origin main --depth=1
+ tip=$(git ls-tree --name-only origin/main:supabase/migrations | sort | tail -1)
+ oldest_new=$(git diff --name-only --diff-filter=A origin/main -- supabase/migrations \
+ | xargs -n1 basename | sort | head -1)
+ if [ -n "$oldest_new" ] && [[ ! "$oldest_new" > "$tip" ]]; then
+ echo "::error::$oldest_new must be renamed to after $tip"
+ exit 1
+ fi
+
- name: Start Supabase local development setup
run: supabase db start
diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts
index 3e11c24..254a621 100644
--- a/src/app/discover/actions.ts
+++ b/src/app/discover/actions.ts
@@ -7,6 +7,7 @@ import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format";
import { fetchInChunks } from "@/utils/array";
import { HackCardAttributes } from "@/components/HackCard";
import type { DiscoverSortOption } from "@/types/discover";
+import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
const TRENDING_WINDOW_DAYS = 3;
const TIME_TO_LIVE = 600; // 10 minutes
@@ -37,7 +38,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise();
+ const customPatcherSlugs = new Set();
+ if (slugs.length > 0) {
+ const { data: customPatchRows, error: customPatchRowsError } = await supabase
+ .from("hack_patcher_patches")
+ .select("hack_slug, sort_order, patches!inner(version)")
+ .in("hack_slug", slugs)
+ .order("sort_order", { ascending: true });
+ if (customPatchRowsError) throw customPatchRowsError;
+
+ (customPatchRows || []).forEach((row: any) => {
+ customPatcherSlugs.add(row.hack_slug);
+ if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
+ const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
+ if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
+ });
+ }
+
// Calculate trending scores if needed
let trendingScores: Map | null = null;
if (sort === "trending") {
@@ -232,14 +251,21 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise();
const publishedAtBySlug = new Map();
(rows || []).forEach((r: any) => {
- if (typeof r.current_patch === "number") {
- const version = versionsByPatchId.get(r.current_patch) || "Pre-release";
- mappedVersions.set(r.slug, version);
+ const currentPatchVersion = typeof r.current_patch === "number"
+ ? versionsByPatchId.get(r.current_patch) || "Pre-release"
+ : "";
+ mappedVersions.set(r.slug, resolveHackDisplayVersion({
+ isArchive: r.is_archive,
+ isCustomPatcherActive: customPatcherSlugs.has(r.slug),
+ customVersionName: r.custom_version_name,
+ customDefaultPatchVersion: customDefaultVersionsBySlug.get(r.slug),
+ currentPatchVersion,
+ }));
+ if (typeof r.current_patch === "number") {
const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null;
publishedAtBySlug.set(r.slug, publishedAt);
} else {
- mappedVersions.set(r.slug, r.is_archive ? "Archive" : "Pre-release");
publishedAtBySlug.set(r.slug, null);
}
});
diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts
index 44e6503..29854a6 100644
--- a/src/app/hack/[slug]/actions.ts
+++ b/src/app/hack/[slug]/actions.ts
@@ -11,6 +11,9 @@ import { revalidatePath, revalidateTag } from "next/cache";
import { unstable_cache as cache } from "next/cache";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { Database, Constants } from "@/types/db";
+import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
+import { CUSTOM_VERSION_NAME_MAX_LENGTH, resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
+import type { SelectablePatch } from "@/types/patcher";
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
"Patches Download Permission"
@@ -37,6 +40,7 @@ export interface HackMetadata {
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
verification_contact_info: string | null;
};
+ displayVersion: string;
images: string[];
tags: string[];
profile: {
@@ -57,6 +61,10 @@ export interface HackMetadata {
created_at: string;
changelog: string | null;
} | null;
+ patcherSelector: {
+ selectablePatches: SelectablePatch[];
+ defaultPatchId: number | null;
+ };
}
export async function getHackMetadata(slug: string): Promise {
@@ -66,7 +74,7 @@ export async function getHackMetadata(slug: string): Promise 0,
+ customVersionName: hack.custom_version_name,
+ customDefaultPatchVersion: selectablePatches[0]?.version,
+ currentPatchVersion: patch?.version,
+ });
+
return {
hack,
+ displayVersion,
images,
tags,
profile: profile ? {
@@ -172,6 +190,10 @@ export async function getHackMetadata(slug: string): Promise {
return runner();
}
-export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
+export async function getSignedPatchUrl(
+ slug: string,
+ options?: {
+ patchId?: number;
+ }
+): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
// Get user for permission check
@@ -243,22 +270,30 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url:
return { ok: false, error: "Archive hacks do not have patch files available" };
}
- // Check if patch exists
- if (hack.current_patch == null) {
- return { ok: false, error: "No patch available" };
+ // Get selectable patches and validate selected patch id
+ const { selectablePatches } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
+ const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id));
+ const selectedPatchId = options?.patchId ?? hack.current_patch;
+
+ if (selectedPatchId === null || !allowedPatchIds.has(selectedPatchId)) {
+ return { ok: false, error: "Patch not available" };
}
// Fetch patch info
const { data: patch, error: patchError } = await supabase
.from("patches")
- .select("id, bucket, filename")
- .eq("id", hack.current_patch as number)
+ .select("id, bucket, filename, parent_hack, published, archived")
+ .eq("id", selectedPatchId)
.maybeSingle();
if (patchError || !patch) {
return { ok: false, error: "Patch not found" };
}
+ if (patch.parent_hack !== slug || !patch.published || patch.archived) {
+ return { ok: false, error: "Patch not found" };
+ }
+
try {
const workerUrl = buildPatchDownloadUrl(patch.filename);
if (workerUrl) {
@@ -461,8 +496,16 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
return { ok: false, error: "Unauthorized" };
}
if (permission === "Current") {
- if (hack.current_patch == null || patch.id !== hack.current_patch) {
- return { ok: false, error: "Unauthorized" };
+ const { savedPatchIds, selectablePatches } = await getPatcherSelectablePatches(supabase, hack.slug, hack.current_patch);
+ if (savedPatchIds.length === 0) { // Latest Patcher Mode is active
+ if (hack.current_patch == null || patch.id !== hack.current_patch) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ } else { // Custom Patcher Mode is active
+ const allowedPatchIds = new Set(selectablePatches.map((p) => p.id));
+ if (!allowedPatchIds.has(patch.id)) {
+ return { ok: false, error: "Unauthorized" };
+ }
}
}
// "All": published + non-archived already satisfied
@@ -559,6 +602,20 @@ export async function archivePatchVersion(slug: string, patchId: number): Promis
return { ok: false, error: "Patch not found" };
}
+ const { data: customRows, error: customErr } = await supabase
+ .from("hack_patcher_patches")
+ .select("patch_id")
+ .eq("hack_slug", slug);
+ if (customErr) return { ok: false, error: customErr.message };
+
+ const isInCustomList = (customRows || []).some((row) => row.patch_id === patchId);
+ if (isInCustomList && (customRows?.length || 0) <= 2) {
+ return {
+ ok: false,
+ error: "This 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.",
+ };
+ }
+
// Archive the patch
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
@@ -568,6 +625,17 @@ export async function archivePatchVersion(slug: string, patchId: number): Promis
if (updateErr) return { ok: false, error: updateErr.message };
+ if (isInCustomList) {
+ const { error: deleteErr } = await serviceClient
+ .from("hack_patcher_patches")
+ .delete()
+ .eq("hack_slug", slug)
+ .eq("patch_id", patchId);
+ if (deleteErr) return { ok: false, error: deleteErr.message };
+ }
+
+ revalidateTag(`hack:${slug}:metadata`);
+ revalidatePath(`/hack/${slug}`);
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}
@@ -665,6 +733,7 @@ export async function rollbackToVersion(slug: string, patchId: number): Promise<
if (unpubErr) return { ok: false, error: unpubErr.message };
revalidateTag(`hack:${slug}:metadata`);
+ revalidateTag("discover");
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
@@ -815,20 +884,29 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
return { ok: false, error: "Patch not found" };
}
- // Check if this patch is newer than current_patch
+ // Check if hack has any patches in hack_patcher_patches
+ const { data: patcherPatches, error: ppErr } = await supabase
+ .from("hack_patcher_patches")
+ .select("patch_id")
+ .eq("hack_slug", slug);
+ if (ppErr) return { ok: false, error: ppErr.message };
+
+ // Check if this patch is newer than current_patch, but only if there are no patcher patches
let willBecomeCurrent = false;
const serviceClient = await createServiceClient();
- if (hack.current_patch) {
- const { data: currentPatch } = await serviceClient
- .from("patches")
- .select("created_at")
- .eq("id", hack.current_patch)
- .maybeSingle();
- if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
+ if (patcherPatches.length === 0) {
+ if (hack.current_patch) {
+ const { data: currentPatch } = await serviceClient
+ .from("patches")
+ .select("created_at")
+ .eq("id", hack.current_patch)
+ .maybeSingle();
+ if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
+ willBecomeCurrent = true;
+ }
+ } else {
willBecomeCurrent = true;
}
- } else {
- willBecomeCurrent = true;
}
// Publish the patch
@@ -838,7 +916,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
- // If newer than current_patch, update current_patch
+ // If newer than current_patch and no patcher patches, update current_patch
if (willBecomeCurrent) {
const { error: updateHackErr } = await supabase
.from("hacks")
@@ -848,6 +926,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
}
revalidateTag(`hack:${slug}:metadata`);
+ revalidateTag("discover");
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true, willBecomeCurrent };
@@ -945,3 +1024,80 @@ export async function confirmReuploadPatchVersion(
return { ok: true };
}
+export async function updatePatcherSelectablePatches(
+ slug: string,
+ patchIds: number[],
+ customVersionName?: string | null,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author, is_archive")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ // Check permissions: creator first (optimization), then admin
+ if (!canEditAsCreator(hack, user.id)) {
+ const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase);
+ if (!editableAsAdmin) {
+ return { ok: false, error: "Forbidden" };
+ }
+ }
+
+ // Dedupe patch ids
+ const uniquePatchIds = [...new Set(patchIds)];
+ const trimmedCustomVersionName = customVersionName?.trim() || undefined;
+
+ if (uniquePatchIds.length > 0) {
+ if (!trimmedCustomVersionName) {
+ return { ok: false, error: "Custom version name is required." };
+ }
+ if (trimmedCustomVersionName.length > CUSTOM_VERSION_NAME_MAX_LENGTH) {
+ return { ok: false, error: "Custom version name must be 12 characters or fewer." };
+ }
+ }
+
+ if (uniquePatchIds.length > 0) {
+ // Verify patches belong to this hack
+ const { data: patches, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack, published, archived")
+ .in("id", uniquePatchIds)
+ .eq("parent_hack", slug);
+ if (pErr || patches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches do not belong to this hack" };
+
+ // Verify patches are not archived
+ const archivedPatches = patches.filter((patch) => patch.archived);
+ if (archivedPatches.length > 0) return { ok: false, error: "One or more patches are archived" };
+
+ const unpublishedPatchIds = patches.filter((patch) => !patch.published).map((patch) => patch.id);
+ if (unpublishedPatchIds.length > 0) {
+ const serviceClient = await createServiceClient();
+ const { error: publishErr } = await serviceClient
+ .from("patches")
+ .update({ published: true, published_at: new Date().toISOString() })
+ .in("id", unpublishedPatchIds);
+ if (publishErr) return { ok: false, error: publishErr.message };
+ }
+ }
+
+ // Replace patcher patches
+ const { error: replaceErr } = await supabase.rpc("replace_hack_patcher_patches", {
+ p_hack_slug: slug,
+ p_patch_ids: uniquePatchIds,
+ p_custom_version_name: uniquePatchIds.length > 0 ? trimmedCustomVersionName : undefined,
+ });
+ if (replaceErr) return { ok: false, error: replaceErr.message };
+
+ revalidateTag(`hack:${slug}:metadata`);
+ revalidateTag("discover");
+ revalidatePath(`/hack/${slug}`);
+ revalidatePath(`/hack/${slug}/versions`);
+
+ return { ok: true };
+}
diff --git a/src/app/hack/[slug]/edit/patch/page.tsx b/src/app/hack/[slug]/edit/patch/page.tsx
index 921df28..b2ea937 100644
--- a/src/app/hack/[slug]/edit/patch/page.tsx
+++ b/src/app/hack/[slug]/edit/patch/page.tsx
@@ -4,6 +4,7 @@ import HackPatchForm from "@/components/Hack/HackPatchForm";
import Link from "next/link";
import { FaChevronLeft } from "react-icons/fa6";
import { isInformationalArchiveHack, isDownloadableArchiveHack, canEditAsCreator, canEditAsAdmin, canEditAsArchiver } from "@/utils/hack";
+import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
interface EditPatchPageProps {
params: Promise<{ slug: string }>;
@@ -19,7 +20,7 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
const { data: hack } = await supabase
.from("hacks")
- .select("slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive")
+ .select("slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive,custom_version_name")
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
@@ -55,8 +56,11 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
.order("created_at", { ascending: true });
const existingVersions = (patchRows || []).map((p: any) => p.version as string);
+ const patcherSelection = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
+ const isCustomPatcherActive = patcherSelection.savedPatchIds.length > 0;
+
const currentPatch = patchRows?.find((p: any) => p.id === hack.current_patch);
- const currentVersion = currentPatch?.version;
+ const currentVersion = isCustomPatcherActive ? hack.custom_version_name ?? undefined : currentPatch?.version;
return (
@@ -70,6 +74,8 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
slug={slug}
baseRomId={hack.base_rom}
existingVersions={existingVersions}
+ isCustomPatcherActive={isCustomPatcherActive}
+ customVersionName={isCustomPatcherActive ? hack.custom_version_name : undefined}
currentVersion={currentVersion}
/>
diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx
index ef31225..059ad53 100644
--- a/src/app/hack/[slug]/page.tsx
+++ b/src/app/hack/[slug]/page.tsx
@@ -115,7 +115,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
if (!metadata) return notFound();
- const { hack, images, tags, profile, otherHacks, patch } = metadata;
+ const { hack, images, tags, profile, otherHacks, patch, displayVersion } = metadata;
const baseRom = baseRoms.find((r) => r.id === hack.base_rom);
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
@@ -148,7 +148,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
// Extract patch info from cached metadata
const patchFilename = patch?.filename || null;
- const patchVersion = isArchive ? "Archive" : (patch?.version || "");
+ const patchVersion = displayVersion;
const patchId = patch?.id || null;
const lastUpdated = patch ? new Date(patch.created_at).toLocaleDateString() : null;
const patchCreatedAt = patch?.created_at || null;
@@ -228,6 +228,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
patchFilename={patchFilename}
patchId={patchId ?? undefined}
hackSlug={hack.slug}
+ patcherSelector={metadata.patcherSelector}
/>
)}
@@ -596,7 +597,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
using our built-in patcher.
- By pressing the "Patch Now" button, your browser will apply the downloaded {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded.
+ By pressing "Agree and Patch", your browser will download and apply the {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded.
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.
diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx
index 533cdbb..8b9d05d 100644
--- a/src/app/hack/[slug]/versions/page.tsx
+++ b/src/app/hack/[slug]/versions/page.tsx
@@ -3,9 +3,11 @@ import { createClient } from "@/utils/supabase/server";
import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
import VersionList from "@/components/Hack/VersionList";
import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings";
+import PatcherVersionManager from "@/components/Hack/PatcherVersionManager";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import Link from "next/link";
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
+import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
interface VersionsPageProps {
params: Promise<{ slug: string }>;
@@ -19,7 +21,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
// Fetch hack
const { data: hack } = await supabase
.from("hacks")
- .select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive, patches_download_permission")
+ .select("slug, title, created_by, current_patch, custom_version_name, original_author, permission_from, base_rom, is_archive, patches_download_permission")
.eq("slug", slug)
.maybeSingle();
@@ -53,6 +55,8 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
const allPatches = [...(patches || []), ...unpublishedPatches].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
+ const patcherSelection = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
+ const isCustomPatcherActive = patcherSelection.savedPatchIds.length > 0;
return (
@@ -89,60 +93,117 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
- {canEdit && (
-
+ currentPatchId={hack.current_patch}
+ initialSavedPatchIds={patcherSelection?.savedPatchIds ?? []}
+ initialCustomVersionName={hack.custom_version_name}
+ patches={allPatches}
+ baseRom={hack.base_rom}
+ patchesDownloadPermission={hack.patches_download_permission}
+ >
+
+
+
+ ) : (
+ <>
+
+
+ >
)}
-
-
-
-
-
-
- Current
-
-
- {canEdit ?
- "The version that is currently active and visible to all users. This is the version users will download when pressing \"Patch Now\" on the hack page." :
- "This is the version you will download when pressing \"Patch Now\" on the hack page."
- }
-
-
- {canEdit && <>
-
-
- Unpublished
-
-
- Versions that are only visible to you, and will not appear in the public version list or changelog.
-
-
-
-
- Archived
-
-
- Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
-
-
- >}
-
-
-
-
);
}
+function VersionStatusGuide({
+ canEdit,
+ isCustomPatcherActive,
+}: {
+ canEdit: boolean;
+ isCustomPatcherActive: boolean;
+}) {
+ const showCurrentGuide = canEdit || !isCustomPatcherActive;
+ const showPatchableGuide = canEdit || isCustomPatcherActive;
+
+ return (
+
+
+ {showCurrentGuide && (
+
+
+
+ Current
+
+
+ {canEdit ?
+ <>The version used by the Latest published patch option. This is the default downloader version when Custom patcher versions are not active.> :
+ "This is the version you will download when using the patch button on the hack page."
+ }
+
+
+ )}
+ {canEdit && (
+
+
+
+ Default
+
+
+ The first version in the Custom patcher list. This is the version players will download by default if they don't select a different version.
+
+
+ )}
+ {showPatchableGuide && (
+
+
+
+ Patchable
+
+
+ {canEdit ?
+ "Additional Custom versions available to choose from before using the patch button on the hack page." :
+ "This version can be selected before using the patch button on the hack page."
+ }
+
+
+ )}
+ {canEdit && <>
+
+
+ Unpublished
+
+
+ Versions that are only visible to you, and will not appear in the public version list or changelog.
+
+
+
+
+ Archived
+
+
+ Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
+
+
+ >}
+
+
+ );
+}
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index a14b666..a454f14 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -6,6 +6,7 @@ import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { HackCardAttributes } from "@/components/HackCard";
+import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
export const metadata: Metadata = {
alternates: {
@@ -22,7 +23,7 @@ export default async function Home() {
// Fetch top 6 approved hacks ordered by downloads
const { data: popularHacks } = await supabase
.from("hacks")
- .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author,is_archive")
+ .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,is_archive")
.eq("approved", true)
.not("current_patch", "is", null)
.is("is_archive", false)
@@ -75,21 +76,47 @@ export default async function Home() {
});
// Fetch versions
- let mappedVersions = new Map();
- await Promise.all(
- popularHacks.map(async (r) => {
- if (r.current_patch) {
- const { data: currentPatch } = await supabase
- .from("patches")
- .select("version")
- .eq("id", r.current_patch)
- .maybeSingle();
- mappedVersions.set(r.slug, currentPatch?.version || "Pre-release");
- } else {
- mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release");
- }
- })
- );
+ const patchIds = popularHacks
+ .map((hack) => hack.current_patch)
+ .filter((id): id is number => typeof id === "number");
+ const versionsByPatchId = new Map();
+ if (patchIds.length > 0) {
+ const { data: patchRows } = await supabase
+ .from("patches")
+ .select("id,version")
+ .in("id", patchIds);
+ (patchRows || []).forEach((patch) => {
+ versionsByPatchId.set(patch.id, patch.version || "Pre-release");
+ });
+ }
+
+ const customDefaultVersionsBySlug = new Map();
+ const customPatcherSlugs = new Set();
+ const { data: customPatchRows } = await supabase
+ .from("hack_patcher_patches")
+ .select("hack_slug, sort_order, patches!inner(version)")
+ .in("hack_slug", slugs)
+ .order("sort_order", { ascending: true });
+ (customPatchRows || []).forEach((row: any) => {
+ customPatcherSlugs.add(row.hack_slug);
+ if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
+ const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
+ if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
+ });
+
+ const mappedVersions = new Map();
+ popularHacks.forEach((hack) => {
+ const currentPatchVersion = typeof hack.current_patch === "number"
+ ? versionsByPatchId.get(hack.current_patch) || "Pre-release"
+ : "";
+ mappedVersions.set(hack.slug, resolveHackDisplayVersion({
+ isArchive: hack.is_archive,
+ isCustomPatcherActive: customPatcherSlugs.has(hack.slug),
+ customVersionName: hack.custom_version_name,
+ customDefaultPatchVersion: customDefaultVersionsBySlug.get(hack.slug),
+ currentPatchVersion,
+ }));
+ });
// Fetch profiles
const userIds = [...new Set(popularHacks.map((h) => h.created_by).filter(Boolean))];
diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts
index 1717776..8c85d82 100644
--- a/src/app/submit/actions.ts
+++ b/src/app/submit/actions.ts
@@ -251,6 +251,17 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (vErr) return { ok: false, error: vErr.message } as const;
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
+ let shouldPublishAutomatically = !!args.publishAutomatically;
+ if (shouldPublishAutomatically) {
+ const { data: customPatcherRows, error: customPatcherErr } = await supabase
+ .from("hack_patcher_patches")
+ .select("patch_id")
+ .eq("hack_slug", args.slug)
+ .limit(1);
+ if (customPatcherErr) return { ok: false, error: customPatcherErr.message } as const;
+ shouldPublishAutomatically = (customPatcherRows || []).length === 0;
+ }
+
// Create patch row
const patchInsert: any = {
bucket: PATCHES_BUCKET,
@@ -260,7 +271,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
};
// Set published status based on publishAutomatically flag
- if (args.publishAutomatically) {
+ if (shouldPublishAutomatically) {
patchInsert.published = true;
patchInsert.published_at = new Date().toISOString();
} else {
@@ -275,7 +286,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (pErr) return { ok: false, error: pErr.message } as const;
// Only update current_patch if publishAutomatically is true
- if (args.publishAutomatically) {
+ if (shouldPublishAutomatically) {
// Check if this patch is newer than current_patch
let shouldUpdateCurrentPatch = true;
if (hack.current_patch) {
diff --git a/src/components/Hack/DownloadPermissionSettings.tsx b/src/components/Hack/DownloadPermissionSettings.tsx
index 86367a8..2acb7fb 100644
--- a/src/components/Hack/DownloadPermissionSettings.tsx
+++ b/src/components/Hack/DownloadPermissionSettings.tsx
@@ -8,27 +8,29 @@ import { FaUserGear } from "react-icons/fa6";
export type PatchesDownloadPermission = Database["public"]["Enums"]["Patches Download Permission"];
+function patchDownloadOptionDescription(
+ value: PatchesDownloadPermission,
+ isCustomPatcherActive: boolean,
+): string {
+ switch (value) {
+ case "None":
+ return "Users can only download your hack through the built-in patcher.";
+ case "Current":
+ return isCustomPatcherActive
+ ? "Only patch versions in your Custom patcher list can be downloaded directly."
+ : "Only the patch version marked Current can be downloaded directly.";
+ case "All":
+ return "Every published patch version can be downloaded directly.";
+ }
+}
+
export const PATCH_DOWNLOAD_OPTIONS: {
value: PatchesDownloadPermission;
label: string;
- /** Short helper shown next to or below the option */
- description: string;
}[] = [
- {
- value: "None",
- label: "None",
- description: "Users can only download your hack through the built-in patcher.",
- },
- {
- value: "Current",
- label: "Current only",
- description: "Only the patch version marked Current can be downloaded directly.",
- },
- {
- value: "All",
- label: "All published",
- description: "Every published patch version can be downloaded directly.",
- },
+ { value: "None", label: "None" },
+ { value: "Current", label: "Current only" },
+ { value: "All", label: "All published" },
];
function optionLabel(value: PatchesDownloadPermission): string {
@@ -38,11 +40,13 @@ function optionLabel(value: PatchesDownloadPermission): string {
interface DownloadPermissionSettingsProps {
hackSlug: string;
initialPermission: PatchesDownloadPermission;
+ isCustomPatcherActive?: boolean;
}
export default function DownloadPermissionSettings({
hackSlug,
initialPermission,
+ isCustomPatcherActive = false,
}: DownloadPermissionSettingsProps) {
const [savedPermission, setSavedPermission] = useState(initialPermission);
const [selectedPermission, setSelectedPermission] = useState(initialPermission);
@@ -129,10 +133,14 @@ export default function DownloadPermissionSettings({
Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher.
+ {isCustomPatcherActive && (
+ <> With Custom patcher versions active, "Current only" applies to every version in your Custom patcher list—not the Current badge alone.>
+ )}
@@ -170,10 +178,12 @@ export default function DownloadPermissionSettings({
function RadioCardsBody({
selectedPermission,
savedPermission,
+ isCustomPatcherActive,
onSelect,
}: {
selectedPermission: PatchesDownloadPermission;
savedPermission: PatchesDownloadPermission;
+ isCustomPatcherActive: boolean;
onSelect: (v: PatchesDownloadPermission) => void;
}) {
const dirty = selectedPermission !== savedPermission;
@@ -207,7 +217,9 @@ function RadioCardsBody({
{opt.label}
- {opt.description}
+
+ {patchDownloadOptionDescription(opt.value, isCustomPatcherActive)}
+
{showSavedBadge && (
Saved
diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx
index 2cf6aff..804f991 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,41 @@ 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;
+ const selectedFilename = selectedPatch?.filename ?? patchFilename;
+
+ function isRomReadyForPatch() {
+ return !!file || hasCached(baseRomId) || (isLinked(baseRomId) && hasPermission(baseRomId));
+ }
+
+ 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(() => {
@@ -93,11 +129,8 @@ const HackActions: React.FC = ({
// When patch URL is fetched and terms are agreed, automatically proceed with patching if ROM is ready
React.useEffect(() => {
if (termsAgreed && patchUrl && patchBlob && status === "idle") {
- const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
+ const romReady = isRomReadyForPatch();
if (romReady) {
- // Automatically start patching
- setStatus("ready");
- // Use setTimeout to avoid calling onPatch during render
const timeoutId = setTimeout(() => {
onPatch();
}, 0);
@@ -183,39 +216,40 @@ const HackActions: React.FC = ({
}
}
- async function onAgreeToTerms() {
+ async function onAgreeToTerms(): Promise<{ url: string; blob: Blob } | null> {
try {
setError(null);
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");
- return;
+ return null;
}
setPatchUrl(result.url);
setTermsAgreed(true);
- // 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);
- // Update status based on ROM readiness
- const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId)));
- if (romReady) {
- setStatus("ready");
- } else {
+ const romReady = isRomReadyForPatch();
+ if (!romReady) {
setStatus("idle");
}
+
+ return { url: result.url, blob };
} catch (e: any) {
setError(e?.message || "Failed to fetch patch URL");
setStatus("idle");
setTermsAgreed(false);
+ return null;
}
}
@@ -223,14 +257,20 @@ const HackActions: React.FC = ({
try {
setError(null);
- // If terms not agreed yet, trigger agreement flow
- if (!termsAgreed || !patchUrl || !patchBlob) {
- await onAgreeToTerms();
- return;
+ let url = patchUrl;
+ let blob = patchBlob;
+
+ if (!termsAgreed || !url || !blob) {
+ const downloaded = await onAgreeToTerms();
+ if (!downloaded) return;
+ url = downloaded.url;
+ blob = downloaded.blob;
+
+ const romReady = isRomReadyForPatch();
+ if (!romReady) return;
}
- // Prevent multiple patching attempts
- if (status === "patching" || status === "done") {
+ if (status === "patching") {
return;
}
@@ -246,45 +286,36 @@ const HackActions: React.FC = ({
baseFile = linkedFile;
}
- if (!patchUrl) return;
-
setStatus("patching");
- // Read inputs
- const [romBuf, patchBuf] = await Promise.all([
- baseFile.arrayBuffer(),
+ await Promise.all([
+ new Promise((r) => setTimeout(r, 1000)),
(async () => {
- let blob = patchBlob;
- if (!blob) {
- const resp = await fetch(patchUrl);
- if (!resp.ok) throw new Error("Failed to fetch patch");
- blob = await resp.blob();
- setPatchBlob(blob);
- }
- return await blob.arrayBuffer();
+ const [romBuf, patchBuf] = await Promise.all([
+ baseFile.arrayBuffer(),
+ blob.arrayBuffer(),
+ ]);
+
+ const romBin = new BinFile(romBuf);
+ romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : "");
+ const patchBin = new BinFile(patchBuf);
+
+ const patch = BPS.fromFile(patchBin);
+ const patchedRom = patch.apply(romBin);
+
+ const outExt = platform ? platform.toLowerCase() : 'bin';
+ const outputName = `${title} (${selectedVersion}).${outExt}`;
+ patchedRom.fileName = outputName;
+ patchedRom.save();
})(),
]);
- // Build BinFiles
- const romBin = new BinFile(romBuf);
- romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : "");
- const patchBin = new BinFile(patchBuf);
-
- // Parse and apply BPS
- const patch = BPS.fromFile(patchBin);
- const patchedRom = patch.apply(romBin);
-
- // Name output and download
- const outExt = platform ? platform.toLowerCase() : 'bin';
- const outputName = `${title} (${version}).${outExt}`;
- patchedRom.fileName = outputName;
- patchedRom.save();
-
setStatus("done");
// 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 +325,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,9 +347,12 @@ const HackActions: React.FC = ({
<>
("bps");
const [patchFile, setPatchFile] = React.useState(null);
@@ -163,8 +165,19 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{currentVersion !== undefined && (
-
-
Current version: {currentVersion || 'Not set'}
+
+
+
+
+
+ {isCustomPatcherActive ? 'Public version name:' : 'Current version:'} {currentVersion || 'Not set'}
+
+ {isCustomPatcherActive && (
+
+ Custom selected for the Patcher Version Settings .
+
+ )}
+
)}
@@ -262,18 +275,29 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{!!error &&
{error}
}
-
+
setPublishAutomatically(e.target.checked)}
+ disabled={isCustomPatcherActive || submitting}
+ checked={!isCustomPatcherActive && publishAutomatically}
+ onChange={(e) => {
+ if (isCustomPatcherActive) return;
+ setPublishAutomatically(e.target.checked);
+ }}
className="mt-0.5 rounded border-[var(--border)] text-emerald-600 focus:ring-emerald-600"
/>
Publish Automatically
-
- If checked, this version will be published and set as the current patch immediately after upload.
-
+ {isCustomPatcherActive ? (
+
+
Because you have "Custom" selected for the Patcher Version Settings, this version cannot be published automatically.
+
To make this patch available for download, you will need to manually select it after pressing the "Edit patcher versions" button.
+
+ ): (
+
+ If checked, this version will be published and set as the current patch immediately after upload.
+
+ )}
diff --git a/src/components/Hack/PatcherVersionManager.tsx b/src/components/Hack/PatcherVersionManager.tsx
new file mode 100644
index 0000000..cb1fba2
--- /dev/null
+++ b/src/components/Hack/PatcherVersionManager.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { FiX } from "react-icons/fi";
+import { updatePatcherSelectablePatches } from "@/app/hack/[slug]/actions";
+import PatcherVersionSettings from "@/components/Hack/PatcherVersionSettings";
+import VersionList from "@/components/Hack/VersionList";
+import { CUSTOM_VERSION_NAME_MAX_LENGTH, suggestCustomVersionName } from "@/utils/patches/hack-display-version";
+import type { PatchesDownloadPermission } from "@/components/Hack/DownloadPermissionSettings";
+
+type PatcherOption = "latest" | "custom";
+
+interface Patch {
+ id: number;
+ version: string;
+ created_at: string;
+ updated_at: string | null;
+ changelog: string | null;
+ published: boolean;
+ archived: boolean;
+}
+
+interface PatcherVersionManagerProps {
+ hackSlug: string;
+ currentPatchId: number | null;
+ initialSavedPatchIds: number[];
+ initialCustomVersionName: string | null;
+ patches: Patch[];
+ baseRom: string;
+ patchesDownloadPermission: PatchesDownloadPermission;
+ children?: React.ReactNode;
+}
+
+function sameOrderedIds(a: number[], b: number[]) {
+ if (a.length !== b.length) return false;
+ return a.every((id, index) => id === b[index]);
+}
+
+function optionFromSavedIds(savedPatchIds: number[]): PatcherOption {
+ return savedPatchIds.length > 0 ? "custom" : "latest";
+}
+
+function initialCustomName(name: string | null, savedPatchIds: number[], patches: Patch[]) {
+ const trimmedName = name?.trim();
+ if (trimmedName) return trimmedName.slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
+ if (savedPatchIds.length === 0) return "";
+ const firstSavedPatch = patches.find((patch) => patch.id === savedPatchIds[0]);
+ return (firstSavedPatch?.version || "").slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
+}
+
+export default function PatcherVersionManager({
+ hackSlug,
+ currentPatchId,
+ initialSavedPatchIds,
+ initialCustomVersionName,
+ patches,
+ baseRom,
+ patchesDownloadPermission,
+ children,
+}: PatcherVersionManagerProps) {
+ const router = useRouter();
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [publishedOption, setPublishedOption] = useState
(() => optionFromSavedIds(initialSavedPatchIds));
+ const [draftOption, setDraftOption] = useState(() => optionFromSavedIds(initialSavedPatchIds));
+ const [savedPatchIds, setSavedPatchIds] = useState(initialSavedPatchIds);
+ const [draftPatchIds, setDraftPatchIds] = useState(initialSavedPatchIds);
+ const [savedCustomVersionName, setSavedCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches));
+ const [draftCustomVersionName, setDraftCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches));
+ const [showPublishModal, setShowPublishModal] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [publishError, setPublishError] = useState(null);
+ const [showSaved, setShowSaved] = useState(false);
+ const [savedFadeOut, setSavedFadeOut] = useState(false);
+ const savedTimersRef = useRef<{
+ hold?: ReturnType;
+ fade?: ReturnType;
+ }>({});
+
+ const initialSavedKey = initialSavedPatchIds.join(",");
+ const initialCustomVersionNameKey = initialCustomVersionName ?? "";
+
+ useEffect(() => {
+ const nextOption = optionFromSavedIds(initialSavedPatchIds);
+ const nextCustomVersionName = initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches);
+ setPublishedOption(nextOption);
+ setDraftOption(nextOption);
+ setSavedPatchIds(initialSavedPatchIds);
+ setDraftPatchIds(initialSavedPatchIds);
+ setSavedCustomVersionName(nextCustomVersionName);
+ setDraftCustomVersionName(nextCustomVersionName);
+ setSelectionMode(false);
+ }, [initialSavedKey, initialSavedPatchIds, initialCustomVersionNameKey, initialCustomVersionName, patches]);
+
+ function clearSavedFeedbackTimers() {
+ const t = savedTimersRef.current;
+ if (t.hold) clearTimeout(t.hold);
+ if (t.fade) clearTimeout(t.fade);
+ t.hold = undefined;
+ t.fade = undefined;
+ }
+
+ useEffect(() => {
+ return () => {
+ clearSavedFeedbackTimers();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (showPublishModal) {
+ 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;
+ };
+ }
+ }, [showPublishModal]);
+
+ const patchById = useMemo(() => new Map(patches.map((patch) => [patch.id, patch])), [patches]);
+ const currentPatch = currentPatchId !== null ? patchById.get(currentPatchId) ?? null : null;
+ const savedPatchIdSet = useMemo(() => new Set(savedPatchIds), [savedPatchIds]);
+
+ const labelsForIds = (ids: number[]) => (
+ ids.map((id) => patchById.get(id)?.version).filter((label): label is string => Boolean(label))
+ );
+
+ const liveVersionLabels = publishedOption === "custom"
+ ? labelsForIds(savedPatchIds)
+ : currentPatch
+ ? [currentPatch.version]
+ : [];
+ const latestVersionLabels = currentPatch ? [currentPatch.version] : [];
+ const draftVersionLabels = draftOption === "custom"
+ ? labelsForIds(draftPatchIds)
+ : currentPatch
+ ? [currentPatch.version]
+ : [];
+ const selectedUnpublishedVersionLabels = draftOption === "custom"
+ ? draftPatchIds
+ .map((id) => patchById.get(id))
+ .filter((patch): patch is Patch => Boolean(patch))
+ .filter((patch) => !patch.published && !patch.archived)
+ .map((patch) => patch.version)
+ : [];
+ const suggestedCustomVersionName = useMemo(
+ () => suggestCustomVersionName(draftVersionLabels),
+ [draftVersionLabels],
+ );
+
+ const hasUnsavedChanges = draftOption !== publishedOption
+ || (draftOption === "custom" && !sameOrderedIds(draftPatchIds, savedPatchIds))
+ || (draftOption === "custom" && draftCustomVersionName.trim() !== savedCustomVersionName);
+ const publishDisabled = saving
+ || !hasUnsavedChanges
+ || (draftOption === "custom" && (draftPatchIds.length === 0 || !draftCustomVersionName.trim()));
+
+ function enterCustomSelectionMode() {
+ setDraftOption("custom");
+ setSelectionMode(true);
+ setError(null);
+ }
+
+ function chooseOption(option: PatcherOption) {
+ setError(null);
+ if (option === "latest") {
+ setDraftOption("latest");
+ setSelectionMode(false);
+ return;
+ }
+ enterCustomSelectionMode();
+ }
+
+ function togglePatch(patchId: number) {
+ const patch = patchById.get(patchId);
+ if (!patch || patch.archived) return;
+
+ setDraftOption("custom");
+ setDraftPatchIds((current) => {
+ if (current.includes(patchId)) {
+ return current.filter((id) => id !== patchId);
+ }
+ return [...current, patchId];
+ });
+ }
+
+ function clearSelections() {
+ setDraftOption("custom");
+ setDraftPatchIds([]);
+ setSelectionMode(true);
+ setError(null);
+ }
+
+ function cancelDraft() {
+ setDraftOption(publishedOption);
+ setDraftPatchIds(savedPatchIds);
+ setDraftCustomVersionName(savedCustomVersionName);
+ setSelectionMode(false);
+ setError(null);
+ setPublishError(null);
+ setShowPublishModal(false);
+ setShowSaved(false);
+ setSavedFadeOut(false);
+ }
+
+ function openPublishModal() {
+ if (publishDisabled) return;
+ setError(null);
+ setPublishError(null);
+ setShowPublishModal(true);
+ }
+
+ function applySuggestedCustomVersionName() {
+ if (!suggestedCustomVersionName) return;
+ setDraftCustomVersionName(suggestedCustomVersionName);
+ }
+
+ async function confirmPublish() {
+ const idsToSave = draftOption === "custom" ? draftPatchIds : [];
+ const customNameToSave = draftOption === "custom" ? draftCustomVersionName.trim() : null;
+ setSaving(true);
+ setPublishError(null);
+ try {
+ const result = await updatePatcherSelectablePatches(hackSlug, idsToSave, customNameToSave);
+ if (!result.ok) {
+ setPublishError(result.error || "Failed to publish changes");
+ return;
+ }
+
+ setSavedPatchIds(idsToSave);
+ setDraftPatchIds(idsToSave);
+ setSavedCustomVersionName(customNameToSave || "");
+ setDraftCustomVersionName(customNameToSave || "");
+ setPublishedOption(draftOption);
+ setSelectionMode(false);
+ setShowPublishModal(false);
+ clearSavedFeedbackTimers();
+ setSavedFadeOut(false);
+ setShowSaved(true);
+ const HOLD_MS = 4000;
+ const FADE_MS = 450;
+ savedTimersRef.current.hold = setTimeout(() => {
+ setSavedFadeOut(true);
+ savedTimersRef.current.fade = setTimeout(() => {
+ setShowSaved(false);
+ setSavedFadeOut(false);
+ }, FADE_MS);
+ }, HOLD_MS);
+ router.refresh();
+ } catch {
+ setPublishError("Failed to publish changes");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+ <>
+
+ {children}
+
+ {showPublishModal && (
+ !saving && setShowPublishModal(false)}
+ >
+
+ These patches will be available to choose from in the downloader on your hack's homepage:
+
+ {draftVersionLabels.length > 0 ? (
+
+ {draftVersionLabels.map((label, index) => (
+
+ -
+ {label}
+ {draftOption === "custom" && index === 0 && (
+
+ Default
+
+ )}
+
+ ))}
+
+ ) : (
+ No current patch is set.
+ )}
+ {draftOption === "custom" && (
+
+ Public version name: {draftCustomVersionName.trim()}
+
+ )}
+ {selectedUnpublishedVersionLabels.length > 0 && (
+
+ Selected unpublished versions will be published when these changes are saved.
+
+ )}
+ {publishError && (
+
+ {publishError}
+
+ )}
+
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+ {
+ setShowPublishModal(false);
+ setPublishError(null);
+ }}
+ disabled={saving}
+ className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
+ >
+ Cancel
+
+
+
+ )}
+ >
+ );
+}
+
+function Modal({
+ title,
+ children,
+ onClose,
+}: {
+ title: string;
+ children: React.ReactNode;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
{title}
+ {children}
+
+
+ );
+}
diff --git a/src/components/Hack/PatcherVersionSettings.tsx b/src/components/Hack/PatcherVersionSettings.tsx
new file mode 100644
index 0000000..12caacc
--- /dev/null
+++ b/src/components/Hack/PatcherVersionSettings.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import React from "react";
+import { FaCode } from "react-icons/fa";
+import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
+import { CUSTOM_VERSION_NAME_MAX_LENGTH } from "@/utils/patches/hack-display-version";
+
+type PatcherOption = "latest" | "custom";
+
+interface PatcherVersionSettingsProps {
+ publishedOption: PatcherOption;
+ draftOption: PatcherOption;
+ latestVersionLabels: string[];
+ liveVersionLabels: string[];
+ draftVersionLabels: string[];
+ publishedCustomVersionName: string;
+ customVersionName: string;
+ suggestedCustomVersionName: string | null;
+ customSelectionCount: number;
+ selectionMode: boolean;
+ hasUnsavedChanges: boolean;
+ publishDisabled: boolean;
+ saving: boolean;
+ error: string | null;
+ showSaved: boolean;
+ savedFadeOut: boolean;
+ onChooseOption: (option: PatcherOption) => void;
+ onEnterSelectionMode: () => void;
+ onClearSelections: () => void;
+ onCustomVersionNameChange: (name: string) => void;
+ onApplySuggestedCustomVersionName: () => void;
+ onCancel: () => void;
+ onPublish: () => void;
+}
+
+function optionLabel(option: PatcherOption) {
+ return option === "latest" ? "Latest published patch" : "Custom";
+}
+
+function versionSummary(labels: string[], emptyLabel: string) {
+ if (labels.length === 0) return emptyLabel;
+ return labels.join(", ");
+}
+
+export default function PatcherVersionSettings({
+ publishedOption,
+ draftOption,
+ latestVersionLabels,
+ liveVersionLabels,
+ draftVersionLabels,
+ publishedCustomVersionName,
+ customVersionName,
+ suggestedCustomVersionName,
+ customSelectionCount,
+ selectionMode,
+ hasUnsavedChanges,
+ publishDisabled,
+ saving,
+ error,
+ showSaved,
+ savedFadeOut,
+ onChooseOption,
+ onEnterSelectionMode,
+ onClearSelections,
+ onCustomVersionNameChange,
+ onApplySuggestedCustomVersionName,
+ onCancel,
+ onPublish,
+}: PatcherVersionSettingsProps) {
+ const isSwitchingFromLatestToCustom = publishedOption === "latest" && draftOption === "custom";
+ const liveCustomVersionName = publishedCustomVersionName.trim();
+ const showCustomVersionNameHint = draftOption === "custom" && selectionMode && publishDisabled && customVersionName.trim().length === 0;
+ const showSuggestedNameButton = suggestedCustomVersionName !== null && customVersionName.trim() !== suggestedCustomVersionName;
+ const customOptionDetail = publishedOption === "custom" && liveCustomVersionName
+ ? (
+ <>
+ {liveCustomVersionName}: {versionSummary(liveVersionLabels, "No custom versions are published.")}
+ >
+ )
+ : versionSummary(liveVersionLabels, "No custom versions are published.");
+ const liveOptionLabel = publishedOption === "custom" && liveCustomVersionName
+ ? `${optionLabel(publishedOption)} (${liveCustomVersionName})`
+ : optionLabel(publishedOption);
+ const summaryStatus = (() => {
+ if (hasUnsavedChanges && draftOption !== publishedOption) {
+ return { prefix: " · Draft: ", label: optionLabel(draftOption) };
+ }
+ if (hasUnsavedChanges) {
+ return { prefix: " · ", label: "Unsaved changes" };
+ }
+ if (selectionMode) {
+ return { prefix: " · ", label: "Selecting versions" };
+ }
+ return null;
+ })();
+ const summary = (
+ <>
+ Live:
+ {liveOptionLabel}
+ {summaryStatus && (
+ <>
+ {summaryStatus.prefix}
+ {summaryStatus.label}
+ >
+ )}
+ >
+ );
+
+ return (
+ }
+ summary={summary}
+ className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
+ >
+
+
+ Choose which patch versions players can use from the downloader on your hack's homepage.
+
+
+
+
+
+ {draftOption === "custom" && selectionMode && (
+
+
Custom draft
+
+ {customSelectionCount > 0
+ ? versionSummary(draftVersionLabels, "No versions selected.")
+ : "No versions selected. Choose at least one version to publish Custom."}
+
+
+ Public version name
+
+
onCustomVersionNameChange(event.target.value)}
+ maxLength={CUSTOM_VERSION_NAME_MAX_LENGTH}
+ className="mt-1 block h-9 w-full rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
+ placeholder="e.g. 2.1.2"
+ />
+
+ Shown on the hack page and discover cards. Max {CUSTOM_VERSION_NAME_MAX_LENGTH} characters.
+
+ {showCustomVersionNameHint && (
+
Custom version name is required.
+ )}
+ {showSuggestedNameButton && (
+
+ Use "{suggestedCustomVersionName}"
+
+ )}
+
+ )}
+
+ {selectionMode ? (
+
+
+ Clear selections
+
+
+
+ Cancel
+
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+
+
+ ) : (
+ <>
+
+ {draftOption === "custom" && !isSwitchingFromLatestToCustom && (
+
+ Edit patcher versions
+
+ )}
+
+
+ {hasUnsavedChanges && (
+
+ Cancel
+
+ )}
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+
+ >
+ )}
+ {(showSaved || error) && (
+
+ {showSaved ? (
+
+ Changes published.
+
+ ) : (
+ {error}
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function OptionCard({
+ option,
+ selected,
+ saved,
+ label,
+ description,
+ detail,
+ onSelect,
+}: {
+ option: PatcherOption;
+ selected: boolean;
+ saved: boolean;
+ label: string;
+ description: string;
+ detail: React.ReactNode;
+ onSelect: (option: PatcherOption) => void;
+}) {
+ return (
+ onSelect(option)}
+ className={`w-full text-left rounded-md border-2 px-3 py-3 transition-colors flex gap-3 items-start touch-manipulation ${
+ selected
+ ? "border-[var(--accent)] bg-[var(--surface-2)]"
+ : "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
+ }`}
+ >
+
+ {selected && }
+
+
+
+ {label}
+ {saved && (
+
+ Published
+
+ )}
+
+ {description}
+ {detail}
+
+
+ );
+}
diff --git a/src/components/Hack/StickyActionBar.tsx b/src/components/Hack/StickyActionBar.tsx
index 01d9d8e..8a01165 100644
--- a/src/components/Hack/StickyActionBar.tsx
+++ b/src/components/Hack/StickyActionBar.tsx
@@ -2,12 +2,17 @@
import React from "react";
import Link from "next/link";
+import { FiChevronDown, FiX } from "react-icons/fi";
import { platformAccept } from "@/utils/idb";
+import { useBaseRoms } from "@/contexts/BaseRomContext";
import type { Platform } from "@/data/baseRoms";
interface StickyActionBarProps {
title: string;
version?: string;
+ selectablePatches?: { id: number; version: string }[];
+ selectedPatchId?: number | null;
+ onVersionChange?: (patchId: number) => void;
author: string;
filename: string | null;
baseRomName?: string | null;
@@ -27,6 +32,9 @@ interface StickyActionBarProps {
export default function StickyActionBar({
title,
version,
+ selectablePatches = [],
+ selectedPatchId,
+ onVersionChange,
author,
filename,
baseRomName,
@@ -44,10 +52,13 @@ export default function StickyActionBar({
}: StickyActionBarProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
+ const { loading: baseRomsLoading } = useBaseRoms();
const uploadInputRef = React.useRef(null);
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,27 +89,66 @@ export default function StickyActionBar({
}
}, [status]);
+ const handleVersionSelect = (patchId: number, closeAfterSelect: boolean) => {
+ onVersionChange?.(patchId);
+ if (closeAfterSelect) {
+ setVersionPickerOpen(false);
+ }
+ };
+
return (
{title}
- {version && (
+ {hasVersionPicker ? (
+
setVersionPickerOpen((open) => !open)}
+ className="shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]"
+ >
+ {version}
+ {versionPickerOpen ? (
+
+ ) : (
+
+ )}
+
+ ) : version && (
{version}
)}
By {author}
-
+ {hasVersionPicker && versionPickerOpen && (
+
+
+
handleVersionSelect(patchId, true)}
+ />
+
+ )}
+
{!termsAgreed || status === "downloading" ? (
- !romReady ? (
+ baseRomsLoading ? (
+
+ Loading base ROMs…
+
+ ) : !romReady ? (
- To download the patch file, you must select a clean ROM for the patcher to use.
+ To patch this hack, you must select a clean ROM for the patcher to use.
) : (
- By downloading this patch, you agree to the Terms of Service.
+ By patching, you agree to the Terms of Service.
)
) : (
@@ -112,7 +162,7 @@ export default function StickyActionBar({
{romReady ? (filename ?? ".bps file ready") : isLinked ? "Permission needed" : "Base ROM needed"}
)}
- {!romReady && !isLinked && (
+ {!baseRomsLoading && !romReady && !isLinked && (
)}
- {!romReady && isLinked && (
+ {!baseRomsLoading && !romReady && isLinked && (
+ {hasVersionPicker && versionPickerOpen && (
+
+
setVersionPickerOpen(false)}
+ />
+
+ setVersionPickerOpen(false)}
+ aria-label="Close modal"
+ 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)]"
+ >
+ ×
+
+
Select which version to download
+ handleVersionSelect(patchId, false)}
+ />
+
+
+ )}
{errorMessage !== null && (
void;
+}) {
+ return (
+
+ {patches.map((patch, index) => {
+ const selected = selectedPatchId === patch.id;
+ return (
+ onSelect(patch.id)}
+ className={`flex w-full items-center justify-between gap-3 px-3 py-3 text-left text-sm transition-colors md:py-2.5 ${
+ selected
+ ? "bg-[var(--accent)]/10 text-foreground"
+ : "text-foreground/75 hover:bg-[var(--surface-3)]"
+ } ${index > 0 ? "border-t border-[var(--border)]" : ""}`}
+ >
+ {patch.version}
+
+ {selected && }
+
+
+ );
+ })}
+
+ );
+}
+
diff --git a/src/components/Hack/VersionActions.tsx b/src/components/Hack/VersionActions.tsx
index 81d7474..9fb86ef 100644
--- a/src/components/Hack/VersionActions.tsx
+++ b/src/components/Hack/VersionActions.tsx
@@ -41,6 +41,9 @@ interface VersionActionsProps {
hackSlug: string;
baseRom: string;
currentPatchCreatedAt: string | null;
+ isCustomPatcherActive?: boolean;
+ isInCustomPatcherList?: boolean;
+ customPatcherPatchCount?: number;
onActionComplete: () => void;
}
@@ -50,6 +53,9 @@ export default function VersionActions({
hackSlug,
baseRom,
currentPatchCreatedAt,
+ isCustomPatcherActive = false,
+ isInCustomPatcherList = false,
+ customPatcherPatchCount = 0,
onActionComplete,
}: VersionActionsProps) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms();
@@ -83,7 +89,8 @@ export default function VersionActions({
: false;
// Don't show Rollback if the patch is unpublished and newer than the current version
- const shouldShowRollback = !isCurrent && !(!patch.published && isNewerThanCurrent);
+ const shouldShowRollback = !isCustomPatcherActive && !isCurrent && !(!patch.published && isNewerThanCurrent);
+ const archiveWouldRemoveLastCustomPatch = isInCustomPatcherList && customPatcherPatchCount <= 2;
useEffect(() => {
if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) {
@@ -579,17 +586,32 @@ export default function VersionActions({
title="Archive Version"
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
-
- Are you sure you want to archive version {patch.version} ? This will hide it from public view, but it can be restored later.
-
+ {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.
+
+ )}
+ >
+ )}
-
- {actionLoading ? "Archiving..." : "Archive"}
-
+ {!archiveWouldRemoveLastCustomPatch && (
+
+ {actionLoading ? "Archiving..." : "Archive"}
+
+ )}
setShowDeleteModal(false)}
disabled={actionLoading}
@@ -639,7 +661,9 @@ export default function VersionActions({
Publish version {patch.version} ? This will make it viewable to the public along with its changelog.
- If this version is newer than the current patch, it will become the primary download used for all users.
+ {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."}
void;
}
export default function VersionList({
@@ -99,6 +108,11 @@ export default function VersionList({
hackSlug,
baseRom,
patchesDownloadPermission,
+ patcherSelectionMode = false,
+ draftPatchIds = [],
+ savedPatchIds = [],
+ isCustomPatcherActive = false,
+ onTogglePatcherPatch,
}: VersionListProps) {
// Initialize with first patch's changelog expanded if it exists
const getInitialExpanded = () => {
@@ -157,6 +171,8 @@ export default function VersionList({
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
: patches;
+ const draftPatchIdSet = new Set(draftPatchIds);
+ const savedPatchIdSet = new Set(savedPatchIds);
if (patches.length === 0 && (!showArchived || archivedPatches.length === 0)) {
return (
@@ -182,6 +198,8 @@ export default function VersionList({
{allPatches.map((patch) => {
const isCurrent = currentPatchId === patch.id;
+ const isPatchable = isCustomPatcherActive && savedPatchIdSet.has(patch.id);
+ const isDefaultPatcherPatch = isCustomPatcherActive && savedPatchIds[0] === patch.id;
const hasChangelog = patch.changelog && patch.changelog.trim().length > 0;
const isExpanded = expandedChangelogs.has(patch.id);
const isEditing = editingChangelog === patch.id;
@@ -189,7 +207,17 @@ export default function VersionList({
const currentPatchCreatedAt = currentPatch?.created_at || null;
const showPublicPatchDownload =
!canEdit &&
- shouldShowPublicPatchDownload(patchesDownloadPermission, patch, isCurrent);
+ shouldShowPublicPatchDownload(
+ patchesDownloadPermission,
+ patch,
+ isCurrent,
+ isPatchable,
+ isCustomPatcherActive,
+ );
+ const isHighlighted = isCustomPatcherActive ? isPatchable : isCurrent;
+ const selectedForPatcher = draftPatchIdSet.has(patch.id);
+ const patcherSelectionIndex = draftPatchIds.indexOf(patch.id);
+ const canSelectForPatcher = !patch.archived;
const titleBar = (
)}
- {isCurrent && editingVersion !== patch.id && (
+ {!isCustomPatcherActive && isCurrent && editingVersion !== patch.id && (
Current
)}
+ {isCustomPatcherActive && isDefaultPatcherPatch && editingVersion !== patch.id && (
+
+
+ Default
+
+ )}
+ {isCustomPatcherActive && isPatchable && !isDefaultPatcherPatch && editingVersion !== patch.id && (
+
+
+ Patchable
+
+ )}
{!patch.published && (
Unpublished
@@ -265,10 +305,80 @@ export default function VersionList({
);
+ if (patcherSelectionMode) {
+ const minimalBody = (
+ <>
+
+
+
+
{patch.version}
+ {isCurrent && !isCustomPatcherActive && (
+
+
+ Current
+
+ )}
+ {!patch.published && (
+
+ Unpublished
+
+ )}
+ {patch.archived && (
+
+ Archived
+
+ )}
+
+ {datesBlock}
+
+
+ {selectedForPatcher ? patcherSelectionIndex + 1 : ""}
+
+
+ >
+ );
+
+ if (!canSelectForPatcher) {
+ return (
+
+ {minimalBody}
+
+ );
+ }
+
+ return (
+ onTogglePatcherPatch?.(patch.id)}
+ aria-pressed={selectedForPatcher}
+ className={`card block p-4 sm:p-5 cursor-pointer transition-colors border ${
+ selectedForPatcher
+ ? "ring-2 ring-emerald-500/50 border-emerald-500/50"
+ : "border-[var(--border)] hover:border-[var(--accent)]/60"
+ } w-full text-left`}
+ >
+ {minimalBody}
+
+ );
+ }
+
return (
{showPublicPatchDownload ? (
@@ -295,6 +405,9 @@ export default function VersionList({
hackSlug={hackSlug}
baseRom={baseRom}
currentPatchCreatedAt={currentPatchCreatedAt}
+ isCustomPatcherActive={isCustomPatcherActive}
+ isInCustomPatcherList={savedPatchIdSet.has(patch.id)}
+ customPatcherPatchCount={savedPatchIds.length}
onActionComplete={() => {
router.refresh();
setEditingChangelog(null);
diff --git a/src/contexts/BaseRomContext.tsx b/src/contexts/BaseRomContext.tsx
index 37a3975..d33b316 100644
--- a/src/contexts/BaseRomContext.tsx
+++ b/src/contexts/BaseRomContext.tsx
@@ -7,6 +7,7 @@ import { baseRoms } from "@/data/baseRoms";
type ContextValue = {
supported: boolean;
+ loading: boolean;
linked: Record
;
statuses: Record;
cached: Record;
@@ -35,6 +36,7 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = React.useState>({});
const [cached, setCached] = React.useState>({});
const [totalCachedBytes, setTotalCachedBytes] = React.useState(0);
+ const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
@@ -70,6 +72,8 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) {
setTotalCachedBytes(total);
} catch (e) {
// noop
+ } finally {
+ setLoading(false);
}
})();
}, []);
@@ -288,6 +292,7 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) {
const value: ContextValue = {
supported,
+ loading,
linked,
statuses,
cached,
diff --git a/src/types/db.ts b/src/types/db.ts
index 0582313..7719f48 100644
--- a/src/types/db.ts
+++ b/src/types/db.ts
@@ -66,6 +66,42 @@ export type Database = {
},
]
}
+ hack_patcher_patches: {
+ Row: {
+ created_at: string
+ hack_slug: string
+ patch_id: number
+ sort_order: number
+ }
+ Insert: {
+ created_at?: string
+ hack_slug: string
+ patch_id: number
+ sort_order: number
+ }
+ Update: {
+ created_at?: string
+ hack_slug?: string
+ patch_id?: number
+ sort_order?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "hack_patcher_patches_hack_slug_fkey"
+ columns: ["hack_slug"]
+ isOneToOne: false
+ referencedRelation: "hacks"
+ referencedColumns: ["slug"]
+ },
+ {
+ foreignKeyName: "hack_patcher_patches_patch_id_fkey"
+ columns: ["patch_id"]
+ isOneToOne: false
+ referencedRelation: "patches"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
hack_tags: {
Row: {
hack_slug: string
@@ -141,6 +177,7 @@ export type Database = {
created_at: string
created_by: string
current_patch: number | null
+ custom_version_name: string | null
description: string
downloads: number
estimated_release: string | null
@@ -177,6 +214,7 @@ export type Database = {
created_at?: string
created_by: string
current_patch?: number | null
+ custom_version_name?: string | null
description: string
downloads?: number
estimated_release?: string | null
@@ -213,6 +251,7 @@ export type Database = {
created_at?: string
created_by?: string
current_patch?: number | null
+ custom_version_name?: string | null
description?: string
downloads?: number
estimated_release?: string | null
@@ -463,6 +502,14 @@ export type Database = {
}
is_archiver: { Args: never; Returns: boolean }
is_claims_admin: { Args: never; Returns: boolean }
+ replace_hack_patcher_patches: {
+ Args: {
+ p_custom_version_name?: string
+ p_hack_slug: string
+ p_patch_ids: number[]
+ }
+ Returns: undefined
+ }
set_claim: {
Args: { claim: string; uid: string; value: Json }
Returns: string
diff --git a/src/types/patcher.ts b/src/types/patcher.ts
new file mode 100644
index 0000000..7ba919e
--- /dev/null
+++ b/src/types/patcher.ts
@@ -0,0 +1,12 @@
+export interface SelectablePatch {
+ id: number;
+ version: string;
+ created_at: string;
+ filename: string | null;
+};
+
+export interface PatcherPatchSelection {
+ savedPatchIds: number[];
+ selectablePatches: SelectablePatch[];
+ defaultPatchId: number | null;
+};
diff --git a/src/utils/patches/hack-display-version.ts b/src/utils/patches/hack-display-version.ts
new file mode 100644
index 0000000..63aa5cd
--- /dev/null
+++ b/src/utils/patches/hack-display-version.ts
@@ -0,0 +1,46 @@
+export const CUSTOM_VERSION_NAME_MAX_LENGTH = 12;
+
+interface ResolveHackDisplayVersionArgs {
+ isArchive: boolean;
+ isCustomPatcherActive: boolean;
+ customVersionName?: string | null;
+ customDefaultPatchVersion?: string | null;
+ currentPatchVersion?: string | null;
+}
+
+export function resolveHackDisplayVersion({
+ isArchive,
+ isCustomPatcherActive,
+ customVersionName,
+ customDefaultPatchVersion,
+ currentPatchVersion,
+}: ResolveHackDisplayVersionArgs) {
+ if (isArchive) return "Archive";
+ if (isCustomPatcherActive) {
+ return customVersionName?.trim()
+ || customDefaultPatchVersion
+ || currentPatchVersion
+ || "";
+ }
+ return currentPatchVersion || "";
+}
+
+export function suggestCustomVersionName(versionLabels: string[]) {
+ if (versionLabels.length === 0) return null;
+
+ let prefix = versionLabels[0];
+ for (const label of versionLabels.slice(1)) {
+ let index = 0;
+ while (index < prefix.length && index < label.length && prefix[index] === label[index]) {
+ index += 1;
+ }
+ prefix = prefix.slice(0, index);
+ if (!prefix) return null;
+ }
+
+ const suggestion = prefix
+ .replace(/[-_.+ ]+$/g, "")
+ .trim()
+ .slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
+ return suggestion || null;
+}
diff --git a/src/utils/patches/patcher-selectable-patches.ts b/src/utils/patches/patcher-selectable-patches.ts
new file mode 100644
index 0000000..77ca6c2
--- /dev/null
+++ b/src/utils/patches/patcher-selectable-patches.ts
@@ -0,0 +1,62 @@
+import type { Database } from "@/types/db";
+import type { PatcherPatchSelection, SelectablePatch } from "@/types/patcher";
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+export async function getPatcherSelectablePatches(
+ supabase: SupabaseClient,
+ slug: string,
+ currentPatchId: number | null,
+): Promise {
+ const { data: rows, error } = await supabase
+ .from("hack_patcher_patches")
+ .select("patch_id, sort_order, patches!inner(id, version, created_at, published, archived, filename)")
+ .eq("hack_slug", slug)
+ .eq("patches.published", true)
+ .eq("patches.archived", false)
+ .order("sort_order", { ascending: true });
+ if (error) {
+ console.error(error);
+ return {
+ savedPatchIds: [],
+ selectablePatches: [],
+ defaultPatchId: null,
+ };
+ }
+ const savedPatchIds = rows.map((row) => row.patch_id);
+ let selectablePatches: SelectablePatch[] = rows.map((row) => row.patches).flat().map((patch) => ({
+ id: patch.id,
+ version: patch.version,
+ created_at: patch.created_at,
+ filename: patch.filename,
+ }));
+ const hasSavedRows = selectablePatches.length > 0;
+ if (selectablePatches.length === 0 && currentPatchId !== null) {
+ const { data: currentPatch, error: currentPatchError } = await supabase
+ .from("patches")
+ .select("id, version, created_at, published, archived, filename")
+ .eq("id", currentPatchId)
+ .maybeSingle();
+ if (currentPatchError) {
+ console.error(currentPatchError);
+ return {
+ savedPatchIds: [],
+ selectablePatches: [],
+ defaultPatchId: null,
+ };
+ }
+ if (currentPatch?.published && !currentPatch?.archived) {
+ selectablePatches = [currentPatch];
+ }
+ }
+ const defaultPatchId = hasSavedRows
+ ? selectablePatches[0]?.id ?? null
+ : (currentPatchId !== null && selectablePatches.some((patch) => patch.id === currentPatchId))
+ ? currentPatchId
+ : selectablePatches[0]?.id ?? null;
+
+ return {
+ savedPatchIds,
+ selectablePatches,
+ defaultPatchId,
+ };
+}
diff --git a/supabase/migrations/20260711041720_hack_patcher_patches.sql b/supabase/migrations/20260711041720_hack_patcher_patches.sql
new file mode 100644
index 0000000..197931e
--- /dev/null
+++ b/supabase/migrations/20260711041720_hack_patcher_patches.sql
@@ -0,0 +1,66 @@
+create table if not exists public.hack_patcher_patches (
+ hack_slug text not null references public.hacks(slug) on update cascade on delete cascade,
+ patch_id bigint not null references public.patches(id) on update cascade on delete cascade,
+ sort_order integer not null,
+ created_at timestamptz not null default now(),
+ primary key (hack_slug, patch_id)
+);
+
+create index hack_patcher_patches_hack_slug_idx
+ on public.hack_patcher_patches (hack_slug);
+
+create index hack_patcher_patches_patch_id_idx
+ on public.hack_patcher_patches (patch_id);
+
+alter table public.hack_patcher_patches enable row level security;
+
+-- Public read (same as patch_groups)
+create policy "Patcher patches are viewable by everyone"
+ on public.hack_patcher_patches for select using (true);
+
+-- Insert / update / delete: creator, admin, archiver.
+create policy "Users can insert patcher patches for own hacks"
+ on public.hack_patcher_patches for insert
+ with check (
+ (public.is_admin() OR
+ (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR
+ exists (
+ select 1 from public.hacks h
+ where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid()
+ ))
+ AND exists (
+ select 1 from public.patches p
+ where p.id = hack_patcher_patches.patch_id
+ and p.parent_hack = hack_patcher_patches.hack_slug
+ )
+ );
+
+create policy "Users can update patcher patches for own hacks"
+ on public.hack_patcher_patches for update
+ using (
+ public.is_admin() OR
+ (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR
+ exists (
+ select 1 from public.hacks h
+ where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid()
+ )
+ )
+ with check (
+ public.is_admin() OR
+ (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR
+ exists (
+ select 1 from public.hacks h
+ where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid()
+ )
+ );
+
+create policy "Users can delete patcher patches for own hacks"
+ on public.hack_patcher_patches for delete
+ using (
+ public.is_admin() OR
+ (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR
+ exists (
+ select 1 from public.hacks h
+ where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid()
+ )
+ );
diff --git a/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql b/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql
new file mode 100644
index 0000000..8f2f3e2
--- /dev/null
+++ b/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql
@@ -0,0 +1,54 @@
+create or replace function public.replace_hack_patcher_patches(
+ p_hack_slug text,
+ p_patch_ids bigint[],
+ p_custom_version_name text default null
+)
+returns void
+language plpgsql
+security invoker
+set search_path = public
+as $$
+declare
+ v_count integer;
+begin
+ -- Empty list = switch to Latest mode
+ if coalesce(cardinality(p_patch_ids), 0) = 0 then
+ delete from public.hack_patcher_patches where hack_slug = p_hack_slug;
+ update public.hacks set custom_version_name = null where slug = p_hack_slug;
+ return;
+ end if;
+
+ if p_custom_version_name is null or length(btrim(p_custom_version_name)) = 0 then
+ raise exception 'Custom version name is required';
+ end if;
+ if length(btrim(p_custom_version_name)) > 12 then
+ raise exception 'Custom version name must 12 characters or less';
+ end if;
+
+ -- All IDs must belong to the same hack
+ select count(*) into v_count
+ from public.patches p
+ where p.id = any(p_patch_ids)
+ and p.parent_hack = p_hack_slug
+ and p.archived = false;
+
+ if v_count <> cardinality(p_patch_ids) then
+ raise exception 'One or more patches either do not belong to this hack or are archived';
+ end if;
+
+ delete from public.hack_patcher_patches where hack_slug = p_hack_slug;
+ insert into public.hack_patcher_patches (hack_slug, patch_id, sort_order)
+ select
+ p_hack_slug,
+ patch_id,
+ ordinality::integer
+ from unnest(p_patch_ids) with ordinality as t(patch_id, ordinality);
+ update public.hacks
+ set custom_version_name = btrim(p_custom_version_name)
+ where slug = p_hack_slug;
+end;
+$$;
+
+revoke all on function public.replace_hack_patcher_patches(text, bigint[], text) from public;
+grant execute on function public.replace_hack_patcher_patches(text, bigint[], text) to authenticated;
+grant execute on function public.replace_hack_patcher_patches(text, bigint[], text) to service_role;
diff --git a/supabase/migrations/20260711041848_custom_version_name.sql b/supabase/migrations/20260711041848_custom_version_name.sql
new file mode 100644
index 0000000..57fc14b
--- /dev/null
+++ b/supabase/migrations/20260711041848_custom_version_name.sql
@@ -0,0 +1,2 @@
+alter table if exists public.hacks
+ add column if not exists custom_version_name text;