mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-26 10:34:26 -05:00
Add custom public version names for Custom patcher mode
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "{suggestedCustomVersionName}"
|
||||
</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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
46
src/utils/patches/hack-display-version.ts
Normal file
46
src/utils/patches/hack-display-version.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table if exists public.hacks
|
||||
add column if not exists custom_version_name text;
|
||||
Reference in New Issue
Block a user