mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-06-20 22:29:47 -05:00
Add creator UI for Latest vs Custom patcher version settings
This commit is contained in:
parent
2e21b03644
commit
9f04ba9e35
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
369
src/components/Hack/PatcherVersionManager.tsx
Normal file
369
src/components/Hack/PatcherVersionManager.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
266
src/components/Hack/PatcherVersionSettings.tsx
Normal file
266
src/components/Hack/PatcherVersionSettings.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user