mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Refactor SubmitForm and add edit form
This commit is contained in:
80
src/app/hack/[slug]/edit/page.tsx
Normal file
80
src/app/hack/[slug]/edit/page.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center text-3xl tracking-tight">
|
||||
Edit
|
||||
<FaChevronRight size={22} className="inline-block mx-2 text-foreground/50 align-middle" />
|
||||
<span className="gradient-text font-bold">{hack.title}</span>
|
||||
</h1>
|
||||
<Link href={`/hack/${slug}`} className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
<FaChevronLeft size={16} className="inline-block mr-1" />
|
||||
Back to hack
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<HackForm mode="edit" slug={slug} initial={initial} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span>{formatCompactNumber(hack.downloads)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span>{formatCompactNumber(hack.downloads)}</span>
|
||||
</div>
|
||||
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
162
src/app/hack/actions.ts
Normal file
162
src/app/hack/actions.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">Share your hack so others can discover and play it.</p>
|
||||
<div className="mt-8">
|
||||
<SubmitForm dummy={!user || needsInitialSetup} />
|
||||
<HackForm mode="create" dummy={!user || needsInitialSetup} />
|
||||
</div>
|
||||
{!user ? (
|
||||
<SubmitAuthOverlay
|
||||
|
||||
445
src/components/Hack/HackEditForm.tsx
Normal file
445
src/components/Hack/HackEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
29
src/components/Hack/HackForm.tsx
Normal file
29
src/components/Hack/HackForm.tsx
Normal 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} />;
|
||||
}
|
||||
|
||||
|
||||
98
src/components/Hack/HackOptionsMenu.tsx
Normal file
98
src/components/Hack/HackOptionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy }
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { RxDragHandleDots2, RxPlus } from "react-icons/rx";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { Database } from "@/types/db";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import TagSelector from "@/components/Submit/TagSelector";
|
||||
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
|
||||
@@ -56,20 +55,16 @@ function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string;
|
||||
);
|
||||
}
|
||||
|
||||
interface SubmitFormProps {
|
||||
interface HackSubmitFormProps {
|
||||
dummy?: boolean;
|
||||
}
|
||||
|
||||
// Tag selection handled by TagSelector component
|
||||
|
||||
export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
|
||||
const MAX_COVERS = 10;
|
||||
const { profile } = useAuthContext();
|
||||
const [title, setTitle] = React.useState("");
|
||||
// Author derived from profile later
|
||||
const [summary, setSummary] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
// Deprecated: coverUrls removed; using files array for screenshots
|
||||
const [newCoverFiles, setNewCoverFiles] = React.useState<File[]>([]);
|
||||
const [coverErrors, setCoverErrors] = React.useState<string[]>([]);
|
||||
const [baseRom, setBaseRom] = React.useState("");
|
||||
@@ -106,7 +101,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
const baseRomNeedsPermission = baseRomName && isLinked(baseRomName) && !baseRomReady;
|
||||
const baseRomMissing = baseRomName && !isLinked(baseRomName) && !hasCached(baseRomName);
|
||||
|
||||
// Build object URLs for local screenshot previews and clean them up when files change
|
||||
const coverPreviews = React.useMemo(() => {
|
||||
return newCoverFiles.map((f) => URL.createObjectURL(f));
|
||||
}, [newCoverFiles]);
|
||||
@@ -142,7 +136,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
function getAllowedSizesForPlatform(platform: "GB" | "GBC" | "GBA" | "NDS") {
|
||||
if (platform === "GB" || platform === "GBC") return [{ w: 160, h: 144 }];
|
||||
if (platform === "GBA") return [{ w: 240, h: 160 }];
|
||||
// NDS
|
||||
return [{ w: 256, h: 192 }, { w: 256, h: 384 }];
|
||||
}
|
||||
|
||||
@@ -164,7 +157,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
}
|
||||
const overLimit = newCoverFiles.length > MAX_COVERS;
|
||||
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
setNewCoverFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
@@ -183,11 +175,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
// Tags handled in TagSelector
|
||||
|
||||
// Tag fetching and filtering moved to TagSelector
|
||||
|
||||
// Focus the first input on each step when step changes
|
||||
React.useEffect(() => {
|
||||
if (isDummy) return;
|
||||
if (step === 1) {
|
||||
@@ -201,8 +188,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
}
|
||||
}, [step, isDummy]);
|
||||
|
||||
// Removed legacy key handling; TagSelector manages interactions
|
||||
|
||||
const slugify = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
@@ -219,10 +204,8 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
|
||||
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
|
||||
|
||||
const hasOneSocial = [discord, twitter, pokecommunity].some((s) => !!s.trim());
|
||||
const allSocialValid = [discord, twitter, pokecommunity].every((s) => !s || urlLike(s));
|
||||
|
||||
|
||||
const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim();
|
||||
const step2Valid = !!version.trim() && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0;
|
||||
const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid;
|
||||
@@ -232,7 +215,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
if (!isValid || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Step 1: create hack & tags
|
||||
const fd = new FormData();
|
||||
fd.set('title', title);
|
||||
fd.set('summary', summary);
|
||||
@@ -249,12 +231,10 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
const prepared = await prepareSubmission(fd);
|
||||
if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare');
|
||||
|
||||
// Step 2: upload covers to storage and save rows
|
||||
const uploadedCoverUrls = await uploadCovers(prepared.slug);
|
||||
const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls });
|
||||
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
|
||||
|
||||
// Step 3: upload patch via signed URL
|
||||
if (patchFile) {
|
||||
await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } });
|
||||
const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version });
|
||||
@@ -320,7 +300,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
const [origBuf, modBuf] = await Promise.all([baseFile.arrayBuffer(), mod.arrayBuffer()]);
|
||||
const origBin = new BinFile(origBuf);
|
||||
const modBin = new BinFile(modBuf);
|
||||
const deltaMode = origBin.fileSize <= 4194304; // Not having this check causes the site to freeze for larger ROMs
|
||||
const deltaMode = origBin.fileSize <= 4194304;
|
||||
const patch = BPS.buildFromRoms(origBin, modBin, deltaMode);
|
||||
const fileName = slug || title || "patch";
|
||||
const patchBin = patch.export(fileName);
|
||||
@@ -359,12 +339,10 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
<div className="flex flex-col gap-8 lg:flex-row w-full">
|
||||
<div className="flex-1">
|
||||
<form className="grid gap-5">
|
||||
{/* Required note */}
|
||||
<div className="text-xs italic text-foreground/60">* Required</div>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
{/* Title */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Title <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
@@ -386,7 +364,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
<div className="mt-1 text-xs text-foreground/60">URL preview: <span className="text-foreground/80">/hack/{slug || "your-title"}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Platform <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
@@ -409,7 +386,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base ROM */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Base ROM <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
@@ -431,7 +407,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Language <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
@@ -454,7 +429,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
{/* Version */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Version <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
@@ -470,13 +444,11 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<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>
|
||||
|
||||
{/* Summary */}
|
||||
<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>
|
||||
@@ -500,7 +472,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<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>
|
||||
@@ -524,7 +495,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
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)] px-3 py-2 ring-1 ring-inset ring-[var(--border)] ${description ? "" : "text-foreground/60 text-sm"}`}>
|
||||
<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>
|
||||
)}
|
||||
@@ -534,7 +505,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
{/* Screenshots */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Screenshots <span className="text-red-500">*</span></label>
|
||||
{allowedSizes.length > 0 && (
|
||||
@@ -568,12 +538,12 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
Choose images to upload
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDummy ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNewCoverFiles([]); }}
|
||||
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
|
||||
@@ -590,8 +560,8 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<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 && (
|
||||
@@ -599,37 +569,36 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
Rejected (wrong size): {coverErrors.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
{newCoverFiles.length === 0 ? (
|
||||
<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
|
||||
!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>
|
||||
|
||||
{/* Box art URL */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Box art URL</label>
|
||||
<label className="text-sm text-foreground/80">Box art URL</label>
|
||||
{!isDummy ? (
|
||||
<input
|
||||
value={boxArt}
|
||||
@@ -642,9 +611,8 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Social links</label>
|
||||
<label className="text-sm text-foreground/80">Social links</label>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{!isDummy ? (
|
||||
<>
|
||||
@@ -680,7 +648,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upload patch file */}
|
||||
{step === 4 && (
|
||||
<div className="grid gap-3">
|
||||
<label className="text-sm text-foreground/80">Provide patch <span className="text-red-500">*</span></label>
|
||||
@@ -762,7 +729,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
{!isDummy && (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--border)] pt-4 mt-4">
|
||||
<button
|
||||
@@ -806,7 +772,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
</div>
|
||||
|
||||
<aside className="flex flex-col gap-5 lg:sticky lg:top-20 self-start basis-[360px]">
|
||||
<PreviewCard hack={preview} />
|
||||
<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">
|
||||
@@ -820,7 +786,4 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCard({ hack }: { hack: any }) {
|
||||
return <HackCard hack={hack} clickable={false} />;
|
||||
}
|
||||
|
||||
100
src/components/Hack/SortableCovers.tsx
Normal file
100
src/components/Hack/SortableCovers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) {
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Tags"
|
||||
className="min-w-[18rem] max-w-[70vw] overflow-auto p-2 outline-none"
|
||||
className="min-w-[18rem] flex-1 overflow-auto p-2 outline-none"
|
||||
>
|
||||
<div className="mb-1 px-1 text-xs uppercase tracking-wider text-foreground/60">{activeCategory === "advanced" ? "Advanced" : (activeCategory || "Pick a category")}</div>
|
||||
<div className="grid gap-1 pr-1">
|
||||
|
||||
Reference in New Issue
Block a user