mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-28 11:34:32 -05:00
Add ability for admins to approve hacks
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
113
src/app/hack/[slug]/approve/page.tsx
Normal file
113
src/app/hack/[slug]/approve/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user