mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-07 00:08:54 -05:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
7
src/app/discover/revalidate.ts
Normal file
7
src/app/discover/revalidate.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
|
||||
export function revalidateDiscoverCatalog() {
|
||||
revalidateTag("discover");
|
||||
revalidatePath("/discover");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
src/components/Discover/DiscoverLastUpdated.tsx
Normal file
34
src/components/Discover/DiscoverLastUpdated.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user