diff --git a/package-lock.json b/package-lock.json index f4c638c..333c396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9d0089a..d220ffa 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index 5480f25..57fd77a 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -53,7 +53,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise @@ -33,7 +28,7 @@ export default async function DiscoverPage(props: DiscoverPageProps) {
- +
); diff --git a/src/app/discover/search-params.ts b/src/app/discover/search-params.ts new file mode 100644 index 0000000..472237c --- /dev/null +++ b/src/app/discover/search-params.ts @@ -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; +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(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): DiscoverUrlState { + const valid = new Set(validTags); + const tags = state.tags.filter((tag) => valid.has(tag)); + + return tags.length === state.tags.length ? state : { ...state, tags }; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1316556..cae263d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ + diff --git a/src/components/AppToaster.tsx b/src/components/AppToaster.tsx new file mode 100644 index 0000000..7384d9a --- /dev/null +++ b/src/components/AppToaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { Toaster } from "sonner"; + +export default function AppToaster() { + return ( + + ); +} diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 33819f2..b543440 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -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 = { 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([]); - const [selectedBaseRoms, setSelectedBaseRoms] = React.useState([]); - const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState([]); - const [sort, setSort] = React.useState(initialSort ?? "trending"); +export default function DiscoverBrowser({ initialState }: DiscoverBrowserProps) { + const [query, setQuery] = React.useState(initialState.query); + const [selectedTags, setSelectedTags] = React.useState(() => [...initialState.tags]); + const [selectedBaseRoms, setSelectedBaseRoms] = React.useState(() => [...initialState.baseRoms]); + const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState(() => [...initialState.completionStatuses]); + const [sort, setSort] = React.useState(initialState.sort); const [hacks, setHacks] = React.useState([]); const [tagGroups, setTagGroups] = React.useState>({}); const [ungroupedTags, setUngroupedTags] = React.useState([]); const [loadingHacks, setLoadingHacks] = React.useState(true); const [loadingTags, setLoadingTags] = React.useState(true); - const [onlyReady, setOnlyReady] = React.useState(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(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( + () => ({ + 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 ?