v4: a profile's lists of dumps can be narrowed to a category — /@name/dumps and /@name/upvoted showed every dump, with no way to see only those filed under one category, as the index can. The title row of each list now has the index's category menu, and the category picked goes in the URL as ?category=<slug>, which the other list keeps and every other tab drops (a path segment would collide with /@name/dumps/<slug>, a dump's own page). GET /api/users/:key/dumps and /upvoted take ?category= as the followed feed does, an unknown slug answering an empty list; resolveCategoryFilter moves to category-service.ts for both. useUserDumpFeed passes everything the socket changes through the category, so a dump edited out of it leaves and an upvote outside it doesn't come in. CategorySwitcher now reports the category picked to its owner (onSelect) instead of navigating to the index's path itself, and the divider after it in the index's tab row is drawn by CSS, so the profile gets none. useTabParam's setter takes the query string to go to, the current one by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 18:23:03 +00:00
parent 52a23db504
commit 63803c552f
17 changed files with 422 additions and 199 deletions

View File

@@ -1,6 +1,6 @@
/**
* Renders the menu that switches the index page's feed between all dumps and a
* category.
* Renders the menu that narrows a list of dumps to a category, on the index
* page and on a profile's lists.
*
* @module
*/
@@ -13,32 +13,29 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { useCategories } from "../../hooks/category/useCategories.ts";
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
import { feedPath, type FeedTab } from "../../../shared/paths.ts";
/** Passed to {@link CategorySwitcher} by `FeedTabBar`. */
/**
* Passed to {@link CategorySwitcher} by `FeedTabBar` and `ProfileDumpsTab`,
* which keep the category in their URL.
*/
export interface CategorySwitcherProps {
/** The category shown, or none for every dump. */
categorySlug?: string;
/** The tab shown, which picking a category keeps. */
tab: FeedTab;
/** Called with the slug of the category picked, or undefined for "All". */
onSelect: (slug: string | undefined) => void;
}
/**
* Renders the category menu at the head of `FeedTabBar`. Picking a category
* keeps the feed tab, from `/music/new` to `/cinema/new`, and "All" leads to
* `/new`; the viewer's default tab stays the bare path. It renders nothing
* while the site has no categories.
* Renders a button naming the category shown, "All" without one, that opens
* the menu of every category. It renders nothing while the site has no
* categories.
*/
export function CategorySwitcher(
{ categorySlug, tab }: CategorySwitcherProps,
{ categorySlug, onSelect }: CategorySwitcherProps,
): React.JSX.Element | null {
const { categories } = useCategories();
const navigate = useNavigate();
const defaultTab = useViewerDefaultFeedTab();
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
@@ -71,12 +68,19 @@ export function CategorySwitcher(
}, [open]);
// The menu is fixed and portaled to <body>, so the tab row's
// `overflow-x: auto` can't clip it. It must follow the button on scroll.
// `overflow-x: auto` can't clip it. It must follow the button on scroll,
// and stays within the viewport when the button sits at its right edge.
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setMenuStyle({ top: r.bottom + 6, left: r.left });
if (!r) return;
const width = menuRef.current?.offsetWidth ?? 0;
const left = Math.max(
8,
Math.min(r.left, globalThis.innerWidth - width - 8),
);
setMenuStyle({ top: r.bottom + 6, left });
};
update();
globalThis.addEventListener("scroll", update, true);
@@ -102,7 +106,7 @@ export function CategorySwitcher(
function select(slug: string) {
setOpen(false);
navigate(feedPath(slug || undefined, tab === defaultTab ? undefined : tab));
onSelect(slug || undefined);
}
return (
@@ -147,8 +151,6 @@ export function CategorySwitcher(
</ul>,
document.body,
)}
<span className="feed-sort-sep" aria-hidden="true" />
</div>
);
}