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:
khannurien
2026-10-10 08:51:37 +00:00
parent 350e945eb1
commit 561cdf98b0
41 changed files with 1198 additions and 387 deletions

19
API.md
View File

@@ -15,9 +15,10 @@ default view is the bare path, with no redirect. A category is a segment where
the page is the category's own, a dump category's feed at the root (`/music`)
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
narrows a list on another page, a profile's or a search's, it is `?category=`,
of the kind that list holds. Switching tabs, or a tab's views, takes the place
of the current history entry; picking a category, or opening another page or an
editor, adds one.
of the kind that list holds. The order of a list of playlists is `?sort=`, the
default order being the bare path. Switching tabs, a tab's views or a list's
order takes the place of the current history entry; picking a category, or
opening another page or an editor, adds one.
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
@@ -33,14 +34,14 @@ has one word, the same as in pages.
| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` |
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
| `/playlists` | `/playlists/<category>`, a playlist category's | anyone | `Playlists` |
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either | anyone | `Playlists` |
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
@@ -135,7 +136,7 @@ there: those endpoints answer it 400.
- `GET /api/users/:key/avatar` — anyone. The avatar.
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
to their owner only. Their lists; `?category=` narrows one to a category of
its kind.
its kind, and `?sort=` orders the playlists as `GET /api/playlists` does.
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
to their owner only. An item by its page path, by an alias too.
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
@@ -174,8 +175,10 @@ there: those endpoints answer it 400.
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
playlist categories it names.
- `GET /api/playlists` — anyone. Every public playlist, newest first;
`?category=<slug>` narrows it to a playlist category.
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
narrows it to a playlist category, and `?sort=updated|new|dumps` orders it:
the last to take a dump first (the default), the newest, or the fullest. The
caller's own private dumps count, and date their playlists, for them alone.
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
slug, description, visibility, categories; deletion.

View File

@@ -37,26 +37,51 @@ export function isMemberVisibleTo(
}
/**
* The `dump_count` column of a query on `playlists p`: how many of the
* playlist's dumps the viewer may see, by {@link isMemberVisibleTo}'s rule.
* Its `?` takes the viewer's id, or `""` for a signed-out one, and comes
* before the query's other parameters, the column being in its select list.
* The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
* that tell who may see it, and when it was added. The playlist service reads
* them for its broadcasts.
*/
export const VISIBLE_DUMP_COUNT = `(SELECT COUNT(*) FROM playlist_dumps pd
export interface DatedPlaylistMember extends PlaylistMember {
addedAt: Date;
}
/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */
const visibleDumps = (figure: string) =>
`(SELECT ${figure} FROM playlist_dumps pd
INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = p.id
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?))
) as dump_count`;
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
)`;
/**
* Returns how many of `members` the viewer may see in `playlist`, the
* `dumpCount` the playlist has for them.
* The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
* how many of the playlist's dumps the viewer may see, by
* {@link isMemberVisibleTo}'s rule, and when the latest of them was added,
* null without any. Both read the viewer's id, or `""` for a signed-out one,
* as `?1`: it is the query's first parameter, and each `?` after it takes the
* next.
*/
export function countVisibleTo(
members: PlaylistMember[],
export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
${visibleDumps("MAX(pd.added_at)")} as last_dump_at`;
/**
* Returns what `playlist` says of its dumps to the viewer, `members` being
* all of them: the `dumpCount` and `lastDumpAt` that
* {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts
* them on the playlist each socket is sent.
*/
export function dumpFiguresFor(
members: DatedPlaylistMember[],
playlist: Pick<Playlist, "userId">,
viewerId: string | null | undefined,
): number {
return members.filter((m) => isMemberVisibleTo(m, playlist, viewerId))
.length;
): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
const visible = members.filter((m) =>
isMemberVisibleTo(m, playlist, viewerId)
);
return {
dumpCount: visible.length,
lastDumpAt: visible.length
? new Date(Math.max(...visible.map((m) => m.addedAt.getTime())))
: undefined,
};
}

View File

@@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts";
/**
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
* which join the owner's name as `owner_username`. A query may add a
* `dump_count` column, which {@link playlistRowToApi} carries over when
* present.
* which join the owner's name as `owner_username`. A query may add the
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
* {@link playlistRowToApi} carries over when present.
*/
export interface PlaylistRow {
id: string;
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
lastDumpAt: typeof row.last_dump_at === "string"
? new Date(row.last_dump_at)
: undefined,
ownerUsername: row.owner_username,
};
}

View File

