Add creator UI for Latest vs Custom patcher version settings

This commit is contained in:
Jared Schoeny 2026-06-19 23:53:43 -06:00
parent 2e21b03644
commit 9f04ba9e35
5 changed files with 886 additions and 65 deletions

View File

@ -3,9 +3,11 @@ import { createClient } from "@/utils/supabase/server";
import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
import VersionList from "@/components/Hack/VersionList";
import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings";
import PatcherVersionManager from "@/components/Hack/PatcherVersionManager";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import Link from "next/link";
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
interface VersionsPageProps {
params: Promise<{ slug: string }>;
@ -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,115 @@ 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 ?? []}
patches={allPatches}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}
>
<DownloadPermissionSettings
hackSlug={slug}
initialPermission={hack.patches_download_permission}
/>
<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 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 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 pressing \"Patch Now\" on the hack page." :
"This version can be selected before 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>
);
}

View File

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

View File

@ -0,0 +1,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 = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{optionLabel(publishedOption)}</span>
{summaryStatus && (
<>
<span className="text-foreground/40">{summaryStatus.prefix}</span>
<span className="text-foreground/70 font-medium">{summaryStatus.label}</span>
</>
)}
</>
);
return (
<CollapsibleCard
title="Patcher Version Settings"
titleId="patcher-version-settings-heading"
leading={<FaCode size={20} />}
summary={summary}
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
>
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
Choose which patch versions players can use from the downloader on your hack&apos;s homepage.
</p>
<div className="grid gap-3" role="radiogroup" aria-label="Patcher version source">
<OptionCard
option="latest"
selected={draftOption === "latest"}
saved={publishedOption === "latest"}
label="Only use the latest published patch"
description="Use the hack's current published patch. Players will not see a version dropdown."
detail={versionSummary(latestVersionLabels, "No current patch is set.")}
onSelect={onChooseOption}
/>
<OptionCard
option="custom"
selected={draftOption === "custom"}
saved={publishedOption === "custom"}
label="Custom"
description="Choose specific non-archived versions for the downloader. Great for multiple variants of the same version, like builds with different features or optional changes."
detail={versionSummary(liveVersionLabels, "No custom versions are published.")}
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>
</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: string;
onSelect: (option: PatcherOption) => void;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => onSelect(option)}
className={`w-full text-left rounded-md border-2 px-3 py-3 transition-colors flex gap-3 items-start touch-manipulation ${
selected
? "border-[var(--accent)] bg-[var(--surface-2)]"
: "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
}`}
>
<span
className={`mt-0.5 shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
selected ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-hidden
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{label}</span>
{saved && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
Published
</span>
)}
</span>
<span className="mt-1 block text-xs text-foreground/60">{description}</span>
<span className="mt-2 block text-xs font-medium text-foreground/75">{detail}</span>
</span>
</button>
);
}

View File

@ -41,6 +41,9 @@ interface VersionActionsProps {
hackSlug: string;
baseRom: string;
currentPatchCreatedAt: string | null;
isCustomPatcherActive?: boolean;
isInCustomPatcherList?: boolean;
customPatcherPatchCount?: number;
onActionComplete: () => void;
}
@ -50,6 +53,9 @@ export default function VersionActions({
hackSlug,
baseRom,
currentPatchCreatedAt,
isCustomPatcherActive = false,
isInCustomPatcherList = false,
customPatcherPatchCount = 0,
onActionComplete,
}: VersionActionsProps) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms();
@ -83,7 +89,8 @@ export default function VersionActions({
: false;
// Don't show Rollback if the patch is unpublished and newer than the current version
const shouldShowRollback = !isCurrent && !(!patch.published && isNewerThanCurrent);
const shouldShowRollback = !isCustomPatcherActive && !isCurrent && !(!patch.published && isNewerThanCurrent);
const archiveWouldRemoveLastCustomPatch = isInCustomPatcherList && customPatcherPatchCount <= 2;
useEffect(() => {
if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) {
@ -579,17 +586,32 @@ export default function VersionActions({
title="Archive Version"
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
<p className="text-foreground/80 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
{archiveWouldRemoveLastCustomPatch ? (
<p className="text-foreground/80 mb-4">
Version <strong>{patch.version}</strong> is one of the last 2 versions in the <strong>Custom</strong> patcher list. Switch to <strong>Latest published patch</strong> or add another Custom version before archiving it.
</p>
) : (
<>
<p className="text-foreground/80 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
{isInCustomPatcherList && (
<p className="text-sm text-foreground/60 mb-4">
This version will also be removed from the Custom patcher list.
</p>
)}
</>
)}
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
{!archiveWouldRemoveLastCustomPatch && (
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
)}
<button
onClick={() => setShowDeleteModal(false)}
disabled={actionLoading}
@ -639,7 +661,9 @@ export default function VersionActions({
Publish version <strong>{patch.version}</strong>? This will make it viewable to the public along with its changelog.
</p>
<p className="text-sm text-foreground/60 mb-4">
If this version is newer than the current patch, it will become the primary download used for all users.
{isCustomPatcherActive
? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader."
: "If this version is newer than the current patch, it will become the primary download used for all users."}
</p>
<div className="flex gap-2">
<button

View File

@ -90,6 +90,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 +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 = (
<div
@ -221,12 +239,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 +295,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 +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);