From 43aa0236e1ae0cc9e4a9729ff78cf9a48c5a5591 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 10 Jul 2026 22:42:51 -0600 Subject: [PATCH] Add customizable patch version selector + related UX improvements (#62) * Add `hack_patcher_patches` table * Implement patcher version server actions * Use first curated patch as default when custom patcher list is active * Allow saving custom patcher lists with unpublished patch auto-publish * Keep custom patcher list consistent across archive and new uploads * Add creator UI for Latest vs Custom patcher version settings * Add patch version picker to hack page downloader * Add custom public version names for Custom patcher mode * Fix direct patch download consistency between Latest vs Custom modes * Consolidate download and patch to one button press * Fix rom ready for patching checks * Fix Select ROM pop-in while base roms loading * Tighten patcher patches db insertion * Harden getSignedPatchUrl permission checks * Fix not using selected patch's filename * Acknowledge Custom patcher setting in HackPatchForm * Update types/db.ts * Refresh discover cache on patch published * Add migration ordering check to ci.yaml * Fix new migrations ordering --- .github/workflows/ci.yaml | 11 + src/app/discover/actions.ts | 36 +- src/app/hack/[slug]/actions.ts | 196 ++++++++- src/app/hack/[slug]/edit/patch/page.tsx | 10 +- src/app/hack/[slug]/page.tsx | 7 +- src/app/hack/[slug]/versions/page.tsx | 165 ++++--- src/app/page.tsx | 59 ++- src/app/submit/actions.ts | 15 +- .../Hack/DownloadPermissionSettings.tsx | 48 +- src/components/Hack/HackActions.tsx | 138 +++--- src/components/Hack/HackPatchForm.tsx | 42 +- src/components/Hack/PatcherVersionManager.tsx | 412 ++++++++++++++++++ .../Hack/PatcherVersionSettings.tsx | 317 ++++++++++++++ src/components/Hack/StickyActionBar.tsx | 141 +++++- src/components/Hack/VersionActions.tsx | 48 +- src/components/Hack/VersionList.tsx | 121 ++++- src/contexts/BaseRomContext.tsx | 5 + src/types/db.ts | 47 ++ src/types/patcher.ts | 12 + src/utils/patches/hack-display-version.ts | 46 ++ .../patches/patcher-selectable-patches.ts | 62 +++ .../20260711041720_hack_patcher_patches.sql | 66 +++ ...41816_replace_hack_patcher_patches_rpc.sql | 54 +++ .../20260711041848_custom_version_name.sql | 2 + 24 files changed, 1857 insertions(+), 203 deletions(-) create mode 100644 src/components/Hack/PatcherVersionManager.tsx create mode 100644 src/components/Hack/PatcherVersionSettings.tsx create mode 100644 src/types/patcher.ts create mode 100644 src/utils/patches/hack-display-version.ts create mode 100644 src/utils/patches/patcher-selectable-patches.ts create mode 100644 supabase/migrations/20260711041720_hack_patcher_patches.sql create mode 100644 supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql create mode 100644 supabase/migrations/20260711041848_custom_version_name.sql diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d6fadcb..c1105f1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,17 @@ jobs: with: version: latest + - name: Check new migrations are after main + run: | + git fetch origin main --depth=1 + tip=$(git ls-tree --name-only origin/main:supabase/migrations | sort | tail -1) + oldest_new=$(git diff --name-only --diff-filter=A origin/main -- supabase/migrations \ + | xargs -n1 basename | sort | head -1) + if [ -n "$oldest_new" ] && [[ ! "$oldest_new" > "$tip" ]]; then + echo "::error::$oldest_new must be renamed to after $tip" + exit 1 + fi + - name: Start Supabase local development setup run: supabase db start diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index 3e11c24..254a621 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -7,6 +7,7 @@ import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format"; import { fetchInChunks } from "@/utils/array"; import { HackCardAttributes } from "@/components/HackCard"; import type { DiscoverSortOption } from "@/types/discover"; +import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version"; const TRENDING_WINDOW_DAYS = 3; const TIME_TO_LIVE = 600; // 10 minutes @@ -37,7 +38,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise(); + const customPatcherSlugs = new Set(); + if (slugs.length > 0) { + const { data: customPatchRows, error: customPatchRowsError } = await supabase + .from("hack_patcher_patches") + .select("hack_slug, sort_order, patches!inner(version)") + .in("hack_slug", slugs) + .order("sort_order", { ascending: true }); + if (customPatchRowsError) throw customPatchRowsError; + + (customPatchRows || []).forEach((row: any) => { + customPatcherSlugs.add(row.hack_slug); + if (customDefaultVersionsBySlug.has(row.hack_slug)) return; + const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches; + if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version); + }); + } + // Calculate trending scores if needed let trendingScores: Map | null = null; if (sort === "trending") { @@ -232,14 +251,21 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise(); const publishedAtBySlug = new Map(); (rows || []).forEach((r: any) => { - if (typeof r.current_patch === "number") { - const version = versionsByPatchId.get(r.current_patch) || "Pre-release"; - mappedVersions.set(r.slug, version); + const currentPatchVersion = typeof r.current_patch === "number" + ? versionsByPatchId.get(r.current_patch) || "Pre-release" + : ""; + mappedVersions.set(r.slug, resolveHackDisplayVersion({ + isArchive: r.is_archive, + isCustomPatcherActive: customPatcherSlugs.has(r.slug), + customVersionName: r.custom_version_name, + customDefaultPatchVersion: customDefaultVersionsBySlug.get(r.slug), + currentPatchVersion, + })); + if (typeof r.current_patch === "number") { const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null; publishedAtBySlug.set(r.slug, publishedAt); } else { - mappedVersions.set(r.slug, r.is_archive ? "Archive" : "Pre-release"); publishedAtBySlug.set(r.slug, null); } }); diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 44e6503..29854a6 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -11,6 +11,9 @@ import { revalidatePath, revalidateTag } from "next/cache"; import { unstable_cache as cache } from "next/cache"; import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { Database, Constants } from "@/types/db"; +import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches"; +import { CUSTOM_VERSION_NAME_MAX_LENGTH, resolveHackDisplayVersion } from "@/utils/patches/hack-display-version"; +import type { SelectablePatch } from "@/types/patcher"; const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[ "Patches Download Permission" @@ -37,6 +40,7 @@ export interface HackMetadata { completion_status: Database["public"]["Enums"]["Completion Status"] | null; verification_contact_info: string | null; }; + displayVersion: string; images: string[]; tags: string[]; profile: { @@ -57,6 +61,10 @@ export interface HackMetadata { created_at: string; changelog: string | null; } | null; + patcherSelector: { + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; + }; } export async function getHackMetadata(slug: string): Promise { @@ -66,7 +74,7 @@ export async function getHackMetadata(slug: string): Promise 0, + customVersionName: hack.custom_version_name, + customDefaultPatchVersion: selectablePatches[0]?.version, + currentPatchVersion: patch?.version, + }); + return { hack, + displayVersion, images, tags, profile: profile ? { @@ -172,6 +190,10 @@ export async function getHackMetadata(slug: string): Promise { return runner(); } -export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> { +export async function getSignedPatchUrl( + slug: string, + options?: { + patchId?: number; + } +): Promise<{ ok: true; url: string } | { ok: false; error: string }> { const supabase = await createClient(); // Get user for permission check @@ -243,22 +270,30 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: return { ok: false, error: "Archive hacks do not have patch files available" }; } - // Check if patch exists - if (hack.current_patch == null) { - return { ok: false, error: "No patch available" }; + // Get selectable patches and validate selected patch id + const { selectablePatches } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch); + const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id)); + const selectedPatchId = options?.patchId ?? hack.current_patch; + + if (selectedPatchId === null || !allowedPatchIds.has(selectedPatchId)) { + return { ok: false, error: "Patch not available" }; } // Fetch patch info const { data: patch, error: patchError } = await supabase .from("patches") - .select("id, bucket, filename") - .eq("id", hack.current_patch as number) + .select("id, bucket, filename, parent_hack, published, archived") + .eq("id", selectedPatchId) .maybeSingle(); if (patchError || !patch) { return { ok: false, error: "Patch not found" }; } + if (patch.parent_hack !== slug || !patch.published || patch.archived) { + return { ok: false, error: "Patch not found" }; + } + try { const workerUrl = buildPatchDownloadUrl(patch.filename); if (workerUrl) { @@ -461,8 +496,16 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true; return { ok: false, error: "Unauthorized" }; } if (permission === "Current") { - if (hack.current_patch == null || patch.id !== hack.current_patch) { - return { ok: false, error: "Unauthorized" }; + const { savedPatchIds, selectablePatches } = await getPatcherSelectablePatches(supabase, hack.slug, hack.current_patch); + if (savedPatchIds.length === 0) { // Latest Patcher Mode is active + if (hack.current_patch == null || patch.id !== hack.current_patch) { + return { ok: false, error: "Unauthorized" }; + } + } else { // Custom Patcher Mode is active + const allowedPatchIds = new Set(selectablePatches.map((p) => p.id)); + if (!allowedPatchIds.has(patch.id)) { + return { ok: false, error: "Unauthorized" }; + } } } // "All": published + non-archived already satisfied @@ -559,6 +602,20 @@ export async function archivePatchVersion(slug: string, patchId: number): Promis return { ok: false, error: "Patch not found" }; } + const { data: customRows, error: customErr } = await supabase + .from("hack_patcher_patches") + .select("patch_id") + .eq("hack_slug", slug); + if (customErr) return { ok: false, error: customErr.message }; + + const isInCustomList = (customRows || []).some((row) => row.patch_id === patchId); + if (isInCustomList && (customRows?.length || 0) <= 2) { + return { + ok: false, + error: "This is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it.", + }; + } + // Archive the patch const serviceClient = await createServiceClient(); const { error: updateErr } = await serviceClient @@ -568,6 +625,17 @@ export async function archivePatchVersion(slug: string, patchId: number): Promis if (updateErr) return { ok: false, error: updateErr.message }; + if (isInCustomList) { + const { error: deleteErr } = await serviceClient + .from("hack_patcher_patches") + .delete() + .eq("hack_slug", slug) + .eq("patch_id", patchId); + if (deleteErr) return { ok: false, error: deleteErr.message }; + } + + revalidateTag(`hack:${slug}:metadata`); + revalidatePath(`/hack/${slug}`); revalidatePath(`/hack/${slug}/versions`); return { ok: true }; } @@ -665,6 +733,7 @@ export async function rollbackToVersion(slug: string, patchId: number): Promise< if (unpubErr) return { ok: false, error: unpubErr.message }; revalidateTag(`hack:${slug}:metadata`); + revalidateTag("discover"); revalidatePath(`/hack/${slug}/versions`); revalidatePath(`/hack/${slug}`); return { ok: true }; @@ -815,20 +884,29 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis return { ok: false, error: "Patch not found" }; } - // Check if this patch is newer than current_patch + // Check if hack has any patches in hack_patcher_patches + const { data: patcherPatches, error: ppErr } = await supabase + .from("hack_patcher_patches") + .select("patch_id") + .eq("hack_slug", slug); + if (ppErr) return { ok: false, error: ppErr.message }; + + // Check if this patch is newer than current_patch, but only if there are no patcher patches let willBecomeCurrent = false; const serviceClient = await createServiceClient(); - if (hack.current_patch) { - const { data: currentPatch } = await serviceClient - .from("patches") - .select("created_at") - .eq("id", hack.current_patch) - .maybeSingle(); - if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) { + if (patcherPatches.length === 0) { + if (hack.current_patch) { + const { data: currentPatch } = await serviceClient + .from("patches") + .select("created_at") + .eq("id", hack.current_patch) + .maybeSingle(); + if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) { + willBecomeCurrent = true; + } + } else { willBecomeCurrent = true; } - } else { - willBecomeCurrent = true; } // Publish the patch @@ -838,7 +916,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis .eq("id", patchId); if (updateErr) return { ok: false, error: updateErr.message }; - // If newer than current_patch, update current_patch + // If newer than current_patch and no patcher patches, update current_patch if (willBecomeCurrent) { const { error: updateHackErr } = await supabase .from("hacks") @@ -848,6 +926,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis } revalidateTag(`hack:${slug}:metadata`); + revalidateTag("discover"); revalidatePath(`/hack/${slug}/versions`); revalidatePath(`/hack/${slug}`); return { ok: true, willBecomeCurrent }; @@ -945,3 +1024,80 @@ export async function confirmReuploadPatchVersion( return { ok: true }; } +export async function updatePatcherSelectablePatches( + slug: string, + patchIds: number[], + customVersionName?: string | null, +): Promise<{ ok: true } | { ok: false; error: string }> { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" }; + + // Fetch hack and verify permissions + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, created_by, current_patch, original_author, is_archive") + .eq("slug", slug) + .maybeSingle(); + if (hErr || !hack) return { ok: false, error: "Hack not found" }; + + // Check permissions: creator first (optimization), then admin + if (!canEditAsCreator(hack, user.id)) { + const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase); + if (!editableAsAdmin) { + return { ok: false, error: "Forbidden" }; + } + } + + // Dedupe patch ids + const uniquePatchIds = [...new Set(patchIds)]; + const trimmedCustomVersionName = customVersionName?.trim() || undefined; + + if (uniquePatchIds.length > 0) { + if (!trimmedCustomVersionName) { + return { ok: false, error: "Custom version name is required." }; + } + if (trimmedCustomVersionName.length > CUSTOM_VERSION_NAME_MAX_LENGTH) { + return { ok: false, error: "Custom version name must be 12 characters or fewer." }; + } + } + + if (uniquePatchIds.length > 0) { + // Verify patches belong to this hack + const { data: patches, error: pErr } = await supabase + .from("patches") + .select("id, parent_hack, published, archived") + .in("id", uniquePatchIds) + .eq("parent_hack", slug); + if (pErr || patches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches do not belong to this hack" }; + + // Verify patches are not archived + const archivedPatches = patches.filter((patch) => patch.archived); + if (archivedPatches.length > 0) return { ok: false, error: "One or more patches are archived" }; + + const unpublishedPatchIds = patches.filter((patch) => !patch.published).map((patch) => patch.id); + if (unpublishedPatchIds.length > 0) { + const serviceClient = await createServiceClient(); + const { error: publishErr } = await serviceClient + .from("patches") + .update({ published: true, published_at: new Date().toISOString() }) + .in("id", unpublishedPatchIds); + if (publishErr) return { ok: false, error: publishErr.message }; + } + } + + // Replace patcher patches + const { error: replaceErr } = await supabase.rpc("replace_hack_patcher_patches", { + p_hack_slug: slug, + p_patch_ids: uniquePatchIds, + p_custom_version_name: uniquePatchIds.length > 0 ? trimmedCustomVersionName : undefined, + }); + if (replaceErr) return { ok: false, error: replaceErr.message }; + + revalidateTag(`hack:${slug}:metadata`); + revalidateTag("discover"); + revalidatePath(`/hack/${slug}`); + revalidatePath(`/hack/${slug}/versions`); + + return { ok: true }; +} diff --git a/src/app/hack/[slug]/edit/patch/page.tsx b/src/app/hack/[slug]/edit/patch/page.tsx index 921df28..b2ea937 100644 --- a/src/app/hack/[slug]/edit/patch/page.tsx +++ b/src/app/hack/[slug]/edit/patch/page.tsx @@ -4,6 +4,7 @@ import HackPatchForm from "@/components/Hack/HackPatchForm"; import Link from "next/link"; import { FaChevronLeft } from "react-icons/fa6"; import { isInformationalArchiveHack, isDownloadableArchiveHack, canEditAsCreator, canEditAsAdmin, canEditAsArchiver } from "@/utils/hack"; +import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches"; interface EditPatchPageProps { params: Promise<{ slug: string }>; @@ -19,7 +20,7 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive") + .select("slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive,custom_version_name") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); @@ -55,8 +56,11 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) { .order("created_at", { ascending: true }); const existingVersions = (patchRows || []).map((p: any) => p.version as string); + const patcherSelection = await getPatcherSelectablePatches(supabase, slug, hack.current_patch); + const isCustomPatcherActive = patcherSelection.savedPatchIds.length > 0; + const currentPatch = patchRows?.find((p: any) => p.id === hack.current_patch); - const currentVersion = currentPatch?.version; + const currentVersion = isCustomPatcherActive ? hack.custom_version_name ?? undefined : currentPatch?.version; return (
@@ -70,6 +74,8 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) { slug={slug} baseRomId={hack.base_rom} existingVersions={existingVersions} + isCustomPatcherActive={isCustomPatcherActive} + customVersionName={isCustomPatcherActive ? hack.custom_version_name : undefined} currentVersion={currentVersion} />
diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index ef31225..059ad53 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -115,7 +115,7 @@ export default async function HackDetail({ params }: HackDetailProps) { if (!metadata) return notFound(); - const { hack, images, tags, profile, otherHacks, patch } = metadata; + const { hack, images, tags, profile, otherHacks, patch, displayVersion } = metadata; const baseRom = baseRoms.find((r) => r.id === hack.base_rom); const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown"); @@ -148,7 +148,7 @@ export default async function HackDetail({ params }: HackDetailProps) { // Extract patch info from cached metadata const patchFilename = patch?.filename || null; - const patchVersion = isArchive ? "Archive" : (patch?.version || ""); + const patchVersion = displayVersion; const patchId = patch?.id || null; const lastUpdated = patch ? new Date(patch.created_at).toLocaleDateString() : null; const patchCreatedAt = patch?.created_at || null; @@ -228,6 +228,7 @@ export default async function HackDetail({ params }: HackDetailProps) { patchFilename={patchFilename} patchId={patchId ?? undefined} hackSlug={hack.slug} + patcherSelector={metadata.patcherSelector} /> )} @@ -596,7 +597,7 @@ export default async function HackDetail({ params }: HackDetailProps) { using our built-in patcher.

- By pressing the "Patch Now" button, your browser will apply the downloaded {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded. + By pressing "Agree and Patch", your browser will download and apply the {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded.

No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device. diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx index 533cdbb..8b9d05d 100644 --- a/src/app/hack/[slug]/versions/page.tsx +++ b/src/app/hack/[slug]/versions/page.tsx @@ -3,9 +3,11 @@ import { createClient } from "@/utils/supabase/server"; import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack"; import VersionList from "@/components/Hack/VersionList"; import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings"; +import PatcherVersionManager from "@/components/Hack/PatcherVersionManager"; import CollapsibleCard from "@/components/Primitives/CollapsibleCard"; import Link from "next/link"; import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6"; +import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches"; interface VersionsPageProps { params: Promise<{ slug: string }>; @@ -19,7 +21,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) { // Fetch hack const { data: hack } = await supabase .from("hacks") - .select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive, patches_download_permission") + .select("slug, title, created_by, current_patch, custom_version_name, original_author, permission_from, base_rom, is_archive, patches_download_permission") .eq("slug", slug) .maybeSingle(); @@ -53,6 +55,8 @@ export default async function VersionsPage({ params }: VersionsPageProps) { const allPatches = [...(patches || []), ...unpublishedPatches].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); + const patcherSelection = await getPatcherSelectablePatches(supabase, slug, hack.current_patch); + const isCustomPatcherActive = patcherSelection.savedPatchIds.length > 0; return (

@@ -89,60 +93,117 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
- {canEdit && ( - + currentPatchId={hack.current_patch} + initialSavedPatchIds={patcherSelection?.savedPatchIds ?? []} + initialCustomVersionName={hack.custom_version_name} + patches={allPatches} + baseRom={hack.base_rom} + patchesDownloadPermission={hack.patches_download_permission} + > + + + + ) : ( + <> + + + )} - - -
-
- - - Current - -

- {canEdit ? - "The version that is currently active and visible to all users. This is the version users will download when pressing \"Patch Now\" on the hack page." : - "This is the version you will download when pressing \"Patch Now\" on the hack page." - } -

-
- {canEdit && <> -
- - Unpublished - -

- Versions that are only visible to you, and will not appear in the public version list or changelog. -

-
-
- - Archived - -

- Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them. -

-
- } -
-
- - ); } +function VersionStatusGuide({ + canEdit, + isCustomPatcherActive, +}: { + canEdit: boolean; + isCustomPatcherActive: boolean; +}) { + const showCurrentGuide = canEdit || !isCustomPatcherActive; + const showPatchableGuide = canEdit || isCustomPatcherActive; + + return ( + +
+ {showCurrentGuide && ( +
+ + + Current + +

+ {canEdit ? + <>The version used by the Latest published patch option. This is the default downloader version when Custom patcher versions are not active. : + "This is the version you will download when using the patch button on the hack page." + } +

+
+ )} + {canEdit && ( +
+ + + Default + +

+ The first version in the Custom patcher list. This is the version players will download by default if they don't select a different version. +

+
+ )} + {showPatchableGuide && ( +
+ + + Patchable + +

+ {canEdit ? + "Additional Custom versions available to choose from before using the patch button on the hack page." : + "This version can be selected before using the patch button on the hack page." + } +

+
+ )} + {canEdit && <> +
+ + Unpublished + +

+ Versions that are only visible to you, and will not appear in the public version list or changelog. +

+
+
+ + Archived + +

+ Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them. +

+
+ } +
+
+ ); +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index a14b666..a454f14 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import HackCard from "@/components/HackCard"; import Button from "@/components/Button"; import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { HackCardAttributes } from "@/components/HackCard"; +import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version"; export const metadata: Metadata = { alternates: { @@ -22,7 +23,7 @@ export default async function Home() { // Fetch top 6 approved hacks ordered by downloads const { data: popularHacks } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author,is_archive") + .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,is_archive") .eq("approved", true) .not("current_patch", "is", null) .is("is_archive", false) @@ -75,21 +76,47 @@ export default async function Home() { }); // Fetch versions - let mappedVersions = new Map(); - await Promise.all( - popularHacks.map(async (r) => { - if (r.current_patch) { - const { data: currentPatch } = await supabase - .from("patches") - .select("version") - .eq("id", r.current_patch) - .maybeSingle(); - mappedVersions.set(r.slug, currentPatch?.version || "Pre-release"); - } else { - mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); - } - }) - ); + const patchIds = popularHacks + .map((hack) => hack.current_patch) + .filter((id): id is number => typeof id === "number"); + const versionsByPatchId = new Map(); + if (patchIds.length > 0) { + const { data: patchRows } = await supabase + .from("patches") + .select("id,version") + .in("id", patchIds); + (patchRows || []).forEach((patch) => { + versionsByPatchId.set(patch.id, patch.version || "Pre-release"); + }); + } + + const customDefaultVersionsBySlug = new Map(); + const customPatcherSlugs = new Set(); + const { data: customPatchRows } = await supabase + .from("hack_patcher_patches") + .select("hack_slug, sort_order, patches!inner(version)") + .in("hack_slug", slugs) + .order("sort_order", { ascending: true }); + (customPatchRows || []).forEach((row: any) => { + customPatcherSlugs.add(row.hack_slug); + if (customDefaultVersionsBySlug.has(row.hack_slug)) return; + const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches; + if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version); + }); + + const mappedVersions = new Map(); + popularHacks.forEach((hack) => { + const currentPatchVersion = typeof hack.current_patch === "number" + ? versionsByPatchId.get(hack.current_patch) || "Pre-release" + : ""; + mappedVersions.set(hack.slug, resolveHackDisplayVersion({ + isArchive: hack.is_archive, + isCustomPatcherActive: customPatcherSlugs.has(hack.slug), + customVersionName: hack.custom_version_name, + customDefaultPatchVersion: customDefaultVersionsBySlug.get(hack.slug), + currentPatchVersion, + })); + }); // Fetch profiles const userIds = [...new Set(popularHacks.map((h) => h.created_by).filter(Boolean))]; diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 1717776..8c85d82 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -251,6 +251,17 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string if (vErr) return { ok: false, error: vErr.message } as const; if (existing) return { ok: false, error: "That version already exists for this hack." } as const; + let shouldPublishAutomatically = !!args.publishAutomatically; + if (shouldPublishAutomatically) { + const { data: customPatcherRows, error: customPatcherErr } = await supabase + .from("hack_patcher_patches") + .select("patch_id") + .eq("hack_slug", args.slug) + .limit(1); + if (customPatcherErr) return { ok: false, error: customPatcherErr.message } as const; + shouldPublishAutomatically = (customPatcherRows || []).length === 0; + } + // Create patch row const patchInsert: any = { bucket: PATCHES_BUCKET, @@ -260,7 +271,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string }; // Set published status based on publishAutomatically flag - if (args.publishAutomatically) { + if (shouldPublishAutomatically) { patchInsert.published = true; patchInsert.published_at = new Date().toISOString(); } else { @@ -275,7 +286,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string if (pErr) return { ok: false, error: pErr.message } as const; // Only update current_patch if publishAutomatically is true - if (args.publishAutomatically) { + if (shouldPublishAutomatically) { // Check if this patch is newer than current_patch let shouldUpdateCurrentPatch = true; if (hack.current_patch) { diff --git a/src/components/Hack/DownloadPermissionSettings.tsx b/src/components/Hack/DownloadPermissionSettings.tsx index 86367a8..2acb7fb 100644 --- a/src/components/Hack/DownloadPermissionSettings.tsx +++ b/src/components/Hack/DownloadPermissionSettings.tsx @@ -8,27 +8,29 @@ import { FaUserGear } from "react-icons/fa6"; export type PatchesDownloadPermission = Database["public"]["Enums"]["Patches Download Permission"]; +function patchDownloadOptionDescription( + value: PatchesDownloadPermission, + isCustomPatcherActive: boolean, +): string { + switch (value) { + case "None": + return "Users can only download your hack through the built-in patcher."; + case "Current": + return isCustomPatcherActive + ? "Only patch versions in your Custom patcher list can be downloaded directly." + : "Only the patch version marked Current can be downloaded directly."; + case "All": + return "Every published patch version can be downloaded directly."; + } +} + export const PATCH_DOWNLOAD_OPTIONS: { value: PatchesDownloadPermission; label: string; - /** Short helper shown next to or below the option */ - description: string; }[] = [ - { - value: "None", - label: "None", - description: "Users can only download your hack through the built-in patcher.", - }, - { - value: "Current", - label: "Current only", - description: "Only the patch version marked Current can be downloaded directly.", - }, - { - value: "All", - label: "All published", - description: "Every published patch version can be downloaded directly.", - }, + { value: "None", label: "None" }, + { value: "Current", label: "Current only" }, + { value: "All", label: "All published" }, ]; function optionLabel(value: PatchesDownloadPermission): string { @@ -38,11 +40,13 @@ function optionLabel(value: PatchesDownloadPermission): string { interface DownloadPermissionSettingsProps { hackSlug: string; initialPermission: PatchesDownloadPermission; + isCustomPatcherActive?: boolean; } export default function DownloadPermissionSettings({ hackSlug, initialPermission, + isCustomPatcherActive = false, }: DownloadPermissionSettingsProps) { const [savedPermission, setSavedPermission] = useState(initialPermission); const [selectedPermission, setSelectedPermission] = useState(initialPermission); @@ -129,10 +133,14 @@ export default function DownloadPermissionSettings({

Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher. + {isCustomPatcherActive && ( + <> With Custom patcher versions active, "Current only" applies to every version in your Custom patcher list—not the Current badge alone. + )}

@@ -170,10 +178,12 @@ export default function DownloadPermissionSettings({ function RadioCardsBody({ selectedPermission, savedPermission, + isCustomPatcherActive, onSelect, }: { selectedPermission: PatchesDownloadPermission; savedPermission: PatchesDownloadPermission; + isCustomPatcherActive: boolean; onSelect: (v: PatchesDownloadPermission) => void; }) { const dirty = selectedPermission !== savedPermission; @@ -207,7 +217,9 @@ function RadioCardsBody({ {opt.label} - {opt.description} + + {patchDownloadOptionDescription(opt.value, isCustomPatcherActive)} + {showSavedBadge && ( Saved diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 2cf6aff..804f991 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -15,6 +15,7 @@ import { isArchiveFile, isAnyRomExtension, } from "@/utils/romFile"; +import type { SelectablePatch } from "@/types/patcher"; interface HackActionsProps { title: string; @@ -25,6 +26,10 @@ interface HackActionsProps { patchFilename: string | null; patchId?: number; hackSlug: string; + patcherSelector: { + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; + }; } const HackActions: React.FC = ({ @@ -36,6 +41,7 @@ const HackActions: React.FC = ({ patchFilename, patchId, hackSlug, + patcherSelector, }) => { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms(); const [file, setFile] = React.useState(null); @@ -46,11 +52,41 @@ const HackActions: React.FC = ({ const [termsAgreed, setTermsAgreed] = React.useState(false); const [romErrorModal, setRomErrorModal] = React.useState(null); const [isVerifyingRom, setIsVerifyingRom] = React.useState(false); + const [selectedPatchId, setSelectedPatchId] = React.useState(patcherSelector.defaultPatchId); const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]); const effectivePlatform = React.useMemo( () => platform ?? baseRoms.find(r => r.id === baseRomId)?.platform, [platform, baseRomId], ); + const selectedPatch = React.useMemo( + () => patcherSelector.selectablePatches.find((patch) => patch.id === selectedPatchId) + ?? patcherSelector.selectablePatches[0] + ?? null, + [patcherSelector.selectablePatches, selectedPatchId], + ); + const selectedVersion = selectedPatch?.version ?? version; + const selectedFilename = selectedPatch?.filename ?? patchFilename; + + function isRomReadyForPatch() { + return !!file || hasCached(baseRomId) || (isLinked(baseRomId) && hasPermission(baseRomId)); + } + + React.useEffect(() => { + setSelectedPatchId(patcherSelector.defaultPatchId); + }, [patcherSelector.defaultPatchId]); + + function resetPatchSession() { + setTermsAgreed(false); + setPatchUrl(null); + setPatchBlob(null); + setStatus("idle"); + } + + function onVersionChange(nextPatchId: number) { + if (nextPatchId === selectedPatchId) return; + setSelectedPatchId(nextPatchId); + resetPatchSession(); + } // Basic client-side bot detection React.useEffect(() => { @@ -93,11 +129,8 @@ const HackActions: React.FC = ({ // When patch URL is fetched and terms are agreed, automatically proceed with patching if ROM is ready React.useEffect(() => { if (termsAgreed && patchUrl && patchBlob && status === "idle") { - const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId))); + const romReady = isRomReadyForPatch(); if (romReady) { - // Automatically start patching - setStatus("ready"); - // Use setTimeout to avoid calling onPatch during render const timeoutId = setTimeout(() => { onPatch(); }, 0); @@ -183,39 +216,40 @@ const HackActions: React.FC = ({ } } - async function onAgreeToTerms() { + async function onAgreeToTerms(): Promise<{ url: string; blob: Blob } | null> { try { setError(null); setStatus("downloading"); - // Fetch signed URL from server - const result = await getSignedPatchUrl(hackSlug); + const result = await getSignedPatchUrl( + hackSlug, + selectedPatch ? { patchId: selectedPatch.id } : undefined, + ); if (!result.ok) { setError(result.error); setStatus("idle"); - return; + return null; } setPatchUrl(result.url); setTermsAgreed(true); - // Download patch blob const res = await fetch(result.url); if (!res.ok) throw new Error("Failed to fetch patch"); const blob = await res.blob(); setPatchBlob(blob); - // Update status based on ROM readiness - const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId))); - if (romReady) { - setStatus("ready"); - } else { + const romReady = isRomReadyForPatch(); + if (!romReady) { setStatus("idle"); } + + return { url: result.url, blob }; } catch (e: any) { setError(e?.message || "Failed to fetch patch URL"); setStatus("idle"); setTermsAgreed(false); + return null; } } @@ -223,14 +257,20 @@ const HackActions: React.FC = ({ try { setError(null); - // If terms not agreed yet, trigger agreement flow - if (!termsAgreed || !patchUrl || !patchBlob) { - await onAgreeToTerms(); - return; + let url = patchUrl; + let blob = patchBlob; + + if (!termsAgreed || !url || !blob) { + const downloaded = await onAgreeToTerms(); + if (!downloaded) return; + url = downloaded.url; + blob = downloaded.blob; + + const romReady = isRomReadyForPatch(); + if (!romReady) return; } - // Prevent multiple patching attempts - if (status === "patching" || status === "done") { + if (status === "patching") { return; } @@ -246,45 +286,36 @@ const HackActions: React.FC = ({ baseFile = linkedFile; } - if (!patchUrl) return; - setStatus("patching"); - // Read inputs - const [romBuf, patchBuf] = await Promise.all([ - baseFile.arrayBuffer(), + await Promise.all([ + new Promise((r) => setTimeout(r, 1000)), (async () => { - let blob = patchBlob; - if (!blob) { - const resp = await fetch(patchUrl); - if (!resp.ok) throw new Error("Failed to fetch patch"); - blob = await resp.blob(); - setPatchBlob(blob); - } - return await blob.arrayBuffer(); + const [romBuf, patchBuf] = await Promise.all([ + baseFile.arrayBuffer(), + blob.arrayBuffer(), + ]); + + const romBin = new BinFile(romBuf); + romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : ""); + const patchBin = new BinFile(patchBuf); + + const patch = BPS.fromFile(patchBin); + const patchedRom = patch.apply(romBin); + + const outExt = platform ? platform.toLowerCase() : 'bin'; + const outputName = `${title} (${selectedVersion}).${outExt}`; + patchedRom.fileName = outputName; + patchedRom.save(); })(), ]); - // Build BinFiles - const romBin = new BinFile(romBuf); - romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : ""); - const patchBin = new BinFile(patchBuf); - - // Parse and apply BPS - const patch = BPS.fromFile(patchBin); - const patchedRom = patch.apply(romBin); - - // Name output and download - const outExt = platform ? platform.toLowerCase() : 'bin'; - const outputName = `${title} (${version}).${outExt}`; - patchedRom.fileName = outputName; - patchedRom.save(); - setStatus("done"); // Best-effort log applied event for counting and animate badge try { - if (patchId != null) { + const countPatchId = selectedPatch?.id ?? patchId; + if (countPatchId != null) { const key = "deviceId"; let deviceId = localStorage.getItem(key); if (!deviceId) { @@ -294,7 +325,7 @@ const HackActions: React.FC = ({ // Defer count update to avoid Safari cancelling the request setTimeout(async () => { const deviceIdObscured = deviceId.split("-"); - const result = await updatePatchDownloadCount(patchId, deviceIdObscured); + const result = await updatePatchDownloadCount(countPatchId, deviceIdObscured); if (!result.ok) { console.error(result.error); } else if (result.didIncrease) { @@ -316,9 +347,12 @@ const HackActions: React.FC = ({ <> ("bps"); const [patchFile, setPatchFile] = React.useState(null); @@ -163,8 +165,19 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{currentVersion !== undefined && (
- -

Current version: {currentVersion || 'Not set'}

+
+ +
+
+

+ {isCustomPatcherActive ? 'Public version name:' : 'Current version:'} {currentVersion || 'Not set'} +

+ {isCustomPatcherActive && ( +

+ Custom selected for the Patcher Version Settings. +

+ )} +
)}
@@ -262,18 +275,29 @@ export default function HackPatchForm(props: HackPatchFormProps) { {!!error &&
{error}
}
-
diff --git a/src/components/Hack/PatcherVersionManager.tsx b/src/components/Hack/PatcherVersionManager.tsx new file mode 100644 index 0000000..cb1fba2 --- /dev/null +++ b/src/components/Hack/PatcherVersionManager.tsx @@ -0,0 +1,412 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { FiX } from "react-icons/fi"; +import { updatePatcherSelectablePatches } from "@/app/hack/[slug]/actions"; +import PatcherVersionSettings from "@/components/Hack/PatcherVersionSettings"; +import VersionList from "@/components/Hack/VersionList"; +import { CUSTOM_VERSION_NAME_MAX_LENGTH, suggestCustomVersionName } from "@/utils/patches/hack-display-version"; +import type { PatchesDownloadPermission } from "@/components/Hack/DownloadPermissionSettings"; + +type PatcherOption = "latest" | "custom"; + +interface Patch { + id: number; + version: string; + created_at: string; + updated_at: string | null; + changelog: string | null; + published: boolean; + archived: boolean; +} + +interface PatcherVersionManagerProps { + hackSlug: string; + currentPatchId: number | null; + initialSavedPatchIds: number[]; + initialCustomVersionName: string | null; + patches: Patch[]; + baseRom: string; + patchesDownloadPermission: PatchesDownloadPermission; + children?: React.ReactNode; +} + +function sameOrderedIds(a: number[], b: number[]) { + if (a.length !== b.length) return false; + return a.every((id, index) => id === b[index]); +} + +function optionFromSavedIds(savedPatchIds: number[]): PatcherOption { + return savedPatchIds.length > 0 ? "custom" : "latest"; +} + +function initialCustomName(name: string | null, savedPatchIds: number[], patches: Patch[]) { + const trimmedName = name?.trim(); + if (trimmedName) return trimmedName.slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH); + if (savedPatchIds.length === 0) return ""; + const firstSavedPatch = patches.find((patch) => patch.id === savedPatchIds[0]); + return (firstSavedPatch?.version || "").slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH); +} + +export default function PatcherVersionManager({ + hackSlug, + currentPatchId, + initialSavedPatchIds, + initialCustomVersionName, + patches, + baseRom, + patchesDownloadPermission, + children, +}: PatcherVersionManagerProps) { + const router = useRouter(); + const [selectionMode, setSelectionMode] = useState(false); + const [publishedOption, setPublishedOption] = useState(() => optionFromSavedIds(initialSavedPatchIds)); + const [draftOption, setDraftOption] = useState(() => optionFromSavedIds(initialSavedPatchIds)); + const [savedPatchIds, setSavedPatchIds] = useState(initialSavedPatchIds); + const [draftPatchIds, setDraftPatchIds] = useState(initialSavedPatchIds); + const [savedCustomVersionName, setSavedCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches)); + const [draftCustomVersionName, setDraftCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches)); + const [showPublishModal, setShowPublishModal] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [publishError, setPublishError] = useState(null); + const [showSaved, setShowSaved] = useState(false); + const [savedFadeOut, setSavedFadeOut] = useState(false); + const savedTimersRef = useRef<{ + hold?: ReturnType; + fade?: ReturnType; + }>({}); + + const initialSavedKey = initialSavedPatchIds.join(","); + const initialCustomVersionNameKey = initialCustomVersionName ?? ""; + + useEffect(() => { + const nextOption = optionFromSavedIds(initialSavedPatchIds); + const nextCustomVersionName = initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches); + setPublishedOption(nextOption); + setDraftOption(nextOption); + setSavedPatchIds(initialSavedPatchIds); + setDraftPatchIds(initialSavedPatchIds); + setSavedCustomVersionName(nextCustomVersionName); + setDraftCustomVersionName(nextCustomVersionName); + setSelectionMode(false); + }, [initialSavedKey, initialSavedPatchIds, initialCustomVersionNameKey, initialCustomVersionName, patches]); + + function clearSavedFeedbackTimers() { + const t = savedTimersRef.current; + if (t.hold) clearTimeout(t.hold); + if (t.fade) clearTimeout(t.fade); + t.hold = undefined; + t.fade = undefined; + } + + useEffect(() => { + return () => { + clearSavedFeedbackTimers(); + }; + }, []); + + useEffect(() => { + if (showPublishModal) { + const html = document.documentElement; + const body = document.body; + const previousHtmlOverflow = html.style.overflow; + const previousBodyOverflow = body.style.overflow; + const previousBodyPaddingRight = body.style.paddingRight; + const scrollBarWidth = window.innerWidth - html.clientWidth; + + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + if (scrollBarWidth > 0) { + body.style.paddingRight = `${scrollBarWidth}px`; + } + + return () => { + html.style.overflow = previousHtmlOverflow; + body.style.overflow = previousBodyOverflow; + body.style.paddingRight = previousBodyPaddingRight; + }; + } + }, [showPublishModal]); + + const patchById = useMemo(() => new Map(patches.map((patch) => [patch.id, patch])), [patches]); + const currentPatch = currentPatchId !== null ? patchById.get(currentPatchId) ?? null : null; + const savedPatchIdSet = useMemo(() => new Set(savedPatchIds), [savedPatchIds]); + + const labelsForIds = (ids: number[]) => ( + ids.map((id) => patchById.get(id)?.version).filter((label): label is string => Boolean(label)) + ); + + const liveVersionLabels = publishedOption === "custom" + ? labelsForIds(savedPatchIds) + : currentPatch + ? [currentPatch.version] + : []; + const latestVersionLabels = currentPatch ? [currentPatch.version] : []; + const draftVersionLabels = draftOption === "custom" + ? labelsForIds(draftPatchIds) + : currentPatch + ? [currentPatch.version] + : []; + const selectedUnpublishedVersionLabels = draftOption === "custom" + ? draftPatchIds + .map((id) => patchById.get(id)) + .filter((patch): patch is Patch => Boolean(patch)) + .filter((patch) => !patch.published && !patch.archived) + .map((patch) => patch.version) + : []; + const suggestedCustomVersionName = useMemo( + () => suggestCustomVersionName(draftVersionLabels), + [draftVersionLabels], + ); + + const hasUnsavedChanges = draftOption !== publishedOption + || (draftOption === "custom" && !sameOrderedIds(draftPatchIds, savedPatchIds)) + || (draftOption === "custom" && draftCustomVersionName.trim() !== savedCustomVersionName); + const publishDisabled = saving + || !hasUnsavedChanges + || (draftOption === "custom" && (draftPatchIds.length === 0 || !draftCustomVersionName.trim())); + + function enterCustomSelectionMode() { + setDraftOption("custom"); + setSelectionMode(true); + setError(null); + } + + function chooseOption(option: PatcherOption) { + setError(null); + if (option === "latest") { + setDraftOption("latest"); + setSelectionMode(false); + return; + } + enterCustomSelectionMode(); + } + + function togglePatch(patchId: number) { + const patch = patchById.get(patchId); + if (!patch || patch.archived) return; + + setDraftOption("custom"); + setDraftPatchIds((current) => { + if (current.includes(patchId)) { + return current.filter((id) => id !== patchId); + } + return [...current, patchId]; + }); + } + + function clearSelections() { + setDraftOption("custom"); + setDraftPatchIds([]); + setSelectionMode(true); + setError(null); + } + + function cancelDraft() { + setDraftOption(publishedOption); + setDraftPatchIds(savedPatchIds); + setDraftCustomVersionName(savedCustomVersionName); + setSelectionMode(false); + setError(null); + setPublishError(null); + setShowPublishModal(false); + setShowSaved(false); + setSavedFadeOut(false); + } + + function openPublishModal() { + if (publishDisabled) return; + setError(null); + setPublishError(null); + setShowPublishModal(true); + } + + function applySuggestedCustomVersionName() { + if (!suggestedCustomVersionName) return; + setDraftCustomVersionName(suggestedCustomVersionName); + } + + async function confirmPublish() { + const idsToSave = draftOption === "custom" ? draftPatchIds : []; + const customNameToSave = draftOption === "custom" ? draftCustomVersionName.trim() : null; + setSaving(true); + setPublishError(null); + try { + const result = await updatePatcherSelectablePatches(hackSlug, idsToSave, customNameToSave); + if (!result.ok) { + setPublishError(result.error || "Failed to publish changes"); + return; + } + + setSavedPatchIds(idsToSave); + setDraftPatchIds(idsToSave); + setSavedCustomVersionName(customNameToSave || ""); + setDraftCustomVersionName(customNameToSave || ""); + setPublishedOption(draftOption); + setSelectionMode(false); + setShowPublishModal(false); + clearSavedFeedbackTimers(); + setSavedFadeOut(false); + setShowSaved(true); + const HOLD_MS = 4000; + const FADE_MS = 450; + savedTimersRef.current.hold = setTimeout(() => { + setSavedFadeOut(true); + savedTimersRef.current.fade = setTimeout(() => { + setShowSaved(false); + setSavedFadeOut(false); + }, FADE_MS); + }, HOLD_MS); + router.refresh(); + } catch { + setPublishError("Failed to publish changes"); + } finally { + setSaving(false); + } + } + + return ( + <> + + {children} + + {showPublishModal && ( + !saving && setShowPublishModal(false)} + > +

+ These patches will be available to choose from in the downloader on your hack's homepage: +

+ {draftVersionLabels.length > 0 ? ( +
    + {draftVersionLabels.map((label, index) => ( +
  • + - + {label} + {draftOption === "custom" && index === 0 && ( + + Default + + )} +
  • + ))} +
+ ) : ( +

No current patch is set.

+ )} + {draftOption === "custom" && ( +

+ Public version name: {draftCustomVersionName.trim()} +

+ )} + {selectedUnpublishedVersionLabels.length > 0 && ( +

+ Selected unpublished versions will be published when these changes are saved. +

+ )} + {publishError && ( +

+ {publishError} +

+ )} +
+ + +
+
+ )} + + ); +} + +function Modal({ + title, + children, + onClose, +}: { + title: string; + children: React.ReactNode; + onClose: () => void; +}) { + return ( +
+
+
+ +

{title}

+ {children} +
+
+ ); +} diff --git a/src/components/Hack/PatcherVersionSettings.tsx b/src/components/Hack/PatcherVersionSettings.tsx new file mode 100644 index 0000000..12caacc --- /dev/null +++ b/src/components/Hack/PatcherVersionSettings.tsx @@ -0,0 +1,317 @@ +"use client"; + +import React from "react"; +import { FaCode } from "react-icons/fa"; +import CollapsibleCard from "@/components/Primitives/CollapsibleCard"; +import { CUSTOM_VERSION_NAME_MAX_LENGTH } from "@/utils/patches/hack-display-version"; + +type PatcherOption = "latest" | "custom"; + +interface PatcherVersionSettingsProps { + publishedOption: PatcherOption; + draftOption: PatcherOption; + latestVersionLabels: string[]; + liveVersionLabels: string[]; + draftVersionLabels: string[]; + publishedCustomVersionName: string; + customVersionName: string; + suggestedCustomVersionName: string | null; + customSelectionCount: number; + selectionMode: boolean; + hasUnsavedChanges: boolean; + publishDisabled: boolean; + saving: boolean; + error: string | null; + showSaved: boolean; + savedFadeOut: boolean; + onChooseOption: (option: PatcherOption) => void; + onEnterSelectionMode: () => void; + onClearSelections: () => void; + onCustomVersionNameChange: (name: string) => void; + onApplySuggestedCustomVersionName: () => void; + onCancel: () => void; + onPublish: () => void; +} + +function optionLabel(option: PatcherOption) { + return option === "latest" ? "Latest published patch" : "Custom"; +} + +function versionSummary(labels: string[], emptyLabel: string) { + if (labels.length === 0) return emptyLabel; + return labels.join(", "); +} + +export default function PatcherVersionSettings({ + publishedOption, + draftOption, + latestVersionLabels, + liveVersionLabels, + draftVersionLabels, + publishedCustomVersionName, + customVersionName, + suggestedCustomVersionName, + customSelectionCount, + selectionMode, + hasUnsavedChanges, + publishDisabled, + saving, + error, + showSaved, + savedFadeOut, + onChooseOption, + onEnterSelectionMode, + onClearSelections, + onCustomVersionNameChange, + onApplySuggestedCustomVersionName, + onCancel, + onPublish, +}: PatcherVersionSettingsProps) { + const isSwitchingFromLatestToCustom = publishedOption === "latest" && draftOption === "custom"; + const liveCustomVersionName = publishedCustomVersionName.trim(); + const showCustomVersionNameHint = draftOption === "custom" && selectionMode && publishDisabled && customVersionName.trim().length === 0; + const showSuggestedNameButton = suggestedCustomVersionName !== null && customVersionName.trim() !== suggestedCustomVersionName; + const customOptionDetail = publishedOption === "custom" && liveCustomVersionName + ? ( + <> + {liveCustomVersionName}: {versionSummary(liveVersionLabels, "No custom versions are published.")} + + ) + : versionSummary(liveVersionLabels, "No custom versions are published."); + const liveOptionLabel = publishedOption === "custom" && liveCustomVersionName + ? `${optionLabel(publishedOption)} (${liveCustomVersionName})` + : optionLabel(publishedOption); + const summaryStatus = (() => { + if (hasUnsavedChanges && draftOption !== publishedOption) { + return { prefix: " · Draft: ", label: optionLabel(draftOption) }; + } + if (hasUnsavedChanges) { + return { prefix: " · ", label: "Unsaved changes" }; + } + if (selectionMode) { + return { prefix: " · ", label: "Selecting versions" }; + } + return null; + })(); + const summary = ( + <> + Live: + {liveOptionLabel} + {summaryStatus && ( + <> + {summaryStatus.prefix} + {summaryStatus.label} + + )} + + ); + + return ( + } + summary={summary} + className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]" + > +
+

+ Choose which patch versions players can use from the downloader on your hack's homepage. +

+
+ + +
+ {draftOption === "custom" && selectionMode && ( +
+
Custom draft
+
+ {customSelectionCount > 0 + ? versionSummary(draftVersionLabels, "No versions selected.") + : "No versions selected. Choose at least one version to publish Custom."} +
+ + onCustomVersionNameChange(event.target.value)} + maxLength={CUSTOM_VERSION_NAME_MAX_LENGTH} + className="mt-1 block h-9 w-full rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20" + placeholder="e.g. 2.1.2" + /> +

+ Shown on the hack page and discover cards. Max {CUSTOM_VERSION_NAME_MAX_LENGTH} characters. +

+ {showCustomVersionNameHint && ( +

Custom version name is required.

+ )} + {showSuggestedNameButton && ( + + )} +
+ )} +
+ {selectionMode ? ( +
+ +
+ + +
+
+ ) : ( + <> +
+ {draftOption === "custom" && !isSwitchingFromLatestToCustom && ( + + )} +
+
+ {hasUnsavedChanges && ( + + )} + +
+ + )} + {(showSaved || error) && ( +
+ {showSaved ? ( + + Changes published. + + ) : ( + {error} + )} +
+ )} +
+
+
+ ); +} + +function OptionCard({ + option, + selected, + saved, + label, + description, + detail, + onSelect, +}: { + option: PatcherOption; + selected: boolean; + saved: boolean; + label: string; + description: string; + detail: React.ReactNode; + onSelect: (option: PatcherOption) => void; +}) { + return ( + + ); +} diff --git a/src/components/Hack/StickyActionBar.tsx b/src/components/Hack/StickyActionBar.tsx index 01d9d8e..8a01165 100644 --- a/src/components/Hack/StickyActionBar.tsx +++ b/src/components/Hack/StickyActionBar.tsx @@ -2,12 +2,17 @@ import React from "react"; import Link from "next/link"; +import { FiChevronDown, FiX } from "react-icons/fi"; import { platformAccept } from "@/utils/idb"; +import { useBaseRoms } from "@/contexts/BaseRomContext"; import type { Platform } from "@/data/baseRoms"; interface StickyActionBarProps { title: string; version?: string; + selectablePatches?: { id: number; version: string }[]; + selectedPatchId?: number | null; + onVersionChange?: (patchId: number) => void; author: string; filename: string | null; baseRomName?: string | null; @@ -27,6 +32,9 @@ interface StickyActionBarProps { export default function StickyActionBar({ title, version, + selectablePatches = [], + selectedPatchId, + onVersionChange, author, filename, baseRomName, @@ -44,10 +52,13 @@ export default function StickyActionBar({ }: StickyActionBarProps) { const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); + const { loading: baseRomsLoading } = useBaseRoms(); const uploadInputRef = React.useRef(null); const [errorMessage, setErrorMessage] = React.useState(null); const [showError, setShowError] = React.useState(false); const [patchAgainReady, setPatchAgainReady] = React.useState(true); + const [versionPickerOpen, setVersionPickerOpen] = React.useState(false); + const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange; // Keep error mounted to allow fade-out when error becomes null React.useEffect(() => { @@ -78,27 +89,66 @@ export default function StickyActionBar({ } }, [status]); + const handleVersionSelect = (patchId: number, closeAfterSelect: boolean) => { + onVersionChange?.(patchId); + if (closeAfterSelect) { + setVersionPickerOpen(false); + } + }; + return (
{title}
- {version && ( + {hasVersionPicker ? ( + + ) : version && ( {version} )}
By {author}
-
+ {hasVersionPicker && versionPickerOpen && ( +
+
+
Select version
+
+ handleVersionSelect(patchId, true)} + /> +
+ )} +
{!termsAgreed || status === "downloading" ? ( - !romReady ? ( + baseRomsLoading ? ( +

+ Loading base ROMs… +

+ ) : !romReady ? (

- To download the patch file, you must select a clean ROM for the patcher to use. + To patch this hack, you must select a clean ROM for the patcher to use.

) : (

- By downloading this patch, you agree to the Terms of Service. + By patching, you agree to the Terms of Service.

) ) : ( @@ -112,7 +162,7 @@ export default function StickyActionBar({ {romReady ? (filename ?? ".bps file ready") : isLinked ? "Permission needed" : "Base ROM needed"} )} - {!romReady && !isLinked && ( + {!baseRomsLoading && !romReady && !isLinked && ( )} - {!romReady && isLinked && ( + {!baseRomsLoading && !romReady && isLinked && (
+ {hasVersionPicker && versionPickerOpen && ( +
+
setVersionPickerOpen(false)} + /> +
+ +

Select which version to download

+ handleVersionSelect(patchId, false)} + /> +
+
+ )} {errorMessage !== null && (
void; +}) { + return ( +
+ {patches.map((patch, index) => { + const selected = selectedPatchId === patch.id; + return ( + + ); + })} +
+ ); +} + diff --git a/src/components/Hack/VersionActions.tsx b/src/components/Hack/VersionActions.tsx index 81d7474..9fb86ef 100644 --- a/src/components/Hack/VersionActions.tsx +++ b/src/components/Hack/VersionActions.tsx @@ -41,6 +41,9 @@ interface VersionActionsProps { hackSlug: string; baseRom: string; currentPatchCreatedAt: string | null; + isCustomPatcherActive?: boolean; + isInCustomPatcherList?: boolean; + customPatcherPatchCount?: number; onActionComplete: () => void; } @@ -50,6 +53,9 @@ export default function VersionActions({ hackSlug, baseRom, currentPatchCreatedAt, + isCustomPatcherActive = false, + isInCustomPatcherList = false, + customPatcherPatchCount = 0, onActionComplete, }: VersionActionsProps) { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms(); @@ -83,7 +89,8 @@ export default function VersionActions({ : false; // Don't show Rollback if the patch is unpublished and newer than the current version - const shouldShowRollback = !isCurrent && !(!patch.published && isNewerThanCurrent); + const shouldShowRollback = !isCustomPatcherActive && !isCurrent && !(!patch.published && isNewerThanCurrent); + const archiveWouldRemoveLastCustomPatch = isInCustomPatcherList && customPatcherPatchCount <= 2; useEffect(() => { if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) { @@ -579,17 +586,32 @@ export default function VersionActions({ title="Archive Version" onClose={() => !actionLoading && setShowDeleteModal(false)} > -

- Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later. -

+ {archiveWouldRemoveLastCustomPatch ? ( +

+ Version {patch.version} is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it. +

+ ) : ( + <> +

+ Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later. +

+ {isInCustomPatcherList && ( +

+ This version will also be removed from the Custom patcher list. +

+ )} + + )}
- + {!archiveWouldRemoveLastCustomPatch && ( + + )} + ); + } + return (
{showPublicPatchDownload ? ( @@ -295,6 +405,9 @@ export default function VersionList({ hackSlug={hackSlug} baseRom={baseRom} currentPatchCreatedAt={currentPatchCreatedAt} + isCustomPatcherActive={isCustomPatcherActive} + isInCustomPatcherList={savedPatchIdSet.has(patch.id)} + customPatcherPatchCount={savedPatchIds.length} onActionComplete={() => { router.refresh(); setEditingChangelog(null); diff --git a/src/contexts/BaseRomContext.tsx b/src/contexts/BaseRomContext.tsx index 37a3975..d33b316 100644 --- a/src/contexts/BaseRomContext.tsx +++ b/src/contexts/BaseRomContext.tsx @@ -7,6 +7,7 @@ import { baseRoms } from "@/data/baseRoms"; type ContextValue = { supported: boolean; + loading: boolean; linked: Record; statuses: Record; cached: Record; @@ -35,6 +36,7 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) { const [statuses, setStatuses] = React.useState>({}); const [cached, setCached] = React.useState>({}); const [totalCachedBytes, setTotalCachedBytes] = React.useState(0); + const [loading, setLoading] = React.useState(true); React.useEffect(() => { (async () => { @@ -70,6 +72,8 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) { setTotalCachedBytes(total); } catch (e) { // noop + } finally { + setLoading(false); } })(); }, []); @@ -288,6 +292,7 @@ export function BaseRomProvider({ children }: { children: React.ReactNode }) { const value: ContextValue = { supported, + loading, linked, statuses, cached, diff --git a/src/types/db.ts b/src/types/db.ts index 0582313..7719f48 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -66,6 +66,42 @@ export type Database = { }, ] } + hack_patcher_patches: { + Row: { + created_at: string + hack_slug: string + patch_id: number + sort_order: number + } + Insert: { + created_at?: string + hack_slug: string + patch_id: number + sort_order: number + } + Update: { + created_at?: string + hack_slug?: string + patch_id?: number + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "hack_patcher_patches_hack_slug_fkey" + columns: ["hack_slug"] + isOneToOne: false + referencedRelation: "hacks" + referencedColumns: ["slug"] + }, + { + foreignKeyName: "hack_patcher_patches_patch_id_fkey" + columns: ["patch_id"] + isOneToOne: false + referencedRelation: "patches" + referencedColumns: ["id"] + }, + ] + } hack_tags: { Row: { hack_slug: string @@ -141,6 +177,7 @@ export type Database = { created_at: string created_by: string current_patch: number | null + custom_version_name: string | null description: string downloads: number estimated_release: string | null @@ -177,6 +214,7 @@ export type Database = { created_at?: string created_by: string current_patch?: number | null + custom_version_name?: string | null description: string downloads?: number estimated_release?: string | null @@ -213,6 +251,7 @@ export type Database = { created_at?: string created_by?: string current_patch?: number | null + custom_version_name?: string | null description?: string downloads?: number estimated_release?: string | null @@ -463,6 +502,14 @@ export type Database = { } is_archiver: { Args: never; Returns: boolean } is_claims_admin: { Args: never; Returns: boolean } + replace_hack_patcher_patches: { + Args: { + p_custom_version_name?: string + p_hack_slug: string + p_patch_ids: number[] + } + Returns: undefined + } set_claim: { Args: { claim: string; uid: string; value: Json } Returns: string diff --git a/src/types/patcher.ts b/src/types/patcher.ts new file mode 100644 index 0000000..7ba919e --- /dev/null +++ b/src/types/patcher.ts @@ -0,0 +1,12 @@ +export interface SelectablePatch { + id: number; + version: string; + created_at: string; + filename: string | null; +}; + +export interface PatcherPatchSelection { + savedPatchIds: number[]; + selectablePatches: SelectablePatch[]; + defaultPatchId: number | null; +}; diff --git a/src/utils/patches/hack-display-version.ts b/src/utils/patches/hack-display-version.ts new file mode 100644 index 0000000..63aa5cd --- /dev/null +++ b/src/utils/patches/hack-display-version.ts @@ -0,0 +1,46 @@ +export const CUSTOM_VERSION_NAME_MAX_LENGTH = 12; + +interface ResolveHackDisplayVersionArgs { + isArchive: boolean; + isCustomPatcherActive: boolean; + customVersionName?: string | null; + customDefaultPatchVersion?: string | null; + currentPatchVersion?: string | null; +} + +export function resolveHackDisplayVersion({ + isArchive, + isCustomPatcherActive, + customVersionName, + customDefaultPatchVersion, + currentPatchVersion, +}: ResolveHackDisplayVersionArgs) { + if (isArchive) return "Archive"; + if (isCustomPatcherActive) { + return customVersionName?.trim() + || customDefaultPatchVersion + || currentPatchVersion + || ""; + } + return currentPatchVersion || ""; +} + +export function suggestCustomVersionName(versionLabels: string[]) { + if (versionLabels.length === 0) return null; + + let prefix = versionLabels[0]; + for (const label of versionLabels.slice(1)) { + let index = 0; + while (index < prefix.length && index < label.length && prefix[index] === label[index]) { + index += 1; + } + prefix = prefix.slice(0, index); + if (!prefix) return null; + } + + const suggestion = prefix + .replace(/[-_.+ ]+$/g, "") + .trim() + .slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH); + return suggestion || null; +} diff --git a/src/utils/patches/patcher-selectable-patches.ts b/src/utils/patches/patcher-selectable-patches.ts new file mode 100644 index 0000000..77ca6c2 --- /dev/null +++ b/src/utils/patches/patcher-selectable-patches.ts @@ -0,0 +1,62 @@ +import type { Database } from "@/types/db"; +import type { PatcherPatchSelection, SelectablePatch } from "@/types/patcher"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +export async function getPatcherSelectablePatches( + supabase: SupabaseClient, + slug: string, + currentPatchId: number | null, +): Promise { + const { data: rows, error } = await supabase + .from("hack_patcher_patches") + .select("patch_id, sort_order, patches!inner(id, version, created_at, published, archived, filename)") + .eq("hack_slug", slug) + .eq("patches.published", true) + .eq("patches.archived", false) + .order("sort_order", { ascending: true }); + if (error) { + console.error(error); + return { + savedPatchIds: [], + selectablePatches: [], + defaultPatchId: null, + }; + } + const savedPatchIds = rows.map((row) => row.patch_id); + let selectablePatches: SelectablePatch[] = rows.map((row) => row.patches).flat().map((patch) => ({ + id: patch.id, + version: patch.version, + created_at: patch.created_at, + filename: patch.filename, + })); + const hasSavedRows = selectablePatches.length > 0; + if (selectablePatches.length === 0 && currentPatchId !== null) { + const { data: currentPatch, error: currentPatchError } = await supabase + .from("patches") + .select("id, version, created_at, published, archived, filename") + .eq("id", currentPatchId) + .maybeSingle(); + if (currentPatchError) { + console.error(currentPatchError); + return { + savedPatchIds: [], + selectablePatches: [], + defaultPatchId: null, + }; + } + if (currentPatch?.published && !currentPatch?.archived) { + selectablePatches = [currentPatch]; + } + } + const defaultPatchId = hasSavedRows + ? selectablePatches[0]?.id ?? null + : (currentPatchId !== null && selectablePatches.some((patch) => patch.id === currentPatchId)) + ? currentPatchId + : selectablePatches[0]?.id ?? null; + + return { + savedPatchIds, + selectablePatches, + defaultPatchId, + }; +} diff --git a/supabase/migrations/20260711041720_hack_patcher_patches.sql b/supabase/migrations/20260711041720_hack_patcher_patches.sql new file mode 100644 index 0000000..197931e --- /dev/null +++ b/supabase/migrations/20260711041720_hack_patcher_patches.sql @@ -0,0 +1,66 @@ +create table if not exists public.hack_patcher_patches ( + hack_slug text not null references public.hacks(slug) on update cascade on delete cascade, + patch_id bigint not null references public.patches(id) on update cascade on delete cascade, + sort_order integer not null, + created_at timestamptz not null default now(), + primary key (hack_slug, patch_id) +); + +create index hack_patcher_patches_hack_slug_idx + on public.hack_patcher_patches (hack_slug); + +create index hack_patcher_patches_patch_id_idx + on public.hack_patcher_patches (patch_id); + +alter table public.hack_patcher_patches enable row level security; + +-- Public read (same as patch_groups) +create policy "Patcher patches are viewable by everyone" + on public.hack_patcher_patches for select using (true); + +-- Insert / update / delete: creator, admin, archiver. +create policy "Users can insert patcher patches for own hacks" + on public.hack_patcher_patches for insert + with check ( + (public.is_admin() OR + (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR + exists ( + select 1 from public.hacks h + where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid() + )) + AND exists ( + select 1 from public.patches p + where p.id = hack_patcher_patches.patch_id + and p.parent_hack = hack_patcher_patches.hack_slug + ) + ); + +create policy "Users can update patcher patches for own hacks" + on public.hack_patcher_patches for update + using ( + public.is_admin() OR + (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR + exists ( + select 1 from public.hacks h + where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid() + ) + ) + with check ( + public.is_admin() OR + (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR + exists ( + select 1 from public.hacks h + where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid() + ) + ); + +create policy "Users can delete patcher patches for own hacks" + on public.hack_patcher_patches for delete + using ( + public.is_admin() OR + (public.is_archiver() AND public.is_archive_hack_for_archiver(hack_slug)) OR + exists ( + select 1 from public.hacks h + where h.slug = hack_patcher_patches.hack_slug and h.created_by = auth.uid() + ) + ); diff --git a/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql b/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql new file mode 100644 index 0000000..8f2f3e2 --- /dev/null +++ b/supabase/migrations/20260711041816_replace_hack_patcher_patches_rpc.sql @@ -0,0 +1,54 @@ +create or replace function public.replace_hack_patcher_patches( + p_hack_slug text, + p_patch_ids bigint[], + p_custom_version_name text default null +) +returns void +language plpgsql +security invoker +set search_path = public +as $$ +declare + v_count integer; +begin + -- Empty list = switch to Latest mode + if coalesce(cardinality(p_patch_ids), 0) = 0 then + delete from public.hack_patcher_patches where hack_slug = p_hack_slug; + update public.hacks set custom_version_name = null where slug = p_hack_slug; + return; + end if; + + if p_custom_version_name is null or length(btrim(p_custom_version_name)) = 0 then + raise exception 'Custom version name is required'; + end if; + if length(btrim(p_custom_version_name)) > 12 then + raise exception 'Custom version name must 12 characters or less'; + end if; + + -- All IDs must belong to the same hack + select count(*) into v_count + from public.patches p + where p.id = any(p_patch_ids) + and p.parent_hack = p_hack_slug + and p.archived = false; + + if v_count <> cardinality(p_patch_ids) then + raise exception 'One or more patches either do not belong to this hack or are archived'; + end if; + + delete from public.hack_patcher_patches where hack_slug = p_hack_slug; + insert into public.hack_patcher_patches (hack_slug, patch_id, sort_order) + select + p_hack_slug, + patch_id, + ordinality::integer + from unnest(p_patch_ids) with ordinality as t(patch_id, ordinality); + update public.hacks + set custom_version_name = btrim(p_custom_version_name) + where slug = p_hack_slug; +end; +$$; + +revoke all on function public.replace_hack_patcher_patches(text, bigint[], text) from public; +grant execute on function public.replace_hack_patcher_patches(text, bigint[], text) to authenticated; +grant execute on function public.replace_hack_patcher_patches(text, bigint[], text) to service_role; diff --git a/supabase/migrations/20260711041848_custom_version_name.sql b/supabase/migrations/20260711041848_custom_version_name.sql new file mode 100644 index 0000000..57fc14b --- /dev/null +++ b/supabase/migrations/20260711041848_custom_version_name.sql @@ -0,0 +1,2 @@ +alter table if exists public.hacks + add column if not exists custom_version_name text;