@@ -3,9 +3,11 @@
* playlist's id; its page reads it by its path, through
* `GET /api/users/:key/playlists/:slug`.
*
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
* every public playlist, newest first, or only those in the playlist
* category at `category`, which 404s when there is none.
* - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a
* page of every public playlist, in the order `sort` names
* (`PLAYLIST_SORTS`, the last to take a dump first by default), or only
* those in the playlist category at `category`, which 404s when there is
* none.
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
* playlist categories it names.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
@@ -44,7 +46,11 @@ import {
isReorderPlaylistRequest,
isUpdatePlaylistRequest,
} from "../validators/playlist.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import {
@@ -87,6 +93,7 @@ router.get("/", async (ctx) => {
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {

View File

@@ -19,9 +19,10 @@
* - `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=&category=` — optional
* - `GET /api/users/:key/playlists?page=&limit=&category=&sort=` — optional
* sign-in. Answers a page of the playlists they made, the private ones only
* to themselves, optionally in one playlist category.
* to themselves, optionally in one playlist category, in the order `sort`
* names, as `GET /api/playlists` does.
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
* playlist whose slug is or was `slug`, with its current slug and the dumps
* the caller may see; a private one 404s but for its owner.
@@ -56,7 +57,11 @@ import {
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import {
fieldOf,
isRegisterUserRequest,
@@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => {
const categoryId = resolveCategoryFilter("playlist", params);
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
: listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
@@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => {
const responseBody: APIResponse<PlaylistWithDumps> = {
success: true,
data: getPlaylist(
getPlaylistByPath(user.id, ctx.params.slug),
getPlaylistByPath(user.id, ctx.params.slug, requestingUserId),
requestingUserId,
),
};

View File

@@ -23,7 +23,7 @@ import {
} from "../model/dump.ts";
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { VISIBLE_DUMP_COUNT } from "../lib/playlist-members.ts";
import { VISIBLE_DUMP_FIGURES } from "../lib/playlist-members.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
@@ -248,7 +248,7 @@ export function getFollowedPlaylistsByUser(
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
${VISIBLE_DUMP_COUNT}
${VISIBLE_DUMP_FIGURES}
FROM playlists p
LEFT JOIN users u ON u.id = p.user_id
INNER JOIN follows f ON f.followed_playlist_id = p.id

View File

@@ -12,6 +12,7 @@ import type {
CreatePlaylistRequest,
Playlist,
PlaylistMembership,
PlaylistSort,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../shared/playlist.ts";
@@ -42,25 +43,36 @@ import {
setCategories,
} from "./category-service.ts";
import {
type DatedPlaylistMember,
isMemberVisibleTo,
type PlaylistMember,
VISIBLE_DUMP_COUNT,
VISIBLE_DUMP_FIGURES,
} from "../lib/playlist-members.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
/**
* The select list and `FROM` clause of a playlist query, adding the owner's
* name and the count of the dumps the viewer may see. Callers append the
* `WHERE`, and pass the viewer's id first, as {@link viewerParam} gives it.
* name, the count of the dumps the viewer may see and when the latest was
* added. Callers append the `WHERE`, and pass the viewer's id first, as
* {@link viewerParam} gives it.
*/
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
${VISIBLE_DUMP_COUNT}
${VISIBLE_DUMP_FIGURES}
FROM playlists p JOIN users u ON u.id = p.user_id`;
/**
* Returns what {@link VISIBLE_DUMP_COUNT}'s parameter takes for `viewerId`: a
* signed-out viewer matches no user.
* The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}:
* `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it.
*/
const PLAYLIST_ORDER: Record<PlaylistSort, string> = {
updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC",
new: "p.created_at DESC",
dumps: "dump_count DESC, p.created_at DESC",
};
/**
* Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`:
* a signed-out viewer matches no user.
*/
function viewerParam(viewerId: string | null | undefined): string {
return viewerId ?? "";
@@ -79,7 +91,8 @@ function playlistsOf(rows: unknown[]): Playlist[] {
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does. Its
* `dumpCount` is `viewerId`'s, or a signed-out viewer's without one.
* `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's
* without one.
*
* @throws {APIException} 404 when there is no such playlist.
*/
@@ -99,13 +112,18 @@ export function getPlaylistById(
/**
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
* slug, it comes with its current one.
* not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
* for `viewerId` likewise. Reached by a former slug, it comes with its current
* one.
*/
export function getPlaylistByPath(userId: string, slug: string): Playlist {
export function getPlaylistByPath(
userId: string,
slug: string,
viewerId: string | null = null,
): Playlist {
const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found");
return getPlaylistById(playlistId);
return getPlaylistById(playlistId, viewerId);
}
/**
@@ -157,8 +175,8 @@ export function createPlaylist(
* Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`, and for
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
* path. A null `requestingUserId` is a signed-out viewer. Its `dumpCount` is
* the number of those dumps.
* path, for the same viewer. A null `requestingUserId` is a signed-out viewer.
* Its `dumpCount` is the number of those dumps.
*
* @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs.
@@ -168,7 +186,7 @@ export function getPlaylist(
requestingUserId: string | null,
): PlaylistWithDumps {
const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId)
? getPlaylistById(playlistId, requestingUserId)
: playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
@@ -228,15 +246,17 @@ export function searchPlaylists(
}
/**
* Returns a page of every public playlist, newest first, for
* Returns a page of every public playlist, in the order `sort` names, for
* `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
* counted, and so ordered, for `requestingUserId`, null being a signed-out
* viewer.
*/
export function listPublicPlaylists(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const offset = (page - 1) * limit;
@@ -244,7 +264,7 @@ export function listPublicPlaylists(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
return pageOf(playlistsOf(rows), limit);
}
@@ -259,13 +279,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
ORDER BY dump_count DESC, p.created_at DESC;`,
ORDER BY ${PLAYLIST_ORDER.dumps};`,
).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows);
}
/**
* Returns a page of `userId`'s playlists, newest first, for
* Returns a page of `userId`'s playlists, in the order `sort` names, for
* `GET /api/users/:key/playlists`, optionally only those in the playlist
* category `categoryId`. The private ones are listed only when
* `requestingUserId` is `userId`.
@@ -275,6 +295,7 @@ export function listPlaylistsByUser(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const isOwner = requestingUserId === userId;
@@ -285,7 +306,7 @@ export function listPlaylistsByUser(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(
viewerParam(requestingUserId),
...catParams,
@@ -620,7 +641,7 @@ export function getPlaylistMembershipsForDump(
): PlaylistMembership[] {
const rows = db.prepare(
`SELECT p.*, u.username as owner_username,
${VISIBLE_DUMP_COUNT},
${VISIBLE_DUMP_FIGURES},
EXISTS (SELECT 1 FROM playlist_dumps pd
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
FROM playlists p JOIN users u ON u.id = p.user_id
@@ -690,18 +711,19 @@ export function getPlaylistImageInfo(
* Returns a playlist's dumps in order, all of them, for the callers to filter
* per viewer.
*/
function getCurrentMembers(playlistId: string): PlaylistMember[] {
function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
const rows = db.prepare(
`SELECT pd.dump_id, d.is_private, d.user_id
`SELECT pd.dump_id, pd.added_at, d.is_private, d.user_id
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
).all(playlistId) as Array<
{ dump_id: string; is_private: number; user_id: string }
{ dump_id: string; added_at: string; is_private: number; user_id: string }
>;
return rows.map((r) => ({
id: r.dump_id,
isPrivate: Boolean(r.is_private),
userId: r.user_id,
addedAt: new Date(r.added_at),
}));
}

View File

@@ -18,9 +18,9 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts";
import {
countVisibleTo,
type DatedPlaylistMember,
dumpFiguresFor,
isMemberVisibleTo,
type PlaylistMember,
} from "../lib/playlist-members.ts";
import type { Job } from "../lib/scheduler.ts";
@@ -298,21 +298,17 @@ export function broadcastCommentLikeUpdate(
* {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without
* their learning what it became. `members` are the dumps of `after`: each
* socket gets the playlist with the count of those its user may see, the
* owner's private dumps being theirs alone to count.
* socket gets the playlist with the count of those its user may see and the
* date of the latest, the owner's private dumps being theirs alone to count.
*/
export function broadcastPlaylistChange(
before: Playlist | null,
after: Playlist | null,
members: PlaylistMember[],
members: DatedPlaylistMember[],
): void {
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
...playlist,
dumpCount: countVisibleTo(
members,
playlist,
toOwner ? playlist.userId : null,
),
...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
});
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist, toOwner) => ({
@@ -339,7 +335,7 @@ export function broadcastPlaylistChange(
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
members: PlaylistMember[],
members: DatedPlaylistMember[],
): void {
for (const client of clients) {
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
@@ -349,6 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
dumpIds: members
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
.map((m) => m.id),
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
});
}
}

View File

@@ -16,10 +16,12 @@ import {
TEST_DIR,
} from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
import {
comparePlaylists,
type Playlist,
PLAYLIST_SORTS,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
@@ -665,7 +667,7 @@ test("playlist categories: filed on creation, replaced on update, dump categorie
assertEquals(cleared.data.categoryIds, []);
});
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
test("GET /api/playlists: every public playlist, by category", async () => {
const owner = await createUser();
const other = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
@@ -729,3 +731,110 @@ test("GET /api/users/:key/playlists?category=: their playlists in one category",
[],
);
});
test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => {
const owner = await createUser();
const made = (title: string, createdAt: string) => {
const { id } = insertPlaylist({ userId: owner.id, title });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(createdAt, id);
return id;
};
const add = (playlistId: string, addedAt: string, isPrivate = false) =>
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, 0, ?);`,
).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt);
const a = made("A", "2026-01-01T00:00:00.000Z");
const b = made("B", "2026-02-01T00:00:00.000Z");
const c = made("C", "2026-03-01T00:00:00.000Z");
add(a, "2026-04-01T00:00:00.000Z");
add(b, "2026-02-10T00:00:00.000Z");
add(b, "2026-02-11T00:00:00.000Z");
// Only the owner sees it: to anyone else, C is empty and as it was made.
add(c, "2026-05-01T00:00:00.000Z", true);
type Auth = typeof owner.auth;
const list = async (query: string, auth?: Auth) =>
(await get<PaginatedData<Playlist>>(query, { auth })).data.items;
const titles = async (query: string, auth?: Auth) =>
(await list(query, auth)).map((p) => p.title);
for (
const url of ["/api/playlists", `/api/users/${owner.id}/playlists`]
) {
assertEquals(await titles(`${url}?sort=updated`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new`), ["C", "B", "A"], url);
assertEquals(await titles(`${url}?sort=dumps`), ["B", "A", "C"], url);
assertEquals(
await titles(`${url}?sort=updated`, owner.auth),
["C", "A", "B"],
url,
);
// A and C hold one dump each to their owner: the newer goes first.
assertEquals(
await titles(`${url}?sort=dumps`, owner.auth),
["B", "C", "A"],
url,
);
// The first order is the default, which an unknown one falls back to.
assertEquals(await titles(url), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=nope`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new&limit=1&page=2`), ["B"], url);
}
const dates = (await list("/api/playlists?sort=new")).map((p) =>
p.lastDumpAt
);
assertEquals(dates, [
undefined,
"2026-02-11T00:00:00.000Z",
"2026-04-01T00:00:00.000Z",
] as unknown[]);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`, { auth: owner.auth }))
.data.lastDumpAt as unknown,
"2026-05-01T00:00:00.000Z",
);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`)).data.lastDumpAt,
undefined,
);
});
test("comparePlaylists orders playlists as the API's ?sort= pages them", async () => {
const owner = await createUser();
// Ties of every kind: dates and counts shared by several playlists.
for (let i = 0; i < 8; i++) {
const { id } = insertPlaylist({ userId: owner.id, title: `P${i}` });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(`2026-01-0${1 + i}T00:00:00.000Z`, id);
for (let n = 0; n < i % 3; n++) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(
id,
insertDump({ userId: owner.id }).id,
n,
`2026-02-0${1 + ((i * 5 + n) % 9)}T00:00:00.000Z`,
);
}
}
for (const sort of PLAYLIST_SORTS) {
const { items } = (await get<PaginatedData<Playlist>>(
`/api/playlists?sort=${sort}`,
)).data;
// The dates come as strings over the wire.
const playlists = items.map((p) => ({
...p,
createdAt: new Date(p.createdAt),
lastDumpAt: p.lastDumpAt && new Date(p.lastDumpAt),
}));
assertEquals(
playlists.toSorted(comparePlaylists(sort)).map((p) => p.title),
items.map((p) => p.title),
sort,
);
}
});

