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 ? ( +