diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index 974e436..56a9e30 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -18,7 +18,7 @@ export default async function EditHackPage({ params }: EditPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,version,language,box_art,social_links,created_by") + .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); @@ -45,13 +45,23 @@ export default async function EditHackPage({ params }: EditPageProps) { .eq("hack_slug", slug); const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[]; + let version = ""; + if (hack.current_patch) { + const { data: currentPatch } = await supabase + .from("patches") + .select("version") + .eq("id", hack.current_patch) + .maybeSingle(); + version = currentPatch?.version || ""; + } + const initial = { title: hack.title, summary: hack.summary, description: hack.description, base_rom: hack.base_rom, language: hack.language, - version: hack.version, + version: version || "Pre-release", box_art: hack.box_art, social_links: (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null, tags, @@ -67,10 +77,15 @@ export default async function EditHackPage({ params }: EditPageProps) { {hack.title} - - - Back to hack - +
+ + + Back to hack + + + Upload new version + +
diff --git a/src/app/hack/[slug]/edit/patch/page.tsx b/src/app/hack/[slug]/edit/patch/page.tsx new file mode 100644 index 0000000..d7de3da --- /dev/null +++ b/src/app/hack/[slug]/edit/patch/page.tsx @@ -0,0 +1,63 @@ +import { redirect, notFound } from "next/navigation"; +import { createClient } from "@/utils/supabase/server"; +import HackPatchForm from "@/components/Hack/HackPatchForm"; +import Link from "next/link"; +import { FaChevronLeft } from "react-icons/fa6"; + +interface EditPatchPageProps { + params: Promise<{ slug: string }>; +} + +export default async function EditPatchPage({ params }: EditPatchPageProps) { + 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%2Fpatch`); + } + + const { data: hack } = await supabase + .from("hacks") + .select("slug,base_rom,created_by,title,current_patch") + .eq("slug", slug) + .maybeSingle(); + if (!hack) return notFound(); + if (hack.created_by !== user!.id) return notFound(); + + const { data: patchRows } = await supabase + .from("patches") + .select("id,version") + .eq("parent_hack", slug) + .order("created_at", { ascending: true }); + const existingVersions = (patchRows || []).map((p: any) => p.version as string); + + const currentPatch = patchRows?.find((p: any) => p.id === hack.current_patch); + const currentVersion = currentPatch?.version; + + return ( +
+

+ Upload new version for + {hack.title} +

+ +
+ +
+ +
+ + + Back to hack + +
+
+ ); +} + + diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 2dd6c5b..9964871 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -21,7 +21,7 @@ export default async function HackDetail({ params }: HackDetailProps) { const supabase = await createClient(); const { data: hack, error } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,version,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by") + .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by") .eq("slug", slug) .maybeSingle(); if (error || !hack) return notFound(); @@ -62,16 +62,18 @@ export default async function HackDetail({ params }: HackDetailProps) { // Resolve a short-lived signed patch URL (if current_patch exists) let signedPatchUrl = ""; + let patchVersion = ""; if (hack.current_patch != null) { const { data: patch } = await supabase .from("patches") - .select("id,bucket,filename") + .select("id,bucket,filename,version") .eq("id", hack.current_patch as number) .maybeSingle(); if (patch) { const client = getMinioClient(); const bucket = patch.bucket || PATCHES_BUCKET; signedPatchUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5); + patchVersion = patch.version || ""; } } @@ -79,7 +81,7 @@ export default async function HackDetail({ params }: HackDetailProps) {

{hack.title}

- {hack.version} + {patchVersion || "Pre-release"}

By {author}

diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index f322fdf..c02e133 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -2,6 +2,7 @@ import { createClient } from "@/utils/supabase/server"; import type { TablesInsert } from "@/types/db"; +import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; export async function updateHack(args: { slug: string; @@ -160,3 +161,41 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] } } +export async function presignNewPatchVersion(args: { slug: string; version: string; objectKey?: string }) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + // Ensure hack exists and belongs to user + 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; + + // Enforce unique version per hack + const { data: existing } = await supabase + .from("patches") + .select("id") + .eq("parent_hack", args.slug) + .eq("version", args.version) + .limit(1) + .maybeSingle(); + if (existing) return { ok: false, error: "That version already exists for this hack." } as const; + + const safeVersion = args.version.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const objectKey = args.objectKey || `${args.slug}-${safeVersion}.bps`; + + const client = getMinioClient(); + // 10 minutes to upload + const url = await client.presignedPutObject(PATCHES_BUCKET, objectKey, 60 * 10); + + return { ok: true, presignedUrl: url, objectKey } as const; +} + + diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 3fa48e5..7fe3bfd 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -163,6 +163,16 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string if (!hack) return { ok: false, error: "Hack not found" } as const; if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + // Enforce unique version per hack defensively (avoid race with presign step) + const { data: existing, error: vErr } = await supabase + .from("patches") + .select("id") + .eq("parent_hack", args.slug) + .eq("version", args.version) + .maybeSingle(); + if (vErr) return { ok: false, error: vErr.message } as const; + if (existing) return { ok: false, error: "That version already exists for this hack." } as const; + // Create patch row const { data: patch, error: pErr } = await supabase .from("patches") diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 7e1f423..40516bb 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -35,7 +35,7 @@ export default function DiscoverBrowser() { const { data: rows } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,version,downloads,created_by,patch_url,updated_at") + .select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch") .order(orderBy, { ascending: false }); const slugs = (rows || []).map((r) => r.slug); const { data: coverRows } = await supabase @@ -77,6 +77,20 @@ export default function DiscoverBrowser() { arr.push(r.tags.name); tagsBySlug.set(r.hack_slug, arr); }); + + let mappedVersions = new Map(); + await Promise.all((rows || []).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, "Pre-release"); + } + })); // Fetch all tags with category to build UI groups const { data: allTagRows } = await supabase .from("tags") @@ -95,7 +109,7 @@ export default function DiscoverBrowser() { tags: tagsBySlug.get(r.slug) || [], downloads: r.downloads, baseRomId: r.base_rom, - version: r.version, + version: mappedVersions.get(r.slug) || "Pre-release", summary: r.summary, description: r.description, })); diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx index e5a37f7..c3eb82f 100644 --- a/src/components/Hack/HackOptionsMenu.tsx +++ b/src/components/Hack/HackOptionsMenu.tsx @@ -57,38 +57,13 @@ export default function HackOptionsMenu({ slug, canEdit }: HackOptionsMenuProps) className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"> Edit - } - {/* - - - - - - {canEdit &&
} - {canEdit && ( - - - Edit - + + Upload new version - )} */} + } diff --git a/src/components/Hack/HackPatchForm.tsx b/src/components/Hack/HackPatchForm.tsx new file mode 100644 index 0000000..60e10d3 --- /dev/null +++ b/src/components/Hack/HackPatchForm.tsx @@ -0,0 +1,277 @@ +"use client"; + +import React from "react"; +import { createClient } from "@/utils/supabase/client"; +import { useBaseRoms } from "@/contexts/BaseRomContext"; +import { baseRoms } from "@/data/baseRoms"; +import { platformAccept } from "@/utils/idb"; +import { sha1Hex } from "@/utils/hash"; +import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js"; +import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js"; +import { presignNewPatchVersion } from "@/app/hack/actions"; +import { confirmPatchUpload } from "@/app/submit/actions"; +import { FaInfoCircle } from "react-icons/fa"; + +export interface HackPatchFormProps { + slug: string; + baseRomId: string; + existingVersions: string[]; + currentVersion?: string; +} + +export default function HackPatchForm(props: HackPatchFormProps) { + const { slug, baseRomId, existingVersions, currentVersion } = props; + const [version, setVersion] = React.useState(""); + const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps"); + const [patchFile, setPatchFile] = React.useState(null); + const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle"); + const [genError, setGenError] = React.useState(""); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(""); + + const versionInputRef = React.useRef(null); + const patchInputRef = React.useRef(null); + const modifiedRomInputRef = React.useRef(null); + + const supabase = createClient(); + const baseRomEntry = React.useMemo(() => baseRoms.find(r => r.id === baseRomId) || null, [baseRomId]); + const baseRomPlatform = baseRomEntry?.platform; + const baseRomName = baseRomEntry?.name; + + const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms(); + const baseRomReady = !!baseRomName && (hasPermission(baseRomName) || hasCached(baseRomName)); + const baseRomNeedsPermission = !!baseRomName && isLinked(baseRomName) && !baseRomReady; + const baseRomMissing = !!baseRomName && !isLinked(baseRomName) && !hasCached(baseRomName); + + const isVersionTaken = version.trim() && existingVersions.includes(version.trim()); + const canSubmit = React.useMemo(() => { + return !!version.trim() && ((!!patchFile && patchMode === "bps") || (patchMode === "rom" && genStatus === "ready")) && !isVersionTaken && !submitting; + }, [version, patchFile, patchMode, genStatus, isVersionTaken, submitting]); + + React.useEffect(() => { + versionInputRef.current?.focus(); + }, []); + + // Suggest next version based on currentVersion (supports: 1, 1.0, 1.0.0, v1, v1.0, v1.0.1) + React.useEffect(() => { + if (version.trim()) return; + if (!currentVersion) return; + const raw = String(currentVersion).trim(); + // Capture prefix (non-digit), numeric core, and any trailing suffix + const m = raw.match(/^([^0-9]*\s*)([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?([\s\S]*)$/); + if (!m) return; + const preservedPrefix = m[1] || ""; + const major = parseInt(m[2] || "0", 10); + const minor = parseInt(m[3] || "0", 10); + const patch = parseInt(m[4] || "0", 10); + const suffix = m[5] || ""; + const next = `${major}.${minor}.${patch + 1}${suffix}`; + setVersion(preservedPrefix + next); + }, [currentVersion]); + + React.useEffect(() => { + setPatchFile(null); + setGenStatus("idle"); + setGenError(""); + patchInputRef.current && (patchInputRef.current.value = ""); + modifiedRomInputRef.current && (modifiedRomInputRef.current.value = ""); + }, [patchMode]); + + async function onGrantPermission() { + if (!baseRomName) return; + await ensurePermission(baseRomName, true); + } + + async function onUploadBaseRom(e: React.ChangeEvent) { + try { + setGenError(""); + const f = e.target.files?.[0]; + if (!f) return; + const matched = await importUploadedBlob(f); + if (!matched) { + setGenError("That ROM doesn't match any supported base ROM."); + return; + } + if (matched !== baseRomName) { + setGenError(`This ROM matches "${matched}", but this hack 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 || !baseRomName) { + setGenStatus("idle"); + return; + } + let baseFile = await getFileBlob(baseRomName); + 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 fname = `${slug}-${(version || "patch").replace(/[^a-zA-Z0-9._-]+/g, "-")}`; + const patchBin = patch.export(fname); + const out = new File([patchBin._u8array], `${fname}.bps`, { type: 'application/octet-stream' }); + setPatchFile(out); + setGenStatus("ready"); + } catch (err: any) { + setGenStatus("error"); + setGenError(err?.message || "Failed to generate patch."); + } + } + + const onSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(""); + try { + const presigned = await presignNewPatchVersion({ slug, version: version.trim() }); + if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign'); + await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } }); + const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim() }); + if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize'); + window.location.href = finalized.redirectTo!; + } catch (e: any) { + setError(e.message || 'Upload failed'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {currentVersion !== undefined && ( +
+ +

Current version: {currentVersion || 'Not set'}

+
+ )} +
+ + 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 ${isVersionTaken ? 'ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20' : 'ring-[var(--border)]'} focus:outline-none focus:ring-2 focus:ring-[var(--ring)]`} + /> +
+ {isVersionTaken ? 'Already used by this hack.' : 'Use semantic versions like v1.2.0.'} +
+ {existingVersions.length > 0 && ( +
Existing versions: {existingVersions.join(', ')}
+ )} +
+ +
+ +
+
+ + +
+ + {patchMode === "bps" && ( +
+ setPatchFile(e.target.files?.[0] || null)} + type="file" + accept=".bps" + className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer" + /> +

Upload a BPS patch file.

+
+ )} + + {patchMode === "rom" && ( +
+
+
Required base ROM
+
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Select base ROM in main Edit page"}
+
+ + {baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"} + + {baseRomNeedsPermission && ( + + )} + {baseRomMissing && ( + + )} +
+ {!!genError &&
{genError}
} +
+ +
+ + +

We'll generate a .bps patch on-device. No ROMs are uploaded.

+ {genStatus === "generating" &&
Generating patch…
} + {genStatus === "ready" && patchFile &&
Patch ready: {patchFile.name}
} + {genStatus === "error" && !!genError &&
{genError}
} +
+
+ )} +
+
+ + {!!error &&
{error}
} + +
+ +
+
+ ); +} + +