View File

@@ -744,6 +744,47 @@ wsTest("a password change closes the user's sockets", async (connect) => {
assertFalse(res.data.token === user.token);
});
wsTest(
"playlist_dumps_updated dates the latest dump each viewer can see",
async (connect) => {
const owner = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const o = await connect(owner);
const anon = await connect();
const add = async (isPrivate: boolean) => {
const dump = insertDump({ userId: owner.id, isPrivate });
await post(`/api/playlists/${pl.id}/dumps/${dump.id}`, {
auth: owner.auth,
});
return [
(await o.next("playlist_dumps_updated")).lastDumpAt,
(await anon.next("playlist_dumps_updated")).lastDumpAt,
];
};
const [first, firstToAnon] = await add(false);
assert(first);
assertEquals(firstToAnon, first);
// The owner's private dump moves the date for them alone.
const [second, secondToAnon] = await add(true);
assert(second && second >= first);
assertEquals(secondToAnon, first);
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
assertEquals(
(await o.next("playlist_updated")).playlist.lastDumpAt,
second,
);
assertEquals(
(await anon.next("playlist_updated")).playlist.lastDumpAt,
first,
);
},
);
wsTest(
"playlist_dumps_updated lists only what each viewer can see",
async (connect) => {

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:125
#: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -207,13 +207,13 @@ msgstr "# Welcome to {site}!\n"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:354
#: src/pages/Dump.tsx:379
#: src/pages/Dump.tsx:606
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:453
#: src/pages/Dump.tsx:474
msgid "+ Playlist"
msgstr "+ Playlist"
@@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:770
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -436,7 +436,7 @@ msgstr "Can't sign in there"
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:495
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Cancel"
@@ -551,7 +551,7 @@ msgstr "Confirm new password"
msgid "Connecting…"
msgstr "Connecting…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
@@ -579,7 +579,7 @@ msgstr "Could not change password"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Could not connect to the server. Check your connection and try again."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:192
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Could not delete"
@@ -628,6 +628,10 @@ msgstr "Create"
msgid "Create & Add"
msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "created {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Creating…"
@@ -675,9 +679,9 @@ msgstr "Delete dump"
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:154
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Delete playlist"
@@ -694,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -767,7 +771,7 @@ msgstr "Dumped!"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Dumps"
@@ -785,7 +789,7 @@ msgstr "Earlier"
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:602
#: src/pages/Dump.tsx:623
msgid "Edit"
msgstr "Edit"
@@ -803,7 +807,7 @@ msgid "Edit email"
msgstr "Edit email"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:512
#: src/pages/Dump.tsx:533
msgid "Edit title"
msgstr "Edit title"
@@ -817,7 +821,7 @@ msgstr "edited"
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:555
#: src/pages/Dump.tsx:576
msgid "edited {0}"
msgstr "edited {0}"
@@ -827,7 +831,7 @@ msgstr "edited {0}"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:553
#: src/pages/Dump.tsx:574
msgid "Edited {0}"
msgstr "Edited {0}"
@@ -948,9 +952,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Followed"
@@ -1021,7 +1025,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:622
#: src/pages/Dump.tsx:643
msgid "In collections"
msgstr "In collections"
@@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "invited by you"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invitees"
@@ -1106,7 +1110,7 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -1122,7 +1126,7 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:338
#: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -1139,7 +1143,7 @@ msgstr "Loading more…"
msgid "Loading playlist…"
msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Loading profile…"
@@ -1177,7 +1181,7 @@ msgid "Log in with {0}"
msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:128
#: src/pages/UserPublicProfile.tsx:133
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Log out"
@@ -1197,7 +1201,7 @@ msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:115
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Manage"
@@ -1214,6 +1218,10 @@ msgstr "Moderator"
msgid "digest.monthly"
msgstr "Monthly recap"
#: src/components/playlist/PlaylistSortTabs.tsx:27
msgid "Most dumps"
msgstr "Most dumps"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Mute"
@@ -1229,6 +1237,7 @@ msgstr "Name"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "New"
@@ -1285,7 +1294,7 @@ msgstr "No dumps yet. Be the first!"
msgid "No emoji found."
msgstr "No emoji found."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
@@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:151
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
@@ -1408,7 +1417,7 @@ msgstr "Or log in with your account on another gerbeur"
msgid "Other instances"
msgstr "Other instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Overview"
@@ -1469,12 +1478,12 @@ msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:132
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:162
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Playlists"
@@ -1498,10 +1507,10 @@ msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:111
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:561
#: src/pages/Dump.tsx:582
msgid "private"
msgstr "private"
@@ -1509,7 +1518,7 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:110
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1544,7 +1553,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:613
#: src/pages/Dump.tsx:634
msgid "Related"
msgstr "Related"
@@ -1631,7 +1640,7 @@ msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:372
#: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Retry"
@@ -1646,7 +1655,7 @@ msgstr "Role"
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:487
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Save"
@@ -1656,7 +1665,7 @@ msgstr "Save"
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:486
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Saving…"
@@ -1765,7 +1774,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1830,7 +1839,7 @@ msgstr "The new order couldn't be saved. Try again."
msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:347
#: src/pages/Dump.tsx:368
msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private."
@@ -1971,6 +1980,14 @@ msgstr "Unsubscribe"
msgid "Unsubscribed"
msgstr "Unsubscribed"
#: src/components/playlist/PlaylistSortTabs.tsx:25
msgid "Updated"
msgstr "Updated"
#: src/components/playlist/PlaylistCard.tsx:141
msgid "updated {ago}"
msgstr "updated {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
@@ -1998,7 +2015,7 @@ msgid "Upvote"
msgstr "Upvote"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Upvoted"
@@ -2039,7 +2056,7 @@ msgstr "User menu"
msgid "Username"
msgstr "Username"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Users"
@@ -2150,7 +2167,7 @@ msgstr "You're getting this email because a password reset was requested for thi
msgid "email.welcome.reason"
msgstr "You're getting this email because an account was created on {site} with this address."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "You're offline — not sent"

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:125
#: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -207,13 +207,13 @@ msgstr "# Bienvenue sur {site} !\n"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:354
#: src/pages/Dump.tsx:379
#: src/pages/Dump.tsx:606
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:453
#: src/pages/Dump.tsx:474
msgid "+ Playlist"
msgstr "+ Collection"
@@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:770
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -436,7 +436,7 @@ msgstr "Connexion impossible"
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:495
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Annuler"
@@ -551,7 +551,7 @@ msgstr "Confirmer le nouveau mot de passe"
msgid "Connecting…"
msgstr "Connexion…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -579,7 +579,7 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:192
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Suppression impossible"
@@ -628,6 +628,10 @@ msgstr "Créer"
msgid "Create & Add"
msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "créée {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Création…"
@@ -675,9 +679,9 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:154
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Supprimer la collection"
@@ -694,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -767,7 +771,7 @@ msgstr "Recommandé !"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Recos"
@@ -785,7 +789,7 @@ msgstr "Plus tôt"
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:602
#: src/pages/Dump.tsx:623
msgid "Edit"
msgstr "Modifier"
@@ -803,7 +807,7 @@ msgid "Edit email"
msgstr "Modifier l'e-mail"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:512
#: src/pages/Dump.tsx:533
msgid "Edit title"
msgstr "Modifier le titre"
@@ -817,7 +821,7 @@ msgstr "modifié"
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:555
#: src/pages/Dump.tsx:576
msgid "edited {0}"
msgstr "modifié {0}"
@@ -827,7 +831,7 @@ msgstr "modifié {0}"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:553
#: src/pages/Dump.tsx:574
msgid "Edited {0}"
msgstr "Modifié le {0}"
@@ -948,9 +952,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Suivi"
@@ -1021,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:622
#: src/pages/Dump.tsx:643
msgid "In collections"
msgstr "Dans les collections"
@@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "sur votre invitation"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invités"
@@ -1106,7 +1110,7 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -1122,7 +1126,7 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:338
#: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -1139,7 +1143,7 @@ msgstr "Chargement…"
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Chargement du profil…"
@@ -1177,7 +1181,7 @@ msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:128
#: src/pages/UserPublicProfile.tsx:133
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Se déconnecter"
@@ -1197,7 +1201,7 @@ msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:115
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Administration"
@@ -1214,6 +1218,10 @@ msgstr "Modérateur"
msgid "digest.monthly"
msgstr "Récap mensuel"
#: src/components/playlist/PlaylistSortTabs.tsx:27
msgid "Most dumps"
msgstr "Les plus fournies"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Couper le son"
@@ -1229,6 +1237,7 @@ msgstr "Nom"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "Nouveau"
@@ -1285,7 +1294,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
@@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:151
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
@@ -1408,7 +1417,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
msgid "Other instances"
msgstr "Autres instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Aperçu"
@@ -1469,12 +1478,12 @@ msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:132
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:162
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Collections"
@@ -1498,10 +1507,10 @@ msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:111
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:561
#: src/pages/Dump.tsx:582
msgid "private"
msgstr "privé"
@@ -1509,7 +1518,7 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:110
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1544,7 +1553,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:613
#: src/pages/Dump.tsx:634
msgid "Related"
msgstr "Connexe"
@@ -1631,7 +1640,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:372
#: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Réessayer"
@@ -1646,7 +1655,7 @@ msgstr "Rôle"
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:487
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Enregistrer"
@@ -1656,7 +1665,7 @@ msgstr "Enregistrer"
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:486
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Enregistrement…"
@@ -1765,7 +1774,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1830,7 +1839,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:347
#: src/pages/Dump.tsx:368
msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1971,6 +1980,14 @@ msgstr "Se désabonner"
msgid "Unsubscribed"
msgstr "Désabonné"
#: src/components/playlist/PlaylistSortTabs.tsx:25
msgid "Updated"
msgstr "Mises à jour"
#: src/components/playlist/PlaylistCard.tsx:141
msgid "updated {ago}"
msgstr "mise à jour {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
@@ -1998,7 +2015,7 @@ msgid "Upvote"
msgstr "Valider"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Validé"
@@ -2039,7 +2056,7 @@ msgstr "Menu utilisateur"
msgid "Username"
msgstr "Nom d'utilisateur"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Utilisateurs"
@@ -2150,7 +2167,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét
msgid "email.welcome.reason"
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"

View File

@@ -6,7 +6,9 @@
* categories. Under a user each segment is a fixed tab name, and under `dumps`
* or `playlists` a slug. A view of a page is one more segment, and a default
* view is the bare path. A category narrowing a list on another page, a
* profile's or a search's, is a query, `?category=`, not a segment.
* profile's or a search's, is a query, `?category=`, not a segment, and so is
* the order of a list of playlists, `?sort=`, the default order being the
* bare path.
*
* The client links every page through the builders (`src/utils/shared/urls.ts`
* wraps the dump and playlist ones), the emails prefix them with the
@@ -19,6 +21,7 @@
*/
import type { CategoryKind } from "./category.ts";
import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
@@ -142,10 +145,31 @@ export function feedPath(
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`.
* playlist category's at `/playlists/<slug>`, in the order `sort` names, the
* default one without it.
*/
export function playlistsPath(categorySlug?: string): string {
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
export function playlistsPath(
categorySlug?: string,
sort?: PlaylistSort,
): string {
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
return `${path}${playlistSortQuery(sort)}`;
}
/**
* Returns the query string that puts a list of playlists in the order `sort`
* names, with its `?`, or `""` for the default order. `extra` holds the
* list's other parameters, such as a profile's `category`.
*/
export function playlistSortQuery(
sort: PlaylistSort = PLAYLIST_SORTS[0],
extra?: URLSearchParams,
): string {
const params = new URLSearchParams(extra);
if (sort === PLAYLIST_SORTS[0]) params.delete("sort");
else params.set("sort", sort);
const query = params.toString();
return query ? `?${query}` : "";
}
/**

View File

@@ -8,6 +8,26 @@
import type { Dump } from "./dump.ts";
/**
* The orders a list of playlists comes in, the first being the default one:
* `updated`, by {@link playlistActivity}, the latest first; `new`, by
* creation, the newest first; `dumps`, the fullest first. `GET /api/playlists`
* and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists`
* page and a profile's playlists tab show them as tabs and keep the one
* picked in their URL's `?sort=`.
*/
export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const;
/** One of the {@link PLAYLIST_SORTS}. */
export type PlaylistSort = (typeof PLAYLIST_SORTS)[number];
/**
* Returns the order a `?sort=` names, the default one when it is missing or
* names none: like a page number, an order never fails a list.
*/
export function parsePlaylistSort(value: string | null): PlaylistSort {
return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0];
}
/**
* Returned by the playlist routes, and by the search, users and dumps routes
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
@@ -44,6 +64,12 @@ export interface Playlist {
* is undefined when the query didn't count them.
*/
dumpCount?: number;
/**
* When the latest of the dumps the viewer may see was added to it, which
* {@link playlistActivity} reads. It is undefined when it holds none, or
* when the query didn't count them.
*/
lastDumpAt?: Date;
/** The owner's current name, which the playlist's path starts with. */
ownerUsername: string;
/**
@@ -53,6 +79,40 @@ export interface Playlist {
categoryIds?: string[];
}
/**
* Returns when `playlist` last took a dump, or was created if it holds none:
* what the `updated` order sorts by and the cards show. Its title, cover and
* description may change after without bringing it back up a list.
*/
export function playlistActivity(
playlist: Pick<Playlist, "createdAt" | "lastDumpAt">,
): Date {
return playlist.lastDumpAt ?? playlist.createdAt;
}
/**
* Returns the comparison that puts playlists in the order `sort` names, as
* the API pages them, the newer first on a tie. `usePlaylistListSync` finds
* with it where a playlist the socket announces goes in a list.
*/
export function comparePlaylists(
sort: PlaylistSort,
): (a: Playlist, b: Playlist) => number {
const byCreation = (a: Playlist, b: Playlist) =>
b.createdAt.getTime() - a.createdAt.getTime();
switch (sort) {
case "updated":
return (a, b) =>
playlistActivity(b).getTime() - playlistActivity(a).getTime() ||
byCreation(a, b);
case "dumps":
return (a, b) =>
(b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b);
case "new":
return byCreation;
}
}
/**
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in

View File

@@ -4,6 +4,8 @@ import {
feedPath,
parsePagePath,
playlistPath,
playlistSortQuery,
playlistsPath,
searchPath,
userPath,
} from "../paths.ts";
@@ -37,6 +39,19 @@ Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(searchPath(), "/search");
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
assertEquals(searchPath("dumps", "x"), "/search?q=x");
assertEquals(playlistsPath(), "/playlists");
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
// The query of a list that has other parameters: the default order takes
// an old `sort` out, and leaves the rest.
const category = new URLSearchParams({ category: "mixes", sort: "new" });
assertEquals(
playlistSortQuery("dumps", category),
"?category=mixes&sort=dumps",
);
assertEquals(playlistSortQuery("updated", category), "?category=mixes");
assertEquals(playlistSortQuery("updated"), "");
});
Deno.test("parsePagePath: what a path under a user names", () => {

View File

@@ -311,8 +311,8 @@ export interface PlaylistCreatedMessage {
/**
* Sent by the server when a playlist changes, to every socket if it is public,
* else to its owner's, as when it has just gone private. Its `dumpCount` is
* the socket's user's, as in `playlist_created`.
* else to its owner's, as when it has just gone private. Its `dumpCount` and
* `lastDumpAt` are the socket's user's, as in `playlist_created`.
*/
export interface PlaylistUpdatedMessage {
type: "playlist_updated";
@@ -342,6 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
* private dump is left out.
*/
dumpIds: string[];
/**
* When the latest of them was added, the playlist's `lastDumpAt` for this
* socket's user. It is missing when `dumpIds` is empty.
*/
lastDumpAt?: Date;
}
/**

View File

@@ -2289,6 +2289,14 @@ body.has-player .chat-fab {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
/* A list's order tabs, as the playlists', take a line of their own under the
title where the row has no room for both, rather than scroll beside it. */
.profile-section-header:has(.playlist-sort-tabs) {
flex-wrap: wrap;
row-gap: 0.25rem;
}
/* ── Profile (own) page ── */
@@ -2761,11 +2769,9 @@ body.has-player .chat-fab {
padding: 1rem;
}
/* A list of dump or playlist cards in a group, or right under a page's header
as on /playlists, fills the column, its edges in line with the label's rule
or the header. */
.page-group .dump-feed,
.page-content--narrow > .dump-feed {
/* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */
.page-group .dump-feed {
padding: 0;
max-width: none;
}
@@ -4064,9 +4070,8 @@ body.has-player .chat-fab {
0.25);
}
/* ── Shared card preview icon ── */
.dump-card-preview-icon,
.playlist-card-icon {
/* ── Dump card preview icon ── */
.dump-card-preview-icon {
font-size: var(--text-3xl);
opacity: 0.7;
line-height: 1;
@@ -4355,6 +4360,82 @@ body.has-player .chat-fab {
}
}
/* ── Playlist grid ──
The playlists page shows its cards as tiles, the cover across the tile's
top and the text under it: the same card as in a .dump-feed, laid out by
the list it is in, so every theme's card skin carries over. */
.playlist-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: 1rem;
}
.playlist-grid > li {
animation: card-enter 0.2s ease both;
}
/* The tiles of a row are as tall as the tallest, each one's text starting at
the same height under covers of one size. */
.playlist-grid .playlist-card {
display: flex;
}
.playlist-grid .playlist-card-inner {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
padding: 0.6rem 0.6rem 0.8rem;
}
.playlist-grid .playlist-card-preview {
width: 100%;
height: auto;
aspect-ratio: 1;
}
/* A cover this size fills the tile: grown as a thumb is, it would spill over
the card's edge. */
.playlist-grid .playlist-card-inner:hover .playlist-card-preview {
transform: none;
}
.playlist-grid .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-display);
}
.playlist-grid .playlist-card-body {
margin-left: 0;
padding-inline: 0.15rem;
}
.playlist-grid .playlist-card-title,
.playlist-grid .playlist-card-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Owner, count and date wrap whole rather than run past a narrow tile. */
.playlist-grid .playlist-card-meta {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.1rem 0.6rem;
}
@media (max-width: 512px) {
.playlist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.dump-card-vote {
flex-shrink: 0;
align-self: center;
@@ -5723,7 +5804,7 @@ body.has-player .chat-fab {
align-self: center;
}
.collections-section .playlist-card-icon {
.collections-section .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-lg);
}

View File

@@ -23,7 +23,7 @@
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's (`Playlists`).
* playlist category's, in the order `?sort=` names (`Playlists`).
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`

View File

@@ -17,14 +17,15 @@ export interface PageHeaderProps {
title: ReactNode;
/**
* It goes at the end of the row, such as the notifications' unread count or
* the playlists' category menu.
* the playlists' category menu and order tabs.
*/
children?: ReactNode;
}
/**
* Renders the page's h1 with its icon, then `children`, as the first block of
* a narrow `PageShell`; `PageGroup`s follow it.
* a `PageShell`: a narrow one's `PageGroup`s follow it, or the playlists
* page's grid.
*/
export function PageHeader(
{ icon, title, children }: PageHeaderProps,

View File

@@ -5,18 +5,20 @@
* @module
*/
import { Link, useNavigate } from "react-router";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import type { Playlist } from "../../../shared/playlist.ts";
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
} from "../../utils/shared/visited.ts";
import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
@@ -41,16 +43,21 @@ export interface PlaylistCardProps {
}
/**
* Renders a playlist as a card that opens the playlist when clicked, with the
* playlist categories it is filed under. Another user's recent playlist shows
* an unread dot until it is opened on this device. The card is an `<li>`, for
* a `<ul>`.
* Renders a playlist as a card that opens the playlist when clicked, in a new
* tab on a middle click (`useCardLink`), with its cover, or a tile of its own
* colour without one, the playlist categories it is filed under and when it
* last took a dump. Another user's recent playlist shows an unread dot until
* it is opened on this device. The card is an `<li>`, for a `<ul>`, whose
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
): React.JSX.Element {
const navigate = useNavigate();
const { mediaToken } = useAuth();
const { cardProps } = useCardLink(
playlistUrl(playlist),
() => markPlaylistVisited(playlist.id),
);
const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id);
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
@@ -58,18 +65,12 @@ export function PlaylistCard(
// room for its text only, and none at all when it is a lone image.
const description = playlist.description &&
markdownToPlainText(playlist.description, "");
function handleNavigate() {
markPlaylistVisited(playlist.id);
navigate(playlistUrl(playlist));
}
const activity = playlistActivity(playlist);
const ago = relativeTime(activity);
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={handleNavigate}
>
<div className="playlist-card-inner" {...cardProps}>
<div className="playlist-card-preview">
{playlist.imageMime
? (
@@ -81,7 +82,12 @@ export function PlaylistCard(
decoding="async"
/>
)
: <span className="playlist-card-icon">📋</span>}
: (
<ThumbnailPlaceholder
seed={playlist.id}
siteName={playlist.title}
/>
)}
</div>
<div className="playlist-card-body">
<Link
@@ -129,9 +135,11 @@ export function PlaylistCard(
/>
</span>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
<Tooltip text={activity.toLocaleString()}>
<time dateTime={activity.toISOString()}>
{playlist.lastDumpAt
? <Trans>updated {ago}</Trans>
: <Trans>created {ago}</Trans>}
</time>
</Tooltip>
</div>

View File

@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
onDelete?: (playlist: Playlist) => void;
/** Passed to each {@link PlaylistCard}. */
privateBadgeOnly?: boolean;
/**
* Lays the cards out as tiles, cover first, several to a row, for a page
* that is a list of playlists and nothing else: the playlists page.
*/
grid?: boolean;
}
/**
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being
* its owner's when the playlist is the signed-in user's.
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
* row, or as a `.playlist-grid` of them, a card being its owner's when the
* playlist is the signed-in user's.
*/
export function PlaylistCardList(
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps,
{ playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
): React.JSX.Element {
const { user } = useAuth();
return (
<ul className="dump-feed">
<ul className={grid ? "playlist-grid" : "dump-feed"}>
{playlists.map((p) => (
<PlaylistCard
key={p.id}

View File

@@ -0,0 +1,43 @@
/**
* Renders the tabs that put a list of playlists in another order, on the
* playlists page and on a profile's playlists tab.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import { PLAYLIST_SORTS, type PlaylistSort } from "../../../shared/playlist.ts";
import { TabBar } from "../shared/TabBar.tsx";
/**
* Passed to {@link PlaylistSortTabs} by `Playlists` and
* `ProfilePlaylistsTab`, which keep the order in their URL's `?sort=`.
*/
export interface PlaylistSortTabsProps {
sort: PlaylistSort;
onChange: (sort: PlaylistSort) => void;
/** Goes before the tabs, as the playlists page's category menu does. */
leading?: ReactNode;
}
const LABELS: Record<PlaylistSort, ReactNode> = {
updated: <Trans>Updated</Trans>,
new: <Trans>New</Trans>,
dumps: <Trans>Most dumps</Trans>,
};
/** Renders one tab per order of `PLAYLIST_SORTS`, `sort` being the active one. */
export function PlaylistSortTabs(
{ sort, onChange, leading }: PlaylistSortTabsProps,
): React.JSX.Element {
return (
<TabBar
tabs={PLAYLIST_SORTS.map((key) => ({ key, label: LABELS[key] }))}
activeTab={sort}
onChange={onChange}
leading={leading}
className="playlist-sort-tabs"
/>
);
}

View File

@@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) {
usePlaylistListSync(
state.status === "loaded" ? state.data.items : [],
setItems,
{ noNewEntries: true },
);
return (
<ListState

View File

@@ -11,7 +11,12 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { playlistSortQuery } from "../../../shared/paths.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
canLoadMore,
@@ -39,6 +44,7 @@ import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../feed/FeedFooter.tsx";
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
@@ -53,19 +59,22 @@ export interface ProfilePlaylistsTabProps {
/**
* Renders the tab's list for the playlist category the URL's `?category=`
* picks, or every playlist without one. Each category's list is another,
* which the tab mounts afresh.
* picks, or every playlist without one, in the order its `?sort=` names.
* Each category's list in each order is another, which the tab mounts
* afresh.
*/
export function ProfilePlaylistsTab(
props: ProfilePlaylistsTabProps,
): React.JSX.Element {
const [params] = useSearchParams();
const categorySlug = params.get("category") || undefined;
const sort = parsePlaylistSort(params.get("sort"));
return (
<PlaylistList
key={categorySlug ?? ""}
key={`${categorySlug ?? ""}:${sort}`}
{...props}
categorySlug={categorySlug}
sort={sort}
/>
);
}
@@ -74,26 +83,28 @@ export function ProfilePlaylistsTab(
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
* the private ones to their owner only, a page at a time as the list scrolls;
* with `categorySlug`, only those in that playlist category, which the menu in
* the title row switches. The socket keeps them live, and going back restores
* the list and the scroll. The owner can make one, which goes first, and
* delete one.
* the title row switches, before the tabs that change the order. The socket
* keeps them live, and going back restores the list and the scroll. The owner
* can make one, which goes first, and delete one.
*/
function PlaylistList(
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
{ user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & {
categorySlug?: string;
sort: PlaylistSort;
},
): React.JSX.Element {
const [, setParams] = useSearchParams();
const { user: me, authFetch } = useAuth();
const { categories } = useCategories("playlist");
const { cached, saveState } = useFeedCache<Playlist>(
`feed:profile-playlists:${user.id}${
categorySlug ? `:${categorySlug}` : ""
}`,
`feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`,
hydratePlaylist,
);
const url = `${userApiUrl(user, "playlists")}${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist
@@ -116,6 +127,7 @@ function PlaylistList(
usePlaylistListSync(loaded?.items ?? [], setItems, {
isOwner: isOwnProfile,
ownerId: user.id,
sort,
});
useOnReconnect(resync);
@@ -149,15 +161,24 @@ function PlaylistList(
title={<Trans>Playlists</Trans>}
actions={
<div className="profile-section-actions">
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
<PlaylistSortTabs
sort={sort}
// Another order of the same list, as a tab is: it takes the
// current history entry's place.
onChange={(s) =>
setParams((p) => playlistSortQuery(s, p), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
/>
}
/>
{isOwnProfile && (
<NewPlaylistButton

View File

@@ -50,6 +50,11 @@ export interface PlaylistEvent {
* order.
*/
dumpIds?: string[];
/**
* With `dumpIds`, when the latest of them was added; undefined when there
* is none.
*/
lastDumpAt?: Date;
}
/**

View File

@@ -599,11 +599,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds } = msg;
const { playlistId, dumpIds, lastDumpAt } = msg;
events.emit("playlist", {
type: "dumps_updated",
playlistId,
dumpIds,
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
});
break;
}

View File

@@ -1,90 +1,21 @@
/**
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
* behaviour: a middle or modified click opens the dump in a new tab.
* Makes a dump's card open the dump as a link would, and mark it visited.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
import type { Dump } from "../../../shared/dump.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../../utils/shared/visited.ts";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.ts";
/**
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
* way it opens the dump marks it visited, which clears its unread dot.
*/
export interface UseDumpCardLinkResult {
/** Opens the dump in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the dump instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make the card of `dump` open it: in this tab on a
* click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors
* inside the card, such as the title or a category, keep their own target.
* Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
* `JournalCard`: every way they open the dump marks it visited, which clears
* its unread dot.
*/
export function useDumpCardLink(
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
): UseDumpCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
markDumpVisited(dump.id);
globalThis.open(dumpUrl(dump), "_blank", "noopener");
}
function go() {
markDumpVisited(dump.id);
navigate(dumpUrl(dump));
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
): UseCardLinkResult {
return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
}

View File

@@ -7,10 +7,14 @@
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts";
import {
comparePlaylists,
type Playlist,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
import { withInOrder } from "../../utils/feed/pagedList.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions {
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
*/
ownerId?: string;
/**
* Adds no new playlist, for a list of followed playlists, since the socket
* doesn't say what the user follows, or of search results. Playlists
* already listed still change, go, and come back where they were.
* The order the list is in, for one the API sorts: the playlists page's and
* a profile's. A list in an order of its own leaves it out, the followed
* playlists being by the date they were followed and a search's results
* the search's: it takes no new playlist, since the socket says neither
* what the user follows nor what a search matches, and moves none.
* Playlists already listed still change, go, and come back where they
* were.
*/
noNewEntries?: boolean;
sort?: PlaylistSort;
}
/**
* Applies the socket's playlist events to `items`, the loaded list, newest
* first, which `setPlaylists` updates. A created playlist goes where its date
* puts it, first when new, an updated one changes in place or leaves when it
* turns private (unless `isOwner`), one that left comes back where it was
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
* its dumps. A renamed user's playlists take their new name.
* Applies the socket's playlist events to `items`, the loaded list, which
* `setPlaylists` updates. A created playlist goes where the list's order puts
* it, an updated one changes in place or leaves when it turns private (unless
* `isOwner`), one that left comes back where it was when it returns, a
* deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
* its dumps. One that a change brings up the order moves up to its place, as
* a playlist does in the `updated` order when it takes a dump; one that falls
* stays where it is, its place being perhaps on a page not loaded. A renamed
* user's playlists take their new name.
*/
export function usePlaylistListSync(
items: Playlist[],
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
});
useWSEvent("playlist", (ev) => {
const { isOwner, ownerId, noNewEntries } = options ?? {};
const { isOwner, ownerId, sort } = options ?? {};
const compare = sort && comparePlaylists(sort);
// Returns `list` with the playlist at `idx` before the ones it now comes
// before, if it rose.
const risen = (list: Playlist[], idx: number) =>
compare && idx > 0 && compare(list[idx], list[idx - 1]) < 0
? withInOrder(list.toSpliced(idx, 1), list[idx], compare)
: list;
const shown = ev.playlist &&
(ev.playlist.isPublic || isOwner) &&
(!ownerId || ev.playlist.userId === ownerId);
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
const idx = prev.findIndex((p) => p.id === playlist.id);
if (idx !== -1) {
if (!shown) return prev.filter((p) => p.id !== playlist.id);
const next = [...prev];
next[idx] = playlist;
return next;
return risen(prev.with(idx, playlist), idx);
}
if (!shown) return prev;
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev;
return withByDate(prev, playlist);
if (ev.type === "updated" || !compare) return prev;
return withInOrder(prev, playlist, compare);
}
if (ev.type === "deleted") {
@@ -84,10 +100,13 @@ export function usePlaylistListSync(
if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev;
const next = [...prev];
// The dumps this user may see, which are those the playlist counts.
next[idx] = { ...next[idx], dumpCount: ev.dumpIds.length };
return next;
const playlist = {
...prev[idx],
dumpCount: ev.dumpIds.length,
lastDumpAt: ev.lastDumpAt,
};
return risen(prev.with(idx, playlist), idx);
}
return prev;

View File

@@ -0,0 +1,91 @@
/**
* Gives a dump's or a playlist's card, which is a clickable `<li>` and not a
* link, a link's behaviour: a middle or modified click opens its page in a
* new tab.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
/**
* Returned by {@link useCardLink} to `PlaylistCard`, and through
* `useDumpCardLink` to `DumpCard` and `JournalCard`.
*/
export interface UseCardLinkResult {
/** Opens the page in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the page in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the page instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make a card open the page at `url`: in this tab
* on a click, in a new tab on a middle click or a ctrl, cmd or shift click.
* Every way it opens the page calls `onOpen` first, which marks the item
* visited. Anchors inside the card, such as the title or a category, keep
* their own target.
*/
export function useCardLink(
url: string,
onOpen: () => void,
): UseCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
onOpen();
globalThis.open(url, "_blank", "noopener");
}
function go() {
onOpen();
navigate(url);
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
}

View File

@@ -1,17 +1,21 @@
/**
* Renders every public playlist, `/playlists`, and those of one playlist
* category, `/playlists/:categorySlug`, newest first.
* category, `/playlists/:categorySlug`, in the order `?sort=` names.
*
* @module
*/
import { useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { playlistsPath } from "../../shared/paths.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistSort,
} from "../../shared/playlist.ts";
import { playlistSortQuery, playlistsPath } from "../../shared/paths.ts";
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
import {
deserializePlaylist,
@@ -34,18 +38,21 @@ import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx";
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ListState } from "../components/shared/ListState.tsx";
import { NotFound } from "./NotFound.tsx";
/**
* Renders the list for the route's category, or every public playlist
* without one. Each category's list is another, which the page mounts
* afresh. A slug that names no playlist category, once the categories have
* loaded, is a page not found.
* without one, in the order of the URL's `?sort=`. Each category's list in
* each order is another, which the page mounts afresh. A slug that names no
* playlist category, once the categories have loaded, is a page not found.
*/
export function Playlists(): React.JSX.Element {
const { categorySlug } = useParams();
const [params] = useSearchParams();
const sort = parsePlaylistSort(params.get("sort"));
const { categories, isLoaded } = useCategories("playlist");
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
@@ -55,31 +62,41 @@ export function Playlists(): React.JSX.Element {
if (categorySlug && isLoaded && !category) return <NotFound />;
return (
<PlaylistsList
key={categorySlug ?? ""}
key={`${categorySlug ?? ""}:${sort}`}
categorySlug={categorySlug}
categoryId={category?.id}
sort={sort}
/>
);
}
/**
* Renders the public playlists, from `GET /api/playlists`, a page at a time
* as the list scrolls; with `categorySlug`, only those in that playlist
* category, which the menu in the title row switches. The socket keeps them
* live, a reconnection fetches the pages loaded again, and going back
* restores the list and the scroll.
* Renders the public playlists, from `GET /api/playlists`, as a grid of
* covers, a page at a time as the list scrolls; with `categorySlug`, only
* those in that playlist category, which the menu before the tabs switches,
* keeping the order, while a tab changes the order in place of the current
* history entry, as a feed's tabs do. The socket keeps them live, a
* reconnection fetches the pages loaded again, and going back restores the
* list and the scroll.
*/
function PlaylistsList(
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
{ categorySlug, categoryId, sort }: {
categorySlug?: string;
categoryId?: string;
sort: PlaylistSort;
},
) {
const navigate = useNavigate();
const { user, authFetch } = useAuth();
const { cached, saveState } = useFeedCache<Playlist>(
`feed:playlists:${categorySlug ?? "all"}`,
`feed:playlists:${categorySlug ?? "all"}:${sort}`,
hydratePlaylist,
);
const url = `${API_URL}/api/playlists${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist
@@ -97,6 +114,7 @@ function PlaylistsList(
usePlaylistListSync(
loaded?.items ?? [],
useCategoryFilter(setAllItems, categoryId),
{ sort },
);
useOnReconnect(resync);
@@ -110,12 +128,19 @@ function PlaylistsList(
useScrollRestore(cached?.scrollY, !!loaded);
return (
<PageShell narrow>
<PageShell>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug))}
<PlaylistSortTabs
sort={sort}
onChange={(s) =>
navigate(playlistsPath(categorySlug, s), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug, sort))}
/>
}
/>
</PageHeader>
<ListState
@@ -129,7 +154,7 @@ function PlaylistsList(
>
{({ data }) => (
<>
<PlaylistCardList playlists={data.items} />
<PlaylistCardList playlists={data.items} grid privateBadgeOnly />
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}

View File

@@ -166,7 +166,7 @@ export function Search(): React.JSX.Element {
setData((d) => ({ ...d, playlists: fn(d.playlists) })),
[setData],
),
{ isOwner: true, noNewEntries: true },
{ isOwner: true },
);
// What changed while the socket was down: every page of dumps loaded is

View File

@@ -44,8 +44,12 @@ 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"];
/**
* The lists of dumps, which one `?category=` narrows, so it passes from one to
* the other. Any other change of tab drops the query: a tab's is its own, as
* the playlists' category, of another kind, and their `?sort=`.
*/
const DUMP_LISTS: readonly (Tab | null)[] = ["dumps", "upvoted"];
/**
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
@@ -168,7 +172,12 @@ export function UserPublicProfile(): React.JSX.Element {
]}
activeTab={tab}
onChange={(key) =>
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
setTab(
key,
DUMP_LISTS.includes(key) && DUMP_LISTS.includes(tab)
? undefined
: "",
)}
className="profile-tabs-scroller"
innerClassName="profile-tabs"
/>

View File

@@ -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>

View File

@@ -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"]);

View File

@@ -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");

View File

@@ -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.

View File

@@ -478,7 +478,6 @@
[data-style="nyt"] .notifications-empty-icon,
[data-style="nyt"] .page-title-icon,
[data-style="nyt"] .dump-card-preview-icon,
[data-style="nyt"] .playlist-card-icon,
[data-style="nyt"] .rich-content-compact-icon,
[data-style="nyt"] .journal-card-glyph,
[data-style="nyt"] .fdz__file-icon,

View File

@@ -105,22 +105,39 @@ export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
}
/**
* Returns `items`, newest first as the API pages them, with `item` where its
* `createdAt` puts it: before the first item no newer than it. An item older
* than every one loaded is left to a later page, and `items` returned as they
* are, as they are when `item` is already there. The list sync hooks put a
* new or newly visible dump or playlist in a list with it.
* Returns `items`, in the order of `compare` as the API pages them, with
* `item` where that order puts it: before the first item that doesn't come
* before it. An item that comes after every one loaded is left to a later
* page, and `items` returned as they are, as they are when `item` is already
* there. `usePlaylistListSync` puts a new, newly visible or risen playlist in
* a list with it, whatever its order.
*/
export function withInOrder<T extends { id: string }>(
items: T[],
item: T,
compare: (a: T, b: T) => number,
): T[] {
if (items.some((i) => i.id === item.id)) return items;
if (items.length === 0) return [item];
const idx = items.findIndex((i) => compare(i, item) >= 0);
if (idx === -1) return items;
return [...items.slice(0, idx), item, ...items.slice(idx)];
}
/**
* Returns `items`, newest first, with `item` where its `createdAt` puts it,
* as {@link withInOrder} does. `useDumpListSync` puts a new or newly visible
* dump in a list with it.
*/
export function withByDate<T extends { id: string; createdAt: Date }>(
items: T[],
item: T,
): T[] {
if (items.some((i) => i.id === item.id)) return items;
if (items.length === 0) return [item];
const time = item.createdAt.getTime();
const idx = items.findIndex((i) => i.createdAt.getTime() <= time);
if (idx === -1) return items;
return [...items.slice(0, idx), item, ...items.slice(idx)];
return withInOrder(
items,
item,
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
}
/**

View File

@@ -95,7 +95,10 @@ export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
/** Returns `raw` as a {@link Playlist}. */
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
return dates(raw);
return {
...dates(raw),
lastDumpAt: raw.lastDumpAt ? new Date(raw.lastDumpAt) : undefined,
};
}
/**
@@ -110,7 +113,10 @@ export function hydratePlaylist(raw: unknown): Playlist {
export function deserializePlaylistWithDumps(
raw: Wire<PlaylistWithDumps>,
): PlaylistWithDumps {
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) };
return {
...deserializePlaylist(raw),
dumps: raw.dumps.map(deserializeDump),
};
}
/** Returns `raw` as a {@link PlaylistMembership}. */