"use client"; import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSlug from "rehype-slug"; import TagSelector from "@/components/Submit/TagSelector"; import { baseRoms } from "@/data/baseRoms"; import Image from "next/image"; import { createClient } from "@/utils/supabase/client"; import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions"; import SortableCovers from "@/components/Hack/SortableCovers"; import Select from "@/components/Primitives/Select"; interface HackEditFormProps { slug: string; initial: { 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; github?: string; } | null; tags: string[]; coverKeys: string[]; // storage keys for covers in order signedCoverUrls?: string[]; // optional signed URLs aligned to keys }; } export default function HackEditForm({ slug, initial }: HackEditFormProps) { const supabase = createClient(); const MAX_COVERS = 10; const [title, setTitle] = React.useState(initial.title); const [summary, setSummary] = React.useState(initial.summary); const [description, setDescription] = React.useState(initial.description); const [showMdPreview, setShowMdPreview] = React.useState(false); const [baseRom, setBaseRom] = React.useState(initial.base_rom); const [language, setLanguage] = React.useState(initial.language); const [version, setVersion] = React.useState(initial.version); const [boxArt, setBoxArt] = React.useState(initial.box_art || ""); const [discord, setDiscord] = React.useState(initial.social_links?.discord || ""); const [twitter, setTwitter] = React.useState(initial.social_links?.twitter || ""); const [pokecommunity, setPokecommunity] = React.useState(initial.social_links?.pokecommunity || ""); const [github, setGithub] = React.useState(initial.social_links?.github || ""); const [tags, setTags] = React.useState(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 || "", github: initial.social_links?.github || "", }); 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] || '' })); }); 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 githubChanged = github !== baseline.github; const contentChanged = titleChanged || summaryChanged || descriptionChanged || languageChanged || boxArtChanged || tagsChanged || discordChanged || twitterChanged || pokeChanged || githubChanged; 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 || github ? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined, github: github || 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 || githubChanged) 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, github, }); } 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 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: item.file, headers: { 'Content-Type': item.file.type || 'image/jpeg' } }); 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 ? (