Add customizable patch version selector + related UX improvements (#62)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled

* 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
This commit is contained in:
Jared Schoeny
2026-07-10 22:42:51 -06:00
committed by GitHub
parent 6e41bb99f8
commit 43aa0236e1
24 changed files with 1857 additions and 203 deletions

View File

@@ -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

View File

@@ -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<Discove
// Build base query for hacks (public/anon view: only approved hacks)
let query = supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at,is_archive,completion_status")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status")
.eq("approved", true);
// Apply sorting based on sort type
@@ -168,6 +169,24 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
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<string, number> | null = null;
if (sort === "trending") {
@@ -232,14 +251,21 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
const mappedVersions = new Map<string, string>();
const publishedAtBySlug = new Map<string, string | null>();
(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);
}
});

View File

@@ -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<HackMetadata | null> {
@@ -66,7 +74,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
const { data: hack, error } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,custom_version_name,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info")
.eq("slug", slug)
.maybeSingle();
@@ -160,8 +168,18 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
}
}
const { savedPatchIds, selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
const displayVersion = resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: savedPatchIds.length > 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<HackMetadata | null
} : null,
otherHacks,
patch,
patcherSelector: {
selectablePatches,
defaultPatchId,
}
};
},
[`hack:${slug}:metadata`],
@@ -207,7 +229,12 @@ export async function getHackDownloads(slug: string): Promise<number | null> {
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 };
}

View File

@@ -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 (
<div className="mx-auto max-w-screen-lg px-6 py-10">
@@ -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}
/>
</div>

View File

@@ -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.
</p>
<p className="mt-2">
By pressing the "Patch Now" button, your browser will apply the downloaded <span className="font-semibold">{hack.title}</span> .bps patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
By pressing "Agree and Patch", your browser will download and apply the <span className="font-semibold">{hack.title}</span> .bps patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
</p>
<p className="mt-2">
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.

View File

@@ -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 (
<div className="mx-auto w-full max-w-screen-md px-4 sm:px-6 py-6 sm:py-10">
@@ -89,60 +93,117 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
</div>
</div>
{canEdit && (
<DownloadPermissionSettings
{canEdit ? (
<PatcherVersionManager
hackSlug={slug}
initialPermission={hack.patches_download_permission}
/>
currentPatchId={hack.current_patch}
initialSavedPatchIds={patcherSelection?.savedPatchIds ?? []}
initialCustomVersionName={hack.custom_version_name}
patches={allPatches}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}
>
<DownloadPermissionSettings
hackSlug={slug}
initialPermission={hack.patches_download_permission}
isCustomPatcherActive={isCustomPatcherActive}
/>
<VersionStatusGuide canEdit={canEdit} isCustomPatcherActive={isCustomPatcherActive} />
</PatcherVersionManager>
) : (
<>
<VersionStatusGuide canEdit={canEdit} isCustomPatcherActive={isCustomPatcherActive} />
<VersionList
patches={allPatches}
currentPatchId={hack.current_patch}
canEdit={canEdit}
hackSlug={slug}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}
isCustomPatcherActive={isCustomPatcherActive}
savedPatchIds={patcherSelection.savedPatchIds}
/>
</>
)}
<CollapsibleCard
title="Version Status Guide"
className="mb-6 bg-[var(--surface-1)] border border-[var(--border)]/50 rounded-lg"
>
<div className="space-y-5 sm:space-y-2.5 text-sm text-foreground/80">
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<FaStar size={10} />
Current
</span>
<p className="text-foreground/70">
{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."
}
</p>
</div>
{canEdit && <>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400 shrink-0 w-fit">
Unpublished
</span>
<p className="text-foreground/70">
Versions that are only visible to you, and will not appear in the public version list or changelog.
</p>
</div>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 shrink-0 w-fit">
Archived
</span>
<p className="text-foreground/70">
Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
</p>
</div>
</>}
</div>
</CollapsibleCard>
<VersionList
patches={allPatches}
currentPatchId={hack.current_patch}
canEdit={canEdit}
hackSlug={slug}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}
/>
</div>
);
}
function VersionStatusGuide({
canEdit,
isCustomPatcherActive,
}: {
canEdit: boolean;
isCustomPatcherActive: boolean;
}) {
const showCurrentGuide = canEdit || !isCustomPatcherActive;
const showPatchableGuide = canEdit || isCustomPatcherActive;
return (
<CollapsibleCard
title="Version Status Guide"
className="mb-6 bg-[var(--surface-1)] border border-[var(--border)]/50 rounded-lg"
>
<div className="space-y-5 sm:space-y-2.5 text-sm text-foreground/80">
{showCurrentGuide && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<FaStar size={10} />
Current
</span>
<p className="text-foreground/70">
{canEdit ?
<>The version used by the <strong>Latest published patch</strong> option. This is the default downloader version when <strong>Custom</strong> patcher versions are not active.</> :
"This is the version you will download when using the patch button on the hack page."
}
</p>
</div>
)}
{canEdit && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<FaStar size={10} />
Default
</span>
<p className="text-foreground/70">
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.
</p>
</div>
)}
{showPatchableGuide && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<FaStar size={10} />
Patchable
</span>
<p className="text-foreground/70">
{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."
}
</p>
</div>
)}
{canEdit && <>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400 shrink-0 w-fit">
Unpublished
</span>
<p className="text-foreground/70">
Versions that are only visible to you, and will not appear in the public version list or changelog.
</p>
</div>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 shrink-0 w-fit">
Archived
</span>
<p className="text-foreground/70">
Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
</p>
</div>
</>}
</div>
</CollapsibleCard>
);
}

