From a0a106f900e54d222133c0ace6338a8ab1387a3f Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 7 Nov 2025 19:30:34 -1000 Subject: [PATCH] Add ability for admins to approve hacks --- src/app/dashboard/page.tsx | 101 +++++++++++++++++ src/app/hack/[slug]/approve/page.tsx | 113 +++++++++++++++++++ src/app/hack/[slug]/page.tsx | 82 ++++++++++++-- src/app/hack/actions.ts | 62 ++++++++++ src/components/Dashboard/DashboardClient.tsx | 4 +- src/components/Hack/HackOptionsMenu.tsx | 7 +- 6 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 src/app/hack/[slug]/approve/page.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c5489a9..a8ac65a 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,7 +1,10 @@ import { createClient } from "@/utils/supabase/server"; +import Link from "next/link"; import { redirect } from "next/navigation"; +import { FiExternalLink } from "react-icons/fi"; import DashboardClient from "@/components/Dashboard/DashboardClient"; import { getDownloadsSeriesAll } from "./actions"; +import type { HackRow } from "@/components/Dashboard/DashboardClient"; export default async function DashboardPage() { const supa = await createClient(); @@ -9,6 +12,32 @@ export default async function DashboardPage() { const user = userResp.user; if (!user) redirect("/login"); + const { data: isAdmin } = await supa.rpc("is_admin"); + let pendingHacks: (HackRow & { created_by: string; creator_username: string | null })[] = []; + if (isAdmin) { + const { data: pendingHacksData } = await supa + .from("hacks") + .select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by") + .eq("approved", false) + .order("created_at", { ascending: false }); + + if (pendingHacksData && pendingHacksData.length > 0) { + // Fetch creator usernames + const creatorIds = [...new Set(pendingHacksData.map(h => h.created_by as string))]; + const { data: profiles } = await supa + .from("profiles") + .select("id,username") + .in("id", creatorIds); + + const usernameById = new Map(); + (profiles || []).forEach((p) => usernameById.set(p.id, p.username)); + + pendingHacks = pendingHacksData.map((h) => ({ + ...h, + creator_username: usernameById.get(h.created_by as string) || null, + })); + } + } const { data: profile } = await supa .from("profiles") @@ -37,6 +66,78 @@ export default async function DashboardPage() { initialSeriesAll={seriesAll} displayName={full_name || `@${username}`} /> + + {pendingHacks.length > 0 && ( +
+

Pending hacks

+
+ {/* Header row (desktop only) */} +
+
Title
+
Creator
+
Created
+
+
+ {pendingHacks.map((h) => { + console.log(h.created_at); + const createdDate = h.created_at + ? new Date(h.created_at).toLocaleTimeString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "Unknown"; + const creator = h.creator_username ? `@${h.creator_username}` : "Unknown"; + + return ( + + {/* Desktop row */} +
+
+
+
+
{h.title}
+
/{h.slug}
+
+
+
+
{creator}
+
+
+
{createdDate}
+ +
+
+
+ {/* Mobile card */} +
+
+
+
{h.title}
+
/{h.slug}
+
+ {creator} + + {createdDate} +
+
+ +
+
+ + ); + })} +
+
+
+ )} ); } diff --git a/src/app/hack/[slug]/approve/page.tsx b/src/app/hack/[slug]/approve/page.tsx new file mode 100644 index 0000000..00572e9 --- /dev/null +++ b/src/app/hack/[slug]/approve/page.tsx @@ -0,0 +1,113 @@ +import { createClient } from "@/utils/supabase/server"; +import { notFound } from "next/navigation"; +import { approveHack } from "@/app/hack/actions"; +import Button from "@/components/Button"; +import Link from "next/link"; +import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; + +interface ApprovePageProps { + params: Promise<{ slug: string }>; +} + +export default async function ApprovePage({ params }: ApprovePageProps) { + const { slug } = await params; + const supabase = await createClient(); + + // Check if user is admin + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return notFound(); + + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) return notFound(); + + // Fetch hack data + const { data: hack, error } = await supabase + .from("hacks") + .select("title, approved, approved_at, approved_by") + .eq("slug", slug) + .maybeSingle(); + + if (error || !hack) return notFound(); + + // If already approved, fetch approver's username + let approverUsername: string | null = null; + if (hack.approved && hack.approved_by) { + const { data: profile } = await supabase + .from("profiles") + .select("username") + .eq("id", hack.approved_by as string) + .maybeSingle(); + approverUsername = profile?.username || null; + } + + const utcDate = hack.approved_at ? new Date(hack.approved_at) : null; // Ensure it's parsed as UTC + const localDateStr = utcDate ? utcDate.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric" + }) : null; + const localTimeStr = utcDate ? utcDate.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }) : null; + + async function handleApprove() { + "use server"; + await approveHack(slug); + } + + return ( +
+
+ {hack.approved ? ( +
+
+ +

{hack.title}

+
+

+ This hack has already been approved + {hack.approved_by && approverUsername + ? by @{approverUsername} + : hack.approved_by + ? " by an admin" + : ""} + {hack.approved_at && on {localDateStr} at {localTimeStr}}. +

+
+ + + +
+
+ ) : ( +
+
+ +

+ Are you sure you want to approve {hack.title}? +

+
+

+ By approving this hack, it will become visible to the public. +

+
+ + + + +
+
+ )} +
+
+ ); +} + diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 4d90478..6a4fa63 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSlug from "rehype-slug"; import Image from "next/image"; -import { FaDiscord, FaTwitter } from "react-icons/fa6"; +import { FaDiscord, FaTwitter, FaTriangleExclamation } from "react-icons/fa6"; import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; import { createClient } from "@/utils/supabase/server"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; @@ -16,6 +16,8 @@ import DownloadsBadge from "@/components/Hack/DownloadsBadge"; import type { CreativeWork, WithContext } from "schema-dts"; import serialize from "serialize-javascript"; import { headers } from "next/headers"; +import { MenuItem } from "@headlessui/react"; +import { FaCircleCheck } from "react-icons/fa6"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -29,16 +31,21 @@ export async function generateMetadata({ params }: HackDetailProps): Promise r.id === hack.base_rom)?.name ?? "Pokémon"; + if (!hack) return { title: "Hack not found" }; const { data: profile } = await supabase - .from("profiles") - .select("username") - .eq("id", hack.created_by as string) - .maybeSingle(); + .from("profiles") + .select("username") + .eq("id", hack.created_by as string) + .maybeSingle(); const author = profile?.username ? `@${profile.username}` : undefined; + if (!hack.approved) return { + title: hack.title, + description: 'This hack is pending approval by an admin.', + } satisfies Metadata; + + const baseRomName = baseRoms.find((r) => r.id === hack.base_rom)?.name ?? "Pokémon"; const pageUrl = `/hack/${slug}`; const title = `${hack.title} | ROM hack download`; const description = `Play ${hack.title}, a fan-made Pokémon ROM hack for ${baseRomName}. ${hack.summary}`; @@ -88,7 +95,7 @@ export default async function HackDetail({ params }: HackDetailProps) { const supabase = await createClient(); const { data: hack, error } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by") + .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved") .eq("slug", slug) .maybeSingle(); if (error || !hack) return notFound(); @@ -127,6 +134,16 @@ export default async function HackDetail({ params }: HackDetailProps) { } = await supabase.auth.getUser(); const canEdit = !!user && user.id === (hack.created_by as string); + let isAdmin = false; + if (!hack.approved && !canEdit) { + const { data: admin } = await supabase.rpc("is_admin"); + if (admin) { + isAdmin = true; + } else { + return notFound(); + } + } + // Resolve a short-lived signed patch URL (if current_patch exists) let patchFilename: string | null = null; let signedPatchUrl = ""; @@ -219,6 +236,42 @@ export default async function HackDetail({ params }: HackDetailProps) { hackSlug={hack.slug} /> + {!hack.approved && ( + isAdmin ? ( +
+
+
+ +
+
+

