Add caching to hack page with revalidation

This commit is contained in:
jschoeny
2025-12-25 19:40:47 -10:00
parent 037b85826a
commit 1275d07c3e
4 changed files with 239 additions and 105 deletions

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { checkUserRoles } from "@/utils/user";
import { createClient } from "@/utils/supabase/server";
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const supa = await createClient();
const { data: user } = await supa.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { isAdmin } = await checkUserRoles(supa);
if (!isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
revalidateTag(`hack:${slug}:metadata`);
revalidateTag(`hack:${slug}:downloads`);
return NextResponse.json({ message: "Cache refreshed" });
}

View File

@@ -6,7 +6,182 @@ import { isInformationalArchiveHack, canEditAsCreator } from "@/utils/hack";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { headers } from "next/headers";
import { validateEmail } from "@/utils/auth";
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { unstable_cache as cache } from "next/cache";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
export interface HackMetadata {
hack: {
slug: string;
title: string;
summary: string;
description: string;
base_rom: string;
created_at: string;
updated_at: string | null;
current_patch: number | null;
box_art: string | null;
social_links: unknown;
created_by: string;
approved: boolean;
original_author: string | null;
permission_from: string | null;
language: string | null;
is_archive: boolean;
};
images: string[];
tags: string[];
profile: {
username: string | null;
avatar_url: string | null;
} | null;
otherHacks: {
slug: string;
title: string;
summary: string;
}[];
patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null;
}
export async function getHackMetadata(slug: string): Promise<HackMetadata | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
// Fetch covers
let images: string[] = [];
const { data: covers } = await supabase
.from("hack_covers")
.select("url, position")
.eq("hack_slug", slug)
.order("position", { ascending: true });
if (covers && covers.length > 0) {
images = getCoverUrls(covers.map(c => c.url));
}
// Fetch tags
const { data: tagRows } = await supabase
.from("hack_tags")
.select("order,tags(name)")
.eq("hack_slug", slug);
const tags = sortOrderedTags(
(tagRows || [])
.map((r) => ({
name: r.tags.name,
order: r.order,
}))
).map((t) => t.name);
// Fetch profile
const { data: profile } = await supabase
.from("profiles")
.select("username,avatar_url")
.eq("id", hack.created_by as string)
.maybeSingle();
// Get other approved hacks by the same author (non-archive hacks only)
let otherHacks: {
slug: string;
title: string;
summary: string;
}[] = [];
if (!hack.is_archive) {
const { data: otherHacksData } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
otherHacks = otherHacksData ?? [];
}
// Get patch info
let patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null = null;
if (hack.current_patch != null) {
const { data: patchData } = await supabase
.from("patches")
.select("id,bucket,filename,version,created_at,changelog")
.eq("id", hack.current_patch)
.maybeSingle();
if (patchData) {
patch = {
id: patchData.id,
filename: patchData.filename,
version: patchData.version || null,
created_at: patchData.created_at,
changelog: patchData.changelog || null,
};
}
}
return {
hack,
images,
tags,
profile: profile ? {
username: profile.username,
avatar_url: profile.avatar_url,
} : null,
otherHacks,
patch,
};
},
[`hack:${slug}:metadata`],
{
revalidate: 14400, // 4 hours
tags: ["hack", `hack:${slug}:metadata`],
}
);
return runner();
}
export async function getHackDownloads(slug: string): Promise<number | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("downloads")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
return hack.downloads || 0;
},
[`hack:${slug}:downloads`],
{
revalidate: 600, // 10 minutes
tags: ["hack", `hack:${slug}:downloads`],
}
);
return runner();
}
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
@@ -387,6 +562,7 @@ export async function rollbackToVersion(slug: string, patchId: number): Promise<
if (unpubErr) return { ok: false, error: unpubErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
@@ -428,6 +604,7 @@ export async function updatePatchChangelog(slug: string, patchId: number, change
if (updateErr) return { ok: false, error: updateErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}/changelog`);
return { ok: true };
@@ -491,6 +668,7 @@ export async function updatePatchVersion(slug: string, patchId: number, version:
if (updateErr) return { ok: false, error: updateErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
@@ -555,6 +733,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
if (updateHackErr) return { ok: false, error: updateHackErr.message };
}
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true, willBecomeCurrent };
@@ -639,6 +818,7 @@ export async function confirmReuploadPatchVersion(
if (updateErr) return { ok: false, error: updateErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}

View File

@@ -19,17 +19,16 @@ import serialize from "serialize-javascript";
import { headers } from "next/headers";
import { MenuItem } from "@headlessui/react";
import { FaCircleCheck } from "react-icons/fa6";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { RiArchiveStackFill } from "react-icons/ri";
import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack";
import Avatar from "@/components/Account/Avatar";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import { getHackMetadata, getHackDownloads } from "@/app/hack/[slug]/actions";
interface HackDetailProps {
params: Promise<{ slug: string }>;
}
export const revalidate = 3600; // 1 hour
export async function generateStaticParams() {
const supabase = await createServiceClient();
@@ -47,19 +46,10 @@ export async function generateStaticParams() {
export async function generateMetadata({ params }: HackDetailProps): Promise<Metadata> {
const { slug } = await params;
const supabase = await createClient();
const { data: hack } = await supabase
.from("hacks")
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch,permission_from,is_archive")
.eq("slug", slug)
.maybeSingle();
if (!hack) return { title: "Hack not found" };
const metadata = await getHackMetadata(slug);
if (!metadata) return { title: "Hack not found" };
const { data: profile } = await supabase
.from("profiles")
.select("username")
.eq("id", hack.created_by as string)
.maybeSingle();
const { hack, profile } = metadata;
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : undefined);
if (!hack.approved) return {
@@ -117,64 +107,18 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
export default async function HackDetail({ params }: HackDetailProps) {
const { slug } = await params;
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,approved,original_author,permission_from,language,is_archive")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return notFound();
const [metadata, downloads] = await Promise.all([
getHackMetadata(slug),
getHackDownloads(slug),
]);
if (!metadata) return notFound();
const { hack, images, tags, profile, otherHacks, patch } = metadata;
const baseRom = baseRoms.find((r) => r.id === hack.base_rom);
let images: string[] = [];
const { data: covers } = await supabase
.from("hack_covers")
.select("url, position")
.eq("hack_slug", slug)
.order("position", { ascending: true });
if (covers && covers.length > 0) {
images = getCoverUrls(covers.map(c => c.url));
}
const { data: tagRows } = await supabase
.from("hack_tags")
.select("order,tags(name)")
.eq("hack_slug", slug);
const tags = sortOrderedTags(
(tagRows || [])
.map((r) => ({
name: r.tags.name,
order: r.order,
}))
).map((t) => t.name);
const { data: profile } = await supabase
.from("profiles")
.select("username,avatar_url")
.eq("id", hack.created_by as string)
.maybeSingle();
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
// Get other approved hacks by the same author (non-archive hacks only)
let otherHacks: {
slug: string;
title: string;
summary: string;
}[] = [];
if (!hack.is_archive) {
const { data: otherHacksData } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
otherHacks = otherHacksData ?? [];
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
@@ -201,28 +145,13 @@ export default async function HackDetail({ params }: HackDetailProps) {
return notFound();
}
// Get patch info, but don't sign URL yet (happens on user interaction)
let patchFilename: string | null = null;
let patchVersion = isArchive ? "Archive" : "";
let patchId: number | null = null;
let lastUpdated: string | null = null;
let patchCreatedAt: string | null = null;
let patchChangelog: string | null = null;
if (hack.current_patch != null) {
const { data: patch } = await supabase
.from("patches")
.select("id,bucket,filename,version,created_at,changelog")
.eq("id", hack.current_patch as number)
.maybeSingle();
if (patch) {
patchFilename = patch.filename;
patchVersion = patch.version || "";
patchId = patch.id;
lastUpdated = new Date(patch.created_at).toLocaleDateString();
patchCreatedAt = patch.created_at;
patchChangelog = patch.changelog;
}
}
// Extract patch info from cached metadata
const patchFilename = patch?.filename || null;
const patchVersion = isArchive ? "Archive" : (patch?.version || "");
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;
// Build canonical URL, sameAs, dates, and JSON-LD
const hdrs = await headers();
@@ -235,7 +164,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
const authorName = hack.original_author || profile?.username || "Unknown";
const sameAs: string[] = [];
const social = (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null;
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);
@@ -365,7 +294,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
</span>
{!isArchive && (
<div className="inline-flex ml-auto md:hidden">
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
<DownloadsBadge slug={hack.slug} initialCount={downloads ?? 0} />
</div>
)}
</div>
@@ -396,7 +325,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
<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={hack.downloads} />
<DownloadsBadge slug={hack.slug} initialCount={downloads ?? 0} />
</div>
)}
<HackShareButton title={hack.title} url={pageUrl} author={hack.original_author || profile?.username || null} />
@@ -482,25 +411,25 @@ export default async function HackDetail({ params }: HackDetailProps) {
<li>Base ROM: {baseRom?.name || "Unknown"}</li>
<li>Created: {new Date(hack.created_at).toLocaleDateString()}</li>
{lastUpdated && <li>Last updated: {lastUpdated}</li>}
{hack.social_links && (
{social && (
<li className="flex flex-wrap items-center justify-center gap-4 mt-4">
{((hack.social_links as unknown) as { discord?: string })?.discord && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={((hack.social_links as unknown) as { discord?: string }).discord!} target="_blank" rel="noreferrer">
{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>
)}
{((hack.social_links as unknown) as { twitter?: string })?.twitter && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={((hack.social_links as unknown) as { twitter?: string }).twitter!} target="_blank" rel="noreferrer">
{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>
)}
{((hack.social_links as unknown) as { pokecommunity?: string })?.pokecommunity && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={((hack.social_links as unknown) as { pokecommunity?: string }).pokecommunity!} target="_blank" rel="noreferrer">
{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>
)}
{((hack.social_links as unknown) as { github?: string })?.github && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={((hack.social_links as unknown) as { github?: string }).github!} target="_blank" rel="noreferrer">
{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>
)}

View File

@@ -3,7 +3,7 @@
import { createClient } from "@/utils/supabase/server";
import type { TablesInsert } from "@/types/db";
import { getMinioClient, PATCHES_BUCKET, COVERS_BUCKET } from "@/utils/minio/server";
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { APIEmbed } from "discord-api-types/v10";
import { sendDiscordMessageEmbed } from "@/utils/discord";
@@ -113,6 +113,7 @@ export async function updateHack(args: {
}
}
revalidateTag(`hack:${args.slug}:metadata`);
return { ok: true } as const;
}
@@ -194,6 +195,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
}
revalidateTag(`hack:${args.slug}:metadata`);
return { ok: true } as const;
}
@@ -326,6 +328,8 @@ export async function approveHack(slug: string) {
]);
}
revalidateTag(`hack:${slug}:metadata`);
revalidateTag(`hack:${slug}:downloads`);
revalidatePath(`/hack/${slug}`);
redirect(`/hack/${slug}`);
}