v4: playlists sort by their last dump, their creation or their size, and /playlists is a grid of covers — every list of playlists came newest first, so one fed every week sank under those made since and left empty. GET /api/playlists and /api/users/:key/playlists take ?sort=updated|new|dumps (PLAYLIST_SORTS, shared/playlist.ts), updated being the default: the last to take a dump first, or its creation while it holds none; a new title, cover or description doesn't bring it back up. A playlist carries that date as lastDumpAt, read with its dumpCount by isMemberVisibleTo's rule (VISIBLE_DUMP_FIGURES, the viewer's id as ?1), so the owner's private dumps date and count their playlists for them alone, in the lists and over the socket: playlist_created, playlist_updated and playlist_dumps_updated carry each socket's own. The /playlists page and a profile's playlists tab show the orders as tabs beside the category menu and keep the one picked in ?sort=, the default being the bare path; a tab takes the current history entry's place, and picking a category keeps the order. Live, usePlaylistListSync takes the list's order: a new playlist goes where it puts it, one that rises moves up to its place, as in the updated order when it takes a dump, and one that falls stays until the list is fetched again, its place being perhaps on a page not loaded. The lists in an order of their own, the followed playlists and the search's, pass none, and take no new playlist as before (noNewEntries goes). comparePlaylists is the order on the client, which a test holds to the API's.
/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …". A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it. A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
|
||||
import { makePlaylist } from "../../fixtures.ts";
|
||||
@@ -38,6 +38,50 @@ describe("PlaylistCard", () => {
|
||||
expect(currentPath()).toBe("/@ann/playlists/mix");
|
||||
});
|
||||
|
||||
test("a middle click anywhere on the card opens it in a new tab", () => {
|
||||
const opened = vi.spyOn(globalThis, "open").mockReturnValue(null);
|
||||
const p = makePlaylist({ slug: "mix", ownerUsername: "ann" });
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<PlaylistCard playlist={p} />
|
||||
</ul>,
|
||||
);
|
||||
fireEvent(
|
||||
container.querySelector(".playlist-card-preview")!,
|
||||
new MouseEvent("auxclick", { bubbles: true, button: 1 }),
|
||||
);
|
||||
expect(opened).toHaveBeenCalledWith(
|
||||
"/@ann/playlists/mix",
|
||||
"_blank",
|
||||
"noopener",
|
||||
);
|
||||
expect(currentPath()).toBe("/");
|
||||
opened.mockRestore();
|
||||
});
|
||||
|
||||
test("it dates its last dump, or its creation while it holds none", () => {
|
||||
const day = 86_400_000;
|
||||
const createdAt = new Date(Date.now() - 40 * day);
|
||||
const filled = renderWithContexts(
|
||||
<ul>
|
||||
<PlaylistCard
|
||||
playlist={makePlaylist({
|
||||
createdAt,
|
||||
lastDumpAt: new Date(Date.now() - 2 * day),
|
||||
})}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("updated 2 days ago")).toBeInTheDocument();
|
||||
filled.unmount();
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<PlaylistCard playlist={makePlaylist({ createdAt })} />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("created last month")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the description shows as text, without its Markdown or its images", () => {
|
||||
const withText = renderWithContexts(
|
||||
<ul>
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("usePlaylistListSync", () => {
|
||||
}
|
||||
|
||||
test("prepends a created playlist once", () => {
|
||||
const { send, items } = setup([makePlaylist({ id: "a" })]);
|
||||
const { send, items } = setup([makePlaylist({ id: "a" })], {
|
||||
sort: "new",
|
||||
});
|
||||
const p = makePlaylist({ id: "b" });
|
||||
send("created", p);
|
||||
send("created", { ...p });
|
||||
@@ -36,28 +38,83 @@ describe("usePlaylistListSync", () => {
|
||||
test("a playlist made public goes where its date puts it", () => {
|
||||
const at = (id: string, day: number) =>
|
||||
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
|
||||
const { send, items } = setup([at("c", 30), at("a", 10)]);
|
||||
const { send, items } = setup([at("c", 30), at("a", 10)], {
|
||||
sort: "new",
|
||||
});
|
||||
send("created", at("b", 20));
|
||||
// Older than every one loaded: a later page holds it.
|
||||
send("created", at("old", 1));
|
||||
expect(ids(items())).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||
test("created: filtered by owner and privacy, and added to a sorted list only", () => {
|
||||
const p = makePlaylist({ id: "p", userId: "them" });
|
||||
const byOwner = setup([], { ownerId: "me" });
|
||||
const byOwner = setup([], { ownerId: "me", sort: "new" });
|
||||
byOwner.send("created", p);
|
||||
expect(byOwner.items()).toEqual([]);
|
||||
|
||||
const priv = setup([]);
|
||||
const priv = setup([], { sort: "new" });
|
||||
priv.send("created", { ...p, isPublic: false });
|
||||
expect(priv.items()).toEqual([]);
|
||||
|
||||
const followed = setup([], { noNewEntries: true });
|
||||
// A list in an order of its own, as the followed playlists'.
|
||||
const followed = setup([]);
|
||||
followed.send("created", p);
|
||||
expect(followed.items()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a created playlist goes where the list's order puts it", () => {
|
||||
const at = (id: string, day: number, dumpCount: number, added?: number) =>
|
||||
makePlaylist({
|
||||
id,
|
||||
dumpCount,
|
||||
createdAt: new Date(Date.UTC(2026, 0, day)),
|
||||
lastDumpAt: added ? new Date(Date.UTC(2026, 1, added)) : undefined,
|
||||
});
|
||||
const loaded = [at("a", 1, 3, 20), at("b", 2, 2, 10), at("c", 3, 0)];
|
||||
// Made between b and c, it took a dump after b did and before a did.
|
||||
const made = at("new", 2.5, 1, 15);
|
||||
|
||||
const updated = setup(loaded, { sort: "updated" });
|
||||
updated.send("created", made);
|
||||
expect(ids(updated.items())).toEqual(["a", "new", "b", "c"]);
|
||||
|
||||
const dumps = setup(loaded, { sort: "dumps" });
|
||||
dumps.send("created", made);
|
||||
expect(ids(dumps.items())).toEqual(["a", "b", "new", "c"]);
|
||||
});
|
||||
|
||||
test("a playlist that rises in the order moves up, one that falls stays", () => {
|
||||
const at = (id: string, day: number) =>
|
||||
makePlaylist({
|
||||
id,
|
||||
dumpCount: 1,
|
||||
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
|
||||
});
|
||||
const loaded = [at("a", 30), at("b", 20), at("c", 10)];
|
||||
const dumped = (playlistId: string, day?: number) => ({
|
||||
type: "dumps_updated" as const,
|
||||
playlistId,
|
||||
dumpIds: day ? ["x", "y"] : [],
|
||||
lastDumpAt: day ? new Date(Date.UTC(2026, 0, day)) : undefined,
|
||||
});
|
||||
|
||||
const sorted = setup(loaded, { sort: "updated" });
|
||||
sorted.emit("playlist", dumped("c", 25));
|
||||
expect(ids(sorted.items())).toEqual(["a", "c", "b"]);
|
||||
expect(sorted.items()[1].dumpCount).toBe(2);
|
||||
sorted.send("updated", { ...at("b", 31), title: "Risen" });
|
||||
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
|
||||
// Emptied, it comes after every other, perhaps on a page not loaded.
|
||||
sorted.emit("playlist", dumped("b"));
|
||||
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
|
||||
expect(sorted.items()[0].lastDumpAt).toBeUndefined();
|
||||
|
||||
const unsorted = setup(loaded);
|
||||
unsorted.emit("playlist", dumped("c", 25));
|
||||
expect(ids(unsorted.items())).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("updated: in place, removed when private, never added by itself", () => {
|
||||
const p = makePlaylist({ id: "p", title: "one" });
|
||||
const other = makePlaylist({ id: "q" });
|
||||
@@ -72,7 +129,7 @@ describe("usePlaylistListSync", () => {
|
||||
|
||||
test("one that left comes back where it was, even to a followed list", () => {
|
||||
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||
const { set, send, items } = setup(initial, { noNewEntries: true });
|
||||
const { set, send, items } = setup(initial);
|
||||
// Gone private for others, as a deletion.
|
||||
set({ deletedPlaylistIds: new Set(["b"]) });
|
||||
expect(ids(items())).toEqual(["a", "c"]);
|
||||
|
||||
@@ -85,6 +85,61 @@ describe("Playlists", () => {
|
||||
expect(screen.queryByText("Filed")).toBeNull();
|
||||
});
|
||||
|
||||
test("the tabs change the order, in the URL, which picking a category keeps", async () => {
|
||||
route(
|
||||
"GET /api/playlists",
|
||||
(req) =>
|
||||
page([
|
||||
makePlaylist({
|
||||
title: `By ${req.url.searchParams.get("sort") ?? "default"}`,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const { user } = open("/playlists");
|
||||
expect(await screen.findByText("By default")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Most dumps" }));
|
||||
expect(currentPath()).toBe("/playlists?sort=dumps");
|
||||
expect(await screen.findByText("By dumps")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/playlists/mixes?sort=dumps");
|
||||
const asked = calls("GET /api/playlists").at(-1)!.url.searchParams;
|
||||
expect([asked.get("category"), asked.get("sort")]).toEqual([
|
||||
"mixes",
|
||||
"dumps",
|
||||
]);
|
||||
// The default order is the bare path, and so is one the URL misnames.
|
||||
await user.click(screen.getByRole("button", { name: "Updated" }));
|
||||
expect(currentPath()).toBe("/playlists/mixes");
|
||||
});
|
||||
|
||||
test("a playlist that takes a dump goes first in the updated order", async () => {
|
||||
const at = (id: string, day: number) =>
|
||||
makePlaylist({
|
||||
id,
|
||||
title: `Playlist ${id}`,
|
||||
dumpCount: 1,
|
||||
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
|
||||
});
|
||||
route("GET /api/playlists", () => page([at("a", 20), at("b", 10)]));
|
||||
const { socket } = open("/playlists");
|
||||
await screen.findByText("Playlist a");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "b",
|
||||
dumpIds: ["x", "y"],
|
||||
lastDumpAt: "2026-01-25T00:00:00.000Z",
|
||||
});
|
||||
expect(
|
||||
screen.getAllByRole("link", { name: /^Playlist \w$/ }).map((a) =>
|
||||
a.textContent
|
||||
),
|
||||
).toEqual(["Playlist b", "Playlist a"]);
|
||||
expect(screen.getByText("2 dumps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a slug that names no playlist category is not found", async () => {
|
||||
route("GET /api/playlists", () => page([]));
|
||||
open("/playlists/music");
|
||||
|
||||
@@ -292,6 +292,42 @@ describe("UserPublicProfile", () => {
|
||||
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the playlists tab's order is in its URL, beside the category, and no other tab's", async () => {
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const { user } = open({ url: "/@ann/playlists", categories: [mixes] });
|
||||
route(
|
||||
"GET /api/users/:u/playlists",
|
||||
() => page([makePlaylist({ title: "Mix", userId: "ann" })]),
|
||||
);
|
||||
const asked = () =>
|
||||
calls("GET /api/users/:u/playlists").at(-1)!.url.searchParams;
|
||||
await screen.findByText("Mix");
|
||||
expect(asked().get("sort")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?sort=new");
|
||||
await waitFor(() => expect(asked().get("sort")).toBe("new"));
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?sort=new&category=mixes");
|
||||
await waitFor(() => expect(asked().get("category")).toBe("mixes"));
|
||||
expect(asked().get("sort")).toBe("new");
|
||||
// The default order is the bare tab's.
|
||||
await user.click(screen.getByRole("button", { name: "Updated" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
|
||||
|
||||
// Neither the order nor a playlist category means anything to the dumps.
|
||||
await user.click(screen.getByRole("button", { name: "Most dumps" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?category=mixes&sort=dumps");
|
||||
await user.click(screen.getByRole("button", { name: "Dumps" }));
|
||||
expect(currentPath()).toBe("/@ann/dumps");
|
||||
});
|
||||
|
||||
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