Serve Discover and the homepage from cache instead of a function per visit (#88)

* Make catalog pages statically cacheable

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

* Match discover skeleton card height

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

* Align skeleton text line reservations

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

* Fix catalog invalidation gaps

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

* Stop animating browser back while keeping tag-picker scroll smooth

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-29 18:43:45 -06:00
committed by GitHub
parent cb120290e5
commit e30d7b0dd0
14 changed files with 447 additions and 467 deletions

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { checkUserRoles } from "@/utils/user";
import { createClient } from "@/utils/supabase/server";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
export async function GET(req: NextRequest) {
const supa = await createClient();
@@ -14,6 +14,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
revalidateTag("discover");
revalidateDiscoverCatalog();
return NextResponse.redirect(new URL("/discover", req.url));
}

View File

@@ -1,377 +1,267 @@
"use server";
import { unstable_cache as cache } from "next/cache";
import { createServiceClient } from "@/utils/supabase/server";
import { getCachedTagsWithUsage, buildTagFilterGroups } from "@/data/tags";
import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format";
import { sortOrderedTags, type OrderedTag, getCoverUrls } from "@/utils/format";
import { fetchInChunks } from "@/utils/array";
import { HackCardAttributes } from "@/components/HackCard";
import type { DiscoverSortOption } from "@/types/discover";
import type { Tables } from "@/types/db";
import type { DiscoverData } from "@/types/discover";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
const TRENDING_WINDOW_DAYS = 3;
const TIME_TO_LIVE = 600; // 10 minutes
const DISCOVER_REVALIDATE_SECONDS = 1800;
const CHUNK_SIZE = 150;
const ROW_BATCH_SIZE = 1000;
export interface DiscoverDataResult {
hacks: HackCardAttributes[];
tagGroups: Record<string, string[]>;
ungroupedTags: string[];
}
type HackTagRow = Pick<Tables<"hack_tags">, "hack_slug" | "order"> & {
tags: Pick<Tables<"tags">, "name">;
};
function getDayStamp() {
const now = new Date();
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
return startOfTodayUtc.toISOString().slice(0, 10); // YYYY-MM-DD
}
type PatchRow = Pick<Tables<"patches">, "id" | "parent_hack" | "version" | "published_at">;
export async function getDiscoverData(sort: DiscoverSortOption): Promise<DiscoverDataResult> {
const dayStamp = getDayStamp();
function buildTagGroups(rows: Pick<Tables<"tags">, "name" | "category">[]) {
const tagGroups: Record<string, string[]> = {};
const ungroupedTags: string[] = [];
const runner = cache(
async () => {
// Must use service role client because cookies cannot be used when caching
// Viewing permissions are enforced manually (only approved hacks are shown)
// TODO: Add `published` as a requirement when it's implemented
const supabase = await createServiceClient();
for (const row of rows) {
if (row.category) {
(tagGroups[row.category] ??= []).push(row.name);
} else {
ungroupedTags.push(row.name);
}
}
// Build base query for hacks (public/anon view: only approved hacks)
let query = supabase
.from("hacks")
.select("slug,title,summary,base_rom,downloads,created_by,updated_at,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status")
.eq("approved", true);
for (const tags of Object.values(tagGroups)) {
tags.sort((a, b) => a.localeCompare(b));
}
ungroupedTags.sort((a, b) => a.localeCompare(b));
// Apply sorting based on sort type
if (sort === "popular") {
// When sorting by popularity, always show non-archive hacks first.
// Archives are defined by the `is_archive` flag, so we order by that after downloads.
query = query
.order("downloads", { ascending: false })
.order("is_archive", { ascending: true });
} else if (sort === "trending") {
// For trending, we'll fetch all and calculate scores in JS
// Still order by downloads first for efficiency, then `is_archive` to keep non-archives first.
query = query
.order("downloads", { ascending: false })
.order("is_archive", { ascending: true });
} else if (sort === "updated") {
// Will sort by current patch published_at in JS after fetching patches
} else if (sort === "alpha") {
query = query.order("title", { ascending: true });
} else {
// "new" or default
query = query.order("approved_at", { ascending: false });
}
return { tagGroups, ungroupedTags };
}
const { data: rows, error: hacksError } = await query;
if (hacksError) throw hacksError;
async function generateDiscoverData(): Promise<DiscoverData> {
const generatedAt = new Date().toISOString();
const supabase = await createServiceClient();
const slugs = (rows || []).map((r) => r.slug);
const { data: rows, error: hacksError } = await supabase
.from("hacks")
.select(
"slug,title,summary,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status",
)
.eq("approved", true)
.eq("is_archive", false);
if (hacksError) throw hacksError;
// Fetch covers
const { data: coverRows, error: coversError } = await fetchInChunks(slugs, CHUNK_SIZE, async (chunk) => {
const slugs = rows.map((row) => row.slug);
const { data: coverRows, error: coversError } = await fetchInChunks(
slugs,
CHUNK_SIZE,
async (slugChunk) => {
const { data, error } = await supabase
.from("hack_covers")
.select("hack_slug,url,position")
.in("hack_slug", slugChunk)
.order("position", { ascending: true });
return { data, error };
},
);
if (coversError) throw coversError;
const coversBySlug = new Map<string, string[]>();
if (coverRows.length > 0) {
const coverKeys = coverRows.map((cover) => cover.url);
const publicUrls = getCoverUrls(coverKeys);
const publicUrlByKey = new Map(
coverKeys.map((key, index) => [key, publicUrls[index]] as const),
);
for (const cover of coverRows) {
const publicUrl = publicUrlByKey.get(cover.url);
if (!publicUrl) continue;
const covers = coversBySlug.get(cover.hack_slug) ?? [];
covers.push(publicUrl);
coversBySlug.set(cover.hack_slug, covers);
}
}
const { data: tagRows, error: tagsError } = await fetchInChunks<string, HackTagRow>(
slugs,
CHUNK_SIZE,
async (slugChunk) => {
const chunkRows: HackTagRow[] = [];
let offset = 0;
while (true) {
const { data, error } = await supabase
.from("hack_covers")
.select("hack_slug,url,position")
.in("hack_slug", chunk)
.order("position", { ascending: true });
return { data, error };
});
if (coversError) throw coversError;
.from("hack_tags")
.select("hack_slug,order,tags(name)")
.in("hack_slug", slugChunk)
.range(offset, offset + ROW_BATCH_SIZE - 1)
.order("hack_slug", { ascending: true });
if (error) return { data: null, error };
if (!data || data.length === 0) break;
const coversBySlug = new Map<string, string[]>();
if (coverRows && coverRows.length > 0) {
const coverKeys = coverRows.map((c) => c.url);
const urls = getCoverUrls(coverKeys);
const urlToSignedUrl = new Map<string, string>();
coverKeys.forEach((key, idx) => {
if (urls[idx]) urlToSignedUrl.set(key, urls[idx]);
});
coverRows.forEach((c) => {
const arr = coversBySlug.get(c.hack_slug) || [];
const signed = urlToSignedUrl.get(c.url);
if (signed) {
arr.push(signed);
coversBySlug.set(c.hack_slug, arr);
}
});
chunkRows.push(...data);
if (data.length < ROW_BATCH_SIZE) break;
offset += ROW_BATCH_SIZE;
}
// Fetch tags - chunk slugs to avoid URI limits,
// paginate rows per chunk to avoid 1000 row limit per query
const ROW_BATCH_SIZE = 1000;
const { data: tagRows, error: tagsError } = await fetchInChunks(slugs, CHUNK_SIZE, async (slugChunk) => {
const rows: any[] = [];
let offset = 0;
let hasMore = true;
return { data: chunkRows, error: null };
},
);
if (tagsError) throw tagsError;
while (hasMore) {
const { data, error } = await supabase
.from("hack_tags")
.select("hack_slug,order,tags(name,category)")
.in("hack_slug", slugChunk)
.range(offset, offset + ROW_BATCH_SIZE - 1)
.order("hack_slug", { ascending: true });
const tagsBySlug = new Map<string, OrderedTag[]>();
for (const row of tagRows) {
if (!row.tags?.name) continue;
const tags = tagsBySlug.get(row.hack_slug) ?? [];
tags.push({ name: row.tags.name, order: row.order });
tagsBySlug.set(row.hack_slug, tags);
}
if (error) return { data: null, error };
const { data: patchRows, error: patchesError } = await fetchInChunks<string, PatchRow>(
slugs,
CHUNK_SIZE,
async (slugChunk) => {
const chunkRows: PatchRow[] = [];
let offset = 0;
if (!data || data.length === 0) {
hasMore = false;
} else {
rows.push(...data);
hasMore = data.length === ROW_BATCH_SIZE;
if (hasMore) offset += ROW_BATCH_SIZE;
}
}
return { data: rows, error: null };
});
if (tagsError) throw tagsError;
const tagsBySlug = new Map<string, OrderedTag[]>();
(tagRows || []).forEach((r: any) => {
if (!r.tags?.name) return;
const arr = tagsBySlug.get(r.hack_slug) || [];
arr.push({
name: r.tags.name,
order: r.order,
});
tagsBySlug.set(r.hack_slug, arr);
});
// Fetch patches for version mapping
const patchIds = Array.from(
new Set(
(rows || [])
.map((r: any) => r.current_patch as number | null)
.filter((id): id is number => typeof id === "number")
)
);
const versionsByPatchId = new Map<number, string>();
const publishedAtByPatchId = new Map<number, string | null>();
if (patchIds.length > 0) {
const { data: patchRows, error: patchesError } = await fetchInChunks(patchIds, CHUNK_SIZE, async (chunk) => {
const { data, error } = await supabase
.from("patches")
.select("id,version,published_at")
.in("id", chunk);
return { data, error };
});
if (patchesError) throw patchesError;
(patchRows || []).forEach((p: any) => {
if (typeof p.id === "number") {
versionsByPatchId.set(p.id, p.version || "Pre-release");
publishedAtByPatchId.set(p.id, p.published_at ?? null);
}
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
if (slugs.length > 0) {
const { data: customPatchRows, error: customPatchRowsError } = await supabase
.from("hack_patcher_patches")
.select("hack_slug, sort_order, patches!inner(version)")
.in("hack_slug", slugs)
.order("sort_order", { ascending: true });
if (customPatchRowsError) throw customPatchRowsError;
(customPatchRows || []).forEach((row: any) => {
customPatcherSlugs.add(row.hack_slug);
if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
});
}
// Calculate trending scores if needed
let trendingScores: Map<string, number> | null = null;
if (sort === "trending") {
// Get all patches for all hacks, grouped by slug.
// Paginate rows per slug chunk to avoid the PostgREST 1000-row cap.
const { data: allPatches, error: allPatchesError } = await fetchInChunks(slugs, CHUNK_SIZE, async (chunk) => {
const rows: { id: number; parent_hack: string | null }[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const { data, error } = await supabase
.from("patches")
.select("id,parent_hack")
.in("parent_hack", chunk)
.order("id", { ascending: true })
.range(offset, offset + ROW_BATCH_SIZE - 1);
if (error) return { data: null, error };
if (!data || data.length === 0) {
hasMore = false;
} else {
rows.push(...data);
hasMore = data.length === ROW_BATCH_SIZE;
if (hasMore) offset += ROW_BATCH_SIZE;
}
}
return { data: rows, error: null };
});
if (allPatchesError) throw allPatchesError;
// Group patch IDs by parent_hack (slug)
const patchIdsBySlug = new Map<string, number[]>();
(allPatches || []).forEach((p: any) => {
if (typeof p.id === "number" && p.parent_hack) {
const arr = patchIdsBySlug.get(p.parent_hack) || [];
arr.push(p.id);
patchIdsBySlug.set(p.parent_hack, arr);
}
});
// Calculate recent downloads over the trending window
const since = new Date();
since.setDate(since.getDate() - TRENDING_WINDOW_DAYS);
const sinceISO = since.toISOString();
const recentDownloadsBySlug = new Map<string, number>();
// Query download counts per slug using head: true with count: 'exact'
// This avoids fetching all download rows and just gets counts
// One query per slug instead of one per patch
const downloadCountPromises = Array.from(patchIdsBySlug.entries()).map(async ([slug, patchIds]) => {
const { count, error } = await supabase
.from("patch_downloads")
.select("*", { count: "exact", head: true })
.in("patch", patchIds)
.gte("created_at", sinceISO);
if (error) throw error;
return { slug, count: count || 0 };
});
const downloadCounts = await Promise.all(downloadCountPromises);
downloadCounts.forEach(({ slug, count }) => {
recentDownloadsBySlug.set(slug, count);
});
// Calculate trending scores: recent_downloads_window + (8 * log(downloads + 1))
// Give small boost to longer lived popular hacks
trendingScores = new Map<string, number>();
(rows || []).forEach((r: any) => {
const recentDownloads = recentDownloadsBySlug.get(r.slug) || 0;
const lifetimeDownloads = r.downloads || 0;
const score = recentDownloads + (8 * Math.log(lifetimeDownloads + 1));
trendingScores!.set(r.slug, score);
});
}
// Map versions and current patch published_at per hack
const mappedVersions = new Map<string, string>();
const publishedAtBySlug = new Map<string, string | null>();
(rows || []).forEach((r: any) => {
const currentPatchVersion = typeof r.current_patch === "number"
? versionsByPatchId.get(r.current_patch) || "Pre-release"
: "";
mappedVersions.set(r.slug, resolveHackDisplayVersion({
isArchive: r.is_archive,
isCustomPatcherActive: customPatcherSlugs.has(r.slug),
customVersionName: r.custom_version_name,
customDefaultPatchVersion: customDefaultVersionsBySlug.get(r.slug),
currentPatchVersion,
}));
if (typeof r.current_patch === "number") {
const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null;
publishedAtBySlug.set(r.slug, publishedAt);
} else {
publishedAtBySlug.set(r.slug, null);
}
});
const catalogTags = await getCachedTagsWithUsage();
const { tagGroups: groups, ungroupedTags: ungrouped } = buildTagFilterGroups(catalogTags);
// Fetch profiles for author names
const creatorIds = [...new Set(rows.map((r) => r.created_by))];
const { data: profiles, error: profilesError } = await fetchInChunks(creatorIds, CHUNK_SIZE, async (chunk) => {
while (true) {
const { data, error } = await supabase
.from("profiles")
.select("id,username")
.in("id", chunk);
return { data, error };
});
if (profilesError) throw profilesError;
.from("patches")
.select("id,parent_hack,version,published_at")
.in("parent_hack", slugChunk)
.order("id", { ascending: true })
.range(offset, offset + ROW_BATCH_SIZE - 1);
if (error) return { data: null, error };
if (!data || data.length === 0) break;
const usernameById = new Map<string, string>();
(profiles || []).forEach((p) => usernameById.set(p.id, p.username ? `@${p.username}` : "Unknown"));
// Transform rows to HackCardAttributes
let mapped = (rows || []).map((r) => ({
slug: r.slug,
title: r.title,
author: r.original_author ? r.original_author : usernameById.get(r.created_by as string) || "Unknown",
covers: coversBySlug.get(r.slug) || [],
tags: sortOrderedTags(tagsBySlug.get(r.slug) || []),
downloads: r.downloads,
baseRomId: r.base_rom,
version: mappedVersions.get(r.slug) || "Pre-release",
summary: r.summary,
is_archive: r.is_archive,
completion_status: r.completion_status,
}));
// Sort by current patch published_at for "updated" sort
if (sort === "updated") {
mapped = [...mapped].sort((a, b) => {
const aPub = publishedAtBySlug.get(a.slug);
const bPub = publishedAtBySlug.get(b.slug);
// Nulls (no published_at) go last
if (!aPub && !bPub) return 0;
if (!aPub) return 1;
if (!bPub) return -1;
const aTime = new Date(aPub).getTime();
const bTime = new Date(bPub).getTime();
// Secondary sort: when times are equal, push archives to end
if (aTime === bTime) {
if (a.is_archive && !b.is_archive) return 1;
if (!a.is_archive && b.is_archive) return -1;
}
return bTime - aTime; // Descending order (newest first)
});
chunkRows.push(...data);
if (data.length < ROW_BATCH_SIZE) break;
offset += ROW_BATCH_SIZE;
}
// Sort by trending score if needed
if (sort === "trending" && trendingScores) {
mapped = [...mapped].sort((a, b) => {
const scoreA = trendingScores!.get(a.slug) || 0;
const scoreB = trendingScores!.get(b.slug) || 0;
return { data: chunkRows, error: null };
},
);
if (patchesError) throw patchesError;
// Secondary sort: push archives to end
if (scoreA === scoreB) {
if (a.is_archive && !b.is_archive) return 1;
if (!a.is_archive && b.is_archive) return -1;
}
const patchesById = new Map(patchRows.map((patch) => [patch.id, patch] as const));
const patchIdsBySlug = new Map<string, number[]>();
for (const patch of patchRows) {
if (!patch.parent_hack) continue;
const patchIds = patchIdsBySlug.get(patch.parent_hack) ?? [];
patchIds.push(patch.id);
patchIdsBySlug.set(patch.parent_hack, patchIds);
}
return scoreB - scoreA; // Descending order
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
if (slugs.length > 0) {
const { data: customPatchRows, error: customPatchRowsError } = await supabase
.from("hack_patcher_patches")
.select("hack_slug,sort_order,patches!inner(version)")
.in("hack_slug", slugs)
.order("sort_order", { ascending: true });
if (customPatchRowsError) throw customPatchRowsError;
return {
hacks: mapped,
tagGroups: groups,
ungroupedTags: ungrouped,
} satisfies DiscoverDataResult;
},
[`discover-data:${sort}:${dayStamp}`], // Cache key
{ revalidate: TIME_TO_LIVE, tags: ["discover"] } // Cache duration
);
for (const row of customPatchRows) {
customPatcherSlugs.add(row.hack_slug);
if (customDefaultVersionsBySlug.has(row.hack_slug)) continue;
const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
}
}
return runner();
}
const since = new Date(generatedAt);
since.setUTCDate(since.getUTCDate() - TRENDING_WINDOW_DAYS);
const downloadCounts = await Promise.all(
[...patchIdsBySlug.entries()].map(async ([slug, patchIds]) => {
const { count, error } = await supabase
.from("patch_downloads")
.select("*", { count: "exact", head: true })
.in("patch", patchIds)
.gte("created_at", since.toISOString());
if (error) throw error;
return [slug, count ?? 0] as const;
}),
);
const recentDownloadsBySlug = new Map(downloadCounts);
const { data: catalogTags, error: catalogTagsError } = await supabase
.from("tags")
.select("name,category");
if (catalogTagsError) throw catalogTagsError;
const { tagGroups, ungroupedTags } = buildTagGroups(catalogTags);
const creatorIds = [...new Set(rows.map((row) => row.created_by))];
const { data: profiles, error: profilesError } = await fetchInChunks(
creatorIds,
CHUNK_SIZE,
async (idChunk) => {
const { data, error } = await supabase
.from("profiles")
.select("id,username")
.in("id", idChunk);
return { data, error };
},
);
if (profilesError) throw profilesError;
const usernameById = new Map(
profiles.map((profile) => [
profile.id,
profile.username ? `@${profile.username}` : "Unknown",
]),
);
const hacks = rows.map((row) => {
const currentPatch = row.current_patch
? patchesById.get(row.current_patch)
: undefined;
const currentPatchVersion = currentPatch?.version ?? "";
const downloads = row.downloads ?? 0;
const recentDownloads = recentDownloadsBySlug.get(row.slug) ?? 0;
return {
slug: row.slug,
title: row.title,
author: row.original_author || usernameById.get(row.created_by) || "Unknown",
covers: coversBySlug.get(row.slug) ?? [],
tags: sortOrderedTags(tagsBySlug.get(row.slug) ?? []),
downloads,
baseRomId: row.base_rom,
version: resolveHackDisplayVersion({
isArchive: false,
isCustomPatcherActive: customPatcherSlugs.has(row.slug),
customVersionName: row.custom_version_name,
customDefaultPatchVersion: customDefaultVersionsBySlug.get(row.slug),
currentPatchVersion,
}),
summary: row.summary,
is_archive: false,
completion_status: row.completion_status,
approvedAt: row.approved_at,
publishedAt: currentPatch?.published_at ?? null,
trendingScore: recentDownloads + 8 * Math.log(downloads + 1),
};
});
return {
hacks,
generatedAt,
tagGroups,
ungroupedTags,
};
}
const getCachedDiscoverData = cache(
generateDiscoverData,
["discover-data"],
{
revalidate: DISCOVER_REVALIDATE_SECONDS,
tags: ["discover"],
},
);
export async function getDiscoverData(): Promise<DiscoverData> {
return getCachedDiscoverData();
}

View File

@@ -1,6 +1,10 @@
import DiscoverBrowser from "@/components/Discover/DiscoverBrowser";
import type { Metadata } from "next";
import { parseDiscoverSearchParams } from "./search-params";
import { getDiscoverData } from "./actions";
import { DISCOVER_DEFAULT_STATE } from "./search-params";
export const dynamic = "error";
export const revalidate = 1800;
export const metadata: Metadata = {
description: "Find and download Pokémon romhacks for Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.",
@@ -9,13 +13,8 @@ export const metadata: Metadata = {
},
};
interface DiscoverPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function DiscoverPage(props: DiscoverPageProps) {
const searchParams = await props.searchParams;
const initialState = parseDiscoverSearchParams(searchParams);
export default async function DiscoverPage() {
const { hacks, generatedAt, tagGroups, ungroupedTags } = await getDiscoverData();
return (
<div className="mx-auto max-w-screen-2xl px-6 py-10">
@@ -28,10 +27,14 @@ export default async function DiscoverPage(props: DiscoverPageProps) {
</div>
</div>
<div className="mt-6">
<DiscoverBrowser initialState={initialState} />
<DiscoverBrowser
catalog={hacks}
generatedAt={generatedAt}
initialState={DISCOVER_DEFAULT_STATE}
tagGroups={tagGroups}
ungroupedTags={ungroupedTags}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { revalidatePath, revalidateTag } from "next/cache";
export function revalidateDiscoverCatalog() {
revalidateTag("discover");
revalidatePath("/discover");
revalidatePath("/");
}

View File

@@ -75,9 +75,8 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
/* Smooth scrolling and stable layout when overlays lock page scrolling. */
/* Stable layout when overlays lock page scrolling. */
html {
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
:root { --anchor-offset: 38px; }

View File

@@ -14,6 +14,7 @@ import { Database, Constants } from "@/types/db";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
import { CUSTOM_VERSION_NAME_MAX_LENGTH, resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import type { SelectablePatch } from "@/types/patcher";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
"Patches Download Permission"
@@ -640,6 +641,7 @@ export async function archivePatchVersion(slug: string, patchId: number): Promis
}
revalidateTag(`hack:${slug}:metadata`);
revalidateDiscoverCatalog();
revalidatePath(`/hack/${slug}`);
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
@@ -738,7 +740,7 @@ export async function rollbackToVersion(slug: string, patchId: number): Promise<
if (unpubErr) return { ok: false, error: unpubErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidateTag("discover");
revalidateDiscoverCatalog();
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
@@ -853,6 +855,7 @@ export async function updatePatchVersion(slug: string, patchId: number, version:
if (updateErr) return { ok: false, error: updateErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidateDiscoverCatalog();
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
@@ -931,7 +934,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
}
revalidateTag(`hack:${slug}:metadata`);
revalidateTag("discover");
revalidateDiscoverCatalog();
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true, willBecomeCurrent };
@@ -1101,7 +1104,7 @@ export async function updatePatcherSelectablePatches(
if (replaceErr) return { ok: false, error: replaceErr.message };
revalidateTag(`hack:${slug}:metadata`);
revalidateTag("discover");
revalidateDiscoverCatalog();
revalidatePath(`/hack/${slug}`);
revalidatePath(`/hack/${slug}/versions`);

View File

@@ -17,6 +17,7 @@ import {
getHackReviewThread,
postHackReviewMessage,
} from "@/utils/hack-review";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
export async function updateHack(args: {
slug: string;
@@ -131,6 +132,7 @@ export async function updateHack(args: {
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
revalidateDiscoverCatalog();
return { ok: true } as const;
}
@@ -214,6 +216,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
revalidateDiscoverCatalog();
return { ok: true } as const;
}
@@ -327,6 +330,7 @@ export async function approveHack(slug: string, verified?: boolean) {
// If already approved, return success
if (hack.approved) {
revalidatePath(`/hack/${slug}`);
revalidateDiscoverCatalog();
return { ok: true } as const;
}
@@ -341,6 +345,7 @@ export async function approveHack(slug: string, verified?: boolean) {
.eq("slug", slug);
if (updateErr) return { ok: false, error: updateErr.message } as const;
revalidateDiscoverCatalog();
try {
const { data: creatorData, error: creatorError } = await serviceClient.auth.admin.getUserById(hack.created_by);

View File

@@ -1,7 +1,7 @@
import { Metadata } from "next";
import Link from "next/link";
import { FaArrowRightLong } from "react-icons/fa6";
import { createClient } from "@/utils/supabase/server";
import { createServiceClient } from "@/utils/supabase/server";
import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import MilestoneCelebration from "@/components/Home/MilestoneCelebration";
@@ -17,12 +17,12 @@ export const metadata: Metadata = {
},
};
// TODO: Lower to 3600 (1 hour) once we get more traffic
export const revalidate = 604800; // 1 week in seconds
export const dynamic = "error";
export const revalidate = 1800;
export default async function Home() {
const downloadsMilestone = process.env.NEXT_PUBLIC_DOWNLOADS_MILESTONE?.trim();
const supabase = await createClient();
const supabase = await createServiceClient();
// Fetch top 6 approved hacks ordered by downloads
const { data: popularHacks } = await supabase

View File

@@ -14,6 +14,7 @@ import {
getHackReviewThread,
postHackReviewMessage,
} from "@/utils/hack-review";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
type HackInsert = TablesInsert<"hacks">;
@@ -269,6 +270,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
let shouldPublishAutomatically = !!args.publishAutomatically;
let didUpdateCurrentPatch = false;
if (shouldPublishAutomatically) {
const { data: customPatcherRows, error: customPatcherErr } = await supabase
.from("hack_patcher_patches")
@@ -324,9 +326,14 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
.update({ current_patch: patch.id })
.eq("slug", args.slug);
if (uErr) return { ok: false, error: uErr.message } as const;
didUpdateCurrentPatch = true;
}
}
if (hack.approved && didUpdateCurrentPatch) {
revalidateDiscoverCatalog();
}
const { data: profile } = await supabase
.from("profiles")
.select("username")

View File

@@ -24,8 +24,6 @@ import { IoEllipsisHorizontal } from "react-icons/io5";
import { BsSdCardFill } from "react-icons/bs";
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { HackCardAttributes } from "@/components/HackCard";
import { getDiscoverData } from "@/app/discover/actions";
import {
buildDiscoverSearchParams,
DISCOVER_COMPLETION_STATUSES,
@@ -33,9 +31,10 @@ import {
validateDiscoverTags,
type DiscoverUrlState,
} from "@/app/discover/search-params";
import type { DiscoverSortOption } from "@/types/discover";
import type { DiscoverHack, DiscoverSortOption } from "@/types/discover";
import Select, { SelectOption } from "@/components/Primitives/Select";
import { useDiscoverUrlState } from "./useDiscoverUrlState";
import DiscoverLastUpdated from "./DiscoverLastUpdated";
const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
trending: MdWhatshot,
@@ -56,25 +55,30 @@ const SORT_OPTIONS: SelectOption[] = [
const HACKS_PER_PAGE = 9;
interface DiscoverBrowserProps {
catalog: DiscoverHack[];
generatedAt: string;
initialState: DiscoverUrlState;
tagGroups: Record<string, string[]>;
ungroupedTags: string[];
}
export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps) {
export default function DiscoverBrowser({
catalog,
generatedAt,
initialState,
tagGroups,
ungroupedTags,
}: DiscoverBrowserProps) {
const [query, setQuery] = React.useState(initialState.query);
const [selectedTags, setSelectedTags] = React.useState<string[]>(() => [...initialState.tags]);
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>(() => [...initialState.baseRoms]);
const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState<string[]>(() => [...initialState.completionStatuses]);
const [sort, setSort] = React.useState<DiscoverSortOption>(initialState.sort);
const [hacks, setHacks] = React.useState<HackCardAttributes[]>([]);
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
const [ungroupedTags, setUngroupedTags] = React.useState<string[]>([]);
const [loadingHacks, setLoadingHacks] = React.useState(true);
const [loadingTags, setLoadingTags] = React.useState(true);
const [onlyReady, setOnlyReady] = React.useState(initialState.onlyReady);
const [currentPage, setCurrentPage] = React.useState(initialState.page);
const listRef = React.useRef<HTMLDivElement | null>(null);
const { cached, statuses, countReady } = useBaseRoms();
const { cached, statuses, countReady, loading: baseRomsLoading } = useBaseRoms();
const readyBaseRomIds = React.useMemo(() => {
const set = new Set<string>();
try {
@@ -111,40 +115,23 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
setCurrentPage(nextState.page);
}, []);
const { syncUrl, syncUrlWith, scheduleSearchUrlSync } = useDiscoverUrlState({
const {
initialUrlStateApplied,
syncUrl,
syncUrlWith,
scheduleSearchUrlSync,
} = useDiscoverUrlState({
currentState: currentUrlState,
onUrlStateChange: applyUrlState,
});
React.useEffect(() => {
const run = async () => {
setLoadingHacks(true);
setLoadingTags(true);
try {
const result = await getDiscoverData(sort);
setHacks(result.hacks);
setTagGroups(result.tagGroups);
setUngroupedTags(result.ungroupedTags);
} catch (error) {
console.error("Failed to fetch hacks:", error);
setHacks([]);
setTagGroups({});
setUngroupedTags([]);
} finally {
setLoadingHacks(false);
setLoadingTags(false);
}
};
run();
}, [sort]);
const validTagNames = React.useMemo(
() => new Set([...Object.values(tagGroups).flat(), ...ungroupedTags]),
[tagGroups, ungroupedTags]
);
React.useEffect(() => {
if (loadingTags || selectedTags.length === 0) return;
if (!initialUrlStateApplied || selectedTags.length === 0) return;
const nextState = validateDiscoverTags(currentUrlState, validTagNames);
if (discoverUrlStatesEqual(nextState, currentUrlState)) return;
@@ -152,10 +139,10 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
setSelectedTags([...nextState.tags]);
setCurrentPage(1);
syncUrl({ ...nextState, page: 1 }, "replace");
}, [currentUrlState, loadingTags, selectedTags.length, syncUrl, validTagNames]);
}, [currentUrlState, initialUrlStateApplied, selectedTags.length, syncUrl, validTagNames]);
const filtered = React.useMemo(() => {
let out = hacks;
let out = catalog;
const q = query.toLowerCase();
if (q) {
out = out.filter((h) =>
@@ -188,8 +175,34 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
if (onlyReady) {
out = out.filter((h) => !h.is_archive && h.baseRomId && readyBaseRomIds.has(h.baseRomId));
}
return out;
}, [hacks, query, selectedTags, selectedBaseRoms, selectedCompletionStatuses, onlyReady, readyBaseRomIds]);
return [...out].sort((a, b) => {
if (sort === "popular") return b.downloads - a.downloads;
if (sort === "new") {
return (b.approvedAt ? Date.parse(b.approvedAt) : 0) -
(a.approvedAt ? Date.parse(a.approvedAt) : 0);
}
if (sort === "updated") {
if (!a.publishedAt && !b.publishedAt) return 0;
if (!a.publishedAt) return 1;
if (!b.publishedAt) return -1;
return Date.parse(b.publishedAt) - Date.parse(a.publishedAt);
}
if (sort === "alpha") return a.title.localeCompare(b.title);
return b.trendingScore - a.trendingScore;
});
}, [
catalog,
onlyReady,
query,
readyBaseRomIds,
selectedBaseRoms,
selectedCompletionStatuses,
selectedTags,
sort,
]);
const showSkeleton =
!initialUrlStateApplied || (onlyReady && baseRomsLoading);
const totalPages = React.useMemo(
() => Math.max(1, Math.ceil(filtered.length / HACKS_PER_PAGE)),
@@ -198,11 +211,11 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
React.useEffect(() => {
// Clamp current page if the number of results shrinks
if (!loadingHacks && currentPage > totalPages) {
if (!showSkeleton && currentPage > totalPages) {
setCurrentPage(totalPages);
syncUrlWith({ page: totalPages }, "replace");
}
}, [currentPage, loadingHacks, syncUrlWith, totalPages]);
}, [currentPage, showSkeleton, syncUrlWith, totalPages]);
const paginationRange = React.useMemo(() => {
const startIndex = (currentPage - 1) * HACKS_PER_PAGE;
@@ -408,52 +421,40 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
syncUrlWith({ completionStatuses: vals, page: 1 });
}}
/>
{loadingTags ? (
<>
{[
"w-28","w-36","w-32","w-24","w-24","w-28","w-36","w-36"
].map((w, i) => (
<div key={i} className={`h-8 ${w} animate-pulse rounded-full bg-[var(--surface-2)]`} />
))}
</>
) : (
<>
{Object.keys(tagGroups)
.sort((a, b) => a.localeCompare(b))
.map((cat) => (
<MultiSelectDropdown
key={cat}
icon={CATEGORY_ICONS[cat]}
label={cat}
options={tagGroups[cat].map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}
onChange={(vals) => {
// Replace selections for this category while keeping others
const others = selectedTags.filter((tag) => !tagGroups[cat].includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
))}
{/* Advanced dropdown for ungrouped tags at the end */}
{ungroupedTags.length > 0 && (
<MultiSelectDropdown
icon={MdTune}
label="Advanced"
options={ungroupedTags.map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => ungroupedTags.includes(t))}
onChange={(vals) => {
const others = selectedTags.filter((tag) => !ungroupedTags.includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
)}
</>
{Object.keys(tagGroups)
.sort((a, b) => a.localeCompare(b))
.map((cat) => (
<MultiSelectDropdown
key={cat}
icon={CATEGORY_ICONS[cat]}
label={cat}
options={tagGroups[cat].map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}
onChange={(vals) => {
// Replace selections for this category while keeping others
const others = selectedTags.filter((tag) => !tagGroups[cat].includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
))}
{/* Advanced dropdown for ungrouped tags at the end */}
{ungroupedTags.length > 0 && (
<MultiSelectDropdown
icon={MdTune}
label="Advanced"
options={ungroupedTags.map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => ungroupedTags.includes(t))}
onChange={(vals) => {
const others = selectedTags.filter((tag) => !ungroupedTags.includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
)}
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && (
<button
@@ -465,9 +466,9 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
)}
</div>
{loadingHacks ? (
{showSkeleton ? (
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
{Array.from({ length: HACKS_PER_PAGE }).map((_, i) => (
<HackCardSkeleton key={i} />
))}
</div>
@@ -599,6 +600,7 @@ export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps)
)}
</>
)}
{!showSkeleton && <DiscoverLastUpdated generatedAt={generatedAt} />}
</div>
);
}
@@ -702,23 +704,26 @@ function MultiSelectDropdown({
function HackCardSkeleton() {
return (
<div className="group block">
<div className="rounded-[12px] overflow-hidden card ring-1 ring-[var(--border)]">
<div className="h-full overflow-hidden rounded-[12px] card ring-1 ring-[var(--border)] flex flex-col">
<div className="relative aspect-[3/2] w-full rounded-[12px] overflow-hidden bg-[var(--surface-2)]">
<div className="absolute inset-0 animate-pulse bg-gradient-to-b from-black/5 to-black/0 dark:from-white/5 dark:to-white/0" />
</div>
<div className="p-4">
<div className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="mt-2 h-3 w-24 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="h-[22.5px] w-2/3 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="mt-1 h-4 w-24 animate-pulse rounded bg-[var(--surface-2)]" />
</div>
<div className="h-4 w-12 shrink-0 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="h-5 w-12 shrink-0 animate-pulse rounded bg-[var(--surface-2)]" />
</div>
<div className="mt-3 space-y-2">
<div className="mt-2 flex h-10 flex-col justify-around">
<div className="h-3 w-full animate-pulse rounded bg-[var(--surface-2)]" />
<div className="h-3 w-5/6 animate-pulse rounded bg-[var(--surface-2)]" />
</div>
<div className="mt-3 h-3 w-32 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="mt-auto flex justify-between pt-3">
<div className="h-4 w-32 animate-pulse rounded bg-[var(--surface-2)]" />
<div className="h-4 w-16 animate-pulse rounded bg-[var(--surface-2)]" />
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,34 @@
"use client";
import React from "react";
interface DiscoverLastUpdatedProps {
generatedAt: string;
}
function getMinutesAgo(generatedAt: string) {
const elapsed = Date.now() - new Date(generatedAt).getTime();
return Math.max(0, Math.floor(elapsed / 60_000));
}
export default function DiscoverLastUpdated({
generatedAt,
}: DiscoverLastUpdatedProps) {
const [minutesAgo, setMinutesAgo] = React.useState(0);
React.useLayoutEffect(() => {
const update = () => setMinutesAgo(getMinutesAgo(generatedAt));
update();
const interval = window.setInterval(update, 60_000);
return () => window.clearInterval(interval);
}, [generatedAt]);
return (
<p className="mt-4 text-center text-xs text-foreground/60">
Last updated{" "}
<time dateTime={generatedAt}>
{minutesAgo} {minutesAgo === 1 ? "minute" : "minutes"} ago
</time>
</p>
);
}

View File

@@ -20,6 +20,7 @@ interface UseDiscoverUrlStateArgs {
export function useDiscoverUrlState({ currentState, onUrlStateChange }: UseDiscoverUrlStateArgs) {
const pathname = usePathname();
const [initialUrlStateApplied, setInitialUrlStateApplied] = React.useState(false);
const searchUrlTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const currentStateRef = React.useRef(currentState);
const onUrlStateChangeRef = React.useRef(onUrlStateChange);
@@ -86,21 +87,34 @@ export function useDiscoverUrlState({ currentState, onUrlStateChange }: UseDisco
React.useEffect(() => clearSearchUrlTimeout, [clearSearchUrlTimeout]);
React.useLayoutEffect(() => {
const nextState = parseDiscoverSearchParams(
new URLSearchParams(window.location.search),
);
if (!discoverUrlStatesEqual(nextState, currentStateRef.current)) {
onUrlStateChangeRef.current(nextState);
currentStateRef.current = nextState;
}
setInitialUrlStateApplied(true);
}, []);
React.useEffect(() => {
const applyUrlState = () => {
const nextState = parseDiscoverSearchParams(new URLSearchParams(window.location.search));
const nextState = parseDiscoverSearchParams(
new URLSearchParams(window.location.search),
);
if (discoverUrlStatesEqual(nextState, currentStateRef.current)) return;
clearSearchUrlTimeout();
onUrlStateChangeRef.current(nextState);
currentStateRef.current = nextState;
};
applyUrlState();
window.addEventListener("popstate", applyUrlState);
return () => window.removeEventListener("popstate", applyUrlState);
}, [clearSearchUrlTimeout]);
return {
initialUrlStateApplied,
syncUrl,
syncUrlWith,
scheduleSearchUrlSync,

View File

@@ -247,7 +247,7 @@ export default function TagSelector({ value, onChange, catalogTags, newTagsCutof
React.useEffect(() => {
const el = activeCategory ? categoryRefs.current[activeCategory] : null;
if (el) {
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
try { el.scrollIntoView({ block: "nearest", behavior: "smooth" }); } catch {}
}
}, [activeCategory]);
@@ -257,7 +257,7 @@ export default function TagSelector({ value, onChange, catalogTags, newTagsCutof
if (activeTagIndex == null) return;
const el = tagItemRefs.current[activeTagIndex];
if (el) {
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
try { el.scrollIntoView({ block: "nearest", behavior: "smooth" }); } catch {}
}
}, [activeTagIndex]);

View File

@@ -1,3 +1,16 @@
import type { HackCardAttributes } from "@/components/HackCard";
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alpha";
export interface DiscoverHack extends HackCardAttributes {
approvedAt: string | null;
publishedAt: string | null;
trendingScore: number;
}
export interface DiscoverData {
hacks: DiscoverHack[];
generatedAt: string;
tagGroups: Record<string, string[]>;
ungroupedTags: string[];
}