diff --git a/src/app/api/discover/refresh/route.ts b/src/app/api/discover/refresh/route.ts index ac67aee..b7d9d81 100644 --- a/src/app/api/discover/refresh/route.ts +++ b/src/app/api/discover/refresh/route.ts @@ -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)); } diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index 8a8b464..beb8dbd 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -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; - ungroupedTags: string[]; - } +type HackTagRow = Pick, "hack_slug" | "order"> & { + tags: Pick, "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, "id" | "parent_hack" | "version" | "published_at">; -export async function getDiscoverData(sort: DiscoverSortOption): Promise { - const dayStamp = getDayStamp(); +function buildTagGroups(rows: Pick, "name" | "category">[]) { + const tagGroups: Record = {}; + 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 { + 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(); + 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( + 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(); - if (coverRows && coverRows.length > 0) { - const coverKeys = coverRows.map((c) => c.url); - const urls = getCoverUrls(coverKeys); - const urlToSignedUrl = new Map(); - 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(); + 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( + 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(); - (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(); - const publishedAtByPatchId = new Map(); - 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(); - const customPatcherSlugs = new Set(); - 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 | 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(); - (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(); - - // 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(); - (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(); - const publishedAtBySlug = new Map(); - (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(); - (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(); + 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(); + const customPatcherSlugs = new Set(); + 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 { + return getCachedDiscoverData(); +} diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index 6db02e3..3d82dca 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -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>; -}; - -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 (
@@ -28,10 +27,14 @@ export default async function DiscoverPage(props: DiscoverPageProps) {
- +
); } - - diff --git a/src/app/discover/revalidate.ts b/src/app/discover/revalidate.ts new file mode 100644 index 0000000..898d339 --- /dev/null +++ b/src/app/discover/revalidate.ts @@ -0,0 +1,7 @@ +import { revalidatePath, revalidateTag } from "next/cache"; + +export function revalidateDiscoverCatalog() { + revalidateTag("discover"); + revalidatePath("/discover"); + revalidatePath("/"); +} diff --git a/src/app/globals.css b/src/app/globals.css index ea38c12..0707cd7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 988af3d..b7b896e 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -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`); diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index ccb3a03..0f8035a 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -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); diff --git a/src/app/page.tsx b/src/app/page.tsx index c09fcfe..cbdfc21 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index bced923..293ca01 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -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") diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 32b949c..249d65a 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -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 = { 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; + 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(() => [...initialState.tags]); const [selectedBaseRoms, setSelectedBaseRoms] = React.useState(() => [...initialState.baseRoms]); const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState(() => [...initialState.completionStatuses]); const [sort, setSort] = React.useState(initialState.sort); - const [hacks, setHacks] = React.useState([]); - const [tagGroups, setTagGroups] = React.useState>({}); - const [ungroupedTags, setUngroupedTags] = React.useState([]); - 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(null); - const { cached, statuses, countReady } = useBaseRoms(); + const { cached, statuses, countReady, loading: baseRomsLoading } = useBaseRoms(); const readyBaseRomIds = React.useMemo(() => { const set = new Set(); 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) => ( -
- ))} - - ) : ( - <> - {Object.keys(tagGroups) - .sort((a, b) => a.localeCompare(b)) - .map((cat) => ( - ({ 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 && ( - ({ 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) => ( + ({ 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 && ( + ({ 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) && (
); } @@ -702,23 +704,26 @@ function MultiSelectDropdown({ function HackCardSkeleton() { return (
-
+
-
+
-
-
+
+
-
+
-
+
-
+
+
+
+
diff --git a/src/components/Discover/DiscoverLastUpdated.tsx b/src/components/Discover/DiscoverLastUpdated.tsx new file mode 100644 index 0000000..f45b875 --- /dev/null +++ b/src/components/Discover/DiscoverLastUpdated.tsx @@ -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 ( +

+ Last updated{" "} + +

+ ); +} diff --git a/src/components/Discover/useDiscoverUrlState.ts b/src/components/Discover/useDiscoverUrlState.ts index c1dac45..bf3f648 100644 --- a/src/components/Discover/useDiscoverUrlState.ts +++ b/src/components/Discover/useDiscoverUrlState.ts @@ -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 | 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, diff --git a/src/components/Submit/TagSelector.tsx b/src/components/Submit/TagSelector.tsx index 24695b1..6bd1cbf 100644 --- a/src/components/Submit/TagSelector.tsx +++ b/src/components/Submit/TagSelector.tsx @@ -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]); diff --git a/src/types/discover.ts b/src/types/discover.ts index 95ad2a1..7ac25eb 100644 --- a/src/types/discover.ts +++ b/src/types/discover.ts @@ -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; + ungroupedTags: string[]; +}