Serve anonymous hack pages from cache without dropping owner tools (#86)

* Prerender public hack pages so anonymous visits can be served from cache.

Signed-in owners and admins still get the session-gated page at the same URL via a cookie-only rewrite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the site notice off the static hack page cache.

Signed-in visits to /session redirect to the canonical hack URL; signed-out visits 404.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Prerender the top 750 public hack pages at build time.

Almost the whole catalog is visited within a week, so first-hit generation after deploys is wasted compared to a slightly longer SSG step.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Show the public hack page when session auth is missing.

An expired or leftover cookie was rewriting visitors onto /session and 404ing approved hacks that anonymous users can see.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Note that forks must replace the hardcoded Supabase project ID.

Middleware cookie matchers cannot use env vars, so other communities need to swap in their own project ref.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-29 11:51:11 -06:00
committed by GitHub
parent 6308852df0
commit cb120290e5
9 changed files with 1018 additions and 638 deletions

View File

@@ -0,0 +1,29 @@
import { get } from "@vercel/edge-config";
const NOTICE_KEY =
process.env.NEXT_PUBLIC_NOTICE_KEY ?? "global_notice_message";
export const dynamic = "force-static";
export const revalidate = 60;
export async function GET() {
let message: string | null = null;
try {
const value = await get<string | null>(NOTICE_KEY);
if (typeof value === "string" && value.trim().length > 0) {
message = value.trim();
}
} catch {
// Fail silently if Edge Config is unavailable or misconfigured.
}
return Response.json(
{ message },
{
headers: {
"Cache-Control": "s-maxage=60, stale-while-revalidate",
},
},
);
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { checkUserRoles } from "@/utils/user";
import { createClient } from "@/utils/supabase/server";
@@ -17,5 +17,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ slug
revalidateTag(`hack:${slug}:metadata`);
revalidateTag(`hack:${slug}:downloads`);
revalidatePath(`/hack/${slug}`);
return NextResponse.redirect(new URL(`/hack/${slug}`, req.url));
}

View File

@@ -0,0 +1,93 @@
import { getHackMetadata } from "@/app/hack/[slug]/actions";
import { baseRoms } from "@/data/baseRoms";
import { isArchiveHack } from "@/utils/hack";
import type { Metadata } from "next";
export interface HackDetailPageProps {
params: Promise<{ slug: string }>;
}
export function getHackPageUrl(slug: string) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "");
return siteUrl ? `${siteUrl}/hack/${slug}` : `/hack/${slug}`;
}
export async function getHackPageMetadata(
slug: string,
includeRestricted: boolean,
): Promise<Metadata> {
const metadata = await getHackMetadata(slug);
if (
!metadata ||
(!includeRestricted &&
(!metadata.hack.approved || isArchiveHack(metadata.hack)))
) {
return { title: "Hack not found" };
}
const { hack, profile } = metadata;
const author = hack.original_author
? hack.original_author
: profile?.username
? `@${profile.username}`
: undefined;
if (!hack.approved) {
return {
title: hack.title,
description: "This hack is pending approval by an admin.",
};
}
const isArchive = isArchiveHack(hack);
const baseRomName =
baseRoms.find((rom) => rom.id === hack.base_rom)?.name ?? "Pokémon";
const pageUrl = getHackPageUrl(slug);
const title = isArchive
? `${hack.title} | Archive`
: `${hack.title} | ROM hack download`;
const description = isArchive
? `Archive entry for ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`
: `Play ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`;
return {
title,
description,
keywords: [
hack.title,
`${hack.title} rom hack`,
`${hack.title} patch`,
`${hack.title} patcher`,
`${hack.title} patched rom`,
`${hack.title} rom download`,
`${hack.title} download patch`,
baseRomName,
"Pokemon rom hack",
"Pokemon patch file",
`${baseRomName} rom hack`,
`${baseRomName} patch file`,
],
alternates: {
canonical: pageUrl,
},
openGraph: {
title,
description,
url: pageUrl,
authors: author ? [author] : undefined,
type: "article",
publishedTime: new Date(hack.created_at).toISOString(),
modifiedTime: hack.updated_at
? new Date(hack.updated_at).toISOString()
: undefined,
images: hack.box_art
? [
{
url: hack.box_art,
alt: `${hack.title} ROM hack box art`,
},
]
: undefined,
},
};
}

View File

