feat(web): persist list-page filters in URL search params

Resources, projects, and allocations filter state now syncs to/from
URL so filters survive refresh and can be shared via link.
Text inputs are debounced (300ms) to avoid URL churn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 17:00:30 +02:00
parent 7264f0728a
commit 6bf60c8e07
4 changed files with 172 additions and 46 deletions
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useRef, useTransition } from "react";
// Typed-routes are enabled in next.config.ts; bypass for dynamic URLs.
type PlainRouter = { replace: (url: string, opts?: { scroll?: boolean }) => void };
/**
* Syncs a filter object with URL search params.
* T must be a flat record of string | undefined values.
*
* - Reads current filter values from URL search params, falling back to `defaults`.
* - Returns a stable `setFilters` updater that writes to the URL via `router.replace`
* (no back-button history entry per keystroke).
* - Default values are NOT written to the URL, keeping URLs clean and shareable.
*/
export function useUrlFilters<T extends Record<string, string | undefined>>(
defaults: T,
): [T, (updates: Partial<T>) => void] {
const searchParams = useSearchParams();
const router = useRouter() as unknown as PlainRouter;
const pathname = usePathname();
const [, startTransition] = useTransition();
// Stable ref so setFilters doesn't need defaults in its deps array.
const defaultsRef = useRef(defaults);
defaultsRef.current = defaults;
// Read current values from URL, falling back to defaults
const filters = Object.fromEntries(
Object.keys(defaults).map((key) => {
const val = searchParams.get(key);
return [key, val ?? defaults[key]];
}),
) as T;
const setFilters = useCallback(
(updates: Partial<T>) => {
const params = new URLSearchParams(searchParams.toString());
const defs = defaultsRef.current;
for (const [key, value] of Object.entries(updates)) {
if (value === undefined || value === defs[key as keyof T]) {
params.delete(key); // Clean URL — don't show default values
} else {
params.set(key, String(value));
}
}
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[searchParams, router, pathname],
);
return [filters, setFilters];
}