mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Add caching to hack page with revalidation
This commit is contained in:
21
src/app/api/refresh/[slug]/route.ts
Normal file
21
src/app/api/refresh/[slug]/route.ts
Normal 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" });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user