"use client";
import React from "react";
import Image from "next/image";
import { baseRoms } from "@/data/baseRoms";
import { getScreenshotTutorial } from "@/data/screenshotTutorials";
import HackCard from "@/components/HackCard";
import { createClient } from "@/utils/supabase/client";
import { prepareSubmission, presignPatchAndSaveCovers, confirmPatchUpload, saveHackCovers } from "@/app/submit/actions";
import { presignCoverUpload } from "@/app/hack/actions";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Markdown from "@/components/Markdown/Markdown";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaDiscord, FaTwitter, FaGithub } from "react-icons/fa6";
import { FiExternalLink } from "react-icons/fi";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
import { useAuthContext } from "@/contexts/AuthContext";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import TagSelector from "@/components/Submit/TagSelector";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import Select, { SelectOption, SelectDivider } from "@/components/Primitives/Select";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb";
import { slugify, sortOrderedTags } from "@/utils/format";
function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; index: number; url: string; filename: string; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
{filename}
{index === 0 &&
Primary
}
);
}
interface HackSubmitFormProps {
dummy?: boolean;
isArchive?: boolean;
permissionFrom?: string;
customCreator?: string;
}
export default function HackSubmitForm({
dummy = false,
isArchive = false,
permissionFrom = undefined,
customCreator = undefined,
}: HackSubmitFormProps) {
const MAX_COVERS = 10;
const { profile, user } = useAuthContext();
const [isHydrating, setIsHydrating] = React.useState(true);
const [restoredDraft, setRestoredDraft] = React.useState(false);
const hydratedFromDraftRef = React.useRef(false);
const draftKey = React.useMemo(() => (user?.id ? `hack-submit/v1/${user.id}` : null), [user?.id]);
const initialDraftRef = React.useRef(undefined);
if (initialDraftRef.current === undefined && typeof window !== "undefined") {
try {
if (draftKey) {
const raw = localStorage.getItem(draftKey);
initialDraftRef.current = raw ? JSON.parse(raw) : {};
} else {
initialDraftRef.current = null; // will hydrate later when user is known
}
} catch {
initialDraftRef.current = {};
}
}
const [title, setTitle] = React.useState(() => initialDraftRef.current?.title || "");
const [summary, setSummary] = React.useState(() => initialDraftRef.current?.summary || "");
const [description, setDescription] = React.useState(() => initialDraftRef.current?.description || "");
const [newCoverFiles, setNewCoverFiles] = React.useState([]);
const [coverErrors, setCoverErrors] = React.useState([]);
const [baseRom, setBaseRom] = React.useState(() => initialDraftRef.current?.baseRom || "");
const [platform, setPlatform] = React.useState<"GB" | "GBC" | "GBA" | "NDS" | "">(() => (initialDraftRef.current?.platform as any) || "");
const [version, setVersion] = React.useState(() => initialDraftRef.current?.version || "");
const [language, setLanguage] = React.useState(() => initialDraftRef.current?.language || "");
const [completionStatus, setCompletionStatus] = React.useState<"Complete" | "Demo" | "Alpha" | "Beta" | "">(() => (initialDraftRef.current?.completionStatus as any) || "");
const [boxArt, setBoxArt] = React.useState(() => initialDraftRef.current?.boxArt || "");
const [discord, setDiscord] = React.useState(() => initialDraftRef.current?.discord || "");
const [twitter, setTwitter] = React.useState(() => initialDraftRef.current?.twitter || "");
const [pokecommunity, setPokecommunity] = React.useState(() => initialDraftRef.current?.pokecommunity || "");
const [github, setGithub] = React.useState(() => initialDraftRef.current?.github || "");
const [verificationContactInfo, setVerificationContactInfo] = React.useState(() => initialDraftRef.current?.verificationContactInfo || "");
const [tags, setTags] = React.useState(() => (Array.isArray(initialDraftRef.current?.tags) ? initialDraftRef.current.tags : []));
const [showMdPreview, setShowMdPreview] = React.useState(() => !!initialDraftRef.current?.showMdPreview);
const [originalAuthor, setOriginalAuthor] = React.useState(() => {
// If customCreator is provided, use it; otherwise use draft or empty string
if (customCreator) return customCreator;
return initialDraftRef.current?.originalAuthor || "";
});
const [patchFile, setPatchFile] = React.useState(null);
const [patchMode, setPatchMode] = React.useState<"bps" | "rom">(() => (initialDraftRef.current?.patchMode === "rom" ? "rom" : "bps"));
const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle");
const [checksumStatus, setChecksumStatus] = React.useState<"idle" | "validating" | "valid" | "invalid" | "unknown">("idle");
const [checksumError, setChecksumError] = React.useState("");
const [genError, setGenError] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const maxSteps = isArchive ? 3 : 4;
const [step, setStep] = React.useState(() => {
const s = initialDraftRef.current?.step;
return Number.isInteger(s) ? Math.min(maxSteps, Math.max(1, s)) : 1;
});
const supabase = createClient();
const isDummy = !!dummy;
const titleInputRef = React.useRef(null);
const versionInputRef = React.useRef(null);
const screenshotsInputRef = React.useRef(null);
const patchInputRef = React.useRef(null);
const modifiedRomInputRef = React.useRef(null);
const baseRomEntry = React.useMemo(() => baseRoms.find((r) => r.id === baseRom) || null, [baseRom]);
const baseRomName = baseRomEntry?.name || "";
const baseRomPlatform = baseRomEntry?.platform;
const tutorialInfo = React.useMemo(() => getScreenshotTutorial(baseRomPlatform), [baseRomPlatform]);
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms();
const baseRomReady = baseRom && (hasPermission(baseRom) || hasCached(baseRom));
const baseRomNeedsPermission = baseRom && isLinked(baseRom) && !baseRomReady;
const baseRomMissing = baseRom && !isLinked(baseRom) && !hasCached(baseRom);
const coverPreviews = React.useMemo(() => {
return newCoverFiles.map((f) => URL.createObjectURL(f));
}, [newCoverFiles]);
React.useEffect(() => {
return () => {
coverPreviews.forEach((u) => URL.revokeObjectURL(u));
};
}, [coverPreviews]);
// Load persisted screenshot blobs after user/draftKey known
React.useEffect(() => {
if (dummy || !draftKey) return;
(async () => {
try {
const files = await getDraftCovers(draftKey);
if (files && files.length) {
setNewCoverFiles(files);
hydratedFromDraftRef.current = true; setRestoredDraft(true);
}
} catch {}
})();
}, [dummy, draftKey]);
React.useEffect(() => {
setPatchFile(null);
setGenStatus("idle");
setGenError("");
patchInputRef.current && (patchInputRef.current.value = "");
modifiedRomInputRef.current && (modifiedRomInputRef.current.value = "");
}, [patchMode]);
// Sync originalAuthor with customCreator if provided
React.useEffect(() => {
if (customCreator) {
setOriginalAuthor(customCreator);
}
}, [customCreator]);
const uploadCovers = async (slug: string) => {
if (!newCoverFiles || newCoverFiles.length === 0) return [] as string[];
const urls: string[] = [];
for (let i = 0; i < newCoverFiles.length; i++) {
const file = newCoverFiles[i];
const fileExt = file.name.split('.').pop();
const path = `${slug}/${Date.now()}-${i}.${fileExt}`;
const presigned = await presignCoverUpload({ slug, objectKey: path });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign cover upload');
await fetch(presigned.presignedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'image/jpeg' } });
urls.push(path);
}
return urls;
};
function getAllowedSizesForPlatform(platform: "GB" | "GBC" | "GBA" | "NDS") {
if (platform === "GB" || platform === "GBC") return [{ w: 160, h: 144 }];
if (platform === "GBA") return [{ w: 240, h: 160 }];
return [{ w: 256, h: 192 }, { w: 256, h: 384 }];
}
async function validateImageDimensions(file: File, allowed: { w: number; h: number }[]) {
return new Promise((resolve) => {
const img = document.createElement("img");
const url = URL.createObjectURL(file);
img.onload = () => {
const ok = allowed.some((s) => img.naturalWidth === s.w && img.naturalHeight === s.h);
URL.revokeObjectURL(url);
resolve(ok);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(false);
};
img.src = url;
});
}
const overLimit = newCoverFiles.length > MAX_COVERS;
const removeAt = (index: number) => {
setNewCoverFiles((prev) => prev.filter((_, i) => i !== index));
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const onDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = newCoverFiles.map((f, i) => `${f.name}-${i}`);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex));
};
// Persist draft covers whenever they change
React.useEffect(() => {
if (dummy || !draftKey) return;
(async () => {
try { await setDraftCovers(draftKey, newCoverFiles); } catch {}
})();
}, [dummy, draftKey, newCoverFiles]);
React.useEffect(() => {
if (isDummy || isHydrating) return;
let target: HTMLInputElement | null = null;
if (step === 1) {
target = titleInputRef.current;
} else if (step === 2 && !isArchive) {
target = versionInputRef.current;
} else if ((step === 2 && isArchive) || (step === 3 && !isArchive)) {
target = screenshotsInputRef.current;
} else if (step === 4 && !isArchive) {
target = patchInputRef.current;
}
if (!target) return;
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
if (!target?.disabled) target?.focus();
});
// Cleanup nested rAF on effect re-run
return () => cancelAnimationFrame(raf2);
});
return () => cancelAnimationFrame(raf1);
}, [step, isDummy, isHydrating]);
const slug = slugify(title || "");
// Draft load after user is known (covers case where user id wasn't ready at first render)
React.useEffect(() => {
if (dummy) { setIsHydrating(false); return; }
if (!draftKey) return; // wait for user
try {
// If we didn't have the draft synchronously, hydrate now
if (!initialDraftRef.current || Object.keys(initialDraftRef.current || {}).length === 0) {
const raw = localStorage.getItem(draftKey);
if (raw) {
const data = JSON.parse(raw);
if (data && typeof data === "object") {
const isEmpty =
!title && !summary && !description && !baseRom && !platform && !version && !language && !completionStatus && !boxArt && !discord && !twitter && !pokecommunity && !github && !verificationContactInfo && (!tags || tags.length === 0) && !originalAuthor;
if (isEmpty) {
let applied = false;
if (typeof data.title === "string") setTitle(data.title);
if (typeof data.title === "string") applied = applied || !!data.title;
if (typeof data.summary === "string") setSummary(data.summary);
if (typeof data.summary === "string") applied = applied || !!data.summary;
if (typeof data.description === "string") setDescription(data.description);
if (typeof data.description === "string") applied = applied || !!data.description;
if (typeof data.baseRom === "string") setBaseRom(data.baseRom);
if (typeof data.baseRom === "string") applied = applied || !!data.baseRom;
if (["GB","GBC","GBA","NDS",""].includes(data.platform)) setPlatform(data.platform);
if (["GB","GBC","GBA","NDS",""].includes(data.platform)) applied = applied || !!data.platform;
if (typeof data.version === "string") setVersion(data.version);
if (typeof data.version === "string") applied = applied || !!data.version;
if (typeof data.language === "string") setLanguage(data.language);
if (typeof data.language === "string") applied = applied || !!data.language;
if (["Complete","Demo","Alpha","Beta",""].includes(data.completionStatus)) setCompletionStatus(data.completionStatus);
if (["Complete","Demo","Alpha","Beta",""].includes(data.completionStatus)) applied = applied || !!data.completionStatus;
if (typeof data.boxArt === "string") setBoxArt(data.boxArt);
if (typeof data.boxArt === "string") applied = applied || !!data.boxArt;
if (typeof data.discord === "string") setDiscord(data.discord);
if (typeof data.discord === "string") applied = applied || !!data.discord;
if (typeof data.twitter === "string") setTwitter(data.twitter);
if (typeof data.twitter === "string") applied = applied || !!data.twitter;
if (typeof data.pokecommunity === "string") setPokecommunity(data.pokecommunity);
if (typeof data.pokecommunity === "string") applied = applied || !!data.pokecommunity;
if (typeof data.github === "string") setGithub(data.github);
if (typeof data.github === "string") applied = applied || !!data.github;
if (typeof data.verificationContactInfo === "string") setVerificationContactInfo(data.verificationContactInfo);
if (typeof data.verificationContactInfo === "string") applied = applied || !!data.verificationContactInfo;
if (Array.isArray(data.tags)) setTags(data.tags.filter((t: any) => typeof t === "string"));
if (Array.isArray(data.tags)) applied = applied || data.tags.length > 0;
// Only load originalAuthor from draft if customCreator is not provided
if (!customCreator && typeof data.originalAuthor === "string") {
setOriginalAuthor(data.originalAuthor);
applied = applied || !!data.originalAuthor;
}
if (data.step && Number.isInteger(data.step)) setStep(Math.min(maxSteps, Math.max(1, data.step)));
if (typeof data.showMdPreview === "boolean") setShowMdPreview(data.showMdPreview);
if (data.patchMode === "bps" || data.patchMode === "rom") setPatchMode(data.patchMode);
if (applied) { hydratedFromDraftRef.current = true; setRestoredDraft(true); }
}
}
}
}
} catch {
// ignore
} finally {
setIsHydrating(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftKey]);
// If we synchronously seeded from initialDraftRef, mark as restored
React.useEffect(() => {
if (dummy || !draftKey || hydratedFromDraftRef.current) return;
const d = initialDraftRef.current;
if (!d || typeof d !== "object") return;
// Don't count originalAuthor if customCreator is provided
const hasAny = Boolean(
d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.completionStatus || d.boxArt || d.discord || d.twitter || d.pokecommunity || d.github || d.verificationContactInfo || (Array.isArray(d.tags) && d.tags.length > 0) || (!customCreator && d.originalAuthor)
);
if (hasAny) { hydratedFromDraftRef.current = true; setRestoredDraft(true); }
}, [dummy, draftKey, customCreator]);
React.useEffect(() => {
if (dummy || !draftKey || isHydrating) return;
const handle = setTimeout(() => {
try {
const data: any = {
title,
summary,
description,
baseRom,
platform,
version,
language,
completionStatus,
boxArt,
discord,
twitter,
pokecommunity,
github,
verificationContactInfo,
tags,
step,
showMdPreview,
patchMode,
};
// Only save originalAuthor if customCreator is not provided
if (!customCreator) {
data.originalAuthor = originalAuthor;
}
localStorage.setItem(draftKey, JSON.stringify(data));
} catch {
// ignore
}
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dummy,
draftKey,
isHydrating,
title,
summary,
description,
baseRom,
platform,
version,
language,
completionStatus,
boxArt,
discord,
twitter,
pokecommunity,
github,
verificationContactInfo,
tags,
originalAuthor,
customCreator,
step,
showMdPreview,
patchMode,
]);
const summaryLimit = 120;
const summaryTooLong = summary.length > summaryLimit;
const allowedSizes = platform ? getAllowedSizesForPlatform(platform) : [];
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
const allSocialValid = [discord, twitter, pokecommunity, github].every((s) => !s || urlLike(s));
const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim() && !!completionStatus.trim() && (isArchive ? !!originalAuthor.trim() : true);
const step2Valid = (isArchive ? true : !!version.trim()) && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0;
const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid;
const isValid = step1Valid && step2Valid && step3Valid && (isArchive ? true : !!patchFile);
const onSubmit = async () => {
if (!isValid || submitting) return;
setSubmitting(true);
try {
const fd = new FormData();
fd.set('title', title);
fd.set('summary', summary);
fd.set('description', description);
fd.set('base_rom', baseRom);
fd.set('language', language);
fd.set('completion_status', completionStatus);
fd.set('version', version);
if (boxArt) fd.set('box_art', boxArt);
if (discord) fd.set('discord', discord);
if (twitter) fd.set('twitter', twitter);
if (pokecommunity) fd.set('pokecommunity', pokecommunity);
if (github) fd.set('github', github);
if (verificationContactInfo) fd.set('verification_contact_info', verificationContactInfo);
if (tags.length) fd.set('tags', tags.join(','));
if (isArchive) {
fd.set('is_archive', 'true');
}
if (originalAuthor) {
fd.set('original_author', originalAuthor);
}
if (permissionFrom) {
fd.set('permission_from', permissionFrom);
}
console.log('[HackSubmitForm] Preparing submission...');
const prepared = await prepareSubmission(fd);
if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare');
console.log('[HackSubmitForm] Uploading covers...');
const uploadedCoverUrls = await uploadCovers(prepared.slug);
if (isArchive) {
// For archives, we don't need patch upload
const coversSaved = await saveHackCovers({ slug: prepared.slug, coverUrls: uploadedCoverUrls });
if (!coversSaved.ok) throw new Error(coversSaved.error || 'Failed to save covers');
try {
if (draftKey) {
localStorage.removeItem(draftKey);
await deleteDraftCovers(draftKey);
}
} catch {}
window.location.href = `/hack/${prepared.slug}`;
} else {
console.log('[HackSubmitForm] Getting patch upload URL...');
const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
if (patchFile) {
console.log('[HackSubmitForm] Uploading patch...');
await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } });
const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version, firstUpload: true, publishAutomatically: true });
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
try {
if (draftKey) {
localStorage.removeItem(draftKey);
await deleteDraftCovers(draftKey);
}
} catch {}
window.location.href = finalized.redirectTo!;
} else {
console.log('[HackSubmitForm] No patch file, redirecting to hack page...');
try {
if (draftKey) {
localStorage.removeItem(draftKey);
await deleteDraftCovers(draftKey);
}
} catch {}
window.location.href = `/hack/${prepared.slug}`;
}
}
console.log('[HackSubmitForm] Submission successful');
} catch (e: any) {
console.log('[HackSubmitForm] Submission failed', e);
alert(e.message ? `There was an error during submission:\n\n===\n${e.message}\n===\n\nYour hack might have only been partially submitted. Try going to your dashboard and see if your hack is listed there. If not, please contact support.` : 'Submission failed');
} finally {
setSubmitting(false);
}
};
async function onGrantPermission() {
if (!baseRom) return;
await ensurePermission(baseRom, true);
}
async function onUploadBaseRom(e: React.ChangeEvent) {
try {
setGenError("");
const f = e.target.files?.[0];
if (!f) return;
const matchedId = await importUploadedBlob(f);
if (!matchedId) {
setGenError("That ROM doesn't match any supported base ROM.");
return;
}
if (matchedId !== baseRom) {
const matchedName = baseRoms.find(r => r.id === matchedId)?.name;
setGenError(`This ROM matches "${matchedName ?? matchedId}", but the form requires "${baseRomName}".`);
return;
}
} catch {
setGenError("Failed to import base ROM.");
}
}
async function onUploadModifiedRom(e: React.ChangeEvent) {
try {
setGenStatus("generating");
setGenError("");
const mod = e.target.files?.[0] || null;
if (!mod || !baseRom) {
setGenStatus("idle");
return;
}
let baseFile = await getFileBlob(baseRom);
if (!baseFile) {
setGenStatus("idle");
setGenError("Base ROM not available.");
return;
}
if (baseRomEntry?.sha1) {
const hash = await sha1Hex(baseFile);
if (hash.toLowerCase() !== baseRomEntry.sha1.toLowerCase()) {
setGenStatus("error");
setGenError("Selected base ROM hash does not match the chosen base ROM.");
return;
}
}
const [origBuf, modBuf] = await Promise.all([baseFile.arrayBuffer(), mod.arrayBuffer()]);
const origBin = new BinFile(origBuf);
const modBin = new BinFile(modBuf);
const deltaMode = origBin.fileSize <= 4194304;
const patch = BPS.buildFromRoms(origBin, modBin, deltaMode);
const fileName = slug || title || "patch";
const patchBin = patch.export(fileName);
const out = new File([patchBin._u8array], `${fileName}.bps`, { type: 'application/octet-stream' });
setPatchFile(out);
setGenStatus("ready");
} catch (err: any) {
setGenStatus("error");
setGenError(err?.message || "Failed to generate patch.");
}
}
async function onUploadPatch(e: React.ChangeEvent) {
try {
setChecksumStatus("validating");
setChecksumError("");
const patch = e.target.files?.[0] || null;
if (!patch) {
setChecksumStatus("idle");
setChecksumError("");
setPatchFile(null);
return;
}
if (!baseRomEntry) {
setChecksumStatus("unknown");
setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead.");
return;
}
// Verify that the patch is a valid BPS file for the selected base ROM
const bps = BPS.fromFile(new BinFile(await patch.arrayBuffer()));
if (bps.sourceChecksum === 0 || bps.sourceChecksum === undefined) {
setChecksumStatus("unknown");
setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead.");
return;
}
const baseRomChecksum = parseInt(baseRomEntry.crc32, 16);
if (bps.sourceChecksum !== baseRomChecksum) {
setChecksumStatus("invalid");
setChecksumError("Checksum validation failed. The patch file is not compatible with the selected base ROM.");
return;
}
// All checks passed, set the checksum status to valid
setChecksumStatus("valid");
setChecksumError("");
setPatchFile(patch);
}
catch (err: any) {
setChecksumStatus("unknown");
setChecksumError(err?.message || "Failed to validate patch file.");
}
}
const preview = {
slug: slug || "preview",
title: title || "Your hack title",
author: (isArchive || customCreator) ?
(originalAuthor || "Unknown") :
(profile?.username ? `@${profile.username}` : "You"),
summary: (summary || "Short description, max 100 characters.") as string,
description: (description || "Write a longer markdown description here.") as string,
covers: coverPreviews,
baseRomId: baseRom,
downloads: 0,
version: isArchive ? "Archive" : (version || "v0.0.0"),
tags: sortOrderedTags(tags.map((name, index) => ({ name, order: index + 1 }))),
...(boxArt ? { boxArt } : {}),
socialLinks:
discord || twitter || pokecommunity || github
? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined, github: github || undefined }
: undefined,
createdAt: new Date().toISOString(),
patchUrl: "",
is_archive: isArchive,
};
const hasBaseRom = !!baseRom.trim();
// Before each group of base ROMs of the same category, add a divider with the category name
const baseRomOptions = React.useMemo<(SelectOption | SelectDivider)[]>(() => {
let currentCategory = "";
const options: (SelectOption | SelectDivider)[] = [];
for (const rom of baseRoms) {
if (!platform || rom.platform !== platform) continue;
if (rom.category && rom.category !== currentCategory) {
currentCategory = rom.category;
options.push({ type: "divider", label: currentCategory });
}
options.push({
value: rom.id,
label: `${rom.name.replace('Pokémon ', '')} (${rom.region})`,
description: rom.sha1,
});
}
return options;
}, [platform]);
return (
);
}