diff --git a/src/app/os/files/page.tsx b/src/app/os/files/page.tsx index 2ff5ba2..269a4d2 100644 --- a/src/app/os/files/page.tsx +++ b/src/app/os/files/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; -import { useDebounce } from "use-debounce"; import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -10,6 +9,7 @@ import { Button } from "@/components/ui/button"; import FileSystem from "@/components/filesystem"; import { HeaderPortal } from "@/components/header-portal"; import { createEngine } from "@/lib/engine"; +import { useQueryFilter } from "@/hooks/use-query-filter"; export default function Files() { const params = useSearchParams(); @@ -18,10 +18,9 @@ export default function Files() { const [loading, setLoading] = useState(true); const [files, setFiles] = useState([]); - const [keyword, setKeyword] = useState(""); const [expandAll, setExpandAll] = useState(null); - const [debouncedKeyword] = useDebounce(keyword, 200); + const { keyword, setKeyword, debouncedKeyword } = useQueryFilter("q", 200); useEffect(() => { setLoading(true); diff --git a/src/app/os/find/page.tsx b/src/app/os/find/page.tsx index 6d400b2..97c120c 100644 --- a/src/app/os/find/page.tsx +++ b/src/app/os/find/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import FileSystem from "@/components/filesystem"; import { HeaderPortal } from "@/components/header-portal"; import { createEngine } from "@/lib/engine"; +import { useQueryFilter } from "@/hooks/use-query-filter"; export default function FindByKey() { const params = useSearchParams(); @@ -29,17 +30,8 @@ export default function FindByKey() { const [loading, setLoading] = useState(true); const [paths, setPaths] = useState([]); - const [keyword, setKeyword] = useState(""); - const [debouncedKeyword, setDebouncedKeyword] = useState(""); const [expandAll, setExpandAll] = useState(null); - - // Debounce keyword with 300ms delay - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedKeyword(keyword); - }, 300); - return () => clearTimeout(timer); - }, [keyword]); + const { keyword, setKeyword, debouncedKeyword } = useQueryFilter(); useEffect(() => { async function fetchPaths() { diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx index bc17a64..086f3e1 100644 --- a/src/app/os/keys/page.tsx +++ b/src/app/os/keys/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { HeaderPortal } from "@/components/header-portal"; import { createEngine } from "@/lib/engine"; import { tokenizeKeys, getTopTokens } from "@/lib/tokenizer"; +import { useQueryFilter } from "@/hooks/use-query-filter"; export default function Keys() { const params = useSearchParams(); @@ -18,15 +19,7 @@ export default function Keys() { const [loading, setLoading] = useState(true); const [keys, setKeys] = useState([]); - const [keyword, setKeyword] = useState(""); - const [debouncedKeyword, setDebouncedKeyword] = useState(""); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedKeyword(keyword); - }, 300); - return () => clearTimeout(timer); - }, [keyword]); + const { keyword, setKeyword, debouncedKeyword } = useQueryFilter(); useEffect(() => { async function load() { diff --git a/src/components/oslist.tsx b/src/components/oslist.tsx index 9802d7c..d2dd438 100644 --- a/src/components/oslist.tsx +++ b/src/components/oslist.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useQueryFilter } from "@/hooks/use-query-filter"; import { dataURL } from "@/lib/env"; import type { Group, OS } from "@/lib/types"; import { HeaderPortal } from "./header-portal"; @@ -74,15 +75,7 @@ export default function OSList() { const [groups, setGroups] = useState([]); const [highlights, setHighlights] = useState>(new Set()); const [selectedPlatform, setSelectedPlatform] = useState(null); - const [keyword, setKeyword] = useState(""); - const [debouncedKeyword, setDebouncedKeyword] = useState(""); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedKeyword(keyword); - }, 200); - return () => clearTimeout(timer); - }, [keyword]); + const { keyword, setKeyword, debouncedKeyword } = useQueryFilter("q", 200); useEffect(() => { const set: Set = new Set(); diff --git a/src/hooks/use-query-filter.ts b/src/hooks/use-query-filter.ts new file mode 100644 index 0000000..9be7caa --- /dev/null +++ b/src/hooks/use-query-filter.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { useDebounce } from "use-debounce"; + +import { basePath } from "@/lib/env"; + +/** + * Debounced text filter that mirrors its value into a URL query param. + * + * - Initializes the keyword from the param so deep links open pre-filtered. + * - Debounces, so rapid typing ("c" → "co" → "com") only writes the URL once. + * - Uses router.replace (not push), so intermediate filter states don't + * pollute browser history. + */ +export function useQueryFilter(param = "q", delay = 300) { + const params = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [keyword, setKeyword] = useState(() => params.get(param) ?? ""); + const [debouncedKeyword] = useDebounce(keyword, delay); + + useEffect(() => { + const next = new URLSearchParams(params.toString()); + if (debouncedKeyword) { + next.set(param, debouncedKeyword); + } else { + next.delete(param); + } + + // usePathname() may include the configured basePath, which router.replace + // re-applies — strip it to avoid doubling (mirrors version-switcher). + const path = + basePath && pathname.startsWith(basePath) + ? pathname.slice(basePath.length) + : pathname; + const query = next.toString(); + router.replace(query ? `${path}?${query}` : path, { scroll: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedKeyword]); + + return { keyword, setKeyword, debouncedKeyword }; +}