v4: the playlists page is reachable at every width, and lists the user's own — below 1150px the header's text link was hidden and nothing replaced it: the avatar menu's "My playlists" led to the user's own, and a guest has no menu. The menu's entry is now "Playlists", to /playlists, at every width, and the page gains a tab after its orders, "Mine", for a signed-in user: their own playlists, private ones too, from GET /api/users/:id/playlists in the default order, narrowed by the category menu and kept live as the others are. It is /playlists?mine=1 (PLAYLISTS_TABS, parsePlaylistsTab, shared/paths.ts), a tab taking the order's place; a guest isn't offered it, and its link sends one to sign in. PlaylistSortTabs takes the tabs it shows, a profile's being the orders alone. The text link shows to a guest at every width, their header giving up its margins under 400px rather than the link, and to a signed-in user where the row has room beside the icons, as measured in every style and both languages: from 560px up to 860px, where the index page's tabs move into the header, and from 1150px as before. A guest in French in the kanagawa style still overflows the header under 340px, by up to 19px.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 09:14:55 +00:00
parent 561cdf98b0
commit de223baaa4
16 changed files with 312 additions and 130 deletions

View File

@@ -22,13 +22,10 @@ describe("UserMenu", () => {
"href",
"/@ann",
);
// One playlists link, the user's own: the header's leads to everyone's.
expect(screen.getAllByRole("menuitem", { name: /playlists/i }))
.toHaveLength(
1,
);
expect(screen.getByRole("menuitem", { name: "My playlists" }))
.toHaveAttribute("href", "/@ann/playlists");
// One playlists link, to the page of everyone's, whose last tab is the
// user's own: the header's text link is hidden where it has no room.
expect(screen.getByRole("menuitem", { name: "Playlists" }))
.toHaveAttribute("href", "/playlists");
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
"href",
"/community",

View File

@@ -1,9 +1,17 @@
import { describe, expect, test } from "vitest";
import { Route } from "react-router";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Playlists } from "../../pages/Playlists.tsx";
import { calls, route } from "../fetch.ts";
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
import {
makeCategory,
makePlaylist,
makeSession,
page,
wire,
} from "../fixtures.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -15,12 +23,14 @@ const mixes = makeCategory({
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
function open(url: string) {
function open(url: string, session?: Session) {
const user = userEvent.setup();
renderApp(<Playlists />, {
route: url,
path: "/playlists/:categorySlug?",
categories: [music, mixes],
session,
extraRoutes: <Route path="/login" element={null} />,
});
return { user, socket: FakeWebSocket.last };
}
@@ -140,6 +150,77 @@ describe("Playlists", () => {
expect(screen.getByText("2 dumps")).toBeInTheDocument();
});
test("a signed-in user's last tab lists their own, private ones too", async () => {
const session = makeSession({ id: "ann-id", username: "ann" });
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
route(
"GET /api/users/:u/playlists",
() =>
page([
makePlaylist({ title: "Hidden", userId: "ann-id", isPublic: false }),
]),
);
const { user, socket } = open("/playlists/mixes?sort=dumps", session);
expect(await screen.findByText("Theirs")).toBeInTheDocument();
// Their own come in one order: the tab takes the order's place.
await user.click(screen.getByRole("button", { name: "Mine" }));
expect(currentPath()).toBe("/playlists/mixes?mine=1");
expect(await screen.findByText("Hidden")).toBeInTheDocument();
expect(screen.queryByText("Theirs")).toBeNull();
const asked = calls("GET /api/users/:u/playlists").at(-1)!.url;
expect(asked.pathname).toBe("/api/users/ann-id/playlists");
expect([asked.searchParams.get("category"), asked.searchParams.get("sort")])
.toEqual(["mixes", null]);
// The socket brings their new ones, private or not, and no one else's.
socket.welcome();
for (
const playlist of [
makePlaylist({
title: "Mine too",
userId: "ann-id",
isPublic: false,
categoryIds: ["pc1"],
}),
makePlaylist({
title: "Bob's",
userId: "bob-id",
categoryIds: ["pc1"],
}),
]
) {
socket.receive({ type: "playlist_created", playlist: wire(playlist) });
}
expect(screen.getByText("Mine too")).toBeInTheDocument();
expect(screen.queryByText("Bob's")).toBeNull();
// Picking a category keeps the tab, an order leaves it.
await user.click(screen.getByRole("button", { name: "Mixes" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/playlists?mine=1");
await user.click(screen.getByRole("button", { name: "New" }));
expect(currentPath()).toBe("/playlists?sort=new");
});
test("a guest has no such tab", async () => {
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
open("/playlists");
expect(await screen.findByText("Theirs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
});
test("a guest sent to their own playlists signs in first", () => {
open("/playlists?mine=1");
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1");
});
test("a slug that names no playlist category is not found", async () => {
route("GET /api/playlists", () => page([]));
open("/playlists/music");