Refactor SubmitForm and add edit form

This commit is contained in:
Jared Schoeny
2025-10-22 15:06:56 -10:00
parent 682a2b5068
commit 77980ea3fc
10 changed files with 966 additions and 80 deletions

View File

@@ -0,0 +1,445 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import TagSelector from "@/components/Submit/TagSelector";
import { baseRoms } from "@/data/baseRoms";
import Image from "next/image";
import { createClient } from "@/utils/supabase/client";
import { updateHack } from "@/app/hack/actions";
import { saveHackCovers } from "@/app/hack/actions";
import SortableCovers from "@/components/Hack/SortableCovers";
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 } | 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 [tags, setTags] = React.useState<string[]>(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<CoverItem[]>(() => {
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<boolean>((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 (
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="card p-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold tracking-tight">Content</h2>
<div className="flex items-center gap-3">
{contentChanged && (
<button
type="button"
onClick={() => { setTitle(baseline.title); setSummary(baseline.summary); setDescription(baseline.description); setLanguage(baseline.language); setBoxArt(baseline.boxArt); setTags(baseline.tags.slice()); setDiscord(baseline.discord); setTwitter(baseline.twitter); setPokecommunity(baseline.pokecommunity); }}
className="inline-flex items-center underline underline-offset-2 text-[12px] font-semibold cursor-pointer"
>
Revert all
</button>
)}
<button onClick={onSaveMeta} disabled={saving || !contentChanged || contentHasErrors} className="shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
<span>{saving ? "Saving…" : "Save Content/Details"}</span>
</button>
</div>
</div>
<div className="mt-4 grid gap-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Title</label>
{titleChanged && (
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
<span>Modified</span>
<button type="button" onClick={() => setTitle(baseline.title)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
<input value={title} onChange={(e) => 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)]'}`} />
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Summary</label>
<div className="flex items-center gap-2">
{summaryChanged && (
<>
<span className="text-[11px] text-foreground/70 ml-2">Modified</span>
<button type="button" onClick={() => setSummary(baseline.summary)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</>
)}
</div>
</div>
<div className="relative w-full">
<input
value={summary}
onChange={(e) => 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)]'}`}
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-[11px] ${summary.length > summaryLimit ? "text-red-300" : "text-foreground/60"}`}>
{summary.length}/{summaryLimit}
</span>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Description</label>
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Write</button>
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Preview</button>
{descriptionChanged && (
<div className="flex items-center gap-2 ml-2">
<span className="text-[11px] text-foreground/70">Modified</span>
<button type="button" onClick={() => setDescription(baseline.description)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
</div>
{!showMdPreview ? (
<textarea
rows={14}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Supports Markdown"
className={`rounded-md px-3 py-2 min-h-[14rem] text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
/>
) : (
<div className={`prose max-w-none rounded-md min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'} ${description ? "" : "text-foreground/60 text-sm"}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description || "Nothing to preview yet."}</ReactMarkdown>
</div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Tags</label>
{tagsChanged && (
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
<span>Modified</span>
<button type="button" onClick={() => setTags(baseline.tags.slice())} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
<div className={`rounded-md ring-1 ring-inset ${tagsChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'ring-transparent'} p-1`}>
<TagSelector value={tags} onChange={setTags} />
</div>
</div>
</div>
</div>
<div className="card p-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold tracking-tight">Screenshots</h2>
<div className="flex items-center gap-2">
{coversChanged && (
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || supabase.storage.from('hack-covers').getPublicUrl(k).data.publicUrl })))} className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-[12px] cursor-pointer">
Revert
</button>
)}
<button onClick={onSaveCovers} disabled={saving || !coversChanged || overLimit} className="inline-flex shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
<span>{saving ? "Saving…" : "Save images"}</span>
</button>
</div>
</div>
<div className="mt-4 grid gap-4">
{allowedSizes.length > 0 && (
<p className="text-xs text-foreground/60">Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}</p>
)}
<input
type="file"
multiple
accept="image/*"
onChange={(e) => 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 ? (
<p className="text-sm text-foreground/70">No screenshots yet.</p>
) : (
<SortableCovers
items={coverItems.map((item, i) => ({
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}
/>
)}
<div className="text-xs text-foreground/60 flex justify-between">
<p>Images: <span className={overLimit ? "text-red-300 font-bold" : "text-foreground/60"}>{coverItems.length}</span>/{MAX_COVERS}</p>
{overLimit && <p className="text-red-300/80 italic">Remove some to save.</p>}
</div>
</div>
</div>
</div>
<aside className="space-y-6 self-start w-full lg:w-auto">
<div className="card p-5">
<h3 className="text-[15px] font-semibold tracking-tight">Details</h3>
<div className="mt-3 grid gap-3 text-sm">
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM</label>
<p className="flex items-center 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)] text-foreground/60 select-none cursor-not-allowed">
{baseRoms.find(r => r.id === baseRom)?.name || baseRom}
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Language</label>
{languageChanged && (
<div className="ml-auto flex items-center gap-2 text-[11px] text-foreground/70">
<span>Modified</span>
<button type="button" onClick={() => setLanguage(baseline.language)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
<select value={language} onChange={(e) => setLanguage(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${languageChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Current version</label>
<p className="flex items-center 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)] text-foreground/60 select-none cursor-not-allowed">
{version}
</p>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Box art URL</label>
<div className="flex items-center justify-between">
<input value={boxArt} onChange={(e) => setBoxArt(e.target.value)} placeholder="https://..." className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${boxArt && !urlLike(boxArt) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : boxArtChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
{boxArtChanged && (
<button type="button" onClick={() => setBoxArt(baseline.boxArt)} className="ml-3 text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>
)}
</div>
{boxArt && urlLike(boxArt) && (
<div className="relative aspect-square w-full max-h-[300px] overflow-hidden rounded ring-1 ring-[var(--border)]">
<Image src={boxArt} alt="Box art preview" fill className="object-contain" unoptimized />
</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Social links</label>
<div className="grid gap-2">
<div className="flex items-center gap-2">
<input value={discord} onChange={(e) => setDiscord(e.target.value)} placeholder="Discord invite URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${discord && !urlLike(discord) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : discordChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
{discordChanged && <button type="button" onClick={() => setDiscord(baseline.discord)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
<div className="flex items-center gap-2">
<input value={twitter} onChange={(e) => setTwitter(e.target.value)} placeholder="Twitter/X profile URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${twitter && !urlLike(twitter) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : twitterChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
{twitterChanged && <button type="button" onClick={() => setTwitter(baseline.twitter)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
<div className="flex items-center gap-2">
<input value={pokecommunity} onChange={(e) => setPokecommunity(e.target.value)} placeholder="PokeCommunity thread URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${pokecommunity && !urlLike(pokecommunity) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : pokeChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
{pokeChanged && <button type="button" onClick={() => setPokecommunity(baseline.pokecommunity)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
);
}

View File

@@ -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<typeof HackEditForm>["initial"];
}
export type HackFormProps = HackFormCreateProps | HackFormEditProps;
export default function HackForm(props: HackFormProps) {
if (props.mode === "create") {
return <HackSubmitForm dummy={props.dummy} />;
}
return <HackEditForm slug={props.slug} initial={props.initial} />;
}

View File

@@ -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 (
<Menu as="div" className="relative">
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={18} />
</MenuButton>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems anchor="bottom end" className="mt-2 w-40 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none">
<MenuItem
as="button"
onClick={() => {
// TODO: Implement share
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Share
</MenuItem>
<MenuItem
as="button"
onClick={() => {
// TODO: Implement report
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Report
</MenuItem>
{canEdit && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuItem
as="a"
href={`/hack/${slug}/edit`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Edit
</MenuItem>
</>}
{/* <MenuItem>
<button
className={`block w-full px-3 py-2 text-left text-sm data-focus:bg-[var(--surface-3)]`}
onClick={() => {
// TODO: Implement share
}}
>
Share
</button>
</MenuItem>
<MenuItem>
<button
className={`block w-full px-3 py-2 text-left text-sm data-focus:bg-[var(--surface-3)]`}
onClick={() => {
// TODO: Implement report
}}
>
Report
</button>
</MenuItem>
{canEdit && <div className="my-1 h-px bg-[var(--border)]" />}
{canEdit && (
<MenuItem>
<a
className={`block w-full px-3 py-2 text-left text-sm data-focus:bg-[var(--surface-3)]`}
href={`/hack/${slug}/edit`}
>
Edit
</a>
</MenuItem>
)} */}
</MenuItems>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,789 @@
"use client";
import React from "react";
import Image from "next/image";
import { baseRoms } from "@/data/baseRoms";
import HackCard from "@/components/HackCard";
import { createClient } from "@/utils/supabase/client";
import { prepareSubmission, presignPatchAndSaveCovers, confirmPatchUpload } from "@/app/submit/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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { RxDragHandleDots2 } from "react-icons/rx";
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 BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { platformAccept } from "@/utils/idb";
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 (
<div ref={setNodeRef} style={style} className="rounded-md">
<div className={`h-16 flex items-center justify-between gap-3 p-2 bg-[var(--surface-2)] ring-1 ring-inset ring-[var(--border)] ${isDragging ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3">
<div className="cursor-grab select-none pr-1 text-foreground/60" title="Drag to reorder" {...attributes} {...listeners}>
<RxDragHandleDots2 size={24} />
</div>
<div className="relative h-12 w-20 overflow-hidden rounded">
<Image src={url} alt={`Cover ${index + 1}`} fill className="object-cover" unoptimized />
</div>
<div className="min-w-0">
<div className="truncate max-w-[260px] text-xs text-foreground/80">{filename}</div>
{index === 0 && <div className="text-[10px] text-emerald-400/90">Primary</div>}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onRemove}
className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 text-xs text-red-600 transition-colors hover:bg-black/5 dark:text-red-300 dark:hover:bg-white/10"
>
Remove
</button>
</div>
</div>
</div>
);
}
interface HackSubmitFormProps {
dummy?: boolean;
}
export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
const MAX_COVERS = 10;
const { profile } = useAuthContext();
const [title, setTitle] = React.useState("");
const [summary, setSummary] = React.useState("");
const [description, setDescription] = React.useState("");
const [newCoverFiles, setNewCoverFiles] = React.useState<File[]>([]);
const [coverErrors, setCoverErrors] = React.useState<string[]>([]);
const [baseRom, setBaseRom] = React.useState("");
const [platform, setPlatform] = React.useState<"GB" | "GBC" | "GBA" | "NDS" | "">("");
const [version, setVersion] = React.useState("");
const [language, setLanguage] = React.useState("");
const [boxArt, setBoxArt] = React.useState("");
const [discord, setDiscord] = React.useState("");
const [twitter, setTwitter] = React.useState("");
const [pokecommunity, setPokecommunity] = React.useState("");
const [tags, setTags] = React.useState<string[]>([]);
const [showMdPreview, setShowMdPreview] = React.useState(false);
const [patchFile, setPatchFile] = React.useState<File | null>(null);
const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps");
const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle");
const [genError, setGenError] = React.useState<string>("");
const [submitting, setSubmitting] = React.useState(false);
const [step, setStep] = React.useState(1);
const supabase = createClient();
const isDummy = !!dummy;
const titleInputRef = React.useRef<HTMLInputElement | null>(null);
const versionInputRef = React.useRef<HTMLInputElement | null>(null);
const screenshotsInputRef = React.useRef<HTMLInputElement | null>(null);
const patchInputRef = React.useRef<HTMLInputElement | null>(null);
const modifiedRomInputRef = React.useRef<HTMLInputElement | null>(null);
const baseRomEntry = React.useMemo(() => baseRoms.find((r) => r.id === baseRom) || null, [baseRom]);
const baseRomName = baseRomEntry?.name || "";
const baseRomPlatform = baseRomEntry?.platform;
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 coverPreviews = React.useMemo(() => {
return newCoverFiles.map((f) => URL.createObjectURL(f));
}, [newCoverFiles]);
React.useEffect(() => {
return () => {
coverPreviews.forEach((u) => URL.revokeObjectURL(u));
};
}, [coverPreviews]);
React.useEffect(() => {
setPatchFile(null);
setGenStatus("idle");
setGenError("");
patchInputRef.current && (patchInputRef.current.value = "");
modifiedRomInputRef.current && (modifiedRomInputRef.current.value = "");
}, [patchMode]);
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 { error } = await supabase.storage.from('hack-covers').upload(path, file);
if (error) throw error;
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<boolean>((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));
};
React.useEffect(() => {
if (isDummy) return;
if (step === 1) {
titleInputRef.current?.focus();
} else if (step === 2) {
versionInputRef.current?.focus();
} else if (step === 3) {
screenshotsInputRef.current?.focus();
} else if (step === 4) {
patchInputRef.current?.focus();
}
}, [step, isDummy]);
const slugify = (text: string) =>
text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const slug = slugify(title || "");
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].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;
const isValid = step1Valid && step2Valid && step3Valid && !!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('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 (tags.length) fd.set('tags', tags.join(','));
const prepared = await prepareSubmission(fd);
if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare');
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');
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 });
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
window.location.href = finalized.redirectTo!;
} else {
window.location.href = `/hack/${prepared.slug}`;
}
} catch (e: any) {
alert(e.message || 'Submission failed');
} finally {
setSubmitting(false);
}
};
async function onGrantPermission() {
if (!baseRomName) return;
await ensurePermission(baseRomName, true);
}
async function onUploadBaseRom(e: React.ChangeEvent<HTMLInputElement>) {
try {
setGenError("");
const f = e.target.files?.[0];
if (!f) return;
const matchedName = await importUploadedBlob(f);
if (!matchedName) {
setGenError("That ROM doesn't match any supported base ROM.");
return;
}
if (matchedName !== baseRomName) {
setGenError(`This ROM matches "${matchedName}", but the form requires "${baseRomName}".`);
return;
}
} catch {
setGenError("Failed to import base ROM.");
}
}
async function onUploadModifiedRom(e: React.ChangeEvent<HTMLInputElement>) {
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 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.");
}
}
const preview = {
slug: slug || "preview",
title: title || "Your hack title",
author: 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: version || "v0.0.0",
tags,
...(boxArt ? { boxArt } : {}),
socialLinks:
discord || twitter || pokecommunity
? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined }
: undefined,
createdAt: new Date().toISOString(),
patchUrl: "",
};
const hasBaseRom = !!baseRom.trim();
return (
<div className="flex flex-col gap-8 lg:flex-row w-full">
<div className="flex-1">
<form className="grid gap-5">
<div className="text-xs italic text-foreground/60">* Required</div>
{step === 1 && (
<>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Title <span className="text-red-500">*</span></label>
{!isDummy ? (
<input
ref={titleInputRef}
value={title}
onChange={(e) => 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)]"
/>
) : (
<div
role="textbox"
aria-disabled
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none"
>
Your hack title
</div>
)}
<div className="mt-1 text-xs text-foreground/60">URL preview: <span className="text-foreground/80">/hack/{slug || "your-title"}</span></div>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Platform <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
value={platform}
onChange={(e) => { if ((newCoverFiles.length) > 0) return; setPlatform(e.target.value as any); setBaseRom(""); }}
disabled={newCoverFiles.length > 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)] disabled:opacity-50"
>
<option value="" disabled>Select platform</option>
{(["GB","GBC","GBA","NDS"] as const).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
) : (
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{platform || ""}</div>
)}
{newCoverFiles.length > 0 && (
<div className="text-xs text-red-500">Please remove all screenshots before changing the platform.</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
value={baseRom}
onChange={(e) => setBaseRom(e.target.value)}
disabled={!platform}
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"
>
<option value="" disabled>{platform ? "Select base rom" : "Select platform first"}</option>
{baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => (
<option key={id} value={id}>
{name.replace('Pokemon ', '')} ({region})
</option>
))}
</select>
) : (
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{baseRoms.find(r=>r.id===baseRom)?.name || baseRom}</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Language <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
value={language}
onChange={(e) => setLanguage(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)]"
>
<option value="" disabled>Select language</option>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{language}</div>
)}
</div>
</>
)}
{step === 2 && (
<>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Version <span className="text-red-500">*</span></label>
{!isDummy ? (
<input
ref={versionInputRef}
value={version}
onChange={(e) => 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)]`}
/>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">v0.1.0</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
<TagSelector value={tags} onChange={setTags} />
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Summary <span className="text-red-500">*</span></label>
<span className={`text-[11px] ${summaryTooLong ? "text-red-300" : "text-foreground/60"}`}>{summary.length}/{summaryLimit}</span>
</div>
{!isDummy ? (
<input
value={summary}
onChange={(e) => 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)]"}`}
/>
) : (
<div
role="textbox"
aria-disabled
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset flex items-center text-foreground/60 select-none ${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.
</div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Description <span className="text-red-500">*</span></label>
{!isDummy && (
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Write</button>
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Preview</button>
</div>
)}
</div>
{isDummy ? (
<div className="prose max-w-none h-36 rounded-md bg-[var(--surface-2)] px-3 py-2 ring-1 ring-inset ring-[var(--border)] text-foreground/60 select-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description || "Write a longer markdown description here."}</ReactMarkdown>
</div>
) : !showMdPreview ? (
<textarea
rows={14}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Supports Markdown"
className={`rounded-md bg-[var(--surface-2)] px-3 py-2 min-h-[14rem] text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]`}
/>
) : (
<div className={`prose max-w-none rounded-md bg-[var(--surface-2)] min-h-[14rem] px-3 py-2 ring-1 ring-inset ring-[var(--border)] ${description ? "" : "text-foreground/60 text-sm"}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description || "Nothing to preview yet."}</ReactMarkdown>
</div>
)}
</div>
</>
)}
{step === 3 && (
<>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Screenshots <span className="text-red-500">*</span></label>
{allowedSizes.length > 0 && (
<p className="text-xs text-foreground/60">Upload screenshots of your game. Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}.</p>
)}
<div className="space-y-3">
{!isDummy ? (
<input
ref={screenshotsInputRef}
type="file"
multiple
accept="image/*"
onChange={async (e) => {
const list = Array.from(e.target.files || []);
const allowed = baseRoms.find(r => r.id === baseRom)?.platform;
const sizes = allowed ? getAllowedSizesForPlatform(allowed) : [];
const accepted: File[] = [];
const errors: string[] = [];
for (const f of list) {
if (sizes.length === 0) { accepted.push(f); continue; }
const ok = await validateImageDimensions(f, sizes);
if (ok) accepted.push(f); else errors.push(f.name);
}
setCoverErrors(errors);
setNewCoverFiles((prev) => [...prev, ...accepted]);
}}
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 ${!hasBaseRom ? 'pointer-events-none opacity-50 blur-[1px]' : ''}`}
/>
) : (
<div className="w-full h-14 rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] text-foreground/60 select-none">
Choose images to upload
</div>
)}
<div className="flex items-center gap-2">
{!isDummy ? (
<>
<button
type="button"
onClick={() => { setNewCoverFiles([]); }}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-xs font-medium text-foreground/80 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
Clear
</button>
</>
) : (
<>
<button type="button" disabled className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-xs font-medium text-foreground/70 disabled:opacity-40">
Add
</button>
<button type="button" disabled className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-xs font-medium text-foreground/60 disabled:opacity-40">
Clear
</button>
</>
)}
</div>
<div className="text-xs text-foreground/60 flex justify-between">
<p>Images: <span className={overLimit ? "text-red-300 font-bold" : "text-foreground/60"}>{newCoverFiles.length}</span>/{MAX_COVERS}</p>
{overLimit && <p className="text-red-300/80 italic">Remove some to submit.</p>}
</div>
{coverErrors.length > 0 && (
<div className="text-xs text-red-400">
Rejected (wrong size): {coverErrors.join(", ")}
</div>
)}
<div className="grid gap-2">
{newCoverFiles.length === 0 ? (
<p className="text-xs text-foreground/60">No images added yet. Add at least one to preview.</p>
) : (
!isDummy ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={newCoverFiles.map((f, i) => `${f.name}-${i}`)}
strategy={verticalListSortingStrategy}
>
{newCoverFiles.map((f, i) => (
<SortableCoverItem
key={`${f.name}-${i}`}
id={`${f.name}-${i}`}
index={i}
filename={f.name}
url={URL.createObjectURL(f)}
onRemove={() => removeAt(i)}
/>
))}
</SortableContext>
</DndContext>
) : null
)}
</div>
</div>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Box art URL</label>
{!isDummy ? (
<input
value={boxArt}
onChange={(e) => setBoxArt(e.target.value)}
placeholder="https://..."
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${boxArt && !urlLike(boxArt) ? "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)]"}`}
/>
) : (
<div className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset flex items-center text-foreground/60 select-none ${boxArt && !urlLike(boxArt) ? "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)]"}`}>https://...</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Social links</label>
<div className="grid gap-2 sm:grid-cols-3">
{!isDummy ? (
<>
<input
value={discord}
onChange={(e) => setDiscord(e.target.value)}
placeholder="Discord invite URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${discord && !urlLike(discord) ? "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)]"}`}
/>
<input
value={twitter}
onChange={(e) => setTwitter(e.target.value)}
placeholder="Twitter/X profile URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${twitter && !urlLike(twitter) ? "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)]"}`}
/>
<input
value={pokecommunity}
onChange={(e) => setPokecommunity(e.target.value)}
placeholder="PokeCommunity thread URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${pokecommunity && !urlLike(pokecommunity) ? "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)]"}`}
/>
</>
) : (
<>
<div className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset flex items-center text-foreground/60 select-none ${discord && !urlLike(discord) ? "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)]"}`}>Discord invite URL</div>
<div className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset flex items-center text-foreground/60 select-none ${twitter && !urlLike(twitter) ? "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)]"}`}>Twitter/X profile URL</div>
<div className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset flex items-center text-foreground/60 select-none ${pokecommunity && !urlLike(pokecommunity) ? "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)]"}`}>PokeCommunity thread URL</div>
</>
)}
</div>
<p className="text-xs text-foreground/60">Use full URLs starting with http:// or https://</p>
</div>
</>
)}
{step === 4 && (
<div className="grid gap-3">
<label className="text-sm text-foreground/80">Provide patch <span className="text-red-500">*</span></label>
{!isDummy ? (
<div className="flex flex-col gap-3">
<div className="inline-flex items-center">
<button
type="button"
onClick={() => setPatchMode("bps")}
className={`rounded-md rounded-r-none px-3 py-1.5 text-xs border-l-1 border-y-1 ${patchMode === "bps" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload .bps
</button>
<button
type="button"
onClick={() => setPatchMode("rom")}
className={`rounded-md rounded-l-none px-3 py-1.5 text-xs border-1 ${patchMode === "rom" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload modified ROM (auto-generate .bps)
</button>
</div>
{patchMode === "bps" && (
<div className="grid gap-2">
<input
ref={patchInputRef}
onChange={(e) => 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"
/>
<p className="text-xs text-foreground/60">Upload a BPS patch file.</p>
</div>
)}
{patchMode === "rom" && (
<div className="grid gap-3">
<div className="rounded-md border border-[var(--border)] p-3 bg-[var(--surface-2)]/50">
<div className="text-xs text-foreground/75">Required base ROM</div>
<div className="mt-1 text-sm font-medium">{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Select a base ROM in Step 1"}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90" : baseRomNeedsPermission ? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90" : "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"}`}>
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"}
</span>
{baseRomNeedsPermission && (
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
)}
{baseRomMissing && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input type="file" onChange={onUploadBaseRom} className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]" />
<span>Upload base ROM</span>
</label>
)}
</div>
{!!genError && <div className="mt-2 text-xs text-red-400">{genError}</div>}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Modified ROM</label>
<input
ref={modifiedRomInputRef}
type="file"
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
disabled={!baseRomEntry || !baseRomReady || !baseRomPlatform}
onChange={onUploadModifiedRom}
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-foreground/60">We'll generate a .bps patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-foreground/70">Generating patch…</div>}
{genStatus === "ready" && patchFile && <div className="text-xs text-emerald-400/90">Patch ready: {patchFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-red-400">{genError}</div>}
</div>
</div>
)}
</div>
) : (
<div className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] select-none">Choose file</div>
)}
</div>
)}
{!isDummy && (
<div className="flex items-center justify-between gap-3 border-t border-[var(--border)] pt-4 mt-4">
<button
type="button"
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1 || submitting}
className="inline-flex h-11 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 text-sm font-semibold text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
Back
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-foreground/60">Step {step} of 4</span>
</div>
{step < 4 ? (
<button
type="button"
onClick={() => setStep((s) => Math.min(4, s + 1))}
disabled={
submitting ||
(step === 1 && !step1Valid) ||
(step === 2 && !step2Valid) ||
(step === 3 && !step3Valid)
}
className="inline-flex h-11 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 text-sm font-semibold text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
) : (
<button
type="button"
onClick={onSubmit}
disabled={!isValid || submitting}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>{submitting ? 'Submitting' : 'Submit'}</span>
</button>
)}
</div>
)}
</form>
</div>
<aside className="flex flex-col gap-5 lg:sticky lg:top-20 self-start basis-[360px]">
<HackCard hack={preview} clickable={false} />
<div className="card h-max p-5">
<div className="text-[15px] font-semibold tracking-tight">Submission tips</div>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm text-foreground/75">
<li>Use a reliable image URL (e.g. `imgur`).</li>
<li>Include the exact expected base ROM name.</li>
<li>Describe notable features, difficulty, and target players.</li>
</ul>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import React from "react";
import Image from "next/image";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { RxDragHandleDots2 } from "react-icons/rx";
export interface SortableCoverItemData {
id: string;
url: string;
filename: string;
isNew?: boolean;
}
interface SortableCoversProps {
items: SortableCoverItemData[];
onReorder: (oldIndex: number, newIndex: number) => void;
onRemove: (index: number) => void;
}
function Row({ id, index, url, filename, isNew, onRemove }: { id: string; index: number; url: string; filename: string; isNew?: boolean; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
function handleRemove() {
if (!isNew) {
const ok = window.confirm("Delete this screenshot?");
if (!ok) return;
}
onRemove();
}
return (
<div ref={setNodeRef} style={style} className="rounded-md">
<div className={`h-16 flex items-center justify-between gap-3 p-2 bg-[var(--surface-2)] ring-1 ring-inset ring-[var(--border)] ${isDragging ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3">
<div className="cursor-grab select-none pr-1 text-foreground/60" title="Drag to reorder" {...attributes} {...listeners}>
<RxDragHandleDots2 size={24} />
</div>
<div className="relative h-12 w-20 overflow-hidden rounded">
<Image src={url} alt={`Cover ${index + 1}`} fill className="object-cover" unoptimized />
</div>
<div className="min-w-0">
<div className="truncate max-w-[260px] text-xs text-foreground/80">{filename}</div>
{index === 0 && <div className="text-[10px] text-emerald-400/90">Primary</div>}
{isNew && <div className="text-[10px] text-amber-400/90">New</div>}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleRemove}
className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 text-xs text-red-600 transition-colors hover:bg-black/5 dark:text-red-300 dark:hover:bg-white/10"
>
{isNew ? 'Remove' : 'Delete'}
</button>
</div>
</div>
</div>
);
}
export default function SortableCovers({ items, onReorder, onRemove }: SortableCoversProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
function onDragEnd(event: any) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = items.map((it) => it.id);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
onReorder(oldIndex, newIndex);
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map((it) => it.id)} strategy={verticalListSortingStrategy}>
{items.map((it, i) => (
<Row
key={it.id}
id={it.id}
index={i}
url={it.url}
filename={it.filename}
isNew={it.isNew}
onRemove={() => onRemove(i)}
/>
))}
</SortableContext>
</DndContext>
);
}