diff --git a/src/app/api/refresh/[slug]/route.ts b/src/app/api/refresh/[slug]/route.ts new file mode 100644 index 0000000..5532598 --- /dev/null +++ b/src/app/api/refresh/[slug]/route.ts @@ -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" }); +} \ No newline at end of file diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 51ba382..2e92159 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -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 { + 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 { + 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 }; } diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index ed11738..67c3106 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -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 { 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 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) { {!isArchive && (
- +
)} @@ -396,7 +325,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
{!isArchive && (
- +
)} @@ -482,25 +411,25 @@ export default async function HackDetail({ params }: HackDetailProps) {
  • Base ROM: {baseRom?.name || "Unknown"}
  • Created: {new Date(hack.created_at).toLocaleDateString()}
  • {lastUpdated &&
  • Last updated: {lastUpdated}
  • } - {hack.social_links && ( + {social && (
  • - {((hack.social_links as unknown) as { discord?: string })?.discord && ( - + {social.discord && ( + )} - {((hack.social_links as unknown) as { twitter?: string })?.twitter && ( - + {social.twitter && ( + )} - {((hack.social_links as unknown) as { pokecommunity?: string })?.pokecommunity && ( - + {social.pokecommunity && ( + )} - {((hack.social_links as unknown) as { github?: string })?.github && ( - + {social.github && ( + )} diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index fdbd1b6..c1a91b9 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -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}`); }