View File

@@ -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<string, string>();
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<number, string>();
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<string, string>();
const customPatcherSlugs = new Set<string>();
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<string, string>();
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))];

View File

@@ -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) {

View File

@@ -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<PatchesDownloadPermission>(initialPermission);
const [selectedPermission, setSelectedPermission] = useState<PatchesDownloadPermission>(initialPermission);
@@ -129,10 +133,14 @@ export default function DownloadPermissionSettings({
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
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 <strong className="font-medium text-foreground/70">Custom</strong> patcher versions active, "Current only" applies to every version in your Custom patcher listnot the Current badge alone.</>
)}
</p>
<RadioCardsBody
selectedPermission={selectedPermission}
savedPermission={savedPermission}
isCustomPatcherActive={isCustomPatcherActive}
onSelect={setSelectedPermission}
/>
@@ -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({
</span>
<span className="min-w-0 flex-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:gap-x-1.5 sm:gap-y-0 leading-tight">
<span className="text-sm font-semibold sm:text-xs">{opt.label}</span>
<span className="text-xs text-foreground/55 sm:text-[11px]">{opt.description}</span>
<span className="text-xs text-foreground/55 sm:text-[11px]">
{patchDownloadOptionDescription(opt.value, isCustomPatcherActive)}
</span>
{showSavedBadge && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
Saved

View File

@@ -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<HackActionsProps> = ({
@@ -36,6 +41,7 @@ const HackActions: React.FC<HackActionsProps> = ({
patchFilename,
patchId,
hackSlug,
patcherSelector,
}) => {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
const [file, setFile] = React.useState<File | null>(null);
@@ -46,11 +52,41 @@ const HackActions: React.FC<HackActionsProps> = ({
const [termsAgreed, setTermsAgreed] = React.useState(false);
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
const [isVerifyingRom, setIsVerifyingRom] = React.useState(false);
const [selectedPatchId, setSelectedPatchId] = React.useState<number | null>(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<HackActionsProps> = ({
// 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<HackActionsProps> = ({
}
}
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<HackActionsProps> = ({
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<HackActionsProps> = ({
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<HackActionsProps> = ({
// 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<HackActionsProps> = ({
<>
<StickyActionBar
title={title}
version={version}
version={selectedVersion}
selectablePatches={patcherSelector.selectablePatches}
selectedPatchId={selectedPatch?.id ?? selectedPatchId}
onVersionChange={onVersionChange}
author={author}
filename={patchFilename}
filename={selectedFilename}
baseRomName={baseRomName}
baseRomPlatform={platform}
onPatch={onPatch}

View File

@@ -16,11 +16,13 @@ export interface HackPatchFormProps {
slug: string;
baseRomId: string;
existingVersions: string[];
isCustomPatcherActive: boolean;
customVersionName?: string | null;
currentVersion?: string;
}
export default function HackPatchForm(props: HackPatchFormProps) {
const { slug, baseRomId, existingVersions, currentVersion } = props;
const { slug, baseRomId, existingVersions, isCustomPatcherActive, customVersionName, currentVersion } = props;
const [version, setVersion] = React.useState("");
const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps");
const [patchFile, setPatchFile] = React.useState<File | null>(null);
@@ -163,8 +165,19 @@ export default function HackPatchForm(props: HackPatchFormProps) {
<div className="grid gap-5">
{currentVersion !== undefined && (
<div className="flex items-center rounded-md border border-[var(--border)]/70 bg-[var(--surface-2)]/20 px-3 py-2">
<FaInfoCircle size={12} className="mr-1 text-foreground/80" />
<p className="text-xs text-foreground/60">Current version: <span className="text-foreground/90 font-medium">{currentVersion || 'Not set'}</span></p>
<div className="min-w-[24px]">
<FaInfoCircle size={12} className="mr-1 text-foreground/80" />
</div>
<div className="flex flex-col">
<p data-has-custom-patcher={isCustomPatcherActive} className="text-xs text-foreground/60 data-[has-custom-patcher=true]:text-foreground/90 data-[has-custom-patcher=true]:text-sm">
{isCustomPatcherActive ? 'Public version name:' : 'Current version:'} <span className="text-foreground/90 font-bold">{currentVersion || 'Not set'}</span>
</p>
{isCustomPatcherActive && (
<p className="text-xs text-foreground/60 mt-1">
<span className="font-bold">Custom</span> selected for the <span className="font-bold">Patcher Version Settings</span>.
</p>
)}
</div>
</div>
)}
<div className="grid gap-2">
@@ -262,18 +275,29 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{!!error && <div className="text-sm text-red-400">{error}</div>}
<div className="flex items-start gap-3 border-t border-[var(--border)] pt-4 mt-2">
<label className="flex items-start gap-2 cursor-pointer">
<label className="flex items-start gap-2 cursor-pointer has-disabled:cursor-not-allowed">
<input
type="checkbox"
checked={publishAutomatically}
onChange={(e) => 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"
/>
<div className="text-sm">
<div className="font-medium text-foreground/90">Publish Automatically</div>
<div className="text-foreground/60 mt-0.5">
If checked, this version will be published and set as the current patch immediately after upload.
</div>
{isCustomPatcherActive ? (
<div className="italic text-foreground/60 mt-0.5">
<p>Because you have "Custom" selected for the Patcher Version Settings, this version <span className="font-bold">cannot</span> be published automatically.</p>
<p className="mt-1">To make this patch available for download, you will need to manually select it after pressing the <span className="font-bold">"Edit patcher versions"</span> button.</p>
</div>
): (
<div className="text-foreground/60 mt-0.5">
If checked, this version will be published and set as the current patch immediately after upload.
</div>
)}
</div>
</label>
</div>

View File

@@ -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<PatcherOption>(() => optionFromSavedIds(initialSavedPatchIds));
const [draftOption, setDraftOption] = useState<PatcherOption>(() => optionFromSavedIds(initialSavedPatchIds));
const [savedPatchIds, setSavedPatchIds] = useState<number[]>(initialSavedPatchIds);
const [draftPatchIds, setDraftPatchIds] = useState<number[]>(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<string | null>(null);
const [publishError, setPublishError] = useState<string | null>(null);
const [showSaved, setShowSaved] = useState(false);
const [savedFadeOut, setSavedFadeOut] = useState(false);
const savedTimersRef = useRef<{
hold?: ReturnType<typeof setTimeout>;
fade?: ReturnType<typeof setTimeout>;
}>({});
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 (
<>
<PatcherVersionSettings
publishedOption={publishedOption}
draftOption={draftOption}
latestVersionLabels={latestVersionLabels}
liveVersionLabels={liveVersionLabels}
draftVersionLabels={draftVersionLabels}
publishedCustomVersionName={savedCustomVersionName}
customVersionName={draftCustomVersionName}
suggestedCustomVersionName={suggestedCustomVersionName}
customSelectionCount={draftPatchIds.length}
selectionMode={selectionMode}
hasUnsavedChanges={hasUnsavedChanges}
publishDisabled={publishDisabled}
saving={saving}
error={error}
showSaved={showSaved}
savedFadeOut={savedFadeOut}
onChooseOption={chooseOption}
onEnterSelectionMode={enterCustomSelectionMode}
onClearSelections={clearSelections}
onCustomVersionNameChange={setDraftCustomVersionName}
onApplySuggestedCustomVersionName={applySuggestedCustomVersionName}
onCancel={cancelDraft}
onPublish={openPublishModal}
/>
{children}
<VersionList
patches={patches}
currentPatchId={currentPatchId}
canEdit
hackSlug={hackSlug}
baseRom={baseRom}
patchesDownloadPermission={patchesDownloadPermission}
patcherSelectionMode={selectionMode}
draftPatchIds={draftPatchIds}
savedPatchIds={savedPatchIds}
isCustomPatcherActive={publishedOption === "custom"}
onTogglePatcherPatch={togglePatch}
/>
{showPublishModal && (
<Modal
title="Publish Patcher Changes"
onClose={() => !saving && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-3">
These patches will be available to choose from in the downloader on your hack&apos;s homepage:
</p>
{draftVersionLabels.length > 0 ? (
<ul className="mb-4 space-y-1 text-sm text-foreground/75">
{draftVersionLabels.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span aria-hidden>-</span>
<span>{label}</span>
{draftOption === "custom" && index === 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Default
</span>
)}
</li>
))}
</ul>
) : (
<p className="mb-4 text-sm text-foreground/60">No current patch is set.</p>
)}
{draftOption === "custom" && (
<p className="mb-4 text-sm text-foreground/70">
Public version name: <strong className="text-foreground">{draftCustomVersionName.trim()}</strong>
</p>
)}
{selectedUnpublishedVersionLabels.length > 0 && (
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
Selected unpublished versions will be published when these changes are saved.
</p>
)}
{publishError && (
<p className="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{publishError}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={confirmPublish}
disabled={saving}
className="flex-1 inline-flex items-center justify-center rounded-md bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
<button
type="button"
onClick={() => {
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
</button>
</div>
</Modal>
)}
</>
);
}
function Modal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
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)]"
>
<FiX size={20} />
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">{title}</h2>
{children}
</div>
</div>
);
}

View File

@@ -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
? (
<>
<strong>{liveCustomVersionName}:</strong> {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 = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{liveOptionLabel}</span>
{summaryStatus && (
<>
<span className="text-foreground/40">{summaryStatus.prefix}</span>
<span className="text-foreground/70 font-medium">{summaryStatus.label}</span>
</>
)}
</>
);
return (
<CollapsibleCard
title="Patcher Version Settings"
titleId="patcher-version-settings-heading"
leading={<FaCode size={20} />}
summary={summary}
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
>
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
Choose which patch versions players can use from the downloader on your hack&apos;s homepage.
</p>
<div className="grid gap-3" role="radiogroup" aria-label="Patcher version source">
<OptionCard
option="latest"
selected={draftOption === "latest"}
saved={publishedOption === "latest"}
label="Only use the latest published patch"
description="Use the hack's current published patch. Players will not see a version dropdown."
detail={versionSummary(latestVersionLabels, "No current patch is set.")}
onSelect={onChooseOption}
/>
<OptionCard
option="custom"
selected={draftOption === "custom"}
saved={publishedOption === "custom"}
label="Custom"
description="Choose specific non-archived versions for the downloader. Great for multiple variants of the same version, like builds with different features or optional changes."
detail={customOptionDetail}
onSelect={onChooseOption}
/>
</div>
{draftOption === "custom" && selectionMode && (
<div className="mt-4 rounded-md border border-[var(--border)]/70 bg-[var(--surface-1)]/70 px-3 py-2 text-xs text-foreground/65">
<div className="font-medium text-foreground/80 mb-1">Custom draft</div>
<div>
{customSelectionCount > 0
? versionSummary(draftVersionLabels, "No versions selected.")
: "No versions selected. Choose at least one version to publish Custom."}
</div>
<label htmlFor="custom-version-name" className="mt-3 block font-medium text-foreground/80">
Public version name
</label>
<input
id="custom-version-name"
type="text"
value={customVersionName}
onChange={(event) => 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"
/>
<p className="mt-1 text-foreground/55">
Shown on the hack page and discover cards. Max {CUSTOM_VERSION_NAME_MAX_LENGTH} characters.
</p>
{showCustomVersionNameHint && (
<p className="mt-1 text-red-400">Custom version name is required.</p>
)}
{showSuggestedNameButton && (
<button
type="button"
onClick={onApplySuggestedCustomVersionName}
className="mt-2 inline-flex items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 text-xs font-semibold text-foreground/80 transition-colors hover:bg-[var(--surface-3)]"
>
Use &quot;{suggestedCustomVersionName}&quot;
</button>
)}
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{selectionMode ? (
<div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={onClearSelections}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors sm:w-auto"
>
Clear selections
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
disabled={saving}
className="inline-flex flex-1 items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors disabled:opacity-50 sm:flex-none"
>
Cancel
</button>
<button
type="button"
onClick={onPublish}
disabled={publishDisabled}
className="inline-flex flex-1 items-center justify-center min-w-0 h-10 sm:h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)] sm:flex-none sm:min-w-32"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
{draftOption === "custom" && !isSwitchingFromLatestToCustom && (
<button
type="button"
onClick={onEnterSelectionMode}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
>
Edit patcher versions
</button>
)}
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{hasUnsavedChanges && (
<button
type="button"
onClick={onCancel}
disabled={saving}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors disabled:opacity-50"
>
Cancel
</button>
)}
<button
type="button"
onClick={onPublish}
disabled={publishDisabled}
className="inline-flex items-center justify-center min-w-32 h-10 sm:h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)]"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
</div>
</>
)}
{(showSaved || error) && (
<div
className="basis-full text-xs flex items-center min-w-0"
aria-live="polite"
>
{showSaved ? (
<span
className={`text-emerald-600 dark:text-emerald-400 font-medium transition-opacity duration-[450ms] ease-out ${
savedFadeOut ? "opacity-0" : "opacity-100"
}`}
>
Changes published.
</span>
) : (
<span className="text-red-400">{error}</span>
)}
</div>
)}
</div>
</div>
</CollapsibleCard>
);
}
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 (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => 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)]"
}`}
>
<span
className={`mt-0.5 shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
selected ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-hidden
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{label}</span>
{saved && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
Published
</span>
)}
</span>
<span className="mt-1 block text-xs text-foreground/60">{description}</span>
<span className="mt-2 block text-xs font-medium text-foreground/75">{detail}</span>
</span>
</button>
);
}

View File

@@ -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<HTMLInputElement | null>(null);
const [errorMessage, setErrorMessage] = React.useState<string | null>(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 (
<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="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">
<div className="truncate text-xl font-bold md:text-sm md:font-medium">{title}</div>
{version && (
{hasVersionPicker ? (
<button
type="button"
aria-label="Patch version"
aria-haspopup="dialog"
aria-expanded={versionPickerOpen}
onClick={() => 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)]"
>
<span className="truncate">{version}</span>
{versionPickerOpen ? (
<FiX size={13} className="shrink-0 text-foreground/65" aria-hidden />
) : (
<FiChevronDown size={13} className="shrink-0 text-foreground/65" aria-hidden />
)}
</button>
) : version && (
<span className="shrink-0 rounded-full bg-[var(--surface-2)] ml-auto md:ml-0 px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
)}
</div>
<div className="truncate text-sm md:text-xs text-foreground/60">By {author}</div>
</div>
<div className="flex w-full md:w-auto flex-col md:flex-row items-stretch md:items-center gap-2 mb-4 md:mb-0">
{hasVersionPicker && versionPickerOpen && (
<div className="md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="mb-2">
<div className="text-xs font-semibold uppercase tracking-wide text-foreground/55">Select version</div>
</div>
<VersionRadioList
patches={selectablePatches}
selectedPatchId={selectedPatchId}
onSelect={(patchId) => handleVersionSelect(patchId, true)}
/>
</div>
)}
<div className={`${hasVersionPicker && versionPickerOpen ? "hidden md:flex" : "flex"} w-full md:w-auto flex-col md:flex-row items-stretch md:items-center gap-2 mb-4 md:mb-0`}>
{!termsAgreed || status === "downloading" ? (
!romReady ? (
baseRomsLoading ? (
<p className="rounded-full mx-auto md:mx-0 px-2 py-6 md:py-0.5 text-base text-center md:text-right md:mr-1 md:text-balance font-bold">
Loading base ROMs
</p>
) : !romReady ? (
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-right md:text-balance">
To download the patch file, you must select a <span className="font-bold">clean ROM</span> for the patcher to use.
To patch this hack, you must select a <span className="font-bold">clean ROM</span> for the patcher to use.
</p>
) : (
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-right md:text-balance">
By downloading this patch, you agree to the <Link href="/terms" target="_blank" className="underline">Terms of Service</Link>.
By patching, you agree to the <Link href="/terms" target="_blank" className="underline">Terms of Service</Link>.
</p>
)
) : (
@@ -112,7 +162,7 @@ export default function StickyActionBar({
{romReady ? (filename ?? ".bps file ready") : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
)}
{!romReady && !isLinked && (
{!baseRomsLoading && !romReady && !isLinked && (
<label className="min-w-max inline-flex items-center gap-2 text-xs text-foreground/80">
<input
ref={uploadInputRef}
@@ -140,7 +190,7 @@ export default function StickyActionBar({
</button>
</label>
)}
{!romReady && isLinked && (
{!baseRomsLoading && !romReady && isLinked && (
<button
type="button"
onClick={onClickLink}
@@ -161,11 +211,40 @@ export default function StickyActionBar({
status === "downloading" ? "Downloading…" :
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : termsAgreed ? "Patch Now" : "Agree and Download"
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
}</span>
</button>
</div>
</div>
{hasVersionPicker && versionPickerOpen && (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] hidden md:flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={() => setVersionPickerOpen(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-label="Select which version to download"
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={() => 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)]"
>
×
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">Select which version to download</h2>
<VersionRadioList
patches={selectablePatches}
selectedPatchId={selectedPatchId}
onSelect={(patchId) => handleVersionSelect(patchId, false)}
/>
</div>
</div>
)}
{errorMessage !== null && (
<div
className={`absolute inset-x-0 md:left-1/2 md:-translate-x-1/2 md:mt-4 mb-2 md:mx-auto flex flex-col w-full md:w-auto lg:max-w-screen-lg rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)] text-sm text-red-400 transition-all duration-300 ${showError ? "opacity-100 -translate-y-full md:translate-y-full" : "opacity-0 translate-y-0 md:-translate-y-1/2 pointer-events-none"}`}
@@ -180,4 +259,50 @@ export default function StickyActionBar({
);
}
function VersionRadioList({
patches,
selectedPatchId,
onSelect,
}: {
patches: { id: number; version: string }[];
selectedPatchId?: number | null;
onSelect: (patchId: number) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Patch version"
className="overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-2)]"
>
{patches.map((patch, index) => {
const selected = selectedPatchId === patch.id;
return (
<button
key={patch.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => 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)]" : ""}`}
>
<span className="font-medium">{patch.version}</span>
<span
className={`h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
selected ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-hidden
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -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)}
>
<p className="text-foreground/80 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
{archiveWouldRemoveLastCustomPatch ? (
<p className="text-foreground/80 mb-4">
Version <strong>{patch.version}</strong> is one of the last 2 versions in the <strong>Custom</strong> patcher list. Switch to <strong>Latest published patch</strong> or add another Custom version before archiving it.
</p>
) : (
<>
<p className="text-foreground/80 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
{isInCustomPatcherList && (
<p className="text-sm text-foreground/60 mb-4">
This version will also be removed from the Custom patcher list.
</p>
)}
</>
)}
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
{!archiveWouldRemoveLastCustomPatch && (
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
)}
<button
onClick={() => setShowDeleteModal(false)}
disabled={actionLoading}
@@ -639,7 +661,9 @@ export default function VersionActions({
Publish version <strong>{patch.version}</strong>? This will make it viewable to the public along with its changelog.
</p>
<p className="text-sm text-foreground/60 mb-4">
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."}
</p>
<div className="flex gap-2">
<button

View File

@@ -24,11 +24,15 @@ function shouldShowPublicPatchDownload(
permission: PatchesDownloadPermission,
patch: Patch,
isCurrent: boolean,
isPatchable: boolean,
isCustomPatcherActive: boolean,
): boolean {
if (permission === "None") return false;
if (!patch.published || patch.archived) return false;
if (permission === "All") return true;
if (permission === "Current") return isCurrent;
if (permission === "Current") {
return isCustomPatcherActive ? isPatchable : isCurrent;
}
return false;
}
@@ -90,6 +94,11 @@ interface VersionListProps {
hackSlug: string;
baseRom: string;
patchesDownloadPermission: PatchesDownloadPermission;
patcherSelectionMode?: boolean;
draftPatchIds?: number[];
savedPatchIds?: number[];
isCustomPatcherActive?: boolean;
onTogglePatcherPatch?: (patchId: number) => 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 = (
<div
@@ -221,12 +249,24 @@ export default function VersionList({
)}
</>
)}
{isCurrent && editingVersion !== patch.id && (
{!isCustomPatcherActive && isCurrent && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
</span>
)}
{isCustomPatcherActive && isDefaultPatcherPatch && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Default
</span>
)}
{isCustomPatcherActive && isPatchable && !isDefaultPatcherPatch && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Patchable
</span>
)}
{!patch.published && (
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Unpublished
@@ -265,10 +305,80 @@ export default function VersionList({
</div>
);
if (patcherSelectionMode) {
const minimalBody = (
<>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 mb-2">
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{isCurrent && !isCustomPatcherActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
</span>
)}
{!patch.published && (
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Unpublished
</span>
)}
{patch.archived && (
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Archived
</span>
)}
</div>
{datesBlock}
</div>
<span
className={`mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold ${
selectedForPatcher
? "border-emerald-500/70 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: canSelectForPatcher
? "border-[var(--border)] text-foreground/35"
: "border-[var(--border)] text-foreground/25"
}`}
aria-hidden
>
{selectedForPatcher ? patcherSelectionIndex + 1 : ""}
</span>
</div>
</>
);
if (!canSelectForPatcher) {
return (
<div
key={patch.id}
className="card p-4 sm:p-5 border border-[var(--border)] opacity-65 cursor-not-allowed"
>
{minimalBody}
</div>
);
}
return (
<button
type="button"
key={patch.id}
onClick={() => 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}
</button>
);
}
return (
<div
key={patch.id}
className={`card p-4 sm:p-5 ${isCurrent ? "ring-2 ring-emerald-500/50" : ""}`}
className={`card p-4 sm:p-5 ${isHighlighted ? "ring-2 ring-emerald-500/50" : ""}`}
>
<div className="space-y-3 sm:space-y-4">
{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);

View File

@@ -7,6 +7,7 @@ import { baseRoms } from "@/data/baseRoms";
type ContextValue = {
supported: boolean;
loading: boolean;
linked: Record<string, any>;
statuses: Record<string, "granted" | "prompt" | "denied" | "error">;
cached: Record<string, boolean>;
@@ -35,6 +36,7 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = React.useState<Record<string, "granted" | "prompt" | "denied" | "error">>({});
const [cached, setCached] = React.useState<Record<string, boolean>>({});
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,

View File

@@ -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

12
src/types/patcher.ts Normal file
View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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<Database>,
slug: string,
currentPatchId: number | null,
): Promise<PatcherPatchSelection> {
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,
};
}

View File

@@ -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()
)
);

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table if exists public.hacks
add column if not exists custom_version_name text;