Use url params to persist Discover state (#63)

* Persist Discover state in url query params

* Add button to copy link to current discover state

* Make tags clickable in hack detail page

* Improve CollapsibleTags styling
This commit is contained in:
Jared Schoeny
2026-07-07 12:53:27 -06:00
committed by GitHub
parent 350d660936
commit d9986e6af8
11 changed files with 474 additions and 111 deletions

11
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
"schema-dts": "^1.1.5",
"serialize-javascript": "^7.0.0",
"sonner": "^2.0.7",
"unist-util-visit": "^5.0.0",
"uuid": "^13.0.0"
},
@@ -9044,6 +9045,16 @@
"node": "*"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -39,6 +39,7 @@
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
"schema-dts": "^1.1.5",
"serialize-javascript": "^7.0.0",
"sonner": "^2.0.7",
"unist-util-visit": "^5.0.0",
"uuid": "^13.0.0"
},

View File

@@ -53,7 +53,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
.order("is_archive", { ascending: true });
} else if (sort === "updated") {
// Will sort by current patch published_at in JS after fetching patches
} else if (sort === "alphabetical") {
} else if (sort === "alpha") {
query = query.order("title", { ascending: true });
} else {
// "new" or default

View File

@@ -1,6 +1,6 @@
import DiscoverBrowser from "@/components/Discover/DiscoverBrowser";
import type { Metadata } from "next";
import type { DiscoverSortOption } from "@/types/discover";
import { parseDiscoverSearchParams } from "./search-params";
export const metadata: Metadata = {
description: "Find and download Pokémon romhacks for Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.",
@@ -15,12 +15,7 @@ interface DiscoverPageProps {
export default async function DiscoverPage(props: DiscoverPageProps) {
const searchParams = await props.searchParams;
const sortParam = searchParams.sort;
const validSorts: DiscoverSortOption[] = ["trending", "popular", "new", "updated", "alphabetical"];
const sort: DiscoverSortOption =
typeof sortParam === "string" && (validSorts as string[]).includes(sortParam)
? (sortParam as DiscoverSortOption)
: "trending"; // Default to trending if no sort param is provided
const initialState = parseDiscoverSearchParams(searchParams);
return (
<div className="mx-auto max-w-screen-2xl px-6 py-10">
@@ -33,7 +28,7 @@ export default async function DiscoverPage(props: DiscoverPageProps) {
</div>
</div>
<div className="mt-6">
<DiscoverBrowser initialSort={sort} />
<DiscoverBrowser initialState={initialState} />
</div>
</div>
);

View File

@@ -0,0 +1,131 @@
import { baseRoms } from "@/data/baseRoms";
import { Constants } from "@/types/db";
import type { DiscoverSortOption } from "@/types/discover";
export interface DiscoverUrlState {
query: string;
sort: DiscoverSortOption;
page: number;
tags: string[];
baseRoms: string[];
completionStatuses: string[];
onlyReady: boolean;
}
type SearchParamsRecord = Record<string, string | string[] | undefined>;
type SearchParamsLike = URLSearchParams | SearchParamsRecord;
export const DISCOVER_DEFAULT_STATE: DiscoverUrlState = {
query: "",
sort: "trending",
page: 1,
tags: [],
baseRoms: [],
completionStatuses: [],
onlyReady: false,
};
export const DISCOVER_COMPLETION_STATUSES = Constants.public.Enums["Completion Status"];
const VALID_BASE_ROM_IDS = new Set(baseRoms.map((rom) => rom.id));
const VALID_COMPLETION_STATUSES = new Set<string>(DISCOVER_COMPLETION_STATUSES);
function arraysEqual(a: string[], b: string[]) {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
export function discoverUrlStatesEqual(a: DiscoverUrlState, b: DiscoverUrlState) {
return (
a.query === b.query &&
a.sort === b.sort &&
a.page === b.page &&
a.onlyReady === b.onlyReady &&
arraysEqual(a.tags, b.tags) &&
arraysEqual(a.baseRoms, b.baseRoms) &&
arraysEqual(a.completionStatuses, b.completionStatuses)
);
}
function getValues(params: SearchParamsLike, key: string): string[] {
if (params instanceof URLSearchParams) {
return params.getAll(key);
}
const value = params[key];
if (Array.isArray(value)) return value;
return typeof value === "string" ? [value] : [];
}
function getFirstValue(params: SearchParamsLike, keys: string[]): string | undefined {
for (const key of keys) {
const value = getValues(params, key).find((item) => item.length > 0);
if (value !== undefined) return value;
}
}
function getListValues(params: SearchParamsLike, keys: string[]): string[] {
for (const key of keys) {
const values = getValues(params, key)
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
if (values.length > 0) {
return Array.from(new Set(values));
}
}
return [];
}
export function normalizeDiscoverSort(value: string | undefined): DiscoverSortOption {
if (value === "alpha" || value === "alphabetical") return "alpha";
if (value === "popular" || value === "new" || value === "updated" || value === "trending") return value;
return DISCOVER_DEFAULT_STATE.sort;
}
function parsePage(value: string | undefined): number {
const page = Number.parseInt(value ?? "", 10);
return Number.isFinite(page) && page > 0 ? page : DISCOVER_DEFAULT_STATE.page;
}
function parseReady(value: string | undefined): boolean {
if (!value) return false;
return ["1", "true", "yes"].includes(value.toLowerCase());
}
export function parseDiscoverSearchParams(params: SearchParamsLike): DiscoverUrlState {
const onlyReady = parseReady(getFirstValue(params, ["r", "ready"]));
const baseRomValues = getListValues(params, ["b", "baseRom", "baseRoms"]).filter((id) => VALID_BASE_ROM_IDS.has(id));
return {
query: getFirstValue(params, ["q", "query"]) ?? DISCOVER_DEFAULT_STATE.query,
sort: normalizeDiscoverSort(getFirstValue(params, ["s", "sort"])),
page: parsePage(getFirstValue(params, ["p", "page"])),
tags: getListValues(params, ["t", "tags"]),
baseRoms: onlyReady ? [] : baseRomValues,
completionStatuses: getListValues(params, ["c", "completion"]).filter((status) => VALID_COMPLETION_STATUSES.has(status)),
onlyReady,
};
}
export function buildDiscoverSearchParams(state: DiscoverUrlState): URLSearchParams {
const params = new URLSearchParams();
if (state.query) params.set("q", state.query);
if (state.sort !== DISCOVER_DEFAULT_STATE.sort) params.set("s", state.sort);
if (state.page > DISCOVER_DEFAULT_STATE.page) params.set("p", String(state.page));
state.tags.forEach((tag) => params.append("t", tag));
state.baseRoms.forEach((baseRom) => params.append("b", baseRom));
state.completionStatuses.forEach((status) => params.append("c", status));
if (state.onlyReady) params.set("r", "1");
return params;
}
export function validateDiscoverTags(state: DiscoverUrlState, validTags: Iterable<string>): DiscoverUrlState {
const valid = new Set(validTags);
const tags = state.tags.filter((tag) => valid.has(tag));
return tags.length === state.tags.length ? state : { ...state, tags };
}

View File

@@ -8,6 +8,7 @@ import MobileFooterSpacer from "@/components/MobileFooterSpacer";
import { BaseRomProvider } from "@/contexts/BaseRomContext";
import { AuthProvider } from "@/contexts/AuthContext";
import NoticeBanner from "@/components/NoticeBanner";
import AppToaster from "@/components/AppToaster";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -50,6 +51,7 @@ export default function RootLayout({
<MobileFooterSpacer />
</BaseRomProvider>
</AuthProvider>
<AppToaster />
<Analytics />
</body>
</html>

View File

@@ -0,0 +1,35 @@
"use client";
import type { CSSProperties } from "react";
import { Toaster } from "sonner";
export default function AppToaster() {
return (
<Toaster
position="top-center"
offset="72px"
theme="system"
toastOptions={{
classNames: {
toast:
"rounded-xl border border-black/10 bg-white text-zinc-950 shadow-2xl shadow-black/15 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50 dark:shadow-black/30",
icon: "text-zinc-950 dark:text-zinc-50",
title: "text-sm font-medium text-zinc-950 dark:text-zinc-50",
description: "text-sm text-zinc-600 dark:text-zinc-300",
actionButton:
"rounded-md bg-[var(--accent)] px-3 py-1.5 text-sm font-medium text-[var(--accent-foreground)]",
cancelButton:
"rounded-md border border-black/10 bg-black/5 px-3 py-1.5 text-sm text-zinc-950 dark:border-white/15 dark:bg-white/10 dark:text-zinc-50",
},
}}
style={
{
"--normal-bg": "var(--background)",
"--normal-text": "var(--foreground)",
"--normal-border": "var(--border)",
"--border-radius": "0.75rem",
} as CSSProperties
}
/>
);
}

View File

@@ -2,11 +2,13 @@
import React, { Fragment } from "react";
import Link from "next/link";
import { toast } from "sonner";
import HackCard from "@/components/HackCard";
import { baseRoms } from "@/data/baseRoms";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
import { useFloating, offset, flip, shift, size, autoUpdate } from "@floating-ui/react";
import { IconType } from "react-icons";
import { FaLink, FaShare } from "react-icons/fa6";
import {
MdTune,
MdWhatshot,
@@ -23,17 +25,24 @@ import { BsSdCardFill } from "react-icons/bs";
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { HackCardAttributes } from "@/components/HackCard";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { getDiscoverData } from "@/app/discover/actions";
import {
buildDiscoverSearchParams,
DISCOVER_COMPLETION_STATUSES,
discoverUrlStatesEqual,
validateDiscoverTags,
type DiscoverUrlState,
} from "@/app/discover/search-params";
import type { DiscoverSortOption } from "@/types/discover";
import Select, { SelectOption } from "@/components/Primitives/Select";
import { useDiscoverUrlState } from "./useDiscoverUrlState";
const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
trending: MdWhatshot,
popular: MdTrendingUp,
new: MdNewReleases,
updated: MdUpdate,
alphabetical: MdSortByAlpha,
alpha: MdSortByAlpha,
};
const SORT_OPTIONS: SelectOption[] = [
@@ -41,32 +50,28 @@ const SORT_OPTIONS: SelectOption[] = [
{ value: "popular", label: "Most popular", icon: MdTrendingUp },
{ value: "new", label: "Newest", icon: MdNewReleases },
{ value: "updated", label: "Recently updated", icon: MdUpdate },
{ value: "alphabetical", label: "Alphabetical", icon: MdSortByAlpha },
{ value: "alpha", label: "Alphabetical", icon: MdSortByAlpha },
];
const HACKS_PER_PAGE = 9;
interface DiscoverBrowserProps {
initialSort?: DiscoverSortOption;
initialState: DiscoverUrlState;
}
export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBrowserProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [query, setQuery] = React.useState("");
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState<string[]>([]);
const [sort, setSort] = React.useState<DiscoverSortOption>(initialSort ?? "trending");
export default function DiscoverBrowser({ initialState }: 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(false);
const [currentPage, setCurrentPage] = React.useState(1);
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();
@@ -83,10 +88,33 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
return set;
}, [cached, statuses]);
React.useEffect(() => {
// Reset to first page when filters or sort change
setCurrentPage(1);
}, [query, selectedTags, selectedBaseRoms, selectedCompletionStatuses, onlyReady, sort]);
const currentUrlState = React.useMemo<DiscoverUrlState>(
() => ({
query,
sort,
page: currentPage,
tags: selectedTags,
baseRoms: onlyReady ? [] : selectedBaseRoms,
completionStatuses: selectedCompletionStatuses,
onlyReady,
}),
[currentPage, onlyReady, query, selectedBaseRoms, selectedCompletionStatuses, selectedTags, sort]
);
const applyUrlState = React.useCallback((nextState: DiscoverUrlState) => {
setQuery(nextState.query);
setSelectedTags([...nextState.tags]);
setSelectedBaseRoms([...nextState.baseRoms]);
setSelectedCompletionStatuses([...nextState.completionStatuses]);
setSort(nextState.sort);
setOnlyReady(nextState.onlyReady);
setCurrentPage(nextState.page);
}, []);
const { syncUrl, syncUrlWith, scheduleSearchUrlSync } = useDiscoverUrlState({
currentState: currentUrlState,
onUrlStateChange: applyUrlState,
});
React.useEffect(() => {
const run = async () => {
@@ -110,6 +138,22 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
run();
}, [sort]);
const validTagNames = React.useMemo(
() => new Set([...Object.values(tagGroups).flat(), ...ungroupedTags]),
[tagGroups, ungroupedTags]
);
React.useEffect(() => {
if (loadingTags || selectedTags.length === 0) return;
const nextState = validateDiscoverTags(currentUrlState, validTagNames);
if (discoverUrlStatesEqual(nextState, currentUrlState)) return;
setSelectedTags([...nextState.tags]);
setCurrentPage(1);
syncUrl({ ...nextState, page: 1 }, "replace");
}, [currentUrlState, loadingTags, selectedTags.length, syncUrl, validTagNames]);
const filtered = React.useMemo(() => {
let out = hacks;
const q = query.toLowerCase();
@@ -153,10 +197,11 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
React.useEffect(() => {
// Clamp current page if the number of results shrinks
if (currentPage > totalPages) {
if (!loadingHacks && currentPage > totalPages) {
setCurrentPage(totalPages);
syncUrlWith({ page: totalPages }, "replace");
}
}, [currentPage, totalPages]);
}, [currentPage, loadingHacks, syncUrlWith, totalPages]);
const paginationRange = React.useMemo(() => {
const startIndex = (currentPage - 1) * HACKS_PER_PAGE;
@@ -182,17 +227,15 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
const changePage = React.useCallback(
(nextPage: number) => {
setCurrentPage((prev) => {
const clamped = Math.min(Math.max(1, nextPage), totalPages);
// Only scroll when the page actually changes
if (clamped !== prev) {
// Defer scroll until after React has applied the state update
setTimeout(scrollToListTopOnMobile, 0);
}
return clamped;
});
const clamped = Math.min(Math.max(1, nextPage), totalPages);
if (clamped === currentPage) return;
setCurrentPage(clamped);
syncUrlWith({ page: clamped });
// Defer scroll until after React has applied the state update
setTimeout(scrollToListTopOnMobile, 0);
},
[scrollToListTopOnMobile, totalPages]
[currentPage, scrollToListTopOnMobile, syncUrlWith, totalPages]
);
const getPageNumbers = React.useCallback((current: number, total: number): (number | string)[] => {
@@ -232,20 +275,13 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
return pages;
}, []);
function toggleTag(name: string) {
setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]));
}
function clearTags() {
function clearFilters() {
setCurrentPage(1);
setSelectedTags([]);
}
function toggleBaseRom(id: string) {
setSelectedBaseRoms((prev) => (prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]));
}
function clearBaseRoms() {
setSelectedBaseRoms([]);
setSelectedCompletionStatuses([]);
setOnlyReady(false);
syncUrlWith({ tags: [], baseRoms: [], completionStatuses: [], onlyReady: false, page: 1 });
}
const sortIcon = React.useMemo(() => {
@@ -253,37 +289,67 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
return SortIcon ? <SortIcon className="h-5 w-5 text-foreground/80" aria-hidden="true" /> : null;
}, [sort]);
const copyDiscoverLink = React.useCallback(async () => {
if (typeof window === "undefined") return;
const params = buildDiscoverSearchParams({ ...currentUrlState, onlyReady: false }).toString();
const url = `${window.location.origin}${window.location.pathname}${params ? `?${params}` : ""}`;
try {
await navigator.clipboard.writeText(url);
toast.success(params ? "Filtered Discover link copied" : "Discover link copied", {
icon: <FaLink className="h-4 w-4" />,
});
} catch {
toast.error("Unable to copy Discover link");
}
}, [currentUrlState]);
return (
<div className="max-w-[1200px] mx-auto">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
const nextQuery = e.target.value;
const nextState = { ...currentUrlState, query: nextQuery, page: 1 };
setQuery(nextQuery);
setCurrentPage(1);
scheduleSearchUrlSync(nextState);
}}
placeholder="Search by title, author, or keyword"
className="h-11 w-full rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground placeholder:text-foreground/60 ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="flex h-11 w-full items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] sm:inline-flex sm:w-auto">
{sortIcon}
<div className="relative flex-1 sm:flex-none">
<Select
value={sort}
onChange={(value) => {
const nextSort = value as DiscoverSortOption;
setSort(nextSort);
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
current.set("sort", nextSort);
const queryString = current.toString();
const url = queryString ? `${pathname}?${queryString}` : pathname;
router.replace(url);
}}
options={SORT_OPTIONS}
// this css gets a little janky, but it gets the job done
className="flex h-11 w-full items-center rounded-none bg-transparent px-0 pl-1 pr-8 text-left sm:w-fit !ring-0 focus:ring-0"
dropdownClassName="-left-[2.313rem] top-[44px] !min-w-0 !max-w-none !w-[calc(100%_+_3.125rem)] sm:left-auto sm:right-[-12px] sm:!w-max"
/>
<div className="flex w-full items-center gap-2 sm:w-auto">
<div className="flex h-11 flex-1 items-center gap-1.5 rounded-md bg-(--surface-2) px-3 text-sm ring-1 ring-inset ring-(--border) sm:inline-flex sm:flex-none">
{sortIcon}
<div className="relative flex-1 sm:flex-none">
<Select
value={sort}
onChange={(value) => {
const nextSort = value as DiscoverSortOption;
setSort(nextSort);
setCurrentPage(1);
syncUrlWith({ sort: nextSort, page: 1 });
}}
options={SORT_OPTIONS}
// this css gets a little janky, but it gets the job done
className="flex h-11 w-full items-center rounded-none bg-transparent px-0 pl-1 pr-8 text-left sm:w-fit !ring-0 focus:ring-0"
dropdownClassName="-left-[2.313rem] top-[44px] !min-w-0 !max-w-none !w-[calc(100%_+_3.125rem)] sm:left-auto sm:right-[-12px] sm:!w-max"
/>
</div>
</div>
<button
type="button"
onClick={copyDiscoverLink}
aria-label="Copy link to current Discover filters"
title="Copy link to these filters"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-(--surface-2) text-foreground/80 ring-1 ring-inset ring-(--border) transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring) dark:hover:bg-white/10"
>
<FaShare className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
@@ -294,11 +360,17 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
type="button"
aria-pressed={onlyReady}
onClick={() =>
setOnlyReady((v) => {
const next = !v;
if (next) setSelectedBaseRoms([]);
return next;
})
{
const nextOnlyReady = !onlyReady;
setOnlyReady(nextOnlyReady);
if (nextOnlyReady) setSelectedBaseRoms([]);
setCurrentPage(1);
syncUrlWith({
onlyReady: nextOnlyReady,
baseRoms: nextOnlyReady ? [] : selectedBaseRoms,
page: 1,
});
}
}
title="Show only hacks playable on your device (base ROM ready)"
className={`flex items-center gap-2 rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors ${
@@ -320,14 +392,20 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
onChange={(vals) => {
setSelectedBaseRoms(vals);
if (vals.length > 0) setOnlyReady(false);
setCurrentPage(1);
syncUrlWith({ baseRoms: vals, onlyReady: vals.length > 0 ? false : onlyReady, page: 1 });
}}
/>
<MultiSelectDropdown
icon={TbProgressCheck}
label="Completion"
options={['Complete','Demo','Alpha','Beta'].map((s) => ({ id: s, name: s }))}
options={DISCOVER_COMPLETION_STATUSES.map((s) => ({ id: s, name: s }))}
values={selectedCompletionStatuses}
onChange={setSelectedCompletionStatuses}
onChange={(vals) => {
setSelectedCompletionStatuses(vals);
setCurrentPage(1);
syncUrlWith({ completionStatuses: vals, page: 1 });
}}
/>
{loadingTags ? (
<>
@@ -350,10 +428,11 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}
onChange={(vals) => {
// Replace selections for this category while keeping others
setSelectedTags((prev) => {
const others = prev.filter((t) => !tagGroups[cat].includes(t));
return [...others, ...vals];
});
const others = selectedTags.filter((tag) => !tagGroups[cat].includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
))}
@@ -365,10 +444,11 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
options={ungroupedTags.map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => ungroupedTags.includes(t))}
onChange={(vals) => {
setSelectedTags((prev) => {
const others = prev.filter((t) => !ungroupedTags.includes(t));
return [...others, ...vals];
});
const others = selectedTags.filter((tag) => !ungroupedTags.includes(tag));
const nextTags = [...others, ...vals];
setSelectedTags(nextTags);
setCurrentPage(1);
syncUrlWith({ tags: nextTags, page: 1 });
}}
/>
)}
@@ -376,12 +456,7 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
)}
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && (
<button
onClick={() => {
clearTags();
clearBaseRoms();
setSelectedCompletionStatuses([]);
setOnlyReady(false);
}}
onClick={clearFilters}
className="ml-2 rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
>
Clear filters
@@ -415,7 +490,11 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
<div className="flex flex-wrap items-center justify-center gap-2">
{query && (
<button
onClick={() => setQuery("")}
onClick={() => {
setQuery("");
setCurrentPage(1);
syncUrlWith({ query: "", page: 1 });
}}
className="rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
>
Clear search
@@ -423,12 +502,7 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
)}
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && (
<button
onClick={() => {
clearTags();
clearBaseRoms();
setSelectedCompletionStatuses([]);
setOnlyReady(false);
}}
onClick={clearFilters}
className="rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
>
Clear filters

View File

@@ -0,0 +1,107 @@
"use client";
import React from "react";
import { usePathname } from "next/navigation";
import {
buildDiscoverSearchParams,
discoverUrlStatesEqual,
parseDiscoverSearchParams,
type DiscoverUrlState,
} from "@/app/discover/search-params";
const SEARCH_URL_DEBOUNCE_MS = 300;
type UrlSyncMode = "push" | "replace";
interface UseDiscoverUrlStateArgs {
currentState: DiscoverUrlState;
onUrlStateChange: (state: DiscoverUrlState) => void;
}
export function useDiscoverUrlState({ currentState, onUrlStateChange }: UseDiscoverUrlStateArgs) {
const pathname = usePathname();
const searchUrlTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const currentStateRef = React.useRef(currentState);
const onUrlStateChangeRef = React.useRef(onUrlStateChange);
React.useEffect(() => {
currentStateRef.current = currentState;
}, [currentState]);
React.useEffect(() => {
onUrlStateChangeRef.current = onUrlStateChange;
}, [onUrlStateChange]);
const clearSearchUrlTimeout = React.useCallback(() => {
if (searchUrlTimeoutRef.current) {
clearTimeout(searchUrlTimeoutRef.current);
searchUrlTimeoutRef.current = null;
}
}, []);
const syncUrl = React.useCallback(
(state: DiscoverUrlState, mode: UrlSyncMode = "push") => {
if (typeof window === "undefined") return;
const nextState = {
...state,
page: Math.max(1, state.page),
baseRoms: state.onlyReady ? [] : state.baseRoms,
};
const nextParams = buildDiscoverSearchParams(nextState).toString();
const currentParams = new URLSearchParams(window.location.search).toString();
if (nextParams === currentParams) return;
// This is only for client-side Discover state. If these params ever need
// to change server-rendered data, metadata, or route output, use Next
// navigation/searchParams instead of directly updating browser history.
const url = nextParams ? `${pathname}?${nextParams}` : pathname;
if (mode === "replace") {
window.history.replaceState(null, "", url);
} else {
window.history.pushState(null, "", url);
}
},
[pathname]
);
const syncUrlWith = React.useCallback(
(overrides: Partial<DiscoverUrlState>, mode: UrlSyncMode = "push") => {
if (mode === "push") clearSearchUrlTimeout();
syncUrl({ ...currentStateRef.current, ...overrides }, mode);
},
[clearSearchUrlTimeout, syncUrl]
);
const scheduleSearchUrlSync = React.useCallback(
(state: DiscoverUrlState) => {
clearSearchUrlTimeout();
searchUrlTimeoutRef.current = setTimeout(() => {
syncUrl(state, "replace");
searchUrlTimeoutRef.current = null;
}, SEARCH_URL_DEBOUNCE_MS);
},
[clearSearchUrlTimeout, syncUrl]
);
React.useEffect(() => clearSearchUrlTimeout, [clearSearchUrlTimeout]);
React.useEffect(() => {
const applyUrlState = () => {
const nextState = parseDiscoverSearchParams(new URLSearchParams(window.location.search));
if (discoverUrlStatesEqual(nextState, currentStateRef.current)) return;
clearSearchUrlTimeout();
onUrlStateChangeRef.current(nextState);
};
window.addEventListener("popstate", applyUrlState);
return () => window.removeEventListener("popstate", applyUrlState);
}, [clearSearchUrlTimeout]);
return {
syncUrl,
syncUrlWith,
scheduleSearchUrlSync,
};
}

View File

@@ -1,5 +1,7 @@
"use client";
import { buildDiscoverSearchParams, DISCOVER_DEFAULT_STATE } from "@/app/discover/search-params";
import Link from "next/link";
import { useState, useRef, useLayoutEffect, useEffect, useCallback } from "react";
import { FaChevronDown } from "react-icons/fa6";
@@ -7,9 +9,9 @@ interface CollapsibleTagsProps {
tags: string[];
}
// Tag height: ~28px (px-2.5 py-1 text-xs), gap: 8px
// Max height: 2.5 × 28px + 2 × 8px = 86px (rounded to 88px for safety)
const MAX_HEIGHT = 72;
// Tag height: ~24px (16px height + 8px y-padding), gap: 8px
// Max height: 1.25 × 24px + 2 × 8px = 46px - 4px (for aesthetic padding)
const MAX_HEIGHT = 42;
export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
const [isExpanded, setIsExpanded] = useState(false);
@@ -19,7 +21,7 @@ export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
const checkIfExpansionNeeded = useCallback(() => {
if (!contentRef.current) return;
const height = contentRef.current.scrollHeight;
const height = contentRef.current.scrollHeight + 16; // Add 16px for padding
setNaturalHeight(height);
setNeedsExpansion(height > MAX_HEIGHT);
}, []);
@@ -40,7 +42,7 @@ export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
if (tags.length === 0) return null;
return (
<div className="flex flex-col">
<div className="flex flex-col w-full">
<div
className="grid transition-all duration-300 ease-in-out"
style={
@@ -60,18 +62,23 @@ export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
}
}
>
<div className="overflow-hidden">
<div className="overflow-hidden p-0.5">
<div
ref={contentRef}
className="flex flex-wrap gap-2"
>
{tags.map((t) => (
<span
<Link
key={t}
className="rounded-full bg-[var(--surface-2)] px-2.5 py-1 text-xs ring-1 ring-[var(--border)]"
href={`/discover?${buildDiscoverSearchParams({
...DISCOVER_DEFAULT_STATE,
tags: [t],
}).toString()}`}
aria-label={`View hacks tagged ${t}`}
className="rounded-full bg-(--surface-2) px-2.5 py-1 text-xs ring-1 ring-(--border) transition-colors md:cursor-pointer md:hover:bg-(--surface-3)"
>
{t}
</span>
</Link>
))}
</div>
</div>

View File

@@ -1,3 +1,3 @@
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alphabetical";
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alpha";