Add ability for admins to approve hacks

This commit is contained in:
Jared Schoeny
2025-11-07 19:30:34 -10:00
parent fbe36812b6
commit a0a106f900
6 changed files with 356 additions and 13 deletions

View File

@@ -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<string, string | null>();
(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 && (
<div className="mt-12">
<h2 className="text-xl font-semibold mb-4">Pending hacks</h2>
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
<div className="col-span-5">Title</div>
<div className="col-span-3">Creator</div>
<div className="col-span-4">Created</div>
</div>
<div className="divide-y divide-amber-600/20">
{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 (
<Link
key={h.slug}
href={`/hack/${h.slug}`}
target="_blank"
className="group block px-4 py-3 text-sm bg-amber-500/5 hover:bg-amber-500/10 transition-colors"
>
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<div className="col-span-5 min-w-0">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0">
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{h.title}</div>
<div className="mt-0.5 text-xs text-amber-900/60 dark:text-amber-200/60 group-hover:underline">/{h.slug}</div>
</div>
</div>
</div>
<div className="col-span-3 text-amber-900/90 dark:text-amber-200/90">{creator}</div>
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="text-amber-900/90 dark:text-amber-200/90 flex-1">{createdDate}</div>
<FiExternalLink className="h-4 w-4 text-amber-900/90 dark:text-amber-200/90 flex-shrink-0" />
</div>
</div>
</div>
{/* Mobile card */}
<div className="lg:hidden flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0 flex-1">
<div className="font-medium break-words">{h.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
<span>{creator}</span>
<span></span>
<span>{createdDate}</span>
</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="mx-auto max-w-screen-lg w-full pb-28">
<div className="pt-8 md:pt-10 px-6">
{hack.approved ? (
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<FaCircleCheck className="text-green-500 flex-shrink-0" size={24} />
<h1 className="text-2xl">{hack.title}</h1>
</div>
<p className="text-foreground/75">
This hack has already been approved
{hack.approved_by && approverUsername
? <span> by <span className="font-semibold">@{approverUsername}</span></span>
: hack.approved_by
? " by an admin"
: ""}
{hack.approved_at && <span> on <span className="font-semibold">{localDateStr}</span> at <span className="font-semibold">{localTimeStr}</span></span>}.
</p>
<div className="mt-6">
<Link href={`/hack/${slug}`}>
<Button variant="secondary">Back to hack</Button>
</Link>
</div>
</div>
) : (
<div className="card p-6">
<div className="flex items-center gap-4 mb-4">
<FaTriangleExclamation className="text-yellow-500 flex-shrink-0" size={24} />
<h1 className="text-2xl">
Are you sure you want to approve <span className="font-semibold">{hack.title}</span>?
</h1>
</div>
<p className="text-foreground/75 mb-6">
By approving this hack, it will become visible to the public.
</p>
<form action={handleApprove} className="flex gap-3 justify-center md:justify-start">
<Button type="submit" variant="primary">
Approve
</Button>
<Link href={`/hack/${slug}`}>
<Button type="button" variant="secondary">
Cancel
</Button>
</Link>
</form>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<Met
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at")
.eq("slug", slug)
.maybeSingle();
if (!hack || !hack.approved) return { title: "Hack not found" };
const baseRomName = baseRoms.find((r) => 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 ? (
<div className="mx-6 mt-6 rounded-lg border-2 border-yellow-500/60 bg-yellow-50 dark:bg-yellow-900/20 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-yellow-600 dark:text-yellow-400" size={24} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
You are viewing this unpublished hack as an admin.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
This hack is pending approval. Please review the contents of this hack before making a decision. Then choose Approve from the dropdown options.
</p>
</div>
</div>
</div>
) : (
<div className="mx-6 mt-6 rounded-lg border-2 border-yellow-500/60 bg-yellow-50 dark:bg-yellow-900/20 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-yellow-600 dark:text-yellow-400" size={24} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
This hack is pending approval.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Your hack is currently under review and will be visible to all users once approved by an admin.
</p>
</div>
</div>
</div>
)
)}
<div className="pt-8 md:pt-10 px-6">
<div className="flex flex-col items-start justify-between gap-4 md:flex-wrap md:flex-row md:items-end">
<div>
@@ -241,7 +294,18 @@ export default async function HackDetail({ params }: HackDetailProps) {
</div>
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} />
<HackOptionsMenu slug={hack.slug} canEdit={canEdit}>
{isAdmin && (
<MenuItem
as="a"
href={`/hack/${hack.slug}/approve`}
className="block w-full px-3 py-2 text-left text-sm text-green-500 font-medium data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FaCircleCheck className="mr-2 inline-block align-middle mb-0.5 text-green-500" size={12} />
Approve
</MenuItem>
)}
</HackOptionsMenu>
</div>
</div>
</div>

View File

@@ -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}`);
}

View File

@@ -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({
<h2 className="text-xl font-semibold">Your hacks</h2>
<HackList hacks={hacks} />
</div>
{/* Per-hack insights removed; deeper stats are on each hack's /stats page */}
</div>
</DashboardProvider>
);

View File

@@ -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 (
<Menu as="div" className="relative">
<MenuButton
@@ -77,6 +78,10 @@ export default function HackOptionsMenu({ slug, canEdit }: HackOptionsMenuProps)
Upload new version
</MenuItem>
</>}
{children && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
{children}
</>}
</MenuItems>
</Menu>
);