From 9f04ba9e359c0855c5d3b2425170edfe41a0cccd Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 19 Jun 2026 23:53:43 -0600 Subject: [PATCH] Add creator UI for Latest vs Custom patcher version settings --- src/app/hack/[slug]/versions/page.tsx | 161 +++++--- src/components/Hack/PatcherVersionManager.tsx | 369 ++++++++++++++++++ .../Hack/PatcherVersionSettings.tsx | 266 +++++++++++++ src/components/Hack/VersionActions.tsx | 48 ++- src/components/Hack/VersionList.tsx | 107 ++++- 5 files changed, 886 insertions(+), 65 deletions(-) create mode 100644 src/components/Hack/PatcherVersionManager.tsx create mode 100644 src/components/Hack/PatcherVersionSettings.tsx 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} +

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

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

+ {archiveWouldRemoveLastCustomPatch ? ( +

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

+ ) : ( + <> +

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

+ {isInCustomPatcherList && ( +

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

+ )} + + )}
- + {!archiveWouldRemoveLastCustomPatch && ( + + )} + ); + } + return (
{showPublicPatchDownload ? ( @@ -295,6 +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);