@@ -1,623 +1,58 @@
import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms";
import type { Metadata } from "next";
import Link from "next/link";
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/actions";
import {
getHackPageMetadata,
type HackDetailPageProps,
} from "@/app/hack/[slug]/hack-page-shared";
import HackDetailView from "@/components/Hack/HackDetailView";
import { createServiceClient } from "@/utils/supabase/server";
import { isArchiveHack } from "@/utils/hack";
import { notFound } from "next/navigation";
import Gallery from "@/components/Hack/Gallery";
import HackActions from "@/components/Hack/HackActions";
import Markdown from "@/components/Markdown/Markdown";
import Image from "next/image";
import { FaDiscord, FaTwitter, FaGithub, FaTriangleExclamation, FaArrowUpRightFromSquare } from "react-icons/fa6";
import { FiAlertTriangle, FiInfo, FiMail } from "react-icons/fi";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import HackOptionsMenu from "@/components/Hack/HackOptionsMenu";
import DownloadsBadge from "@/components/Hack/DownloadsBadge";
import HackShareButton from "@/components/Hack/HackShareButton";
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";
import { RiArchiveStackFill } from "react-icons/ri";
import { TbProgressCheck } from "react-icons/tb";
import { isArchiveHack, checkEditPermission, checkPatchEditPermission } from "@/utils/hack";
import Avatar from "@/components/Account/Avatar";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import CollapsibleTags from "@/components/Hack/CollapsibleTags";
import { getHackMetadata, getHackDownloads } from "@/app/hack/[slug]/actions";
import CreateReviewThreadMenuItem from "@/components/Hack/CreateReviewThreadMenuItem";
import { getHackReviewThread } from "@/utils/hack-review";
interface HackDetailProps {
params: Promise<{ slug: string }>;
}
export const dynamic = "error";
export async function generateStaticParams() {
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.SUPABASE_SECRET_KEY
) {
return [];
}
const supabase = await createServiceClient();
const { data: hacks } = await supabase
.from("hacks")
.select("slug")
.eq("approved", true)
.eq("is_archive", false)
.order("downloads", { ascending: false })
.limit(100); // Pre-render top 100 most popular hacks
.limit(750);
return (hacks || []).map((hack) => ({
slug: hack.slug,
}));
return (hacks || []).map(({ slug }) => ({ slug }));
}
export async function generateMetadata({ params }: HackDetailProps): Promise<Metadata> {
export async function generateMetadata({ params }: HackDetailPageProps) {
const { slug } = await params;
return getHackPageMetadata(slug, false);
}
export default async function HackDetail({ params }: HackDetailPageProps) {
const { slug } = await params;
const metadata = await getHackMetadata(slug);
if (!metadata) return { title: "Hack not found" };
const { hack, profile } = metadata;
const author = hack.original_author ? hack.original_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 isArchive = isArchiveHack(hack);
const baseRomName = baseRoms.find((r) => r.id === hack.base_rom)?.name ?? "Pokémon";
const pageUrl = `/hack/${slug}`;
const title = isArchive ? `${hack.title} | Archive` : `${hack.title} | ROM hack download`;
const description = isArchive
? `Archive entry for ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`
: `Play ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`;
const keywords: string[] = [
hack.title,
`${hack.title} rom hack`,
`${hack.title} patch`,
`${hack.title} patcher`,
`${hack.title} patched rom`,
`${hack.title} rom download`,
`${hack.title} download patch`,
baseRomName,
"Pokemon rom hack",
"Pokemon patch file",
`${baseRomName} rom hack`,
`${baseRomName} patch file`,
];
return {
title,
description,
keywords,
alternates: {
canonical: pageUrl,
},
openGraph: {
title,
description,
url: pageUrl,
authors: author ? [author] : undefined,
type: "article",
publishedTime: new Date(hack.created_at).toISOString(),
modifiedTime: hack.updated_at ? new Date(hack.updated_at).toISOString() : undefined,
images: hack.box_art ? [
{
url: hack.box_art,
alt: `${hack.title} ROM hack box art`,
},
] : undefined,
},
} satisfies Metadata;
}
export default async function HackDetail({ params }: HackDetailProps) {
const { slug } = await params;
const [metadata, downloads] = await Promise.all([
getHackMetadata(slug),
getHackDownloads(slug),
]);
if (!metadata) return notFound();
const { hack, images, tags, profile, otherHacks, patch, displayVersion } = metadata;
const baseRom = baseRoms.find((r) => r.id === hack.base_rom);
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const {
canEdit,
canEditAsArchiver,
isInformationalArchive,
isDownloadableArchive,
isArchive,
} = await checkEditPermission(hack, user?.id as string, supabase);
const {
canEdit: canUploadPatch,
} = await checkPatchEditPermission(hack, user?.id as string, supabase);
let isAdmin = false;
const { data: admin } = await supabase.rpc("is_admin");
if (admin) {
isAdmin = true;
} else if (!hack.approved || isArchive) {
if (isArchive && !canEditAsArchiver) {
return notFound();
} else if (!canEdit) {
return notFound();
}
// Archive entries are currently available only through the signed-in route.
if (!metadata || !metadata.hack.approved || isArchiveHack(metadata.hack)) {
notFound();
}
const hasReviewThread =
isAdmin && !isArchive
? Boolean(await getHackReviewThread(hack.slug))
: false;
// Extract patch info from cached metadata
const patchFilename = patch?.filename || null;
const patchVersion = displayVersion;
const patchId = patch?.id || null;
const lastUpdated = patch ? new Date(patch.created_at).toLocaleDateString() : null;
const patchCreatedAt = patch?.created_at || null;
const patchChangelog = patch?.changelog || null;
const hasMissingPatch = !hack.approved && patchId === null;
const hasMissingScreenshots = !hack.approved && images.length === 0;
// Build canonical URL, sameAs, dates, and JSON-LD
const hdrs = await headers();
const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : "";
const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https");
const host = siteBase ? "" : (hdrs.get("host") || "");
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
const pageUrl = baseUrl ? `${baseUrl}/hack/${hack.slug}` : `/hack/${hack.slug}`;
const authorName = hack.original_author || profile?.username || "Unknown";
const sameAs: string[] = [];
const social = hack.social_links as { discord?: string; twitter?: string; pokecommunity?: string; github?: string } | null;
if (social?.discord) sameAs.push(social.discord);
if (social?.twitter) sameAs.push(social.twitter);
if (social?.pokecommunity) sameAs.push(social.pokecommunity);
const dateCreated = new Date(hack.created_at).toISOString();
const modifiedRaw = patchCreatedAt || (hack.updated_at as string) || (hack.created_at as string);
const dateModified = new Date(modifiedRaw).toISOString();
// Add common tags to keywords
const commonTags = ["Pokémon", "ROM Hack", "Patch", "BPS", "Romhack", "Pokemon", "Mod", "Game", "Hack"];
if (baseRom) commonTags.push(PLATFORM_NAMES[baseRom.platform], baseRom.platform, baseRom.name);
const keywords = tags.length ? [...tags, ...commonTags] : commonTags;
const jsonLd: WithContext<CreativeWork> = {
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: hack.title,
description: hack.summary || undefined,
url: pageUrl || undefined,
mainEntityOfPage: pageUrl || undefined,
image: images.length ? images : undefined,
thumbnailUrl: images.length ? images[0] : hack.box_art || undefined,
author: { '@type': 'Person', name: authorName },
sameAs: sameAs.length ? sameAs : undefined,
genre: "Game Mod",
dateCreated,
dateModified,
keywords: keywords,
version: patchVersion || undefined,
inLanguage: 'en',
isAccessibleForFree: true,
isBasedOn: baseRom ? {
'@type': 'VideoGame',
name: baseRom.name,
gamePlatform: PLATFORM_NAMES[baseRom.platform],
} : undefined,
};
const downloads = await getHackDownloads(slug);
return (
<div className="mx-auto max-w-screen-lg w-full pb-28">
{/* Honeypot links - hidden from users and screen readers */}
<div style={{ display: 'none' }} aria-hidden="true">
<a href={`/api/download/${hack.slug}/${hack.slug}.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/patch.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/download.bps`} tabIndex={-1} aria-hidden="true" />
<a href={`/api/download/${hack.slug}/rom.${baseRom?.platform?.toLowerCase() || 'gba'}`} tabIndex={-1} aria-hidden="true" />
</div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }}
/>
{!isInformationalArchive && !hasMissingPatch && (
<HackActions
title={hack.title}
version={patchVersion || "Pre-release"}
author={author}
baseRomId={baseRom?.id || ""}
platform={baseRom?.platform}
patchFilename={patchFilename}
patchId={patchId ?? undefined}
hackSlug={hack.slug}
patcherSelector={metadata.patcherSelector}
/>
)}
{isInformationalArchive && (
<div className="flex flex-row items-center gap-4 mx-6 mt-6 rounded-lg border-2 border-rose-500/40 bg-rose-50 dark:bg-rose-900/10 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<RiArchiveStackFill className="text-rose-600 dark:text-rose-400" size={28} />
</div>
</div>
<div className="flex items-center">
<div className="flex-1">
<h3 className="text-lg font-semibold text-rose-900 dark:text-rose-100 mb-0.5 md:mb-1">
Archive Entry
</h3>
<p className="text-sm text-rose-800 dark:text-rose-200">
This is an archive entry for informational and preservation purposes only. No patch file is available for download.
</p>
</div>
</div>
</div>
)}
{!hack.approved && (
<>
{hasMissingPatch && (
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">
This hack is missing a patch file.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
A patch file must be uploaded before this hack can be approved. Please try to ask the creator to upload a patch file.
</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">
Your hack is missing a patch file.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
You need to upload a patch file before an admin can approve your hack. Use the options menu to upload a patch file.
</p>
</div>
</div>
</div>
)
)}
{hasMissingScreenshots && (
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">
This hack is missing screenshots.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Screenshots should be uploaded before this hack can be approved. Please try to ask the creator to add screenshots.
</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">
Your hack is missing screenshots.
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
You should add screenshots before an admin can approve your hack. Use the options menu to add screenshots.
</p>
</div>
</div>
</div>
)
)}
{!hasMissingPatch && !hasMissingScreenshots && (
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">
Your 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>
)
)}
{isAdmin && hack.verification_contact_info && (
<div className="mx-6 mt-6 rounded-lg border-2 border-blue-500/60 bg-blue-50 dark:bg-blue-900/20 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<FiInfo className="text-blue-600 dark:text-blue-400" size={24} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
Verification Contact Information
</h3>
<div className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-line">
{hack.verification_contact_info}
</div>
</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>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{hack.title}</h1>
{hasMissingPatch ? (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
) : (
<span className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-medium text-foreground/85 ring-1 ring-[var(--border)]">
{patchVersion || "Pre-release"}
</span>
)}
{!isArchive && (
<div className="inline-flex ml-auto md:hidden">
<DownloadsBadge slug={hack.slug} initialCount={downloads ?? 0} />
</div>
)}
</div>
<div className={`mt-1 flex items-center ${!hack.original_author ? "h-[28px]" : ""}`}>
{!hack.original_author ? (
<>
<Avatar
uid={hack.created_by as string}
url={profile?.avatar_url ?? null}
size={28}
/>
<p className="text-[16px] md:text-[18px] text-foreground/70 ml-2">{author}</p>
{isAdmin && profile?.verified && (
<div className="relative flex items-center group">
<FaCircleCheck className="text-foreground/70 ml-1" size={16} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover:block group-hover:opacity-100">
Creator is verified
</div>
</div>
)}
</>
) : (
<p className="text-[16px] md:text-[18px] text-foreground/70">By {author}</p>
)}
</div>
{hack.completion_status && hack.completion_status !== "Complete" && (
<p className="w-fit rounded-full bg-[var(--surface-2)] mt-4 px-2 py-0.5 font-bold text-foreground/85 ring-1 ring-[var(--border)]">
<TbProgressCheck className="inline-block align-middle mr-1 text-foreground/85" size={18} />
{hack.completion_status}
</p>
)}
<p className={`${!isArchive && (!hack.completion_status || hack.completion_status === "Complete") ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
</div>
<div className="w-full mt-2 flex flex-col justify-between gap-2 md:gap-6 md:flex-row md:items-end">
<CollapsibleTags tags={tags} />
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
{!isArchive && (
<div className="hidden md:inline-flex mr-2">
<DownloadsBadge slug={hack.slug} initialCount={downloads ?? 0} />
</div>
)}
<HackShareButton title={hack.title} url={pageUrl} author={hack.original_author || profile?.username || null} />
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} canUploadPatch={canUploadPatch}>
{isAdmin && !hack.approved && (
<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>
)}
{isAdmin && profile?.email && (
<MenuItem
as="a"
href={`mailto:${profile.email}`}
className="block w-full px-3 py-2 text-left text-sm text-foreground/80 font-medium data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FiMail className="mr-2 inline-block align-middle mb-0.5 text-foreground/80" size={12} />
Contact creator
</MenuItem>
)}
{isAdmin && !isArchive && !hasReviewThread && (
<CreateReviewThreadMenuItem slug={hack.slug} />
)}
</HackOptionsMenu>
</div>
</div>
</div>
</div>
<div className="mt-6 px-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="min-w-0 space-y-6 lg:min-w-[640px]">
<Gallery images={images} title={hack.title} />
{patchId && patchCreatedAt && (
<CollapsibleCard
title={`${patchVersion || "Pre-release"} released on ${new Date(patchCreatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}`}
className="card-simple"
>
{patchChangelog && patchChangelog.trim().length > 0 ? (
<div className="prose prose-sm max-w-none text-foreground/80">
<Markdown headingLevelOffset={1}>{patchChangelog}</Markdown>
</div>
) : (
<p className="italic text-foreground/60">No changelog provided</p>
)}
</CollapsibleCard>
)}
<div className="card-simple p-5">
<h2 className="text-2xl font-semibold tracking-tight">About this hack</h2>
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
<Markdown headingLevelOffset={1}>{hack.description}</Markdown>
</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>
<ul className="mt-3 grid gap-2 text-sm text-foreground/75">
<li>Language: {hack.language || "Unknown"}</li>
<li>Base ROM: {baseRom?.name || "Unknown"}</li>
<li>First uploaded: {new Date(hack.created_at).toLocaleDateString()}</li>
{lastUpdated && <li>Last updated: {lastUpdated}</li>}
{social && (
<li className="flex flex-wrap items-center justify-center gap-4 mt-4">
{social.discord && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={social.discord} target="_blank" rel="noreferrer">
<FaDiscord size={32} />
</a>
)}
{social.twitter && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={social.twitter} target="_blank" rel="noreferrer">
<FaTwitter size={32} />
</a>
)}
{social.pokecommunity && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={social.pokecommunity} target="_blank" rel="noreferrer">
<PokeCommunityIcon width={32} height={32} color="currentColor" />
</a>
)}
{social.github && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={social.github} target="_blank" rel="noreferrer">
<FaGithub size={32} />
</a>
)}
</li>
)}
</ul>
</div>
{hack.box_art && (
<div className="card overflow-hidden pb-6 lg:pb-0">
<div className="flex items-center justify-between">
<div className="px-5 py-3 text-[15px] font-semibold tracking-tight">Box art</div>
<a
className="px-5 py-3 text-[15px] tracking-tight text-foreground/70 hover:underline"
href={hack.box_art}
download
target="_blank"
rel="noreferrer"
>
Download
</a>
</div>
<div className="relative aspect-square w-full max-h-[340px]">
<Image src={hack.box_art} alt={`${hack.title} box art`} fill className="object-contain" unoptimized />
</div>
</div>
)}
{otherHacks && otherHacks.length > 0 && (
<div className="card p-5">
<h3 className="text-[15px] font-semibold tracking-tight">More from {author}</h3>
<ul className="mt-3 space-y-3 text-sm text-foreground/75">
{otherHacks.map((otherHack) => (
<li key={otherHack.slug}>
<Link
href={`/hack/${otherHack.slug}`}
prefetch={false}
target="_blank"
className="group block hover:text-foreground"
>
<div className="flex items-center gap-1.5">
<span className="font-medium group-hover:underline underline-offset-2">
{otherHack.title}
</span>
<FaArrowUpRightFromSquare className="inline-block shrink-0" size={12} />
</div>
{otherHack.summary && (
<p className="mt-1 text-xs text-foreground/60 group-hover:text-foreground line-clamp-2">
{otherHack.summary}
</p>
)}
</Link>
</li>
))}
</ul>
</div>
)}
{isInformationalArchive ? (
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
<p>
This is an archive entry for <span className="font-semibold">{hack.title}</span> preserved for informational purposes.
{hack.original_author && (
<span> The original author of this hack is <span className="font-semibold">{hack.original_author}</span>.</span>
)}
</p>
<p className="mt-2">
Archive entries do not include patch files and are maintained for historical reference and preservation purposes only.
</p>
</div>
) : (
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
<p>
This page provides {isDownloadableArchive ? "an archived" : "the official"} patch file for <span className="font-semibold">{hack.title}</span>{isDownloadableArchive ? " with permission from the original creator" : ""}. You can safely download the patched ROM for this hack
using our built-in patcher.
</p>
<p className="mt-2">
By pressing "Agree and Patch", your browser will download and apply the <span className="font-semibold">{hack.title}</span> patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
</p>
<p className="mt-2">
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.
</p>
</div>
)}
</aside>
</div>
</div>
<HackDetailView
metadata={metadata}
downloads={downloads}
canEdit={false}
canUploadPatch={false}
isAdmin={false}
/>
);
}

View File

@@ -0,0 +1,100 @@
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/actions";
import {
getHackPageMetadata,
type HackDetailPageProps,
} from "@/app/hack/[slug]/hack-page-shared";
import HackDetailView from "@/components/Hack/HackDetailView";
import {
checkEditPermission,
checkPatchEditPermission,
isArchiveHack,
} from "@/utils/hack";
import { getHackReviewThread } from "@/utils/hack-review";
import { createClient } from "@/utils/supabase/server";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: HackDetailPageProps) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { slug } = await params;
return getHackPageMetadata(slug, Boolean(user));
}
export default async function HackSessionDetail({
params,
}: HackDetailPageProps) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { slug } = await params;
const [metadata, downloads] = await Promise.all([
getHackMetadata(slug),
getHackDownloads(slug),
]);
if (!metadata) {
notFound();
}
if (!user) {
if (!metadata.hack.approved || isArchiveHack(metadata.hack)) {
notFound();
}
return (
<HackDetailView
metadata={metadata}
downloads={downloads}
canEdit={false}
canUploadPatch={false}
isAdmin={false}
/>
);
}
const { hack } = metadata;
const userId = user.id;
const {
canEdit,
canEditAsArchiver,
isArchive,
} = await checkEditPermission(hack, userId, supabase);
const { canEdit: canUploadPatch } = await checkPatchEditPermission(
hack,
userId,
supabase,
);
let isAdmin = false;
const { data: admin } = await supabase.rpc("is_admin");
if (admin) {
isAdmin = true;
} else if (!hack.approved || isArchive) {
if (isArchive && !canEditAsArchiver) {
notFound();
} else if (!canEdit) {
notFound();
}
}
const hasReviewThread =
isAdmin && !isArchive
? Boolean(await getHackReviewThread(hack.slug))
: false;
return (
<HackDetailView
metadata={metadata}
downloads={downloads}
canEdit={canEdit}
canUploadPatch={canUploadPatch}
isAdmin={isAdmin}
hasReviewThread={hasReviewThread}
/>
);
}

View File

@@ -130,6 +130,7 @@ export async function updateHack(args: {
}
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
return { ok: true } as const;
}
@@ -212,6 +213,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
}
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
return { ok: true } as const;
}

View File

@@ -0,0 +1,636 @@
import Avatar from "@/components/Account/Avatar";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import CollapsibleTags from "@/components/Hack/CollapsibleTags";
import CreateReviewThreadMenuItem from "@/components/Hack/CreateReviewThreadMenuItem";
import DownloadsBadge from "@/components/Hack/DownloadsBadge";
import Gallery from "@/components/Hack/Gallery";
import HackActions from "@/components/Hack/HackActions";
import HackOptionsMenu from "@/components/Hack/HackOptionsMenu";
import HackShareButton from "@/components/Hack/HackShareButton";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
import Markdown from "@/components/Markdown/Markdown";
import { getHackPageUrl } from "@/app/hack/[slug]/hack-page-shared";
import type { HackMetadata } from "@/app/hack/[slug]/actions";
import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms";
import {
isArchiveHack,
isDownloadableArchiveHack,
isInformationalArchiveHack,
} from "@/utils/hack";
import { MenuItem } from "@headlessui/react";
import Image from "next/image";
import Link from "next/link";
import {
FaArrowUpRightFromSquare,
FaCircleCheck,
FaDiscord,
FaGithub,
FaTriangleExclamation,
FaTwitter,
} from "react-icons/fa6";
import { FiAlertTriangle, FiInfo, FiMail } from "react-icons/fi";
import { RiArchiveStackFill } from "react-icons/ri";
import { TbProgressCheck } from "react-icons/tb";
import type { CreativeWork, WithContext } from "schema-dts";
import serialize from "serialize-javascript";
interface HackDetailViewProps {
metadata: HackMetadata;
downloads: number | null;
canEdit: boolean;
canUploadPatch: boolean;
isAdmin: boolean;
hasReviewThread?: boolean;
}
export default function HackDetailView({
metadata,
downloads,
canEdit,
canUploadPatch,
isAdmin,
hasReviewThread = false,
}: HackDetailViewProps) {
const { hack, images, tags, profile, otherHacks, patch, displayVersion } =
metadata;
const baseRom = baseRoms.find((rom) => rom.id === hack.base_rom);
const author = hack.original_author
? hack.original_author
: profile?.username
? `@${profile.username}`
: "Unknown";
const isInformationalArchive = isInformationalArchiveHack(hack);
const isDownloadableArchive = isDownloadableArchiveHack(hack);
const isArchive = isArchiveHack(hack);
const patchFilename = patch?.filename || null;
const patchVersion = displayVersion;
const patchId = patch?.id || null;
const lastUpdated = patch
? new Date(patch.created_at).toLocaleDateString()
: null;
const patchCreatedAt = patch?.created_at || null;
const patchChangelog = patch?.changelog || null;
const hasMissingPatch = !hack.approved && patchId === null;
const hasMissingScreenshots = !hack.approved && images.length === 0;
const pageUrl = getHackPageUrl(hack.slug);
const authorName = hack.original_author || profile?.username || "Unknown";
const social = hack.social_links as {
discord?: string;
twitter?: string;
pokecommunity?: string;
github?: string;
} | null;
const sameAs = [
social?.discord,
social?.twitter,
social?.pokecommunity,
].filter((url): url is string => Boolean(url));
const commonTags = [
"Pokémon",
"ROM Hack",
"Patch",
"BPS",
"Romhack",
"Pokemon",
"Mod",
"Game",
"Hack",
];
if (baseRom) {
commonTags.push(
PLATFORM_NAMES[baseRom.platform],
baseRom.platform,
baseRom.name,
);
}
const jsonLd: WithContext<CreativeWork> = {
"@context": "https://schema.org",
"@type": "CreativeWork",
name: hack.title,
description: hack.summary || undefined,
url: pageUrl,
mainEntityOfPage: pageUrl,
image: images.length ? images : undefined,
thumbnailUrl: images.length ? images[0] : hack.box_art || undefined,
author: { "@type": "Person", name: authorName },
sameAs: sameAs.length ? sameAs : undefined,
genre: "Game Mod",
dateCreated: new Date(hack.created_at).toISOString(),
dateModified: new Date(
patchCreatedAt || hack.updated_at || hack.created_at,
).toISOString(),
keywords: tags.length ? [...tags, ...commonTags] : commonTags,
version: patchVersion || undefined,
inLanguage: "en",
isAccessibleForFree: true,
isBasedOn: baseRom
? {
"@type": "VideoGame",
name: baseRom.name,
gamePlatform: PLATFORM_NAMES[baseRom.platform],
}
: undefined,
};
return (
<div className="mx-auto max-w-screen-lg w-full pb-28">
<div style={{ display: "none" }} aria-hidden="true">
<a
href={`/api/download/${hack.slug}/${hack.slug}.bps`}
tabIndex={-1}
aria-hidden="true"
/>
<a
href={`/api/download/${hack.slug}/patch.bps`}
tabIndex={-1}
aria-hidden="true"
/>
<a
href={`/api/download/${hack.slug}/download.bps`}
tabIndex={-1}
aria-hidden="true"
/>
<a
href={`/api/download/${hack.slug}/rom.${baseRom?.platform?.toLowerCase() || "gba"}`}
tabIndex={-1}
aria-hidden="true"
/>
</div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: serialize(jsonLd, { isJSON: true }),
}}
/>
{!isInformationalArchive && !hasMissingPatch && (
<HackActions
title={hack.title}
version={patchVersion || "Pre-release"}
author={author}
baseRomId={baseRom?.id || ""}
platform={baseRom?.platform}
patchFilename={patchFilename}
patchId={patchId ?? undefined}
hackSlug={hack.slug}
patcherSelector={metadata.patcherSelector}
/>
)}
{isInformationalArchive && (
<div className="flex flex-row items-center gap-4 mx-6 mt-6 rounded-lg border-2 border-rose-500/40 bg-rose-50 dark:bg-rose-900/10 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<RiArchiveStackFill
className="text-rose-600 dark:text-rose-400"
size={28}
/>
</div>
</div>
<div className="flex items-center">
<div className="flex-1">
<h3 className="text-lg font-semibold text-rose-900 dark:text-rose-100 mb-0.5 md:mb-1">
Archive Entry
</h3>
<p className="text-sm text-rose-800 dark:text-rose-200">
This is an archive entry for informational and preservation
purposes only. No patch file is available for download.
</p>
</div>
</div>
</div>
)}
{!hack.approved && (
<>
{hasMissingPatch &&
(isAdmin ? (
<PendingWarning
title="This hack is missing a patch file."
description="A patch file must be uploaded before this hack can be approved. Please try to ask the creator to upload a patch file."
/>
) : (
<PendingWarning
title="Your hack is missing a patch file."
description="You need to upload a patch file before an admin can approve your hack. Use the options menu to upload a patch file."
/>
))}
{hasMissingScreenshots &&
(isAdmin ? (
<PendingWarning
title="This hack is missing screenshots."
description="Screenshots should be uploaded before this hack can be approved. Please try to ask the creator to add screenshots."
/>
) : (
<PendingWarning
title="Your hack is missing screenshots."
description="You should add screenshots before an admin can approve your hack. Use the options menu to add screenshots."
/>
))}
{!hasMissingPatch &&
!hasMissingScreenshots &&
(isAdmin ? (
<PendingWarning
title="You are viewing this unpublished hack as an admin."
description="This hack is pending approval. Please review the contents of this hack before making a decision. Then choose Approve from the dropdown options."
/>
) : (
<PendingWarning
title="Your hack is pending approval."
description="Your hack is currently under review and will be visible to all users once approved by an admin."
/>
))}
{isAdmin && hack.verification_contact_info && (
<div className="mx-6 mt-6 rounded-lg border-2 border-blue-500/60 bg-blue-50 dark:bg-blue-900/20 p-4 md:pl-6">
<div className="flex items-center gap-4 md:gap-6">
<div className="flex-shrink-0">
<FiInfo
className="text-blue-600 dark:text-blue-400"
size={24}
/>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
Verification Contact Information
</h3>
<div className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-line">
{hack.verification_contact_info}
</div>
</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>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
{hack.title}
</h1>
{hasMissingPatch ? (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
) : (
<span className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-medium text-foreground/85 ring-1 ring-[var(--border)]">
{patchVersion || "Pre-release"}
</span>
)}
{!isArchive && (
<div className="inline-flex ml-auto md:hidden">
<DownloadsBadge
slug={hack.slug}
initialCount={downloads ?? 0}
/>
</div>
)}
</div>
<div
className={`mt-1 flex items-center ${!hack.original_author ? "h-[28px]" : ""}`}
>
{!hack.original_author ? (
<>
<Avatar
uid={hack.created_by}
url={profile?.avatar_url ?? null}
size={28}
/>
<p className="text-[16px] md:text-[18px] text-foreground/70 ml-2">
{author}
</p>
{isAdmin && profile?.verified && (
<div className="relative flex items-center group">
<FaCircleCheck
className="text-foreground/70 ml-1"
size={16}
/>
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover:block group-hover:opacity-100">
Creator is verified
</div>
</div>
)}
</>
) : (
<p className="text-[16px] md:text-[18px] text-foreground/70">
By {author}
</p>
)}
</div>
{hack.completion_status &&
hack.completion_status !== "Complete" && (
<p className="w-fit rounded-full bg-[var(--surface-2)] mt-4 px-2 py-0.5 font-bold text-foreground/85 ring-1 ring-[var(--border)]">
<TbProgressCheck
className="inline-block align-middle mr-1 text-foreground/85"
size={18}
/>
{hack.completion_status}
</p>
)}
<p
className={`${!isArchive && (!hack.completion_status || hack.completion_status === "Complete") ? "mt-4" : "mt-2"} text-sm text-foreground/75`}
>
{hack.summary}
</p>
</div>
<div className="w-full mt-2 flex flex-col justify-between gap-2 md:gap-6 md:flex-row md:items-end">
<CollapsibleTags tags={tags} />
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
{!isArchive && (
<div className="hidden md:inline-flex mr-2">
<DownloadsBadge
slug={hack.slug}
initialCount={downloads ?? 0}
/>
</div>
)}
<HackShareButton
title={hack.title}
url={pageUrl}
author={hack.original_author || profile?.username || null}
/>
<HackOptionsMenu
slug={hack.slug}
canEdit={canEdit}
canUploadPatch={canUploadPatch}
>
{isAdmin && !hack.approved && (
<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>
)}
{isAdmin && profile?.email && (
<MenuItem
as="a"
href={`mailto:${profile.email}`}
className="block w-full px-3 py-2 text-left text-sm text-foreground/80 font-medium data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FiMail
className="mr-2 inline-block align-middle mb-0.5 text-foreground/80"
size={12}
/>
Contact creator
</MenuItem>
)}
{isAdmin && !isArchive && !hasReviewThread && (
<CreateReviewThreadMenuItem slug={hack.slug} />
)}
</HackOptionsMenu>
</div>
</div>
</div>
</div>
<div className="mt-6 px-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="min-w-0 space-y-6 lg:min-w-[640px]">
<Gallery images={images} title={hack.title} />
{patchId && patchCreatedAt && (
<CollapsibleCard
title={`${patchVersion || "Pre-release"} released on ${new Date(
patchCreatedAt,
).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}`}
className="card-simple"
>
{patchChangelog && patchChangelog.trim().length > 0 ? (
<div className="prose prose-sm max-w-none text-foreground/80">
<Markdown headingLevelOffset={1}>{patchChangelog}</Markdown>
</div>
) : (
<p className="italic text-foreground/60">
No changelog provided
</p>
)}
</CollapsibleCard>
)}
<div className="card-simple p-5">
<h2 className="text-2xl font-semibold tracking-tight">
About this hack
</h2>
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
<Markdown headingLevelOffset={1}>{hack.description}</Markdown>
</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>
<ul className="mt-3 grid gap-2 text-sm text-foreground/75">
<li>Language: {hack.language || "Unknown"}</li>
<li>Base ROM: {baseRom?.name || "Unknown"}</li>
<li>
First uploaded:{" "}
{new Date(hack.created_at).toLocaleDateString()}
</li>
{lastUpdated && <li>Last updated: {lastUpdated}</li>}
{social && (
<li className="flex flex-wrap items-center justify-center gap-4 mt-4">
{social.discord && (
<SocialLink href={social.discord}>
<FaDiscord size={32} />
</SocialLink>
)}
{social.twitter && (
<SocialLink href={social.twitter}>
<FaTwitter size={32} />
</SocialLink>
)}
{social.pokecommunity && (
<SocialLink href={social.pokecommunity}>
<PokeCommunityIcon
width={32}
height={32}
color="currentColor"
/>
</SocialLink>
)}
{social.github && (
<SocialLink href={social.github}>
<FaGithub size={32} />
</SocialLink>
)}
</li>
)}
</ul>
</div>
{hack.box_art && (
<div className="card overflow-hidden pb-6 lg:pb-0">
<div className="flex items-center justify-between">
<div className="px-5 py-3 text-[15px] font-semibold tracking-tight">
Box art
</div>
<a
className="px-5 py-3 text-[15px] tracking-tight text-foreground/70 hover:underline"
href={hack.box_art}
download
target="_blank"
rel="noreferrer"
>
Download
</a>
</div>
<div className="relative aspect-square w-full max-h-[340px]">
<Image
src={hack.box_art}
alt={`${hack.title} box art`}
fill
className="object-contain"
unoptimized
/>
</div>
</div>
)}
{otherHacks.length > 0 && (
<div className="card p-5">
<h3 className="text-[15px] font-semibold tracking-tight">
More from {author}
</h3>
<ul className="mt-3 space-y-3 text-sm text-foreground/75">
{otherHacks.map((otherHack) => (
<li key={otherHack.slug}>
<Link
href={`/hack/${otherHack.slug}`}
prefetch={false}
target="_blank"
className="group block hover:text-foreground"
>
<div className="flex items-center gap-1.5">
<span className="font-medium group-hover:underline underline-offset-2">
{otherHack.title}
</span>
<FaArrowUpRightFromSquare
className="inline-block shrink-0"
size={12}
/>
</div>
{otherHack.summary && (
<p className="mt-1 text-xs text-foreground/60 group-hover:text-foreground line-clamp-2">
{otherHack.summary}
</p>
)}
</Link>
</li>
))}
</ul>
</div>
)}
{isInformationalArchive ? (
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
<p>
This is an archive entry for{" "}
<span className="font-semibold">{hack.title}</span> preserved
for informational purposes.
{hack.original_author && (
<span>
{" "}
The original author of this hack is{" "}
<span className="font-semibold">
{hack.original_author}
</span>
.
</span>
)}
</p>
<p className="mt-2">
Archive entries do not include patch files and are maintained
for historical reference and preservation purposes only.
</p>
</div>
) : (
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
<p>
This page provides{" "}
{isDownloadableArchive ? "an archived" : "the official"} patch
file for <span className="font-semibold">{hack.title}</span>
{isDownloadableArchive
? " with permission from the original creator"
: ""}
. You can safely download the patched ROM for this hack using
our built-in patcher.
</p>
<p className="mt-2">
By pressing &quot;Agree and Patch&quot;, your browser will
download and apply the{" "}
<span className="font-semibold">{hack.title}</span> patch file
to your legally-obtained{" "}
<span className="font-semibold">{baseRom?.name}</span> ROM. The
patched ROM will then be automatically downloaded.
</p>
<p className="mt-2">
No pre-patched ROMs or base ROMs are hosted or distributed on
this site. All patching is done locally on your device.
</p>
</div>
)}
</aside>
</div>
</div>
);
}
function PendingWarning({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<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">
{title}
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{description}
</p>
</div>
</div>
</div>
);
}
function SocialLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<a
className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300"
href={href}
target="_blank"
rel="noreferrer"
>
{children}
</a>
);
}

View File

@@ -1,19 +1,41 @@
import { get } from "@vercel/edge-config";
"use client";
const NOTICE_KEY = process.env.NEXT_PUBLIC_NOTICE_KEY ?? "global_notice_message";
import { useEffect, useState } from "react";
export default async function NoticeBanner() {
let message: string | null = null;
export default function NoticeBanner() {
const [message, setMessage] = useState<string | null>(null);
try {
const value = await get<string | null>(NOTICE_KEY);
if (typeof value === "string" && value.trim().length > 0) {
message = value.trim();
useEffect(() => {
const controller = new AbortController();
async function fetchNotice() {
try {
const response = await fetch("/api/notice", {
signal: controller.signal,
});
if (!response.ok) {
return;
}
const data: unknown = await response.json();
if (
typeof data === "object" &&
data !== null &&
"message" in data &&
(typeof data.message === "string" || data.message === null)
) {
setMessage(data.message);
}
} catch {
// Fail silently if the notice endpoint is unavailable.
}
}
} catch (error) {
// Fail silently if Edge Config is unavailable or misconfigured
return null;
}
void fetchNotice();
return () => controller.abort();
}, []);
if (!message) {
return null;

View File

@@ -1,35 +1,97 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/middleware";
function pathSegments(pathname: string) {
return pathname.split("/").filter(Boolean);
}
function isHackDetailPath(pathname: string) {
const segments = pathSegments(pathname);
return segments.length === 2 && segments[0] === "hack";
}
function isHackSessionPath(pathname: string) {
const segments = pathSegments(pathname);
return (
segments.length === 3 &&
segments[0] === "hack" &&
segments[2] === "session"
);
}
function hasSupabaseAuthCookie(request: NextRequest) {
return request.cookies
.getAll()
.some((cookie) => /^sb-.*-auth-token(?:\.\d+)?$/.test(cookie.name));
}
export async function middleware(request: NextRequest) {
// update user's auth session
return await updateSession(request)
const { pathname } = request.nextUrl;
const hackDetail = isHackDetailPath(pathname);
const hackSession = isHackSessionPath(pathname);
const signedIn = hasSupabaseAuthCookie(request);
// Matcher `has.cookie` is exact-name only and misses chunked Supabase cookies
// (`sb-*-auth-token.0`). Inspect cookies here; see Next.js middleware docs.
if ((hackDetail || hackSession) && !signedIn) {
return NextResponse.next();
}
const sessionResponse = await updateSession(request);
if (hackSession) {
const slug = pathSegments(pathname)[1];
const redirectResponse = NextResponse.redirect(
new URL(`/hack/${slug}`, request.url),
);
sessionResponse.cookies.getAll().forEach((cookie) => {
redirectResponse.cookies.set(cookie);
});
return redirectResponse;
}
if (hackDetail) {
const slug = pathSegments(pathname)[1];
const rewriteResponse = NextResponse.rewrite(
new URL(`/hack/${slug}/session`, request.url),
);
sessionResponse.cookies.getAll().forEach((cookie) => {
rewriteResponse.cookies.set(cookie);
});
return rewriteResponse;
}
return sessionResponse;
}
// All values must be hardcoded in the middleware config,
// which is why the project ID is hardcoded for the cookie key.
// Matcher values must be string literals (statically analyzed), which is why
// the Supabase project ID is hardcoded in the cookie keys. Forks should
// replace `sb-scfxbqapigdjfebqwmoj-auth-token` with their own project ref.
// `/hack/:slug` matches `/hack/foo` but not `/hack/foo/edit`. Separate matcher
// objects are OR; items in a single `has` array are AND.
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
{ source: "/hack/:slug" },
{ source: "/hack/:slug/session" },
{
// Production
source: '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
has: [
{ type: 'cookie', key: 'sb-scfxbqapigdjfebqwmoj-auth-token' },
]
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
has: [{ type: "cookie", key: "sb-scfxbqapigdjfebqwmoj-auth-token" }],
},
{
// Local development
source: '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
has: [
{ type: 'cookie', key: 'sb-127-auth-token' },
]
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
has: [{ type: "cookie", key: "sb-scfxbqapigdjfebqwmoj-auth-token.0" }],
},
{
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
has: [{ type: "cookie", key: "sb-127-auth-token" }],
},
{
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
has: [{ type: "cookie", key: "sb-127-auth-token.0" }],
},
],
}
};