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:
@@ -15,6 +15,7 @@ import {
|
||||
hydrateDump,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useCategories } from "../category/useCategories.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
|
||||
@@ -35,28 +36,43 @@ export interface UseUserDumpFeedResult extends UsePagedListResult<Dump> {
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
/** Passed to {@link useUserDumpFeed} by the profile's overview and tabs. */
|
||||
export interface UseUserDumpFeedOptions {
|
||||
/** Keeps the user's own private dumps in their list as they edit them. */
|
||||
isOwner: boolean;
|
||||
/** Dumps at hand that a vote of the user's own puts first in their upvoted. */
|
||||
source?: Dump[];
|
||||
/** Narrows the list to the category at this slug. */
|
||||
categorySlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `list` of user `userId`, from `GET /api/users/:userId/<list>`, fetched
|
||||
* signed in so users see their own private dumps, with the call that loads its
|
||||
* next page. The socket keeps it live: a dump that goes private and public
|
||||
* again comes back at its place, and the upvoted list follows the user's
|
||||
* votes, `source` holding dumps at hand that a vote of theirs puts first at
|
||||
* once. The list and the scroll position are saved as the page is left, under
|
||||
* a key the overview and the tab share, and going back restores them.
|
||||
* next page; with `categorySlug`, only the dumps in that category. The socket
|
||||
* keeps it live: a dump that goes private and public again comes back at its
|
||||
* place, one edited out of the category leaves, and the upvoted list follows
|
||||
* the user's votes, `source` holding dumps at hand that a vote of theirs puts
|
||||
* first at once. The list and the scroll position are saved as the page is
|
||||
* left, under a key the overview and the unfiltered tab share, and going back
|
||||
* restores them.
|
||||
*/
|
||||
export function useUserDumpFeed(
|
||||
userId: string,
|
||||
list: UserDumpList,
|
||||
{ isOwner, source }: { isOwner: boolean; source?: Dump[] },
|
||||
{ isOwner, source, categorySlug }: UseUserDumpFeedOptions,
|
||||
): UseUserDumpFeedResult {
|
||||
const { user, authFetch } = useAuth();
|
||||
const { lastDumpEvent } = useWS();
|
||||
const { categories } = useCategories();
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:profile-${list}:${userId}`,
|
||||
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const url = `${API_URL}/api/users/${userId}/${list}`;
|
||||
const url = `${API_URL}/api/users/${userId}/${list}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
const paged = usePagedList<Dump>(
|
||||
`${url}:${user?.id ?? "guest"}`,
|
||||
useCallback(
|
||||
@@ -67,9 +83,25 @@ export function useUserDumpFeed(
|
||||
),
|
||||
cached,
|
||||
);
|
||||
const { state, setItems } = paged;
|
||||
const { state, setItems: setAllItems } = paged;
|
||||
const items = state.status === "loaded" ? state.data.items : [];
|
||||
|
||||
// Whatever the socket puts in or changes in a category's list passes
|
||||
// through its filter, so that no dump outside the category shows. A slug
|
||||
// the categories don't know yet lets everything through.
|
||||
const categoryId = categorySlug &&
|
||||
categories.find((c) => c.slug === categorySlug)?.id;
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setAllItems((prev) => {
|
||||
const next = fn(prev);
|
||||
return categoryId
|
||||
? next.filter((d) => d.categoryIds?.includes(categoryId))
|
||||
: next;
|
||||
}),
|
||||
[setAllItems, categoryId],
|
||||
);
|
||||
|
||||
const upvoted = useUpvotedList({
|
||||
profileUserId: list === "upvoted" ? userId : null,
|
||||
setItems,
|
||||
@@ -95,6 +127,7 @@ export function useUserDumpFeed(
|
||||
|
||||
return {
|
||||
...paged,
|
||||
setItems,
|
||||
cardClassName: list === "upvoted" ? upvoted.cardClassName : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import { useLocation, useNavigate } from "react-router";
|
||||
* Returns the tab the path segment after `base` names, `defaultTab` on the
|
||||
* bare `base`, or null when the segment names none of `validTabs`, for the
|
||||
* page to answer as not found; and a setter that navigates to a tab in place
|
||||
* of the current history entry, the default tab to the bare `base`. A segment
|
||||
* after the tab's, as the profile's `/follow/playlists`, is the tab's own
|
||||
* business.
|
||||
* of the current history entry, the default tab to the bare `base`, with the
|
||||
* query string `search`, the current one by default. A segment after the
|
||||
* tab's, as the profile's `/follow/playlists`, is the tab's own business.
|
||||
*/
|
||||
export function useTabParam<T extends string>(
|
||||
base: string,
|
||||
validTabs: Iterable<T>,
|
||||
defaultTab: T,
|
||||
): [T | null, (tab: T) => void] {
|
||||
): [T | null, (tab: T, search?: string) => void] {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -33,11 +33,11 @@ export function useTabParam<T extends string>(
|
||||
: null;
|
||||
|
||||
const setTab = useCallback(
|
||||
(t: T) => {
|
||||
(t: T, search = location.search) => {
|
||||
// Keeps the query string (the search page's `?q=`) and the entry's
|
||||
// state, since this is the same entry and `AppHeader`'s search reads
|
||||
// where to go back to from it.
|
||||
navigate(`${base}${t === defaultTab ? "" : `/${t}`}${location.search}`, {
|
||||
navigate(`${base}${t === defaultTab ? "" : `/${t}`}${search}`, {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user