diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx
index 533cdbb..f264fe8 100644
--- a/src/app/hack/[slug]/versions/page.tsx
+++ b/src/app/hack/[slug]/versions/page.tsx
@@ -3,9 +3,11 @@ import { createClient } from "@/utils/supabase/server";
import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
import VersionList from "@/components/Hack/VersionList";
import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings";
+import PatcherVersionManager from "@/components/Hack/PatcherVersionManager";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import Link from "next/link";
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
+import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
interface VersionsPageProps {
params: Promise<{ slug: string }>;
@@ -53,6 +55,8 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
const allPatches = [...(patches || []), ...unpublishedPatches].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
+ const patcherSelection = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
+ const isCustomPatcherActive = patcherSelection.savedPatchIds.length > 0;
return (
@@ -89,60 +93,115 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
- {canEdit && (
-
+ currentPatchId={hack.current_patch}
+ initialSavedPatchIds={patcherSelection?.savedPatchIds ?? []}
+ patches={allPatches}
+ baseRom={hack.base_rom}
+ patchesDownloadPermission={hack.patches_download_permission}
+ >
+
+
+
+ ) : (
+ <>
+
+
+ >
)}
-
-
-
-
-
-
- Current
-
-
- {canEdit ?
- "The version that is currently active and visible to all users. This is the version users will download when pressing \"Patch Now\" on the hack page." :
- "This is the version you will download when pressing \"Patch Now\" on the hack page."
- }
-
-
- {canEdit && <>
-
-
- Unpublished
-
-
- Versions that are only visible to you, and will not appear in the public version list or changelog.
-
-
-
-
- Archived
-
-
- Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
-
-
- >}
-
-
-
-
);
}
+function VersionStatusGuide({
+ canEdit,
+ isCustomPatcherActive,
+}: {
+ canEdit: boolean;
+ isCustomPatcherActive: boolean;
+}) {
+ const showCurrentGuide = canEdit || !isCustomPatcherActive;
+ const showPatchableGuide = canEdit || isCustomPatcherActive;
+
+ return (
+
+
+ {showCurrentGuide && (
+
+
+
+ Current
+
+
+ {canEdit ?
+ <>The version used by the Latest published patch option. This is the default downloader version when Custom patcher versions are not active.> :
+ "This is the version you will download when pressing \"Patch Now\" on the hack page."
+ }
+
+
+ )}
+ {canEdit && (
+
+
+
+ Default
+
+
+ The first version in the Custom patcher list. This is the version players will download by default if they don't select a different version.
+
+
+ )}
+ {showPatchableGuide && (
+
+
+
+ Patchable
+
+
+ {canEdit ?
+ "Additional Custom versions available to choose from before pressing \"Patch Now\" on the hack page." :
+ "This version can be selected before pressing \"Patch Now\" on the hack page."
+ }
+
+
+ )}
+ {canEdit && <>
+
+
+ Unpublished
+
+
+ Versions that are only visible to you, and will not appear in the public version list or changelog.
+
+
+
+
+ Archived
+
+
+ Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
+
+
+ >}
+
+
+ );
+}
+
diff --git a/src/components/Hack/PatcherVersionManager.tsx b/src/components/Hack/PatcherVersionManager.tsx
new file mode 100644
index 0000000..6eee212
--- /dev/null
+++ b/src/components/Hack/PatcherVersionManager.tsx
@@ -0,0 +1,369 @@
+"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 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[];
+ 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";
+}
+
+export default function PatcherVersionManager({
+ hackSlug,
+ currentPatchId,
+ initialSavedPatchIds,
+ patches,
+ baseRom,
+ patchesDownloadPermission,
+ children,
+}: PatcherVersionManagerProps) {
+ const router = useRouter();
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [publishedOption, setPublishedOption] = useState(() => optionFromSavedIds(initialSavedPatchIds));
+ const [draftOption, setDraftOption] = useState(() => optionFromSavedIds(initialSavedPatchIds));
+ const [savedPatchIds, setSavedPatchIds] = useState(initialSavedPatchIds);
+ const [draftPatchIds, setDraftPatchIds] = useState(initialSavedPatchIds);
+ const [showPublishModal, setShowPublishModal] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [publishError, setPublishError] = useState(null);
+ const [showSaved, setShowSaved] = useState(false);
+ const [savedFadeOut, setSavedFadeOut] = useState(false);
+ const savedTimersRef = useRef<{
+ hold?: ReturnType;
+ fade?: ReturnType;
+ }>({});
+
+ const initialSavedKey = initialSavedPatchIds.join(",");
+
+ useEffect(() => {
+ const nextOption = optionFromSavedIds(initialSavedPatchIds);
+ setPublishedOption(nextOption);
+ setDraftOption(nextOption);
+ setSavedPatchIds(initialSavedPatchIds);
+ setDraftPatchIds(initialSavedPatchIds);
+ setSelectionMode(false);
+ }, [initialSavedKey, initialSavedPatchIds]);
+
+ 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 hasUnsavedChanges = draftOption !== publishedOption
+ || (draftOption === "custom" && !sameOrderedIds(draftPatchIds, savedPatchIds));
+ const publishDisabled = saving || !hasUnsavedChanges || (draftOption === "custom" && draftPatchIds.length === 0);
+
+ 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);
+ setSelectionMode(false);
+ setError(null);
+ setPublishError(null);
+ setShowPublishModal(false);
+ setShowSaved(false);
+ setSavedFadeOut(false);
+ }
+
+ function openPublishModal() {
+ if (publishDisabled) return;
+ setError(null);
+ setPublishError(null);
+ setShowPublishModal(true);
+ }
+
+ async function confirmPublish() {
+ const idsToSave = draftOption === "custom" ? draftPatchIds : [];
+ setSaving(true);
+ setPublishError(null);
+ try {
+ const result = await updatePatcherSelectablePatches(hackSlug, idsToSave);
+ if (!result.ok) {
+ setPublishError(result.error || "Failed to publish changes");
+ return;
+ }
+
+ setSavedPatchIds(idsToSave);
+ setDraftPatchIds(idsToSave);
+ setPublishedOption(draftOption);
+ setSelectionMode(false);
+ setShowPublishModal(false);
+ clearSavedFeedbackTimers();
+ setSavedFadeOut(false);
+ setShowSaved(true);
+ const HOLD_MS = 4000;
+ const FADE_MS = 450;
+ savedTimersRef.current.hold = setTimeout(() => {
+ setSavedFadeOut(true);
+ savedTimersRef.current.fade = setTimeout(() => {
+ setShowSaved(false);
+ setSavedFadeOut(false);
+ }, FADE_MS);
+ }, HOLD_MS);
+ router.refresh();
+ } catch {
+ setPublishError("Failed to publish changes");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+ <>
+
+ {children}
+
+ {showPublishModal && (
+ !saving && setShowPublishModal(false)}
+ >
+
+ These patches will be available to choose from in the downloader on your hack's homepage:
+
+ {draftVersionLabels.length > 0 ? (
+
+ {draftVersionLabels.map((label, index) => (
+
+ -
+ {label}
+ {draftOption === "custom" && index === 0 && (
+
+ Default
+
+ )}
+
+ ))}
+
+ ) : (
+ No current patch is set.
+ )}
+ {selectedUnpublishedVersionLabels.length > 0 && (
+
+ Selected unpublished versions will be published when these changes are saved.
+
+ )}
+ {publishError && (
+
+ {publishError}
+
+ )}
+
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+ {
+ setShowPublishModal(false);
+ setPublishError(null);
+ }}
+ disabled={saving}
+ className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
+ >
+ Cancel
+
+
+
+ )}
+ >
+ );
+}
+
+function Modal({
+ title,
+ children,
+ onClose,
+}: {
+ title: string;
+ children: React.ReactNode;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
{title}
+ {children}
+
+
+ );
+}
diff --git a/src/components/Hack/PatcherVersionSettings.tsx b/src/components/Hack/PatcherVersionSettings.tsx
new file mode 100644
index 0000000..a03ca55
--- /dev/null
+++ b/src/components/Hack/PatcherVersionSettings.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import React from "react";
+import { FaCode } from "react-icons/fa";
+import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
+
+type PatcherOption = "latest" | "custom";
+
+interface PatcherVersionSettingsProps {
+ publishedOption: PatcherOption;
+ draftOption: PatcherOption;
+ latestVersionLabels: string[];
+ liveVersionLabels: string[];
+ draftVersionLabels: string[];
+ 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;
+ 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,
+ customSelectionCount,
+ selectionMode,
+ hasUnsavedChanges,
+ publishDisabled,
+ saving,
+ error,
+ showSaved,
+ savedFadeOut,
+ onChooseOption,
+ onEnterSelectionMode,
+ onClearSelections,
+ onCancel,
+ onPublish,
+}: PatcherVersionSettingsProps) {
+ const isSwitchingFromLatestToCustom = publishedOption === "latest" && draftOption === "custom";
+ const summaryStatus = (() => {
+ if (hasUnsavedChanges && draftOption !== publishedOption) {
+ return { prefix: " · Draft: ", label: optionLabel(draftOption) };
+ }
+ if (hasUnsavedChanges) {
+ return { prefix: " · ", label: "Unsaved changes" };
+ }
+ if (selectionMode) {
+ return { prefix: " · ", label: "Selecting versions" };
+ }
+ return null;
+ })();
+ const summary = (
+ <>
+ Live:
+ {optionLabel(publishedOption)}
+ {summaryStatus && (
+ <>
+ {summaryStatus.prefix}
+ {summaryStatus.label}
+ >
+ )}
+ >
+ );
+
+ return (
+ }
+ summary={summary}
+ className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
+ >
+
+
+ Choose which patch versions players can use from the downloader on your hack's homepage.
+
+
+
+
+
+ {draftOption === "custom" && selectionMode && (
+
+
Custom draft
+
+ {customSelectionCount > 0
+ ? versionSummary(draftVersionLabels, "No versions selected.")
+ : "No versions selected. Choose at least one version to publish Custom."}
+
+
+ )}
+
+ {selectionMode ? (
+
+
+ Clear selections
+
+
+
+ Cancel
+
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+
+
+ ) : (
+ <>
+
+ {draftOption === "custom" && !isSwitchingFromLatestToCustom && (
+
+ Edit patcher versions
+
+ )}
+
+
+ {hasUnsavedChanges && (
+
+ Cancel
+
+ )}
+
+ {saving ? "Publishing..." : "Publish Changes"}
+
+
+ >
+ )}
+ {(showSaved || error) && (
+
+ {showSaved ? (
+
+ Changes published.
+
+ ) : (
+ {error}
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function OptionCard({
+ option,
+ selected,
+ saved,
+ label,
+ description,
+ detail,
+ onSelect,
+}: {
+ option: PatcherOption;
+ selected: boolean;
+ saved: boolean;
+ label: string;
+ description: string;
+ detail: string;
+ onSelect: (option: PatcherOption) => void;
+}) {
+ return (
+ onSelect(option)}
+ className={`w-full text-left rounded-md border-2 px-3 py-3 transition-colors flex gap-3 items-start touch-manipulation ${
+ selected
+ ? "border-[var(--accent)] bg-[var(--surface-2)]"
+ : "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
+ }`}
+ >
+
+ {selected && }
+
+
+
+ {label}
+ {saved && (
+
+ Published
+
+ )}
+
+ {description}
+ {detail}
+
+
+ );
+}
diff --git a/src/components/Hack/VersionActions.tsx b/src/components/Hack/VersionActions.tsx
index 81d7474..9fb86ef 100644
--- a/src/components/Hack/VersionActions.tsx
+++ b/src/components/Hack/VersionActions.tsx
@@ -41,6 +41,9 @@ interface VersionActionsProps {
hackSlug: string;
baseRom: string;
currentPatchCreatedAt: string | null;
+ isCustomPatcherActive?: boolean;
+ isInCustomPatcherList?: boolean;
+ customPatcherPatchCount?: number;
onActionComplete: () => void;
}
@@ -50,6 +53,9 @@ export default function VersionActions({
hackSlug,
baseRom,
currentPatchCreatedAt,
+ isCustomPatcherActive = false,
+ isInCustomPatcherList = false,
+ customPatcherPatchCount = 0,
onActionComplete,
}: VersionActionsProps) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms();
@@ -83,7 +89,8 @@ export default function VersionActions({
: false;
// Don't show Rollback if the patch is unpublished and newer than the current version
- const shouldShowRollback = !isCurrent && !(!patch.published && isNewerThanCurrent);
+ const shouldShowRollback = !isCustomPatcherActive && !isCurrent && !(!patch.published && isNewerThanCurrent);
+ const archiveWouldRemoveLastCustomPatch = isInCustomPatcherList && customPatcherPatchCount <= 2;
useEffect(() => {
if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) {
@@ -579,17 +586,32 @@ export default function VersionActions({
title="Archive Version"
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
-
- Are you sure you want to archive version {patch.version} ? This will hide it from public view, but it can be restored later.
-
+ {archiveWouldRemoveLastCustomPatch ? (
+
+ Version {patch.version} is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it.
+
+ ) : (
+ <>
+
+ Are you sure you want to archive version {patch.version} ? This will hide it from public view, but it can be restored later.
+
+ {isInCustomPatcherList && (
+
+ This version will also be removed from the Custom patcher list.
+
+ )}
+ >
+ )}
-
- {actionLoading ? "Archiving..." : "Archive"}
-
+ {!archiveWouldRemoveLastCustomPatch && (
+
+ {actionLoading ? "Archiving..." : "Archive"}
+
+ )}
setShowDeleteModal(false)}
disabled={actionLoading}
@@ -639,7 +661,9 @@ export default function VersionActions({
Publish version {patch.version} ? This will make it viewable to the public along with its changelog.
- If this version is newer than the current patch, it will become the primary download used for all users.
+ {isCustomPatcherActive
+ ? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader."
+ : "If this version is newer than the current patch, it will become the primary download used for all users."}
void;
}
export default function VersionList({
@@ -99,6 +104,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 +167,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 +194,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;
@@ -190,6 +204,10 @@ export default function VersionList({
const showPublicPatchDownload =
!canEdit &&
shouldShowPublicPatchDownload(patchesDownloadPermission, patch, isCurrent);
+ const isHighlighted = isCustomPatcherActive ? isPatchable : isCurrent;
+ const selectedForPatcher = draftPatchIdSet.has(patch.id);
+ const patcherSelectionIndex = draftPatchIds.indexOf(patch.id);
+ const canSelectForPatcher = !patch.archived;
const titleBar = (
)}
- {isCurrent && editingVersion !== patch.id && (
+ {!isCustomPatcherActive && isCurrent && editingVersion !== patch.id && (
Current
)}
+ {isCustomPatcherActive && isDefaultPatcherPatch && editingVersion !== patch.id && (
+
+
+ Default
+
+ )}
+ {isCustomPatcherActive && isPatchable && !isDefaultPatcherPatch && editingVersion !== patch.id && (
+
+
+ Patchable
+
+ )}
{!patch.published && (
Unpublished
@@ -265,10 +295,80 @@ export default function VersionList({
);
+ if (patcherSelectionMode) {
+ const minimalBody = (
+ <>
+
+
+
+
{patch.version}
+ {isCurrent && !isCustomPatcherActive && (
+
+
+ Current
+
+ )}
+ {!patch.published && (
+
+ Unpublished
+
+ )}
+ {patch.archived && (
+
+ Archived
+
+ )}
+
+ {datesBlock}
+
+
+ {selectedForPatcher ? patcherSelectionIndex + 1 : ""}
+
+
+ >
+ );
+
+ if (!canSelectForPatcher) {
+ return (
+
+ {minimalBody}
+
+ );
+ }
+
+ return (
+ onTogglePatcherPatch?.(patch.id)}
+ aria-pressed={selectedForPatcher}
+ className={`card block p-4 sm:p-5 cursor-pointer transition-colors border ${
+ selectedForPatcher
+ ? "ring-2 ring-emerald-500/50 border-emerald-500/50"
+ : "border-[var(--border)] hover:border-[var(--accent)]/60"
+ } w-full text-left`}
+ >
+ {minimalBody}
+
+ );
+ }
+
return (
{showPublicPatchDownload ? (
@@ -295,6 +395,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);