mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 00:24:34 -05:00
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:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
131
src/app/discover/search-params.ts
Normal file
131
src/app/discover/search-params.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
35
src/components/AppToaster.tsx
Normal file
35
src/components/AppToaster.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
107
src/components/Discover/useDiscoverUrlState.ts
Normal file
107
src/components/Discover/useDiscoverUrlState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alphabetical";
|
||||
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alpha";
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user