"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 (
{`Cover
{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 (
* Required
{isHydrating && (
Checking for existing draft…
)} {customCreator && permissionFrom && (

{customCreator === permissionFrom ? `Submitting on behalf of ${customCreator} with their permission.` : `Submitting on behalf of ${customCreator}`}

{customCreator !== permissionFrom && (

You are submitting this hack with permission from {permissionFrom}.

)}
)} {!isHydrating && restoredDraft && (
Restored a previously saved draft.
)}
{step === 1 && ( <>
{!isDummy ? ( setTitle(e.target.value)} className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" /> ) : (
Your hack title
)}
URL preview: /hack/{slug || "your-title"}
{!isDummy ? ( ) : (
{baseRoms.find(r=>r.id===baseRom)?.name || baseRom}
)}

Missing a base ROM?{" "} Request it on GitHub

{!isDummy ? ( setCompletionStatus(value as any)} placeholder="Select completion status" options={['Complete','Demo','Alpha','Beta'].map(s => ({ value: s, label: s, }))} /> ) : (
{completionStatus || "Select completion status"}
)}
{isArchive && (
{!isDummy ? ( setOriginalAuthor(e.target.value)} disabled={!!customCreator} placeholder="Name of the original hack creator" className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed" /> ) : (
Original author name
)}
The name of the person or team who originally created this hack
)} )} {step === 2 && ( <> {!isArchive && (
{!isDummy ? ( setVersion(e.target.value)} placeholder="e.g. v1.2.0" className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]`} /> ) : (
v0.1.0
)}
)}
{summary.length}/{summaryLimit}
{!isDummy ? ( setSummary(e.target.value)} placeholder="<= 100 characters" className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${summaryTooLong ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`} /> ) : (
Short description, max 100 characters.
)}
{!isDummy && (
)}
{isDummy ? (
{description || "Write a longer markdown description here."}
) : !showMdPreview ? (