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:
@@ -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<Dump> = { items: [], hasMore: false };
|
||||
|
||||
|
||||
@@ -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<PaginatedData<Dump>> = {
|
||||
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<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
|
||||
@@ -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<Dump> {
|
||||
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<Dump> {
|
||||
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");
|
||||
|
||||
@@ -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<PaginatedData<Dump>>(
|
||||
`/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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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é"
|
||||
|
||||
|
||||
18
src/App.css
18
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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface FeedTabBarProps {
|
||||
* Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in
|
||||
* user. A tab leads to the same scope: `/new` for every dump, `/music/new` for
|
||||
* a category, and the viewer's default tab to the bare `/` or `/music`.
|
||||
* Picking a category keeps the tab, from `/music/new` to `/cinema/new`.
|
||||
* `Index` renders it twice, in the header and below it, and CSS shows one by
|
||||
* screen width.
|
||||
*/
|
||||
@@ -50,7 +51,13 @@ export function FeedTabBar(
|
||||
activeTab={tab}
|
||||
onChange={(t) =>
|
||||
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
|
||||
leading={<CategorySwitcher categorySlug={categorySlug} tab={tab} />}
|
||||
leading={
|
||||
<CategorySwitcher
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Renders a profile's "dumps" or "upvoted" tab, the whole list of the user's
|
||||
* dumps or of those they upvoted.
|
||||
* dumps or of those they upvoted, which a category may narrow.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { NewDumpButton } from "../dump/NewDumpButton.tsx";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
@@ -28,43 +30,83 @@ export interface ProfileDumpsTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list from `useUserDumpFeed`, its next page loading as its end
|
||||
* scrolls into view. The owner's own dumps get a "New dump" button.
|
||||
* Renders the list, with the category menu in its title row; the category
|
||||
* picked is the URL's `?category=`, which switching to the other list keeps
|
||||
* and to any other tab drops.
|
||||
* The owner's own dumps get a "New dump" button.
|
||||
*/
|
||||
export function ProfileDumpsTab(
|
||||
{ user, list, isOwnProfile }: ProfileDumpsTabProps,
|
||||
): React.JSX.Element {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const categorySlug = params.get("category") || undefined;
|
||||
|
||||
return (
|
||||
<ProfileSection
|
||||
title={list === "dumps" ? t`Dumps` : t`Upvoted`}
|
||||
actions={
|
||||
<div className="profile-section-actions">
|
||||
<CategorySwitcher
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setParams((p) => {
|
||||
if (slug) p.set("category", slug);
|
||||
else p.delete("category");
|
||||
return p;
|
||||
})}
|
||||
/>
|
||||
{list === "dumps" && isOwnProfile && <NewDumpButton />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DumpList
|
||||
key={categorySlug ?? ""}
|
||||
user={user}
|
||||
list={list}
|
||||
isOwnProfile={isOwnProfile}
|
||||
categorySlug={categorySlug}
|
||||
/>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list from `useUserDumpFeed`, its next page loading as its end
|
||||
* scrolls into view. Each category's list is another, which the tab mounts
|
||||
* afresh.
|
||||
*/
|
||||
function DumpList(
|
||||
{ user, list, isOwnProfile, categorySlug }: ProfileDumpsTabProps & {
|
||||
categorySlug?: string;
|
||||
},
|
||||
): React.JSX.Element {
|
||||
const { state, loadMore, cardClassName } = useUserDumpFeed(user.id, list, {
|
||||
isOwner: isOwnProfile,
|
||||
categorySlug,
|
||||
});
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
|
||||
return (
|
||||
<ProfileSection
|
||||
title={list === "dumps" ? t`Dumps` : t`Upvoted`}
|
||||
actions={list === "dumps" && isOwnProfile && <NewDumpButton />}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<DumpCardList dumps={data.items} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<DumpCardList dumps={data.items} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ import { NotFound } from "./NotFound.tsx";
|
||||
const OVERVIEW = "overview";
|
||||
type Tab = ProfileTab | typeof OVERVIEW;
|
||||
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
|
||||
/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */
|
||||
const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"];
|
||||
|
||||
/**
|
||||
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
|
||||
@@ -165,7 +167,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
: []),
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={(key) => setTab(key)}
|
||||
onChange={(key) =>
|
||||
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
|
||||
className="profile-tabs-scroller"
|
||||
innerClassName="profile-tabs"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { CategorySwitcher } from "../../../components/category/CategorySwitcher.tsx";
|
||||
import type { Category } from "../../../../shared/category.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
@@ -13,57 +13,43 @@ const categories: Category[] = [
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, { route: "/hot" });
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches scope, keeping the tab", async () => {
|
||||
test("picks a category, and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher categorySlug="music" tab="new" />, {
|
||||
categories,
|
||||
route: "/music/new",
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
await user.click(trigger);
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="music" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
expect(onSelect).toHaveBeenCalledWith("cinema");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("All is the root; the default tab stays a bare path; an unknown slug shows as itself", async () => {
|
||||
test("All is no category; an unknown slug shows as itself", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher categorySlug="gone" tab="hot" />, {
|
||||
categories,
|
||||
route: "/gone/hot",
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="gone" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/hot");
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/music/hot");
|
||||
});
|
||||
|
||||
test("on the default tab, a category is its bare path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher tab="journal" />, {
|
||||
categories,
|
||||
route: "/",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema");
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, {
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />, {
|
||||
categories,
|
||||
route: "/hot",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
@@ -28,6 +28,31 @@ describe("FeedTabBar", () => {
|
||||
expect(currentPath()).toBe("/music");
|
||||
});
|
||||
|
||||
test("picking a category keeps the tab, the default one bare", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar categorySlug="music" tab="new" />, {
|
||||
route: "/music/new",
|
||||
categories,
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/new");
|
||||
});
|
||||
|
||||
test("on the default tab, a category is its bare path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar tab="journal" />, {
|
||||
route: "/",
|
||||
categories,
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema");
|
||||
});
|
||||
|
||||
test("signed in, Followed is there", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar tab="follow" />, {
|
||||
|
||||
@@ -175,6 +175,48 @@ describe("UserPublicProfile", () => {
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
|
||||
test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
const tagged = makeDump({
|
||||
id: "m",
|
||||
title: "Tagged",
|
||||
userId: "ann",
|
||||
categoryIds: ["c1"],
|
||||
});
|
||||
const { user, socket } = open({ url: "/@ann/dumps", categories: [music] });
|
||||
route("GET /api/users/:u/dumps", (req) =>
|
||||
page(
|
||||
req.url.searchParams.get("category") === "music"
|
||||
? [tagged]
|
||||
: [tagged, makeDump({ id: "o", title: "Other", userId: "ann" })],
|
||||
));
|
||||
await screen.findByText("Other");
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/@ann/dumps?category=music");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Other")).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText("Tagged")).toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...tagged, categoryIds: [] }),
|
||||
});
|
||||
expect(screen.queryByText("Tagged")).not.toBeInTheDocument();
|
||||
// Switching to the upvoted list keeps the category.
|
||||
await user.click(screen.getByRole("button", { name: "Upvoted" }));
|
||||
expect(currentPath()).toBe("/@ann/upvoted?category=music");
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/users/:u/upvoted").at(-1)!.url.searchParams.get(
|
||||
"category",
|
||||
)).toBe("music")
|
||||
);
|
||||
// Any other tab drops it.
|
||||
await user.click(screen.getByRole("button", { name: "Playlists" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists");
|
||||
});
|
||||
|
||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||
open({ url: "/@ann/dumps" });
|
||||
// After open's, so that it wins.
|
||||
|
||||
Reference in New Issue
Block a user