+ You are viewing this unpublished hack as an admin. +

+

+ This hack is pending approval. Please review the contents of this hack before making a decision. Then choose Approve from the dropdown options. +

+
+
+
+ ) : ( +
+
+
+ +
+
+

+ This hack is pending approval. +

+

+ Your hack is currently under review and will be visible to all users once approved by an admin. +

+
+
+
+ ) + )} +
@@ -241,7 +294,18 @@ export default async function HackDetail({ params }: HackDetailProps) {
- + + {isAdmin && ( + + + Approve + + )} +
diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index c02e133..8e1e39e 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -3,6 +3,10 @@ import { createClient } from "@/utils/supabase/server"; import type { TablesInsert } from "@/types/db"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { APIEmbed } from "discord-api-types/v10"; +import { sendDiscordMessageEmbed } from "@/utils/discord"; export async function updateHack(args: { slug: string; @@ -198,4 +202,62 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri return { ok: true, presignedUrl: url, objectKey } as const; } +export async function approveHack(slug: string) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + // Check if user is admin + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) return { ok: false, error: "Forbidden" } as const; + + // Check if hack exists + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, approved, title") + .eq("slug", slug) + .maybeSingle(); + if (hErr) return { ok: false, error: hErr.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + + // If already approved, return success + if (hack.approved) { + revalidatePath(`/hack/${slug}`); + return { ok: true } as const; + } + + // Approve the hack + const { error: updateErr } = await supabase + .from("hacks") + .update({ + approved: true, + approved_at: new Date().toISOString(), + approved_by: user.id, + }) + .eq("slug", slug); + + if (updateErr) return { ok: false, error: updateErr.message } as const; + + if (process.env.DISCORD_WEBHOOK_ADMIN_URL) { + const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single(); + const displayName = profile?.username ? `@${profile.username}` : user.id; + const embed: APIEmbed = { + title: `:tada: ${hack.title} :tada:`, + description: `A new hack by **${displayName}** is now live!`, + color: 0x40f56a, + url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`, + footer: { + text: `This message brought to you by Hackdex` + } + } + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [ + embed, + ]); + } + + revalidatePath(`/hack/${slug}`); + redirect(`/hack/${slug}`); +} diff --git a/src/components/Dashboard/DashboardClient.tsx b/src/components/Dashboard/DashboardClient.tsx index 8567e49..d1317d1 100644 --- a/src/components/Dashboard/DashboardClient.tsx +++ b/src/components/Dashboard/DashboardClient.tsx @@ -7,7 +7,7 @@ import { DashboardProvider } from "@/contexts/DashboardContext"; import DownloadsChart from "@/components/Dashboard/DownloadsChart"; import HackList from "@/components/Dashboard/HackList"; -type HackRow = { +export type HackRow = { slug: string; title: string; approved: boolean; @@ -98,8 +98,6 @@ export default function DashboardClient({

Your hacks

- - {/* Per-hack insights removed; deeper stats are on each hack's /stats page */} ); diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx index 8ea5be1..0cd6472 100644 --- a/src/components/Hack/HackOptionsMenu.tsx +++ b/src/components/Hack/HackOptionsMenu.tsx @@ -7,9 +7,10 @@ import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessu interface HackOptionsMenuProps { slug: string; canEdit: boolean; + children?: React.ReactNode; } -export default function HackOptionsMenu({ slug, canEdit }: HackOptionsMenuProps) { +export default function HackOptionsMenu({ slug, canEdit, children }: HackOptionsMenuProps) { return ( } + {children && <> + + {children} + } );