diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx
new file mode 100644
index 0000000..974e436
--- /dev/null
+++ b/src/app/hack/[slug]/edit/page.tsx
@@ -0,0 +1,80 @@
+import { notFound, redirect } from "next/navigation";
+import HackForm from "@/components/Hack/HackForm";
+import { createClient } from "@/utils/supabase/server";
+import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
+import Link from "next/link";
+
+interface EditPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function EditHackPage({ params }: EditPageProps) {
+ const { slug } = await params;
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ redirect(`/login?redirectTo=%2Fhack%2F${encodeURIComponent(slug)}%2Fedit`);
+ }
+
+ const { data: hack } = await supabase
+ .from("hacks")
+ .select("slug,title,summary,description,base_rom,version,language,box_art,social_links,created_by")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (!hack) return notFound();
+ if (hack.created_by !== user!.id) notFound();
+
+ let coverKeys: string[] = [];
+ let signedCoverUrls: string[] = [];
+ const { data: covers } = await supabase
+ .from("hack_covers")
+ .select("url, position")
+ .eq("hack_slug", slug)
+ .order("position", { ascending: true });
+ if (covers && covers.length > 0) {
+ coverKeys = covers.map((c: any) => c.url);
+ const { data: urls } = await supabase.storage
+ .from('hack-covers')
+ .createSignedUrls(coverKeys, 60 * 5);
+ if (urls) signedCoverUrls = urls.map((u) => u.signedUrl);
+ }
+
+ const { data: tagRows } = await supabase
+ .from("hack_tags")
+ .select("tags(name)")
+ .eq("hack_slug", slug);
+ const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[];
+
+ const initial = {
+ title: hack.title,
+ summary: hack.summary,
+ description: hack.description,
+ base_rom: hack.base_rom,
+ language: hack.language,
+ version: hack.version,
+ box_art: hack.box_art,
+ social_links: (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null,
+ tags,
+ coverKeys,
+ signedCoverUrls,
+ };
+
+ return (
+
+
+
+ Edit
+
+ {hack.title}
+
+
+
+ Back to hack
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx
index 3591d44..2dd6c5b 100644
--- a/src/app/hack/[slug]/page.tsx
+++ b/src/app/hack/[slug]/page.tsx
@@ -10,6 +10,7 @@ import { FaDiscord, FaTwitter } from "react-icons/fa6";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
import { createClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
+import HackOptionsMenu from "@/components/Hack/HackOptionsMenu";
interface HackDetailProps {
params: Promise<{ slug: string }>;
@@ -54,6 +55,11 @@ export default async function HackDetail({ params }: HackDetailProps) {
.maybeSingle();
const author = profile?.username || "Unknown";
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ const canEdit = !!user && user.id === (hack.created_by as string);
+
// Resolve a short-lived signed patch URL (if current_patch exists)
let signedPatchUrl = "";
if (hack.current_patch != null) {
@@ -99,13 +105,16 @@ export default async function HackDetail({ params }: HackDetailProps) {
))}
-
-
-
{formatCompactNumber(hack.downloads)}
+
+
+
+
{formatCompactNumber(hack.downloads)}
+
+
diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts
new file mode 100644
index 0000000..f322fdf
--- /dev/null
+++ b/src/app/hack/actions.ts
@@ -0,0 +1,162 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import type { TablesInsert } from "@/types/db";
+
+export async function updateHack(args: {
+ slug: string;
+ title?: string;
+ summary?: string;
+ description?: string;
+ base_rom?: string;
+ language?: string;
+ version?: string;
+ box_art?: string | null;
+ social_links?: { discord?: string; twitter?: string; pokecommunity?: string } | null;
+ tags?: string[];
+}) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" } as const;
+
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by")
+ .eq("slug", args.slug)
+ .maybeSingle();
+ if (hErr) return { ok: false, error: hErr.message } as const;
+ if (!hack) return { ok: false, error: "Hack not found" } as const;
+ if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
+
+ const updatePayload: TablesInsert<"hacks"> | any = {};
+ if (args.title !== undefined) updatePayload.title = args.title;
+ if (args.summary !== undefined) updatePayload.summary = args.summary;
+ if (args.description !== undefined) updatePayload.description = args.description;
+ if (args.base_rom !== undefined) updatePayload.base_rom = args.base_rom;
+ if (args.language !== undefined) updatePayload.language = args.language;
+ if (args.version !== undefined) updatePayload.version = args.version;
+ if (args.box_art !== undefined) updatePayload.box_art = args.box_art;
+ if (args.social_links !== undefined) updatePayload.social_links = args.social_links;
+
+ if (Object.keys(updatePayload).length > 0) {
+ const { error: uErr } = await supabase
+ .from("hacks")
+ .update(updatePayload)
+ .eq("slug", args.slug);
+ if (uErr) return { ok: false, error: uErr.message } as const;
+ }
+
+ if (args.tags) {
+ // Resolve desired tag IDs from names and compute diff against current links
+ const { data: existingTags, error: tagErr } = await supabase
+ .from("tags")
+ .select("id, name")
+ .in("name", args.tags);
+ if (tagErr) return { ok: false, error: tagErr.message } as const;
+
+ const desiredIds = Array.from(new Set((existingTags || []).map((t) => t.id)));
+
+ const { data: currentLinks, error: curErr } = await supabase
+ .from("hack_tags")
+ .select("tag_id")
+ .eq("hack_slug", args.slug);
+ if (curErr) return { ok: false, error: curErr.message } as const;
+
+ const currentIds = new Set((currentLinks || []).map((r: any) => r.tag_id as number));
+ const desiredSet = new Set(desiredIds);
+
+ const toAdd = desiredIds.filter((id) => !currentIds.has(id));
+ const toRemove = Array.from(currentIds).filter((id) => !desiredSet.has(id));
+
+ if (toRemove.length > 0) {
+ const { error: delErr } = await supabase
+ .from("hack_tags")
+ .delete()
+ .eq("hack_slug", args.slug)
+ .in("tag_id", toRemove);
+ if (delErr) return { ok: false, error: delErr.message } as const;
+ }
+
+ if (toAdd.length > 0) {
+ const rows = toAdd.map((id) => ({ hack_slug: args.slug, tag_id: id }));
+ const { error: insErr } = await supabase.from("hack_tags").insert(rows);
+ if (insErr) return { ok: false, error: insErr.message } as const;
+ }
+ }
+
+ return { ok: true } as const;
+}
+
+export async function saveHackCovers(args: { slug: string; coverUrls: string[] }) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" } as const;
+
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by")
+ .eq("slug", args.slug)
+ .maybeSingle();
+ if (hErr) return { ok: false, error: hErr.message } as const;
+ if (!hack) return { ok: false, error: "Hack not found" } as const;
+ if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const;
+
+ // Fetch current covers to compute removals and preserve alt text
+ const { data: currentRows, error: cErr } = await supabase
+ .from("hack_covers")
+ .select("id, url, alt")
+ .eq("hack_slug", args.slug)
+ .order("position", { ascending: true });
+ if (cErr) return { ok: false, error: cErr.message } as const;
+
+ const existingAltMap = new Map((currentRows || []).map((r: any) => [r.url as string, (r.alt as string | null) || null]));
+ const existingIdMap = new Map((currentRows || []).map((r: any) => [r.url as string, r.id as number]));
+ const currentUrls = new Set((currentRows || []).map((r: any) => r.url as string));
+ const desiredSet = new Set(args.coverUrls);
+
+ const toRemove = Array.from(currentUrls).filter((u) => !desiredSet.has(u));
+
+ // Remove rows that are no longer desired
+ if (toRemove.length > 0) {
+ const { error: delErr } = await supabase
+ .from("hack_covers")
+ .delete()
+ .eq("hack_slug", args.slug)
+ .in("url", toRemove);
+ if (delErr) return { ok: false, error: delErr.message } as const;
+ // Best-effort removal of orphaned files
+ await supabase.storage.from('hack-covers').remove(toRemove);
+ }
+
+ // Upsert desired rows (insert new and update existing positions/alts)
+ if (args.coverUrls.length > 0) {
+ const rows = args.coverUrls.map((url, idx) => {
+ const base: any = { hack_slug: args.slug, url, position: idx + 1, alt: existingAltMap.get(url) || null };
+ const id = existingIdMap.get(url);
+ base.id = id || undefined; // include pk for existing rows per Supabase upsert requirement
+ return base;
+ });
+
+ const updatedRows = rows.filter((r) => r.id !== undefined);
+ const newRows = rows.filter((r) => r.id === undefined);
+
+ if (updatedRows.length > 0) {
+ const { error: upErr } = await supabase.from("hack_covers").upsert(updatedRows, { onConflict: "id" });
+ if (upErr) return { ok: false, error: upErr.message } as const;
+ }
+
+ if (newRows.length > 0) {
+ const { error: insErr } = await supabase.from("hack_covers").insert(newRows, { defaultToNull: false });
+ if (insErr) return { ok: false, error: insErr.message } as const;
+ }
+
+ }
+
+ return { ok: true } as const;
+}
+
+
diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx
index ee82c31..0d732fd 100644
--- a/src/app/submit/page.tsx
+++ b/src/app/submit/page.tsx
@@ -1,4 +1,4 @@
-import SubmitForm from "@/components/Submit/SubmitForm";
+import HackForm from "@/components/Hack/HackForm";
import { createClient } from "@/utils/supabase/server";
import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay";
@@ -20,7 +20,7 @@ export default async function SubmitPage() {
Submit your ROM hack
Share your hack so others can discover and play it.
-
+
{!user ? (
(initial.tags || []);
+
+ // Baseline state used for change detection and reverting
+ const [baseline, setBaseline] = React.useState({
+ title: initial.title,
+ summary: initial.summary,
+ description: initial.description,
+ language: initial.language,
+ boxArt: initial.box_art || "",
+ tags: initial.tags || [],
+ discord: initial.social_links?.discord || "",
+ twitter: initial.social_links?.twitter || "",
+ pokecommunity: initial.social_links?.pokecommunity || "",
+ });
+
+ type CoverItem =
+ | { type: "existing"; key: string; url: string }
+ | { type: "new"; file: File; url: string };
+
+ const [coverItems, setCoverItems] = React.useState(() => {
+ const keys = initial.coverKeys || [];
+ const urls = initial.signedCoverUrls || [];
+ return keys.map((k, i) => ({ type: "existing", key: k, url: urls[i] || supabase.storage.from('hack-covers').getPublicUrl(k).data.publicUrl }));
+ });
+ const [coversBaseline, setCoversBaseline] = React.useState<{ keys: string[]; urls: string[] }>(() => ({
+ keys: initial.coverKeys || [],
+ urls: (initial.signedCoverUrls || []).slice(),
+ }));
+ const [saving, setSaving] = React.useState(false);
+
+ const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
+
+ 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 platformEntry = React.useMemo(() => baseRoms.find(r => r.id === baseRom), [baseRom]);
+ const allowedSizes = platformEntry ? getAllowedSizesForPlatform(platformEntry.platform) : [];
+ const overLimit = coverItems.length > MAX_COVERS;
+
+ // Change detection helpers
+ const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
+ const tagsChanged = !arraysEqual(tags, baseline.tags);
+ const titleChanged = title !== baseline.title;
+ const summaryChanged = summary !== baseline.summary;
+ const descriptionChanged = description !== baseline.description;
+ const languageChanged = language !== baseline.language;
+ const boxArtChanged = boxArt !== baseline.boxArt;
+ const discordChanged = discord !== baseline.discord;
+ const twitterChanged = twitter !== baseline.twitter;
+ const pokeChanged = pokecommunity !== baseline.pokecommunity;
+ const contentChanged = titleChanged || summaryChanged || descriptionChanged || languageChanged || boxArtChanged || tagsChanged || discordChanged || twitterChanged || pokeChanged;
+
+ const newItemsCount = coverItems.filter((i) => i.type === "new").length;
+ const currentExistingKeys = coverItems.filter((i): i is { type: "existing"; key: string; url: string } => i.type === "existing").map((i) => i.key);
+ const coversChanged = newItemsCount > 0 || !arraysEqual(currentExistingKeys, coversBaseline.keys);
+
+ function removeAt(index: number) {
+ setCoverItems((prev) => prev.filter((_, i) => i !== index));
+ }
+
+ function onReorder(oldIndex: number, newIndex: number) {
+ setCoverItems((prev) => {
+ const copy = prev.slice();
+ const [moved] = copy.splice(oldIndex, 1);
+ copy.splice(newIndex, 0, moved);
+ return copy;
+ });
+ }
+
+ function onAddFiles(files: File[]) {
+ const platform = platformEntry?.platform;
+ const sizes = platform ? getAllowedSizesForPlatform(platform) : [];
+ const doValidate = async () => {
+ const accepted: CoverItem[] = [];
+ for (const f of files) {
+ if (sizes.length === 0) {
+ accepted.push({ type: "new", file: f, url: URL.createObjectURL(f) });
+ continue;
+ }
+ const ok = await validateImageDimensions(f, sizes);
+ if (ok) {
+ accepted.push({ type: "new", file: f, url: URL.createObjectURL(f) });
+ }
+ }
+ setCoverItems((prev) => [...prev, ...accepted]);
+ };
+ void doValidate();
+ }
+
+ async function onSaveMeta() {
+ setSaving(true);
+ try {
+ const social = discord || twitter || pokecommunity ? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined } : null;
+ const updateArgs: any = { slug };
+ if (titleChanged) updateArgs.title = title.trim();
+ if (summaryChanged) updateArgs.summary = summary.trim();
+ if (descriptionChanged) updateArgs.description = description.trim();
+ if (languageChanged) updateArgs.language = language;
+ if (boxArtChanged) updateArgs.box_art = boxArt ? boxArt.trim() : null;
+ if (tagsChanged) updateArgs.tags = tags.slice();
+ if (discordChanged || twitterChanged || pokeChanged) updateArgs.social_links = social; // may be null to clear
+
+ const { ok, error } = await updateHack(updateArgs);
+ if (!ok) throw new Error(error || "Save failed");
+ // Update baseline on success to clear modified indicators
+ setBaseline({
+ title,
+ summary,
+ description,
+ language,
+ boxArt,
+ tags: tags.slice(),
+ discord,
+ twitter,
+ pokecommunity,
+ });
+ } catch (e: any) {
+ alert(e.message || "Save failed");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function onSaveCovers() {
+ setSaving(true);
+ try {
+ // Upload any new files and build final key order
+ const keys: string[] = [];
+ for (let i = 0; i < coverItems.length; i++) {
+ const item = coverItems[i];
+ if (item.type === "existing") {
+ keys.push(item.key);
+ } else {
+ const ext = item.file.name.split('.').pop();
+ const path = `${slug}/${Date.now()}-${i}.${ext}`;
+ const { error } = await supabase.storage.from('hack-covers').upload(path, item.file);
+ if (error) throw error;
+ keys.push(path);
+ }
+ }
+ // Persist cover ordering/rows first
+ const saved = await saveHackCovers({ slug, coverUrls: keys });
+ if (!saved.ok) throw new Error(saved.error || 'Failed to save covers');
+ // Transform any new items into existing with their new keys
+ setCoverItems((prev) => prev.map((item, i) => item.type === "existing" ? item : { type: "existing", key: keys[i], url: item.url }));
+ // Update covers baseline to newly saved order/keys and keep current preview URLs
+ setCoversBaseline({ keys, urls: coverItems.map((c) => c.url) });
+ } catch (e: any) {
+ alert(e.message || "Failed to save covers");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const summaryLimit = 120;
+ const summaryTooLong = summary.length > summaryLimit;
+ const contentHasErrors = summaryTooLong || (!!boxArt && !urlLike(boxArt));
+
+ return (
+
+
+
+
+
Content
+
+ {contentChanged && (
+
+ )}
+
+
+
+
+
+
+
+ {titleChanged && (
+
+ Modified
+
+
+ )}
+
+
setTitle(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${titleChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
+
+
+
+
+
+ {summaryChanged && (
+ <>
+ Modified
+
+ >
+ )}
+
+
+
+ setSummary(e.target.value)}
+ className={`w-full h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 pr-16 ${summary.length > summaryLimit ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : summaryChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
+ />
+ summaryLimit ? "text-red-300" : "text-foreground/60"}`}>
+ {summary.length}/{summaryLimit}
+
+
+
+
+
+
+
+
+
+ {descriptionChanged && (
+
+ Modified
+
+
+ )}
+
+
+ {!showMdPreview ? (
+
+
+
+
+
+ {tagsChanged && (
+
+ Modified
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
Screenshots
+
+ {coversChanged && (
+
+ )}
+
+
+
+
+ {allowedSizes.length > 0 && (
+
Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}
+ )}
+
onAddFiles(Array.from(e.target.files || []))}
+ className="w-full rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none"
+ />
+
+ {coverItems.length === 0 ? (
+
No screenshots yet.
+ ) : (
+
({
+ id: item.type === 'existing' ? `e:${(item as any).key}` : `n:${(item as any).file.name}-${i}`,
+ url: item.url,
+ filename: item.type === 'existing' ? item.key.split('/').pop() || `Cover ${i+1}` : (item as any).file.name,
+ isNew: item.type === 'new'
+ }))}
+ onReorder={onReorder}
+ onRemove={removeAt}
+ />
+ )}
+
+
+
Images: {coverItems.length}/{MAX_COVERS}
+ {overLimit &&
Remove some to save.
}
+
+
+
+
+
+
+
+ );
+}
+
+
diff --git a/src/components/Hack/HackForm.tsx b/src/components/Hack/HackForm.tsx
new file mode 100644
index 0000000..5c91df7
--- /dev/null
+++ b/src/components/Hack/HackForm.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import React from "react";
+import HackSubmitForm from "@/components/Hack/HackSubmitForm";
+import HackEditForm from "@/components/Hack/HackEditForm";
+
+type Mode = "create" | "edit";
+
+interface HackFormCreateProps {
+ mode: "create";
+ dummy?: boolean;
+}
+
+interface HackFormEditProps {
+ mode: "edit";
+ slug: string;
+ initial: React.ComponentProps["initial"];
+}
+
+export type HackFormProps = HackFormCreateProps | HackFormEditProps;
+
+export default function HackForm(props: HackFormProps) {
+ if (props.mode === "create") {
+ return ;
+ }
+ return ;
+}
+
+
diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx
new file mode 100644
index 0000000..e5a37f7
--- /dev/null
+++ b/src/components/Hack/HackOptionsMenu.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import React from "react";
+import { useRouter } from "next/navigation";
+import { FiMoreVertical } from "react-icons/fi";
+import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator, Transition } from "@headlessui/react";
+
+interface HackOptionsMenuProps {
+ slug: string;
+ canEdit: boolean;
+}
+
+export default function HackOptionsMenu({ slug, canEdit }: HackOptionsMenuProps) {
+ const router = useRouter();
+
+ return (
+
+ );
+}
+
+
diff --git a/src/components/Submit/SubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx
similarity index 92%
rename from src/components/Submit/SubmitForm.tsx
rename to src/components/Hack/HackSubmitForm.tsx
index c59de87..4b191de 100644
--- a/src/components/Submit/SubmitForm.tsx
+++ b/src/components/Hack/HackSubmitForm.tsx
@@ -11,9 +11,8 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy }
import { CSS } from "@dnd-kit/utilities";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
-import { RxDragHandleDots2, RxPlus } from "react-icons/rx";
+import { RxDragHandleDots2 } from "react-icons/rx";
import { useAuthContext } from "@/contexts/AuthContext";
-import { Database } from "@/types/db";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import TagSelector from "@/components/Submit/TagSelector";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
@@ -56,20 +55,16 @@ function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string;
);
}
-interface SubmitFormProps {
+interface HackSubmitFormProps {
dummy?: boolean;
}
-// Tag selection handled by TagSelector component
-
-export default function SubmitForm({ dummy = false }: SubmitFormProps) {
+export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
const MAX_COVERS = 10;
const { profile } = useAuthContext();
const [title, setTitle] = React.useState("");
- // Author derived from profile later
const [summary, setSummary] = React.useState("");
const [description, setDescription] = React.useState("");
- // Deprecated: coverUrls removed; using files array for screenshots
const [newCoverFiles, setNewCoverFiles] = React.useState([]);
const [coverErrors, setCoverErrors] = React.useState([]);
const [baseRom, setBaseRom] = React.useState("");
@@ -106,7 +101,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
const baseRomNeedsPermission = baseRomName && isLinked(baseRomName) && !baseRomReady;
const baseRomMissing = baseRomName && !isLinked(baseRomName) && !hasCached(baseRomName);
- // Build object URLs for local screenshot previews and clean them up when files change
const coverPreviews = React.useMemo(() => {
return newCoverFiles.map((f) => URL.createObjectURL(f));
}, [newCoverFiles]);
@@ -142,7 +136,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
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 }];
- // NDS
return [{ w: 256, h: 192 }, { w: 256, h: 384 }];
}
@@ -164,7 +157,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
}
const overLimit = newCoverFiles.length > MAX_COVERS;
-
const removeAt = (index: number) => {
setNewCoverFiles((prev) => prev.filter((_, i) => i !== index));
};
@@ -183,11 +175,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex));
};
- // Tags handled in TagSelector
-
- // Tag fetching and filtering moved to TagSelector
-
- // Focus the first input on each step when step changes
React.useEffect(() => {
if (isDummy) return;
if (step === 1) {
@@ -201,8 +188,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
}
}, [step, isDummy]);
- // Removed legacy key handling; TagSelector manages interactions
-
const slugify = (text: string) =>
text
.toLowerCase()
@@ -219,10 +204,8 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
- const hasOneSocial = [discord, twitter, pokecommunity].some((s) => !!s.trim());
const allSocialValid = [discord, twitter, pokecommunity].every((s) => !s || urlLike(s));
-
const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim();
const step2Valid = !!version.trim() && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0;
const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid;
@@ -232,7 +215,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
if (!isValid || submitting) return;
setSubmitting(true);
try {
- // Step 1: create hack & tags
const fd = new FormData();
fd.set('title', title);
fd.set('summary', summary);
@@ -249,12 +231,10 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
const prepared = await prepareSubmission(fd);
if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare');
- // Step 2: upload covers to storage and save rows
const uploadedCoverUrls = await uploadCovers(prepared.slug);
const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
- // Step 3: upload patch via signed URL
if (patchFile) {
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 });
@@ -320,7 +300,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
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; // Not having this check causes the site to freeze for larger ROMs
+ const deltaMode = origBin.fileSize <= 4194304;
const patch = BPS.buildFromRoms(origBin, modBin, deltaMode);
const fileName = slug || title || "patch";
const patchBin = patch.export(fileName);
@@ -359,12 +339,10 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {