diff --git a/api/routes/follows.ts b/api/routes/follows.ts index b55b62a6..6597b817 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -36,20 +36,7 @@ import { unfollowPlaylist, unfollowUser, } from "../services/follow-service.ts"; -import { getCategoryIdBySlug } from "../services/category-service.ts"; - -/** - * Returns the id of the category the `?category=` slug names, undefined without - * one, or "missing" when it names none, so that the feed comes back empty - * rather than unfiltered. - */ -function resolveCategoryFilter( - params: URLSearchParams, -): string | "missing" | undefined { - const slug = params.get("category"); - if (!slug) return undefined; - return getCategoryIdBySlug(slug) ?? "missing"; -} +import { resolveCategoryFilter } from "../services/category-service.ts"; const EMPTY_FEED: Page = { items: [], hasMore: false }; diff --git a/api/routes/users.ts b/api/routes/users.ts index 843c172a..516c1a9a 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -10,13 +10,15 @@ * - `GET /api/users?q=` — public. Answers up to 8 users whose name starts * with `q`, for @mention suggestions. * - `GET /api/users/:key` — public. Answers a user's profile. - * - `GET /api/users/:key/dumps?page=&limit=` — optional sign-in. Answers a - * page of their dumps, the private ones only to themselves. + * - `GET /api/users/:key/dumps?page=&limit=&category=` — optional sign-in. + * Answers a page of their dumps, the private ones only to themselves, + * optionally in one category. * - `GET /api/users/:key/dumps/:slug` — optional sign-in. Answers their dump * whose slug is or was `slug`, with its current slug; a private one 404s * but for its poster. - * - `GET /api/users/:key/upvoted?page=&limit=` — optional sign-in. Answers a - * page of the dumps they upvoted, private ones only to themselves. + * - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in. + * Answers a page of the dumps they upvoted, private ones only to + * themselves, optionally in one category. * - `GET /api/users/:key/playlists?page=&limit=` — optional sign-in. Answers a * page of the playlists they made, the private ones only to themselves. * - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their @@ -95,6 +97,7 @@ import { getFollowedPlaylistsByUser, getFollowedUsersByUser, } from "../services/follow-service.ts"; +import { resolveCategoryFilter } from "../services/category-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -154,14 +157,13 @@ router.get("/:key", (ctx) => { router.get("/:key/dumps", async (ctx) => { const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); - const { page, limit } = parsePagination(ctx.request.url.searchParams); + const params = ctx.request.url.searchParams; + const { page, limit } = parsePagination(params); + const categoryId = resolveCategoryFilter(params); const includePrivate = requestingUserId === user.id; - const { items, hasMore } = getDumpsByUser( - user.id, - page, - limit, - includePrivate, - ); + const { items, hasMore } = categoryId === "missing" + ? { items: [], hasMore: false } + : getDumpsByUser(user.id, page, limit, includePrivate, categoryId); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, @@ -182,13 +184,12 @@ router.get("/:key/dumps/:slug", async (ctx) => { router.get("/:key/upvoted", async (ctx) => { const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = getVotedDumpsByUser( - user.id, - page, - limit, - requestingUserId, - ); + const params = ctx.request.url.searchParams; + const { page, limit } = parsePagination(params); + const categoryId = resolveCategoryFilter(params); + const { items, hasMore } = categoryId === "missing" + ? { items: [], hasMore: false } + : getVotedDumpsByUser(user.id, page, limit, requestingUserId, categoryId); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 729e2bdd..365960eb 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -96,7 +96,6 @@ export function getCategoryBySlug(slug: string): Category { /** * Returns the id of the category at `slug`, or undefined when there is none. - * The follows routes use it for their `?category=` feed filter. */ export function getCategoryIdBySlug(slug: string): string | undefined { const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); @@ -105,6 +104,20 @@ export function getCategoryIdBySlug(slug: string): string | undefined { : undefined; } +/** + * Returns the id of the category the `?category=` slug of `params` names, + * undefined without one, or "missing" when it names none, so that the list + * comes back empty rather than unfiltered. The follows routes filter the + * index's "followed" tab with it, and the users routes a profile's lists. + */ +export function resolveCategoryFilter( + params: URLSearchParams, +): string | "missing" | undefined { + const slug = params.get("category"); + if (!slug) return undefined; + return getCategoryIdBySlug(slug) ?? "missing"; +} + /** * Returns every category, by position then slug, for `GET /api/categories`. */ diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 5dc6f5ab..8d388eab 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -690,10 +690,24 @@ export async function replaceFileDump( return updatedDump; } +/** + * Returns the join that keeps only the dumps `d` filed under `categoryId`, + * with its parameters, or nothing without one. The lists of a user's dumps + * filter by it. + */ +function categoryJoin(categoryId?: string): [string, string[]] { + return categoryId + ? [ + "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?", + [categoryId], + ] + : ["", []]; +} + /** * Returns a page of `userId`'s dumps, newest first, for - * `GET /api/users/:key/dumps`. The route sets `includePrivate` when users - * ask for their own. + * `GET /api/users/:key/dumps`, optionally only those in `categoryId`. The + * route sets `includePrivate` when users ask for their own. * * @throws {APIException} 500 when a row doesn't parse as a dump. */ @@ -702,12 +716,16 @@ export function getDumpsByUser( page: number, limit: number, includePrivate: boolean, + categoryId?: string, ): Page { const offset = (page - 1) * limit; - const privacyFilter = includePrivate ? "" : " AND is_private = 0"; + const privacyFilter = includePrivate ? "" : " AND d.is_private = 0"; + const [catJoin, catParams] = categoryJoin(categoryId); const rows = db.prepare( - `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit + 1, offset); + `SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin} + WHERE d.user_id = ?${privacyFilter} + ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, + ).all(...catParams, userId, limit + 1, offset); if (!rows.every(isDumpRow)) { throw new APIException(500, "Malformed dump data"); } @@ -718,8 +736,8 @@ export function getDumpsByUser( /** * Returns a page of the dumps `userId` upvoted, latest vote first, for - * `GET /api/users/:key/upvoted`. Their own private dumps are included only - * when they are the one asking. + * `GET /api/users/:key/upvoted`, optionally only those in `categoryId`. + * Their own private dumps are included only when they are the one asking. * * @throws {APIException} 500 when a row doesn't parse as a dump. */ @@ -728,28 +746,28 @@ export function getVotedDumpsByUser( page: number, limit: number, requestingUserId: string | null, + categoryId?: string, ): Page { const offset = (page - 1) * limit; - const dumpCols = SELECT_COLS_ALIASED; - let rows: unknown[]; - - if (requestingUserId === userId) { - rows = db.prepare( - `SELECT ${dumpCols} - FROM dumps d - INNER JOIN votes v ON d.id = v.dump_id - WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?) - ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, userId, limit + 1, offset); - } else { - rows = db.prepare( - `SELECT ${dumpCols} - FROM dumps d - INNER JOIN votes v ON d.id = v.dump_id - WHERE v.user_id = ? AND d.is_private = 0 - ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit + 1, offset); - } + const isOwn = requestingUserId === userId; + const privacyFilter = isOwn + ? "(d.is_private = 0 OR d.user_id = ?)" + : "d.is_private = 0"; + const [catJoin, catParams] = categoryJoin(categoryId); + const rows = db.prepare( + `SELECT ${SELECT_COLS_ALIASED} + FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + ${catJoin} + WHERE v.user_id = ? AND ${privacyFilter} + ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, + ).all( + ...catParams, + userId, + ...(isOwn ? [userId] : []), + limit + 1, + offset, + ); if (!rows.every(isDumpRow)) { throw new APIException(500, "Malformed dump data"); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index bfd63722..4a828e3c 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -5,6 +5,7 @@ import { db, del, get, + insertCategory, insertDump, insertPlaylist, PASSWORD, @@ -241,6 +242,59 @@ test("GET /api/users/:key/dumps and /upvoted respect privacy", async () => { assert((await get(`/api/users/@nobody_at_all/dumps`)).status === 404); }); +test("GET /api/users/:key/dumps and /upvoted filter by category", async () => { + const owner = await createUser(); + const music = insertCategory({ slug: "music" }); + const tagged = insertDump({ + userId: owner.id, + title: "tagged", + createdAt: "2024-01-01T00:00:00Z", + }); + const priv = insertDump({ + userId: owner.id, + title: "priv", + isPrivate: true, + createdAt: "2024-01-02T00:00:00Z", + }); + insertDump({ userId: owner.id, title: "untagged" }); + for (const d of [tagged, priv]) { + db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ).run(d.id, music); + } + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?);`, + ).run( + tagged.id, + owner.id, + "2024-01-01T00:00:00Z", + priv.id, + owner.id, + "2024-01-02T00:00:00Z", + insertDump({ userId: owner.id, title: "voted" }).id, + owner.id, + "2024-01-03T00:00:00Z", + ); + for (const sub of ["dumps", "upvoted"]) { + const titles = async ( + query: string, + opts?: { auth: { Authorization: string } }, + ) => + (await get>( + `/api/users/@${owner.username}/${sub}${query}`, + opts, + )).data.items.map((d) => d.title); + assertEquals(await titles("?category=music"), ["tagged"], sub); + assertEquals( + await titles("?category=music", { auth: owner.auth }), + ["priv", "tagged"], + sub, + ); + // An unknown category filters everything out rather than nothing. + assertEquals(await titles("?category=nope"), [], sub); + } +}); + // ── Login and register ─────────────────────────────────────────────────────── test("POST /api/sessions: right password logs in, wrong one doesn't say which", async () => { diff --git a/locales/en.po b/locales/en.po index 74d1ee77..15d86940 100644 --- a/locales/en.po +++ b/locales/en.po @@ -207,7 +207,7 @@ msgstr "# Welcome to {site}!\n" #: src/components/dump/create/DumpCreateModal.tsx:742 #: src/pages/PlaylistDetail.tsx:80 -#: src/pages/UserPublicProfile.tsx:123 +#: src/pages/UserPublicProfile.tsx:125 msgid "← Back" msgstr "← Back" @@ -339,8 +339,8 @@ msgstr "added to {playlist}" msgid "Admin" msgstr "Admin" -#: src/components/category/CategorySwitcher.tsx:96 -#: src/components/category/CategorySwitcher.tsx:99 +#: src/components/category/CategorySwitcher.tsx:100 +#: src/components/category/CategorySwitcher.tsx:103 msgid "All" msgstr "All" @@ -458,7 +458,7 @@ msgstr "Cancel search" msgid "Categories" msgstr "Categories" -#: src/components/category/CategorySwitcher.tsx:119 +#: src/components/category/CategorySwitcher.tsx:123 msgid "Category" msgstr "Category" @@ -761,9 +761,9 @@ msgstr "Dump it" msgid "Dumped!" msgstr "Dumped!" -#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/components/profile/ProfileDumpsTab.tsx:46 #: src/pages/Search.tsx:120 -#: src/pages/UserPublicProfile.tsx:158 +#: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Dumps" @@ -943,10 +943,10 @@ msgstr "Follow some public playlists to see their dumps here." msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/components/feed/FeedTabBar.tsx:43 +#: src/components/feed/FeedTabBar.tsx:44 #: src/components/profile/ProfileFollowTab.tsx:140 #: src/pages/Settings.tsx:57 -#: src/pages/UserPublicProfile.tsx:161 +#: src/pages/UserPublicProfile.tsx:163 msgid "Followed" msgstr "Followed" @@ -994,7 +994,7 @@ msgstr "Go to login" msgid "email.reset.hi" msgstr "Hi {username}," -#: src/components/feed/FeedTabBar.tsx:39 +#: src/components/feed/FeedTabBar.tsx:40 #: src/pages/Settings.tsx:54 msgid "Hot" msgstr "Hot" @@ -1064,7 +1064,7 @@ msgid "digest.invitedByYou" msgstr "invited by you" #: src/components/profile/ProfileInviteesTab.tsx:77 -#: src/pages/UserPublicProfile.tsx:162 +#: src/pages/UserPublicProfile.tsx:164 msgid "Invitees" msgstr "Invitees" @@ -1140,7 +1140,7 @@ msgstr "Loading more…" msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:106 +#: src/pages/UserPublicProfile.tsx:108 msgid "Loading profile…" msgstr "Loading profile…" @@ -1178,7 +1178,7 @@ msgid "Log in with {0}" msgstr "Log in with {0}" #: src/components/layout/UserMenu.tsx:127 -#: src/pages/UserPublicProfile.tsx:131 +#: src/pages/UserPublicProfile.tsx:133 msgid "Log out" msgstr "Log out" @@ -1198,7 +1198,7 @@ msgstr "Lowercase letters, numbers and single hyphens only" #: src/components/layout/UserMenu.tsx:114 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 -#: src/pages/UserPublicProfile.tsx:164 +#: src/pages/UserPublicProfile.tsx:166 msgid "Manage" msgstr "Manage" @@ -1225,7 +1225,7 @@ msgid "Name" msgstr "Name" #: src/components/chat/ChatModal.tsx:692 -#: src/components/feed/FeedTabBar.tsx:40 +#: src/components/feed/FeedTabBar.tsx:41 #: src/pages/Settings.tsx:55 msgid "New" msgstr "New" @@ -1331,7 +1331,7 @@ msgstr "Nothing happened this month." msgid "Nothing happened this week." msgstr "Nothing happened this week." -#: src/components/profile/ProfileDumpsTab.tsx:53 +#: src/components/profile/ProfileDumpsTab.tsx:96 #: src/components/profile/ProfileOverviewTab.tsx:47 #: src/pages/Notifications.tsx:522 msgid "Nothing here yet." @@ -1404,7 +1404,7 @@ msgstr "Or log in with your account on another gerbeur" msgid "Other instances" msgstr "Other instances" -#: src/pages/UserPublicProfile.tsx:157 +#: src/pages/UserPublicProfile.tsx:159 msgid "Overview" msgstr "Overview" @@ -1465,7 +1465,7 @@ msgstr "Play" #: src/components/profile/ProfileFollowTab.tsx:144 #: src/components/profile/ProfilePlaylistsTab.tsx:118 #: src/pages/Search.tsx:123 -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Playlists" @@ -1987,8 +1987,8 @@ msgstr "Uploading…" msgid "Upvote" msgstr "Upvote" -#: src/components/profile/ProfileDumpsTab.tsx:45 -#: src/pages/UserPublicProfile.tsx:159 +#: src/components/profile/ProfileDumpsTab.tsx:46 +#: src/pages/UserPublicProfile.tsx:161 msgid "Upvoted" msgstr "Upvoted" diff --git a/locales/fr.po b/locales/fr.po index edc3e4c1..964b67c8 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -207,7 +207,7 @@ msgstr "# Bienvenue sur {site} !\n" #: src/components/dump/create/DumpCreateModal.tsx:742 #: src/pages/PlaylistDetail.tsx:80 -#: src/pages/UserPublicProfile.tsx:123 +#: src/pages/UserPublicProfile.tsx:125 msgid "← Back" msgstr "← Retour" @@ -339,8 +339,8 @@ msgstr "ajoutée à {playlist}" msgid "Admin" msgstr "Administrateur" -#: src/components/category/CategorySwitcher.tsx:96 -#: src/components/category/CategorySwitcher.tsx:99 +#: src/components/category/CategorySwitcher.tsx:100 +#: src/components/category/CategorySwitcher.tsx:103 msgid "All" msgstr "Tout" @@ -458,7 +458,7 @@ msgstr "Annuler la recherche" msgid "Categories" msgstr "Catégories" -#: src/components/category/CategorySwitcher.tsx:119 +#: src/components/category/CategorySwitcher.tsx:123 msgid "Category" msgstr "Catégorie" @@ -761,9 +761,9 @@ msgstr "Recommander" msgid "Dumped!" msgstr "Recommandé !" -#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/components/profile/ProfileDumpsTab.tsx:46 #: src/pages/Search.tsx:120 -#: src/pages/UserPublicProfile.tsx:158 +#: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Recos" @@ -943,10 +943,10 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici." msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/components/feed/FeedTabBar.tsx:43 +#: src/components/feed/FeedTabBar.tsx:44 #: src/components/profile/ProfileFollowTab.tsx:140 #: src/pages/Settings.tsx:57 -#: src/pages/UserPublicProfile.tsx:161 +#: src/pages/UserPublicProfile.tsx:163 msgid "Followed" msgstr "Suivi" @@ -994,7 +994,7 @@ msgstr "Aller à la connexion" msgid "email.reset.hi" msgstr "Bonjour {username}," -#: src/components/feed/FeedTabBar.tsx:39 +#: src/components/feed/FeedTabBar.tsx:40 #: src/pages/Settings.tsx:54 msgid "Hot" msgstr "Tendances" @@ -1064,7 +1064,7 @@ msgid "digest.invitedByYou" msgstr "sur votre invitation" #: src/components/profile/ProfileInviteesTab.tsx:77 -#: src/pages/UserPublicProfile.tsx:162 +#: src/pages/UserPublicProfile.tsx:164 msgid "Invitees" msgstr "Invités" @@ -1140,7 +1140,7 @@ msgstr "Chargement…" msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:106 +#: src/pages/UserPublicProfile.tsx:108 msgid "Loading profile…" msgstr "Chargement du profil…" @@ -1178,7 +1178,7 @@ msgid "Log in with {0}" msgstr "Se connecter avec {0}" #: src/components/layout/UserMenu.tsx:127 -#: src/pages/UserPublicProfile.tsx:131 +#: src/pages/UserPublicProfile.tsx:133 msgid "Log out" msgstr "Se déconnecter" @@ -1198,7 +1198,7 @@ msgstr "Minuscules, chiffres et tirets simples seulement" #: src/components/layout/UserMenu.tsx:114 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 -#: src/pages/UserPublicProfile.tsx:164 +#: src/pages/UserPublicProfile.tsx:166 msgid "Manage" msgstr "Administration" @@ -1225,7 +1225,7 @@ msgid "Name" msgstr "Nom" #: src/components/chat/ChatModal.tsx:692 -#: src/components/feed/FeedTabBar.tsx:40 +#: src/components/feed/FeedTabBar.tsx:41 #: src/pages/Settings.tsx:55 msgid "New" msgstr "Nouveau" @@ -1331,7 +1331,7 @@ msgstr "Rien de neuf ce mois-ci." msgid "Nothing happened this week." msgstr "Rien de neuf cette semaine." -#: src/components/profile/ProfileDumpsTab.tsx:53 +#: src/components/profile/ProfileDumpsTab.tsx:96 #: src/components/profile/ProfileOverviewTab.tsx:47 #: src/pages/Notifications.tsx:522 msgid "Nothing here yet." @@ -1404,7 +1404,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur" msgid "Other instances" msgstr "Autres instances" -#: src/pages/UserPublicProfile.tsx:157 +#: src/pages/UserPublicProfile.tsx:159 msgid "Overview" msgstr "Aperçu" @@ -1465,7 +1465,7 @@ msgstr "Lecture" #: src/components/profile/ProfileFollowTab.tsx:144 #: src/components/profile/ProfilePlaylistsTab.tsx:118 #: src/pages/Search.tsx:123 -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Collections" @@ -1987,8 +1987,8 @@ msgstr "Envoi…" msgid "Upvote" msgstr "Voter" -#: src/components/profile/ProfileDumpsTab.tsx:45 -#: src/pages/UserPublicProfile.tsx:159 +#: src/components/profile/ProfileDumpsTab.tsx:46 +#: src/pages/UserPublicProfile.tsx:161 msgid "Upvoted" msgstr "Voté" diff --git a/src/App.css b/src/App.css index c3329aea..30e204dd 100644 --- a/src/App.css +++ b/src/App.css @@ -2283,6 +2283,14 @@ body.has-player .chat-fab { gap: 1rem; } +/* Several actions in the title row, as a list's category menu and its + "New dump" button. */ +.profile-section-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + /* ── Profile (own) page ── */ .profile-username-row { display: flex; @@ -7385,9 +7393,10 @@ body.has-player .chat-fab { border: 0; } -/* Scope switcher — the leading item of the feed-sort tab row. The trigger - reuses .feed-sort-btn so it shares the tabs' exact pill metrics; a thin - divider sets it apart from the sort tabs that follow. */ +/* Scope switcher — the leading item of the feed-sort tab row, and in the + title row of a profile's lists. The trigger reuses .feed-sort-btn so it + shares the tabs' exact pill metrics; in the tab row, a thin divider sets it + apart from the sort tabs that follow. */ .category-switcher { position: relative; display: inline-flex; @@ -7430,7 +7439,8 @@ body.has-player .chat-fab { } /* Vertical rule separating the scope selector from the sort tabs. */ -.feed-sort-sep { +.feed-sort .category-switcher::after { + content: ""; width: 2px; height: 1.05rem; background: var(--color-border); diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx index 8eb23fd6..7f9cf694 100644 --- a/src/components/category/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -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({}); @@ -71,12 +68,19 @@ export function CategorySwitcher( }, [open]); // The menu is fixed and portaled to , 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( , document.body, )} - -