Add custom public version names for Custom patcher mode

This commit is contained in:
Jared Schoeny
2026-06-20 17:10:19 -06:00
parent 15b71ad12d
commit 21bf410083
10 changed files with 259 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ import { getCachedTagsWithUsage, buildTagFilterGroups } from "@/data/tags";
import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format";
import { HackCardAttributes } from "@/components/HackCard";
import type { DiscoverSortOption } from "@/types/discover";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
const TRENDING_WINDOW_DAYS = 3;
const TIME_TO_LIVE = 600; // 10 minutes
@@ -35,7 +36,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
// Build base query for hacks (public/anon view: only approved hacks)
let query = supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at,is_archive,completion_status")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status")
.eq("approved", true);
// Apply sorting based on sort type
@@ -156,6 +157,24 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
if (slugs.length > 0) {
const { data: customPatchRows, error: customPatchRowsError } = await supabase
.from("hack_patcher_patches")
.select("hack_slug, sort_order, patches!inner(version)")
.in("hack_slug", slugs)
.order("sort_order", { ascending: true });
if (customPatchRowsError) throw customPatchRowsError;
(customPatchRows || []).forEach((row: any) => {
customPatcherSlugs.add(row.hack_slug);
if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
});
}
// Calculate trending scores if needed
let trendingScores: Map<string, number> | null = null;
if (sort === "trending") {
@@ -217,14 +236,21 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
const mappedVersions = new Map<string, string>();
const publishedAtBySlug = new Map<string, string | null>();
(rows || []).forEach((r: any) => {
if (typeof r.current_patch === "number") {
const version = versionsByPatchId.get(r.current_patch) || "Pre-release";
mappedVersions.set(r.slug, version);
const currentPatchVersion = typeof r.current_patch === "number"
? versionsByPatchId.get(r.current_patch) || "Pre-release"
: "";
mappedVersions.set(r.slug, resolveHackDisplayVersion({
isArchive: r.is_archive,
isCustomPatcherActive: customPatcherSlugs.has(r.slug),
customVersionName: r.custom_version_name,
customDefaultPatchVersion: customDefaultVersionsBySlug.get(r.slug),
currentPatchVersion,
}));
if (typeof r.current_patch === "number") {
const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null;
publishedAtBySlug.set(r.slug, publishedAt);
} else {
mappedVersions.set(r.slug, r.is_archive ? "Archive" : "Pre-release");
publishedAtBySlug.set(r.slug, null);
}
});

View File

@@ -12,6 +12,7 @@ import { unstable_cache as cache } from "next/cache";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { Database, Constants } from "@/types/db";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
import { CUSTOM_VERSION_NAME_MAX_LENGTH, resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import type { SelectablePatch } from "@/types/patcher";
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
@@ -39,6 +40,7 @@ export interface HackMetadata {
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
verification_contact_info: string | null;
};
displayVersion: string;
images: string[];
tags: string[];
profile: {
@@ -72,7 +74,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
const { data: hack, error } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,custom_version_name,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info")
.eq("slug", slug)
.maybeSingle();
@@ -166,10 +168,18 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
}
}
const { selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
const { savedPatchIds, selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
const displayVersion = resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: savedPatchIds.length > 0,
customVersionName: hack.custom_version_name,
customDefaultPatchVersion: selectablePatches[0]?.version,
currentPatchVersion: patch?.version,
});
return {
hack,
displayVersion,
images,
tags,
profile: profile ? {
@@ -1005,6 +1015,7 @@ export async function confirmReuploadPatchVersion(
export async function updatePatcherSelectablePatches(
slug: string,
patchIds: number[],
customVersionName?: string | null,
): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -1028,6 +1039,16 @@ export async function updatePatcherSelectablePatches(
// Dedupe patch ids
const uniquePatchIds = [...new Set(patchIds)];
const trimmedCustomVersionName = customVersionName?.trim() || null;
if (uniquePatchIds.length > 0) {
if (!trimmedCustomVersionName) {
return { ok: false, error: "Custom version name is required." };
}
if (trimmedCustomVersionName.length > CUSTOM_VERSION_NAME_MAX_LENGTH) {
return { ok: false, error: "Custom version name must be 12 characters or fewer." };
}
}
if (uniquePatchIds.length > 0) {
// Verify patches belong to this hack
@@ -1060,6 +1081,12 @@ export async function updatePatcherSelectablePatches(
});
if (replaceErr) return { ok: false, error: replaceErr.message };
const { error: nameErr } = await supabase
.from("hacks")
.update({ custom_version_name: uniquePatchIds.length > 0 ? trimmedCustomVersionName : null })
.eq("slug", slug);
if (nameErr) return { ok: false, error: nameErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}`);
revalidatePath(`/hack/${slug}/versions`);

View File

@@ -115,7 +115,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
if (!metadata) return notFound();
const { hack, images, tags, profile, otherHacks, patch } = metadata;
const { hack, images, tags, profile, otherHacks, patch, displayVersion } = metadata;
const baseRom = baseRoms.find((r) => r.id === hack.base_rom);
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
@@ -148,7 +148,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
// Extract patch info from cached metadata
const patchFilename = patch?.filename || null;
const patchVersion = isArchive ? "Archive" : (patch?.version || "");
const patchVersion = displayVersion;
const patchId = patch?.id || null;
const lastUpdated = patch ? new Date(patch.created_at).toLocaleDateString() : null;
const patchCreatedAt = patch?.created_at || null;

View File

@@ -21,7 +21,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
// Fetch hack
const { data: hack } = await supabase
.from("hacks")
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive, patches_download_permission")
.select("slug, title, created_by, current_patch, custom_version_name, original_author, permission_from, base_rom, is_archive, patches_download_permission")
.eq("slug", slug)
.maybeSingle();
@@ -98,6 +98,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
hackSlug={slug}
currentPatchId={hack.current_patch}
initialSavedPatchIds={patcherSelection?.savedPatchIds ?? []}
initialCustomVersionName={hack.custom_version_name}
patches={allPatches}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}

View File

@@ -6,6 +6,7 @@ import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { HackCardAttributes } from "@/components/HackCard";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
export const metadata: Metadata = {
alternates: {
@@ -22,7 +23,7 @@ export default async function Home() {
// Fetch top 6 approved hacks ordered by downloads
const { data: popularHacks } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author,is_archive")
.select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,is_archive")
.eq("approved", true)
.not("current_patch", "is", null)
.is("is_archive", false)
@@ -75,21 +76,47 @@ export default async function Home() {
});
// Fetch versions
let mappedVersions = new Map<string, string>();
await Promise.all(
popularHacks.map(async (r) => {
if (r.current_patch) {
const { data: currentPatch } = await supabase
.from("patches")
.select("version")
.eq("id", r.current_patch)
.maybeSingle();
mappedVersions.set(r.slug, currentPatch?.version || "Pre-release");
} else {
mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release");
}
})
);
const patchIds = popularHacks
.map((hack) => hack.current_patch)
.filter((id): id is number => typeof id === "number");
const versionsByPatchId = new Map<number, string>();
if (patchIds.length > 0) {
const { data: patchRows } = await supabase
.from("patches")
.select("id,version")
.in("id", patchIds);
(patchRows || []).forEach((patch) => {
versionsByPatchId.set(patch.id, patch.version || "Pre-release");
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
const { data: customPatchRows } = await supabase
.from("hack_patcher_patches")
.select("hack_slug, sort_order, patches!inner(version)")
.in("hack_slug", slugs)
.order("sort_order", { ascending: true });
(customPatchRows || []).forEach((row: any) => {
customPatcherSlugs.add(row.hack_slug);
if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
});
const mappedVersions = new Map<string, string>();
popularHacks.forEach((hack) => {
const currentPatchVersion = typeof hack.current_patch === "number"
? versionsByPatchId.get(hack.current_patch) || "Pre-release"
: "";
mappedVersions.set(hack.slug, resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: customPatcherSlugs.has(hack.slug),
customVersionName: hack.custom_version_name,
customDefaultPatchVersion: customDefaultVersionsBySlug.get(hack.slug),
currentPatchVersion,
}));
});
// Fetch profiles
const userIds = [...new Set(popularHacks.map((h) => h.created_by).filter(Boolean))];

View File

@@ -6,6 +6,7 @@ import { FiX } from "react-icons/fi";
import { updatePatcherSelectablePatches } from "@/app/hack/[slug]/actions";
import PatcherVersionSettings from "@/components/Hack/PatcherVersionSettings";
import VersionList from "@/components/Hack/VersionList";
import { CUSTOM_VERSION_NAME_MAX_LENGTH, suggestCustomVersionName } from "@/utils/patches/hack-display-version";
import type { PatchesDownloadPermission } from "@/components/Hack/DownloadPermissionSettings";
type PatcherOption = "latest" | "custom";
@@ -24,6 +25,7 @@ interface PatcherVersionManagerProps {
hackSlug: string;
currentPatchId: number | null;
initialSavedPatchIds: number[];
initialCustomVersionName: string | null;
patches: Patch[];
baseRom: string;
patchesDownloadPermission: PatchesDownloadPermission;
@@ -39,10 +41,19 @@ function optionFromSavedIds(savedPatchIds: number[]): PatcherOption {
return savedPatchIds.length > 0 ? "custom" : "latest";
}
function initialCustomName(name: string | null, savedPatchIds: number[], patches: Patch[]) {
const trimmedName = name?.trim();
if (trimmedName) return trimmedName.slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
if (savedPatchIds.length === 0) return "";
const firstSavedPatch = patches.find((patch) => patch.id === savedPatchIds[0]);
return (firstSavedPatch?.version || "").slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
}
export default function PatcherVersionManager({
hackSlug,
currentPatchId,
initialSavedPatchIds,
initialCustomVersionName,
patches,
baseRom,
patchesDownloadPermission,
@@ -54,6 +65,8 @@ export default function PatcherVersionManager({
const [draftOption, setDraftOption] = useState<PatcherOption>(() => optionFromSavedIds(initialSavedPatchIds));
const [savedPatchIds, setSavedPatchIds] = useState<number[]>(initialSavedPatchIds);
const [draftPatchIds, setDraftPatchIds] = useState<number[]>(initialSavedPatchIds);
const [savedCustomVersionName, setSavedCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches));
const [draftCustomVersionName, setDraftCustomVersionName] = useState(() => initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches));
const [showPublishModal, setShowPublishModal] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -66,15 +79,19 @@ export default function PatcherVersionManager({
}>({});
const initialSavedKey = initialSavedPatchIds.join(",");
const initialCustomVersionNameKey = initialCustomVersionName ?? "";
useEffect(() => {
const nextOption = optionFromSavedIds(initialSavedPatchIds);
const nextCustomVersionName = initialCustomName(initialCustomVersionName, initialSavedPatchIds, patches);
setPublishedOption(nextOption);
setDraftOption(nextOption);
setSavedPatchIds(initialSavedPatchIds);
setDraftPatchIds(initialSavedPatchIds);
setSavedCustomVersionName(nextCustomVersionName);
setDraftCustomVersionName(nextCustomVersionName);
setSelectionMode(false);
}, [initialSavedKey, initialSavedPatchIds]);
}, [initialSavedKey, initialSavedPatchIds, initialCustomVersionNameKey, initialCustomVersionName, patches]);
function clearSavedFeedbackTimers() {
const t = savedTimersRef.current;
@@ -139,10 +156,17 @@ export default function PatcherVersionManager({
.filter((patch) => !patch.published && !patch.archived)
.map((patch) => patch.version)
: [];
const suggestedCustomVersionName = useMemo(
() => suggestCustomVersionName(draftVersionLabels),
[draftVersionLabels],
);
const hasUnsavedChanges = draftOption !== publishedOption
|| (draftOption === "custom" && !sameOrderedIds(draftPatchIds, savedPatchIds));
const publishDisabled = saving || !hasUnsavedChanges || (draftOption === "custom" && draftPatchIds.length === 0);
|| (draftOption === "custom" && !sameOrderedIds(draftPatchIds, savedPatchIds))
|| (draftOption === "custom" && draftCustomVersionName.trim() !== savedCustomVersionName);
const publishDisabled = saving
|| !hasUnsavedChanges
|| (draftOption === "custom" && (draftPatchIds.length === 0 || !draftCustomVersionName.trim()));
function enterCustomSelectionMode() {
setDraftOption("custom");
@@ -183,6 +207,7 @@ export default function PatcherVersionManager({
function cancelDraft() {
setDraftOption(publishedOption);
setDraftPatchIds(savedPatchIds);
setDraftCustomVersionName(savedCustomVersionName);
setSelectionMode(false);
setError(null);
setPublishError(null);
@@ -198,12 +223,18 @@ export default function PatcherVersionManager({
setShowPublishModal(true);
}
function applySuggestedCustomVersionName() {
if (!suggestedCustomVersionName) return;
setDraftCustomVersionName(suggestedCustomVersionName);
}
async function confirmPublish() {
const idsToSave = draftOption === "custom" ? draftPatchIds : [];
const customNameToSave = draftOption === "custom" ? draftCustomVersionName.trim() : null;
setSaving(true);
setPublishError(null);
try {
const result = await updatePatcherSelectablePatches(hackSlug, idsToSave);
const result = await updatePatcherSelectablePatches(hackSlug, idsToSave, customNameToSave);
if (!result.ok) {
setPublishError(result.error || "Failed to publish changes");
return;
@@ -211,6 +242,8 @@ export default function PatcherVersionManager({
setSavedPatchIds(idsToSave);
setDraftPatchIds(idsToSave);
setSavedCustomVersionName(customNameToSave || "");
setDraftCustomVersionName(customNameToSave || "");
setPublishedOption(draftOption);
setSelectionMode(false);
setShowPublishModal(false);
@@ -242,6 +275,9 @@ export default function PatcherVersionManager({
latestVersionLabels={latestVersionLabels}
liveVersionLabels={liveVersionLabels}
draftVersionLabels={draftVersionLabels}
publishedCustomVersionName={savedCustomVersionName}
customVersionName={draftCustomVersionName}
suggestedCustomVersionName={suggestedCustomVersionName}
customSelectionCount={draftPatchIds.length}
selectionMode={selectionMode}
hasUnsavedChanges={hasUnsavedChanges}
@@ -253,6 +289,8 @@ export default function PatcherVersionManager({
onChooseOption={chooseOption}
onEnterSelectionMode={enterCustomSelectionMode}
onClearSelections={clearSelections}
onCustomVersionNameChange={setDraftCustomVersionName}
onApplySuggestedCustomVersionName={applySuggestedCustomVersionName}
onCancel={cancelDraft}
onPublish={openPublishModal}
/>
@@ -295,6 +333,11 @@ export default function PatcherVersionManager({
) : (
<p className="mb-4 text-sm text-foreground/60">No current patch is set.</p>
)}
{draftOption === "custom" && (
<p className="mb-4 text-sm text-foreground/70">
Public version name: <strong className="text-foreground">{draftCustomVersionName.trim()}</strong>
</p>
)}
{selectedUnpublishedVersionLabels.length > 0 && (
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
Selected unpublished versions will be published when these changes are saved.

View File

@@ -3,6 +3,7 @@
import React from "react";
import { FaCode } from "react-icons/fa";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import { CUSTOM_VERSION_NAME_MAX_LENGTH } from "@/utils/patches/hack-display-version";
type PatcherOption = "latest" | "custom";
@@ -12,6 +13,9 @@ interface PatcherVersionSettingsProps {
latestVersionLabels: string[];
liveVersionLabels: string[];
draftVersionLabels: string[];
publishedCustomVersionName: string;
customVersionName: string;
suggestedCustomVersionName: string | null;
customSelectionCount: number;
selectionMode: boolean;
hasUnsavedChanges: boolean;
@@ -23,6 +27,8 @@ interface PatcherVersionSettingsProps {
onChooseOption: (option: PatcherOption) => void;
onEnterSelectionMode: () => void;
onClearSelections: () => void;
onCustomVersionNameChange: (name: string) => void;
onApplySuggestedCustomVersionName: () => void;
onCancel: () => void;
onPublish: () => void;
}
@@ -42,6 +48,9 @@ export default function PatcherVersionSettings({
latestVersionLabels,
liveVersionLabels,
draftVersionLabels,
publishedCustomVersionName,
customVersionName,
suggestedCustomVersionName,
customSelectionCount,
selectionMode,
hasUnsavedChanges,
@@ -53,10 +62,25 @@ export default function PatcherVersionSettings({
onChooseOption,
onEnterSelectionMode,
onClearSelections,
onCustomVersionNameChange,
onApplySuggestedCustomVersionName,
onCancel,
onPublish,
}: PatcherVersionSettingsProps) {
const isSwitchingFromLatestToCustom = publishedOption === "latest" && draftOption === "custom";
const liveCustomVersionName = publishedCustomVersionName.trim();
const showCustomVersionNameHint = draftOption === "custom" && selectionMode && publishDisabled && customVersionName.trim().length === 0;
const showSuggestedNameButton = suggestedCustomVersionName !== null && customVersionName.trim() !== suggestedCustomVersionName;
const customOptionDetail = publishedOption === "custom" && liveCustomVersionName
? (
<>
<strong>{liveCustomVersionName}:</strong> {versionSummary(liveVersionLabels, "No custom versions are published.")}
</>
)
: versionSummary(liveVersionLabels, "No custom versions are published.");
const liveOptionLabel = publishedOption === "custom" && liveCustomVersionName
? `${optionLabel(publishedOption)} (${liveCustomVersionName})`
: optionLabel(publishedOption);
const summaryStatus = (() => {
if (hasUnsavedChanges && draftOption !== publishedOption) {
return { prefix: " · Draft: ", label: optionLabel(draftOption) };
@@ -72,7 +96,7 @@ export default function PatcherVersionSettings({
const summary = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{optionLabel(publishedOption)}</span>
<span className="text-foreground/80 font-medium">{liveOptionLabel}</span>
{summaryStatus && (
<>
<span className="text-foreground/40">{summaryStatus.prefix}</span>
@@ -110,7 +134,7 @@ export default function PatcherVersionSettings({
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.")}
detail={customOptionDetail}
onSelect={onChooseOption}
/>
</div>
@@ -122,6 +146,33 @@ export default function PatcherVersionSettings({
? versionSummary(draftVersionLabels, "No versions selected.")
: "No versions selected. Choose at least one version to publish Custom."}
</div>
<label htmlFor="custom-version-name" className="mt-3 block font-medium text-foreground/80">
Public version name
</label>
<input
id="custom-version-name"
type="text"
value={customVersionName}
onChange={(event) => onCustomVersionNameChange(event.target.value)}
maxLength={CUSTOM_VERSION_NAME_MAX_LENGTH}
className="mt-1 block h-9 w-full rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
placeholder="e.g. 2.1.2"
/>
<p className="mt-1 text-foreground/55">
Shown on the hack page and discover cards. Max {CUSTOM_VERSION_NAME_MAX_LENGTH} characters.
</p>
{showCustomVersionNameHint && (
<p className="mt-1 text-red-400">Custom version name is required.</p>
)}
{showSuggestedNameButton && (
<button
type="button"
onClick={onApplySuggestedCustomVersionName}
className="mt-2 inline-flex items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 text-xs font-semibold text-foreground/80 transition-colors hover:bg-[var(--surface-3)]"
>
Use &quot;{suggestedCustomVersionName}&quot;
</button>
)}
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
@@ -226,7 +277,7 @@ function OptionCard({
saved: boolean;
label: string;
description: string;
detail: string;
detail: React.ReactNode;
onSelect: (option: PatcherOption) => void;
}) {
return (

View File

@@ -177,6 +177,7 @@ export type Database = {
created_at: string
created_by: string
current_patch: number | null
custom_version_name: string | null
description: string
downloads: number
estimated_release: string | null
@@ -212,6 +213,7 @@ export type Database = {
created_at?: string
created_by: string
current_patch?: number | null
custom_version_name?: string | null
description: string
downloads?: number
estimated_release?: string | null
@@ -247,6 +249,7 @@ export type Database = {
created_at?: string
created_by?: string
current_patch?: number | null
custom_version_name?: string | null
description?: string
downloads?: number
estimated_release?: string | null

View File

@@ -0,0 +1,46 @@
export const CUSTOM_VERSION_NAME_MAX_LENGTH = 12;
interface ResolveHackDisplayVersionArgs {
isArchive: boolean;
isCustomPatcherActive: boolean;
customVersionName?: string | null;
customDefaultPatchVersion?: string | null;
currentPatchVersion?: string | null;
}
export function resolveHackDisplayVersion({
isArchive,
isCustomPatcherActive,
customVersionName,
customDefaultPatchVersion,
currentPatchVersion,
}: ResolveHackDisplayVersionArgs) {
if (isArchive) return "Archive";
if (isCustomPatcherActive) {
return customVersionName?.trim()
|| customDefaultPatchVersion
|| currentPatchVersion
|| "";
}
return currentPatchVersion || "";
}
export function suggestCustomVersionName(versionLabels: string[]) {
if (versionLabels.length === 0) return null;
let prefix = versionLabels[0];
for (const label of versionLabels.slice(1)) {
let index = 0;
while (index < prefix.length && index < label.length && prefix[index] === label[index]) {
index += 1;
}
prefix = prefix.slice(0, index);
if (!prefix) return null;
}
const suggestion = prefix
.replace(/[-_.+ ]+$/g, "")
.trim()
.slice(0, CUSTOM_VERSION_NAME_MAX_LENGTH);
return suggestion || null;
}

View File

@@ -0,0 +1,2 @@
alter table if exists public.hacks
add column if not exists custom_version_name text;