mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Add customizable patch version selector + related UX improvements (#62)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
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:
11
.github/workflows/ci.yaml
vendored
11
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 list—not 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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
412
src/components/Hack/PatcherVersionManager.tsx
Normal file
412
src/components/Hack/PatcherVersionManager.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
317
src/components/Hack/PatcherVersionSettings.tsx
Normal file
317
src/components/Hack/PatcherVersionSettings.tsx
Normal 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'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 "{suggestedCustomVersionName}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
12
src/types/patcher.ts
Normal 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;
|
||||
};
|
||||
46
src/utils/patches/hack-display-version.ts
Normal file
46
src/utils/patches/hack-display-version.ts
Normal 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;
|
||||
}
|
||||
62
src/utils/patches/patcher-selectable-patches.ts
Normal file
62
src/utils/patches/patcher-selectable-patches.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
supabase/migrations/20260711041720_hack_patcher_patches.sql
Normal file
66
supabase/migrations/20260711041720_hack_patcher_patches.sql
Normal 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()
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table if exists public.hacks
|
||||
add column if not exists custom_version_name text;
|
||||
Reference in New Issue
Block a user