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`) 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 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=`, 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 kind that list holds. The order of a list of playlists is `?sort=`, the
of the current history entry; picking a category, or opening another page or an default order being the bare path. Switching tabs, a tab's views or a list's
editor, adds one. 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 **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 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` | | `/` | `/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` | | `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` | | `/@<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>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` | | `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` | | `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` | | `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` | | `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `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` | | `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` | | `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` | | `/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/avatar` — anyone. The avatar.
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items - `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
to their owner only. Their lists; `?category=` narrows one to a category of 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 - `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. 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 - `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 - `POST /api/playlists` — signed in. Creates a playlist, filed under the
playlist categories it names. playlist categories it names.
- `GET /api/playlists` — anyone. Every public playlist, newest first; - `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
`?category=<slug>` narrows it to a playlist category. 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. - `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title, - `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
slug, description, visibility, categories; deletion. 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 * The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
* playlist's dumps the viewer may see, by {@link isMemberVisibleTo}'s rule. * that tell who may see it, and when it was added. The playlist service reads
* Its `?` takes the viewer's id, or `""` for a signed-out one, and comes * them for its broadcasts.
* before the query's other parameters, the column being in its select list.
*/ */
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 INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = p.id WHERE pd.playlist_id = p.id
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?)) AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
) as dump_count`; )`;
/** /**
* Returns how many of `members` the viewer may see in `playlist`, the * The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
* `dumpCount` the playlist has for them. * 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( export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
members: PlaylistMember[], ${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">, playlist: Pick<Playlist, "userId">,
viewerId: string | null | undefined, viewerId: string | null | undefined,
): number { ): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
return members.filter((m) => isMemberVisibleTo(m, playlist, viewerId)) const visible = members.filter((m) =>
.length; 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`, * 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 * which join the owner's name as `owner_username`. A query may add the
* `dump_count` column, which {@link playlistRowToApi} carries over when * `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
* present. * {@link playlistRowToApi} carries over when present.
*/ */
export interface PlaylistRow { export interface PlaylistRow {
id: string; id: string;
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined, imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : 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, ownerUsername: row.owner_username,
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import type {
CreatePlaylistRequest, CreatePlaylistRequest,
Playlist, Playlist,
PlaylistMembership, PlaylistMembership,
PlaylistSort,
PlaylistWithDumps, PlaylistWithDumps,
UpdatePlaylistRequest, UpdatePlaylistRequest,
} from "../../shared/playlist.ts"; } from "../../shared/playlist.ts";
@@ -42,25 +43,36 @@ import {
setCategories, setCategories,
} from "./category-service.ts"; } from "./category-service.ts";
import { import {
type DatedPlaylistMember,
isMemberVisibleTo, isMemberVisibleTo,
type PlaylistMember, VISIBLE_DUMP_FIGURES,
VISIBLE_DUMP_COUNT,
} from "../lib/playlist-members.ts"; } from "../lib/playlist-members.ts";
import { LIKE, likePattern } from "../lib/like.ts"; import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts"; import { parseRows } from "../model/rows.ts";
/** /**
* The select list and `FROM` clause of a playlist query, adding the owner's * 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 * name, the count of the dumps the viewer may see and when the latest was
* `WHERE`, and pass the viewer's id first, as {@link viewerParam} gives it. * 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, 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`; FROM playlists p JOIN users u ON u.id = p.user_id`;
/** /**
* Returns what {@link VISIBLE_DUMP_COUNT}'s parameter takes for `viewerId`: a * The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}:
* signed-out viewer matches no user. * `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 { function viewerParam(viewerId: string | null | undefined): string {
return viewerId ?? ""; return viewerId ?? "";
@@ -79,7 +91,8 @@ function playlistsOf(rows: unknown[]): Playlist[] {
/** /**
* Returns playlist `playlistId`, private or not. A caller that shows it to * Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does. Its * 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. * @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 * 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 * not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
* slug, it comes with its current one. * 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); const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found"); 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 * Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`, and for * order, for `GET /api/playlists/:playlistId`, and for
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its * `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 * path, for the same viewer. A null `requestingUserId` is a signed-out viewer.
* the number of those dumps. * Its `dumpCount` is the number of those dumps.
* *
* @throws {APIException} 404 when there is no such playlist, or it is private * @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs. * and not theirs.
@@ -168,7 +186,7 @@ export function getPlaylist(
requestingUserId: string | null, requestingUserId: string | null,
): PlaylistWithDumps { ): PlaylistWithDumps {
const playlist = typeof playlistId === "string" const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId) ? getPlaylistById(playlistId, requestingUserId)
: playlistId; : playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) { 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 * `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them. Their dumps are * `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( export function listPublicPlaylists(
requestingUserId: string | null, requestingUserId: string | null,
page: number, page: number,
limit: number, limit: number,
sort: PlaylistSort,
categoryId?: string, categoryId?: string,
): Page<Playlist> { ): Page<Playlist> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -244,7 +264,7 @@ export function listPublicPlaylists(
const rows = db.prepare( const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} `SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1 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); ).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
return pageOf(playlistsOf(rows), limit); return pageOf(playlistsOf(rows), limit);
} }
@@ -259,13 +279,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} `SELECT ${PLAYLIST_SELECT}
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? 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()); ).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows); 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 * `GET /api/users/:key/playlists`, optionally only those in the playlist
* category `categoryId`. The private ones are listed only when * category `categoryId`. The private ones are listed only when
* `requestingUserId` is `userId`. * `requestingUserId` is `userId`.
@@ -275,6 +295,7 @@ export function listPlaylistsByUser(
requestingUserId: string | null, requestingUserId: string | null,
page: number, page: number,
limit: number, limit: number,
sort: PlaylistSort,
categoryId?: string, categoryId?: string,
): Page<Playlist> { ): Page<Playlist> {
const isOwner = requestingUserId === userId; const isOwner = requestingUserId === userId;
@@ -285,7 +306,7 @@ export function listPlaylistsByUser(
const rows = db.prepare( const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} `SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter} WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`, ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all( ).all(
viewerParam(requestingUserId), viewerParam(requestingUserId),
...catParams, ...catParams,
@@ -620,7 +641,7 @@ export function getPlaylistMembershipsForDump(
): PlaylistMembership[] { ): PlaylistMembership[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT p.*, u.username as owner_username, `SELECT p.*, u.username as owner_username,
${VISIBLE_DUMP_COUNT}, ${VISIBLE_DUMP_FIGURES},
EXISTS (SELECT 1 FROM playlist_dumps pd EXISTS (SELECT 1 FROM playlist_dumps pd
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump 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 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 * Returns a playlist's dumps in order, all of them, for the callers to filter
* per viewer. * per viewer.
*/ */
function getCurrentMembers(playlistId: string): PlaylistMember[] { function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
const rows = db.prepare( 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 FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`, WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
).all(playlistId) as Array< ).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) => ({ return rows.map((r) => ({
id: r.dump_id, id: r.dump_id,
isPrivate: Boolean(r.is_private), isPrivate: Boolean(r.is_private),
userId: r.user_id, 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 { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts"; import type { PublicUser, Role } from "../../shared/user.ts";
import { import {
countVisibleTo, type DatedPlaylistMember,
dumpFiguresFor,
isMemberVisibleTo, isMemberVisibleTo,
type PlaylistMember,
} from "../lib/playlist-members.ts"; } from "../lib/playlist-members.ts";
import type { Job } from "../lib/scheduler.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 * {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without * its details. A playlist turned private thus leaves the others' views without
* their learning what it became. `members` are the dumps of `after`: each * 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 * socket gets the playlist with the count of those its user may see and the
* owner's private dumps being theirs alone to count. * date of the latest, the owner's private dumps being theirs alone to count.
*/ */
export function broadcastPlaylistChange( export function broadcastPlaylistChange(
before: Playlist | null, before: Playlist | null,
after: Playlist | null, after: Playlist | null,
members: PlaylistMember[], members: DatedPlaylistMember[],
): void { ): void {
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({ const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
...playlist, ...playlist,
dumpCount: countVisibleTo( ...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
members,
playlist,
toOwner ? playlist.userId : null,
),
}); });
broadcastChange(before, after, (playlist) => !playlist.isPublic, { broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist, toOwner) => ({ created: (playlist, toOwner) => ({
@@ -339,7 +335,7 @@ export function broadcastPlaylistChange(
*/ */
export function broadcastPlaylistDumpsUpdated( export function broadcastPlaylistDumpsUpdated(
playlist: Pick<Playlist, "id" | "isPublic" | "userId">, playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
members: PlaylistMember[], members: DatedPlaylistMember[],
): void { ): void {
for (const client of clients) { for (const client of clients) {
if (!playlist.isPublic && client.userId !== playlist.userId) continue; if (!playlist.isPublic && client.userId !== playlist.userId) continue;
@@ -349,6 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
dumpIds: members dumpIds: members
.filter((m) => isMemberVisibleTo(m, playlist, client.userId)) .filter((m) => isMemberVisibleTo(m, playlist, client.userId))
.map((m) => m.id), .map((m) => m.id),
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
}); });
} }
} }

View File

@@ -16,10 +16,12 @@ import {
TEST_DIR, TEST_DIR,
} from "../helpers.ts"; } from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import type { import {
Playlist, comparePlaylists,
PlaylistMembership, type Playlist,
PlaylistWithDumps, PLAYLIST_SORTS,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../../../shared/playlist.ts"; } from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert"; 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, []); 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 owner = await createUser();
const other = await createUser(); const other = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); 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); 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( wsTest(
"playlist_dumps_updated lists only what each viewer can see", "playlist_dumps_updated lists only what each viewer can see",
async (connect) => { 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}}" msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount #. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:125 #: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{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/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:354 #: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:379 #: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:606 #: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:453 #: src/pages/Dump.tsx:474
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
@@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}" msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#: src/contexts/ws/WSProvider.tsx:377 #: 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." 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." 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/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:495 #: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
@@ -551,7 +551,7 @@ msgstr "Confirm new password"
msgid "Connecting…" msgid "Connecting…"
msgstr "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" msgid "Connection lost — this message may not have been sent"
msgstr "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." 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." 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 #: src/pages/DumpEdit.tsx:555
msgid "Could not delete" msgid "Could not delete"
msgstr "Could not delete" msgstr "Could not delete"
@@ -628,6 +628,10 @@ msgstr "Create"
msgid "Create & Add" msgid "Create & Add"
msgstr "Create & Add" msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "created {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121 #: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…" msgid "Creating…"
msgstr "Creating…" msgstr "Creating…"
@@ -675,9 +679,9 @@ msgstr "Delete dump"
msgid "Delete failed ({0})" msgid "Delete failed ({0})"
msgstr "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/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201 #: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
@@ -694,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Delete this message?" msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200 #: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "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/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:211 #: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:160 #: src/pages/UserPublicProfile.tsx:164
msgid "Dumps" msgid "Dumps"
msgstr "Dumps" msgstr "Dumps"
@@ -785,7 +789,7 @@ msgstr "Earlier"
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60 #: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:602 #: src/pages/Dump.tsx:623
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -803,7 +807,7 @@ msgid "Edit email"
msgstr "Edit email" msgstr "Edit email"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:512 #: src/pages/Dump.tsx:533
msgid "Edit title" msgid "Edit title"
msgstr "Edit title" msgstr "Edit title"
@@ -817,7 +821,7 @@ msgstr "edited"
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95 #: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:555 #: src/pages/Dump.tsx:576
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -827,7 +831,7 @@ msgstr "edited {0}"
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93 #: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:553 #: src/pages/Dump.tsx:574
msgid "Edited {0}" msgid "Edited {0}"
msgstr "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." msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:61 #: 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/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163 #: src/pages/UserPublicProfile.tsx:167
msgid "Followed" msgid "Followed"
msgstr "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" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:622 #: src/pages/Dump.tsx:643
msgid "In collections" msgid "In collections"
msgstr "In collections" msgstr "In collections"
@@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "invited by you" msgstr "invited by you"
#: src/components/profile/ProfileInviteesTab.tsx:77 #: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164 #: src/pages/UserPublicProfile.tsx:168
msgid "Invitees" msgid "Invitees"
msgstr "Invitees" msgstr "Invitees"
@@ -1106,7 +1110,7 @@ msgstr "Link"
msgid "Link or file" msgid "Link or file"
msgstr "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…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "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" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:338 #: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -1139,7 +1143,7 @@ msgstr "Loading more…"
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:108 #: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Loading profile…" msgstr "Loading profile…"
@@ -1177,7 +1181,7 @@ msgid "Log in with {0}"
msgstr "Log in with {0}" msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:128 #: src/components/layout/UserMenu.tsx:128
#: src/pages/UserPublicProfile.tsx:133 #: src/pages/UserPublicProfile.tsx:137
msgid "Log out" msgid "Log out"
msgstr "Log out" msgstr "Log out"
@@ -1197,7 +1201,7 @@ msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:115 #: src/components/layout/UserMenu.tsx:115
#: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40 #: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166 #: src/pages/UserPublicProfile.tsx:170
msgid "Manage" msgid "Manage"
msgstr "Manage" msgstr "Manage"
@@ -1214,6 +1218,10 @@ msgstr "Moderator"
msgid "digest.monthly" msgid "digest.monthly"
msgstr "Monthly recap" msgstr "Monthly recap"
#: src/components/playlist/PlaylistSortTabs.tsx:27
msgid "Most dumps"
msgstr "Most dumps"
#: src/components/player/MediaPlayer.tsx:299 #: src/components/player/MediaPlayer.tsx:299
msgid "Mute" msgid "Mute"
msgstr "Mute" msgstr "Mute"
@@ -1229,6 +1237,7 @@ msgstr "Name"
#: src/components/chat/ChatModal.tsx:692 #: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58 #: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/pages/Settings.tsx:55 #: src/pages/Settings.tsx:55
msgid "New" msgid "New"
msgstr "New" msgstr "New"
@@ -1285,7 +1294,7 @@ msgstr "No dumps yet. Be the first!"
msgid "No emoji found." msgid "No emoji found."
msgstr "No emoji found." msgstr "No emoji found."
#: src/components/profile/ProfileFollowTab.tsx:117 #: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "No followed playlists yet." msgstr "No followed playlists yet."
@@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51 #: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178 #: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:126 #: src/pages/Playlists.tsx:151
msgid "No playlists yet." msgid "No playlists yet."
msgstr "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" 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" 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" msgid "Other instances"
msgstr "Other instances" msgstr "Other instances"
#: src/pages/UserPublicProfile.tsx:159 #: src/pages/UserPublicProfile.tsx:163
msgid "Overview" msgid "Overview"
msgstr "Overview" msgstr "Overview"
@@ -1469,12 +1478,12 @@ msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169 #: src/components/layout/AppHeader.tsx:169
#: src/components/profile/ProfileFollowTab.tsx:148 #: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:149 #: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:53 #: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:114 #: src/pages/Playlists.tsx:132
#: src/pages/Search.tsx:214 #: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:162 #: src/pages/UserPublicProfile.tsx:166
msgid "Playlists" msgid "Playlists"
msgstr "Playlists" msgstr "Playlists"
@@ -1498,10 +1507,10 @@ msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145 #: 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/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:561 #: src/pages/Dump.tsx:582
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -1509,7 +1518,7 @@ msgstr "private"
msgid "Private" msgid "Private"
msgstr "Private" msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:110 #: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:77
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1544,7 +1553,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:613 #: src/pages/Dump.tsx:634
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1631,7 +1640,7 @@ msgid "email.reset.title"
msgstr "Reset your password" msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:372 #: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1646,7 +1655,7 @@ msgstr "Role"
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75 #: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:487 #: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
@@ -1656,7 +1665,7 @@ msgstr "Save"
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72 #: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:486 #: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "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/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong" msgid "Something went wrong"
msgstr "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." msgid "This comment couldn't be deleted. Try again."
msgstr "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." msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private." msgstr "This dump was deleted, or made private."
@@ -1971,6 +1980,14 @@ msgstr "Unsubscribe"
msgid "Unsubscribed" msgid "Unsubscribed"
msgstr "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 #. js-lingui-explicit-id
#: api/mail/digest.ts:237 #: api/mail/digest.ts:237
msgid "email.digest.upload" msgid "email.digest.upload"
@@ -1998,7 +2015,7 @@ msgid "Upvote"
msgstr "Upvote" msgstr "Upvote"
#: src/components/profile/ProfileDumpsTab.tsx:46 #: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161 #: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted" msgid "Upvoted"
msgstr "Upvoted" msgstr "Upvoted"
@@ -2039,7 +2056,7 @@ msgstr "User menu"
msgid "Username" msgid "Username"
msgstr "Username" msgstr "Username"
#: src/components/profile/ProfileFollowTab.tsx:147 #: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213 #: src/pages/Search.tsx:213
msgid "Users" msgid "Users"
msgstr "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" msgid "email.welcome.reason"
msgstr "You're getting this email because an account was created on {site} with this address." 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" msgid "You're offline — not sent"
msgstr "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}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount #. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:125 #: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}" msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -207,13 +207,13 @@ msgstr "# Bienvenue sur {site} !\n"
#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:354 #: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:379 #: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:606 #: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:453 #: src/pages/Dump.tsx:474
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
@@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}" msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#: src/contexts/ws/WSProvider.tsx:377 #: 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." 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." 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/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:495 #: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -551,7 +551,7 @@ msgstr "Confirmer le nouveau mot de passe"
msgid "Connecting…" msgid "Connecting…"
msgstr "Connexion…" msgstr "Connexion…"
#: src/contexts/ws/WSProvider.tsx:758 #: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent" msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" 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." 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." 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 #: src/pages/DumpEdit.tsx:555
msgid "Could not delete" msgid "Could not delete"
msgstr "Suppression impossible" msgstr "Suppression impossible"
@@ -628,6 +628,10 @@ msgstr "Créer"
msgid "Create & Add" msgid "Create & Add"
msgstr "Créer et ajouter" msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "créée {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121 #: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…" msgid "Creating…"
msgstr "Création…" msgstr "Création…"
@@ -675,9 +679,9 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})" msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({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/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201 #: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
@@ -694,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Supprimer ce message ?" msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200 #: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible." msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -767,7 +771,7 @@ msgstr "Recommandé !"
#: src/components/profile/ProfileDumpsTab.tsx:46 #: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:211 #: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:160 #: src/pages/UserPublicProfile.tsx:164
msgid "Dumps" msgid "Dumps"
msgstr "Recos" msgstr "Recos"
@@ -785,7 +789,7 @@ msgstr "Plus tôt"
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60 #: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:602 #: src/pages/Dump.tsx:623
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -803,7 +807,7 @@ msgid "Edit email"
msgstr "Modifier l'e-mail" msgstr "Modifier l'e-mail"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:512 #: src/pages/Dump.tsx:533
msgid "Edit title" msgid "Edit title"
msgstr "Modifier le titre" msgstr "Modifier le titre"
@@ -817,7 +821,7 @@ msgstr "modifié"
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95 #: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:555 #: src/pages/Dump.tsx:576
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -827,7 +831,7 @@ msgstr "modifié {0}"
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93 #: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:553 #: src/pages/Dump.tsx:574
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {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." msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:61 #: 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/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163 #: src/pages/UserPublicProfile.tsx:167
msgid "Followed" msgid "Followed"
msgstr "Suivi" msgstr "Suivi"
@@ -1021,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:622 #: src/pages/Dump.tsx:643
msgid "In collections" msgid "In collections"
msgstr "Dans les collections" msgstr "Dans les collections"
@@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "sur votre invitation" msgstr "sur votre invitation"
#: src/components/profile/ProfileInviteesTab.tsx:77 #: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164 #: src/pages/UserPublicProfile.tsx:168
msgid "Invitees" msgid "Invitees"
msgstr "Invités" msgstr "Invités"
@@ -1106,7 +1110,7 @@ msgstr "Lien"
msgid "Link or file" msgid "Link or file"
msgstr "Lien ou fichier" 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…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -1122,7 +1126,7 @@ msgstr "Charger plus"
msgid "Load older messages" msgid "Load older messages"
msgstr "Charger les messages plus anciens" msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:338 #: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
@@ -1139,7 +1143,7 @@ msgstr "Chargement…"
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:108 #: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Chargement du profil…" msgstr "Chargement du profil…"
@@ -1177,7 +1181,7 @@ msgid "Log in with {0}"
msgstr "Se connecter avec {0}" msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:128 #: src/components/layout/UserMenu.tsx:128
#: src/pages/UserPublicProfile.tsx:133 #: src/pages/UserPublicProfile.tsx:137
msgid "Log out" msgid "Log out"
msgstr "Se déconnecter" msgstr "Se déconnecter"
@@ -1197,7 +1201,7 @@ msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:115 #: src/components/layout/UserMenu.tsx:115
#: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40 #: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166 #: src/pages/UserPublicProfile.tsx:170
msgid "Manage" msgid "Manage"
msgstr "Administration" msgstr "Administration"
@@ -1214,6 +1218,10 @@ msgstr "Modérateur"
msgid "digest.monthly" msgid "digest.monthly"
msgstr "Récap mensuel" msgstr "Récap mensuel"
#: src/components/playlist/PlaylistSortTabs.tsx:27
msgid "Most dumps"
msgstr "Les plus fournies"
#: src/components/player/MediaPlayer.tsx:299 #: src/components/player/MediaPlayer.tsx:299
msgid "Mute" msgid "Mute"
msgstr "Couper le son" msgstr "Couper le son"
@@ -1229,6 +1237,7 @@ msgstr "Nom"
#: src/components/chat/ChatModal.tsx:692 #: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58 #: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/pages/Settings.tsx:55 #: src/pages/Settings.tsx:55
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
@@ -1285,7 +1294,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
msgid "No emoji found." msgid "No emoji found."
msgstr "Aucun emoji trouvé." msgstr "Aucun emoji trouvé."
#: src/components/profile/ProfileFollowTab.tsx:117 #: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies." msgstr "Pas encore de collections suivies."
@@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »." msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51 #: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178 #: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:126 #: src/pages/Playlists.tsx:151
msgid "No playlists yet." msgid "No playlists yet."
msgstr "Pas encore de collections." 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" 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é" 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" msgid "Other instances"
msgstr "Autres instances" msgstr "Autres instances"
#: src/pages/UserPublicProfile.tsx:159 #: src/pages/UserPublicProfile.tsx:163
msgid "Overview" msgid "Overview"
msgstr "Aperçu" msgstr "Aperçu"
@@ -1469,12 +1478,12 @@ msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169 #: src/components/layout/AppHeader.tsx:169
#: src/components/profile/ProfileFollowTab.tsx:148 #: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:149 #: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:53 #: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:114 #: src/pages/Playlists.tsx:132
#: src/pages/Search.tsx:214 #: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:162 #: src/pages/UserPublicProfile.tsx:166
msgid "Playlists" msgid "Playlists"
msgstr "Collections" msgstr "Collections"
@@ -1498,10 +1507,10 @@ msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145 #: 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/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:561 #: src/pages/Dump.tsx:582
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -1509,7 +1518,7 @@ msgstr "privé"
msgid "Private" msgid "Private"
msgstr "Privé" msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:110 #: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:77
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1544,7 +1553,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:613 #: src/pages/Dump.tsx:634
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -1631,7 +1640,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe" msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:372 #: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
@@ -1646,7 +1655,7 @@ msgstr "Rôle"
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75 #: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:487 #: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
@@ -1656,7 +1665,7 @@ msgstr "Enregistrer"
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72 #: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:486 #: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "Enregistrement…" 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/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" 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." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." 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." msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée." msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1971,6 +1980,14 @@ msgstr "Se désabonner"
msgid "Unsubscribed" msgid "Unsubscribed"
msgstr "Désabonné" 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 #. js-lingui-explicit-id
#: api/mail/digest.ts:237 #: api/mail/digest.ts:237
msgid "email.digest.upload" msgid "email.digest.upload"
@@ -1998,7 +2015,7 @@ msgid "Upvote"
msgstr "Valider" msgstr "Valider"
#: src/components/profile/ProfileDumpsTab.tsx:46 #: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161 #: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted" msgid "Upvoted"
msgstr "Validé" msgstr "Validé"
@@ -2039,7 +2056,7 @@ msgstr "Menu utilisateur"
msgid "Username" msgid "Username"
msgstr "Nom d'utilisateur" msgstr "Nom d'utilisateur"
#: src/components/profile/ProfileFollowTab.tsx:147 #: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213 #: src/pages/Search.tsx:213
msgid "Users" msgid "Users"
msgstr "Utilisateurs" 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" msgid "email.welcome.reason"
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse." 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" msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé" 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` * 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 * 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 * 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` * 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 * wraps the dump and playlist ones), the emails prefix them with the
@@ -19,6 +21,7 @@
*/ */
import type { CategoryKind } from "./category.ts"; import type { CategoryKind } from "./category.ts";
import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts"; import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.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 * 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 { export function playlistsPath(
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists"; 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"; 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 * Returned by the playlist routes, and by the search, users and dumps routes
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket * 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. * is undefined when the query didn't count them.
*/ */
dumpCount?: number; 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. */ /** The owner's current name, which the playlist's path starts with. */
ownerUsername: string; ownerUsername: string;
/** /**
@@ -53,6 +79,40 @@ export interface Playlist {
categoryIds?: string[]; 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 * Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in * by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in

View File

@@ -4,6 +4,8 @@ import {
feedPath, feedPath,
parsePagePath, parsePagePath,
playlistPath, playlistPath,
playlistSortQuery,
playlistsPath,
searchPath, searchPath,
userPath, userPath,
} from "../paths.ts"; } from "../paths.ts";
@@ -37,6 +39,19 @@ Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(searchPath(), "/search"); assertEquals(searchPath(), "/search");
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b"); assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
assertEquals(searchPath("dumps", "x"), "/search?q=x"); 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", () => { 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, * 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 * else to its owner's, as when it has just gone private. Its `dumpCount` and
* the socket's user's, as in `playlist_created`. * `lastDumpAt` are the socket's user's, as in `playlist_created`.
*/ */
export interface PlaylistUpdatedMessage { export interface PlaylistUpdatedMessage {
type: "playlist_updated"; type: "playlist_updated";
@@ -342,6 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
* private dump is left out. * private dump is left out.
*/ */
dumpIds: string[]; 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; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; 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 ── */ /* ── Profile (own) page ── */
@@ -2761,11 +2769,9 @@ body.has-player .chat-fab {
padding: 1rem; padding: 1rem;
} }
/* A list of dump or playlist cards in a group, or right under a page's header /* A list of dump or playlist cards in a group fills the column, its edges in
as on /playlists, fills the column, its edges in line with the label's rule line with the label's rule. */
or the header. */ .page-group .dump-feed {
.page-group .dump-feed,
.page-content--narrow > .dump-feed {
padding: 0; padding: 0;
max-width: none; max-width: none;
} }
@@ -4064,9 +4070,8 @@ body.has-player .chat-fab {
0.25); 0.25);
} }
/* ── Shared card preview icon ── */ /* ── Dump card preview icon ── */
.dump-card-preview-icon, .dump-card-preview-icon {
.playlist-card-icon {
font-size: var(--text-3xl); font-size: var(--text-3xl);
opacity: 0.7; opacity: 0.7;
line-height: 1; 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 { .dump-card-vote {
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
@@ -5723,7 +5804,7 @@ body.has-player .chat-fab {
align-self: center; align-self: center;
} }
.collections-section .playlist-card-icon { .collections-section .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-lg); font-size: var(--text-lg);
} }

View File

@@ -23,7 +23,7 @@
* - `/community` and `/community/monthly`, the site's week and month * - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only. * (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a * - `/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, * - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`, * their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug` * signed in only), and their playlists at `playlists/:slug`

View File

@@ -17,14 +17,15 @@ export interface PageHeaderProps {
title: ReactNode; title: ReactNode;
/** /**
* It goes at the end of the row, such as the notifications' unread count or * 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; children?: ReactNode;
} }
/** /**
* Renders the page's h1 with its icon, then `children`, as the first block of * 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( export function PageHeader(
{ icon, title, children }: PageHeaderProps, { icon, title, children }: PageHeaderProps,

View File

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

View File

@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
onDelete?: (playlist: Playlist) => void; onDelete?: (playlist: Playlist) => void;
/** Passed to each {@link PlaylistCard}. */ /** Passed to each {@link PlaylistCard}. */
privateBadgeOnly?: boolean; 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 * Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
* its owner's when the playlist is the signed-in user's. * 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( export function PlaylistCardList(
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps, { playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
): React.JSX.Element { ): React.JSX.Element {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<ul className="dump-feed"> <ul className={grid ? "playlist-grid" : "dump-feed"}>
{playlists.map((p) => ( {playlists.map((p) => (
<PlaylistCard <PlaylistCard
key={p.id} 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( usePlaylistListSync(
state.status === "loaded" ? state.data.items : [], state.status === "loaded" ? state.data.items : [],
setItems, setItems,
{ noNewEntries: true },
); );
return ( return (
<ListState <ListState

View File

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

View File

@@ -50,6 +50,11 @@ export interface PlaylistEvent {
* order. * order.
*/ */
dumpIds?: string[]; 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": { case "playlist_dumps_updated": {
const { playlistId, dumpIds } = msg; const { playlistId, dumpIds, lastDumpAt } = msg;
events.emit("playlist", { events.emit("playlist", {
type: "dumps_updated", type: "dumps_updated",
playlistId, playlistId,
dumpIds, dumpIds,
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
}); });
break; break;
} }

View File

@@ -1,90 +1,21 @@
/** /**
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's * Makes a dump's card open the dump as a link would, and mark it visited.
* behaviour: a middle or modified click opens the dump in a new tab.
* *
* @module * @module
*/ */
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import { dumpUrl } from "../../utils/shared/urls.ts"; import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../../utils/shared/visited.ts"; import { markDumpVisited } from "../../utils/shared/visited.ts";
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.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;
}
/** /**
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every * Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
* way it opens the dump marks it visited, which clears its unread dot. * `JournalCard`: every way they open 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.
*/ */
export function useDumpCardLink( export function useDumpCardLink(
dump: Pick<Dump, "id" | "slug" | "ownerUsername">, dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
): UseDumpCardLinkResult { ): UseCardLinkResult {
const navigate = useNavigate(); return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
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();
},
},
};
} }

View File

@@ -7,10 +7,14 @@
*/ */
import { useEffect, useLayoutEffect, useRef } from "react"; 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 { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.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. */ /** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions { export interface PlaylistListSyncOptions {
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
*/ */
ownerId?: string; ownerId?: string;
/** /**
* Adds no new playlist, for a list of followed playlists, since the socket * The order the list is in, for one the API sorts: the playlists page's and
* doesn't say what the user follows, or of search results. Playlists * a profile's. A list in an order of its own leaves it out, the followed
* already listed still change, go, and come back where they were. * 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 * Applies the socket's playlist events to `items`, the loaded list, which
* first, which `setPlaylists` updates. A created playlist goes where its date * `setPlaylists` updates. A created playlist goes where the list's order puts
* puts it, first when new, an updated one changes in place or leaves when it * it, an updated one changes in place or leaves when it turns private (unless
* turns private (unless `isOwner`), one that left comes back where it was * `isOwner`), one that left comes back where it was when it returns, a
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows * deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
* its dumps. A renamed user's playlists take their new name. * 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( export function usePlaylistListSync(
items: Playlist[], items: Playlist[],
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
}); });
useWSEvent("playlist", (ev) => { 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 && const shown = ev.playlist &&
(ev.playlist.isPublic || isOwner) && (ev.playlist.isPublic || isOwner) &&
(!ownerId || ev.playlist.userId === ownerId); (!ownerId || ev.playlist.userId === ownerId);
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
const idx = prev.findIndex((p) => p.id === playlist.id); const idx = prev.findIndex((p) => p.id === playlist.id);
if (idx !== -1) { if (idx !== -1) {
if (!shown) return prev.filter((p) => p.id !== playlist.id); if (!shown) return prev.filter((p) => p.id !== playlist.id);
const next = [...prev]; return risen(prev.with(idx, playlist), idx);
next[idx] = playlist;
return next;
} }
if (!shown) return prev; if (!shown) return prev;
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx); if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold. // An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev; if (ev.type === "updated" || !compare) return prev;
return withByDate(prev, playlist); return withInOrder(prev, playlist, compare);
} }
if (ev.type === "deleted") { if (ev.type === "deleted") {
@@ -84,10 +100,13 @@ export function usePlaylistListSync(
if (ev.type === "dumps_updated" && ev.dumpIds) { if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId); const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev; if (idx === -1) return prev;
const next = [...prev];
// The dumps this user may see, which are those the playlist counts. // The dumps this user may see, which are those the playlist counts.
next[idx] = { ...next[idx], dumpCount: ev.dumpIds.length }; const playlist = {
return next; ...prev[idx],
dumpCount: ev.dumpIds.length,
lastDumpAt: ev.lastDumpAt,
};
return risen(prev.with(idx, playlist), idx);
} }
return prev; 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 * Renders every public playlist, `/playlists`, and those of one playlist
* category, `/playlists/:categorySlug`, newest first. * category, `/playlists/:categorySlug`, in the order `?sort=` names.
* *
* @module * @module
*/ */
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import type { Playlist } from "../../shared/playlist.ts"; import {
import { playlistsPath } from "../../shared/paths.ts"; 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 { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
import { import {
deserializePlaylist, deserializePlaylist,
@@ -34,18 +38,21 @@ import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx";
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ListState } from "../components/shared/ListState.tsx"; import { ListState } from "../components/shared/ListState.tsx";
import { NotFound } from "./NotFound.tsx"; import { NotFound } from "./NotFound.tsx";
/** /**
* Renders the list for the route's category, or every public playlist * Renders the list for the route's category, or every public playlist
* without one. Each category's list is another, which the page mounts * without one, in the order of the URL's `?sort=`. Each category's list in
* afresh. A slug that names no playlist category, once the categories have * each order is another, which the page mounts afresh. A slug that names no
* loaded, is a page not found. * playlist category, once the categories have loaded, is a page not found.
*/ */
export function Playlists(): React.JSX.Element { export function Playlists(): React.JSX.Element {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
const [params] = useSearchParams();
const sort = parsePlaylistSort(params.get("sort"));
const { categories, isLoaded } = useCategories("playlist"); const { categories, isLoaded } = useCategories("playlist");
const category = categorySlug const category = categorySlug
? categories.find((c) => c.slug === categorySlug) ? categories.find((c) => c.slug === categorySlug)
@@ -55,31 +62,41 @@ export function Playlists(): React.JSX.Element {
if (categorySlug && isLoaded && !category) return <NotFound />; if (categorySlug && isLoaded && !category) return <NotFound />;
return ( return (
<PlaylistsList <PlaylistsList
key={categorySlug ?? ""} key={`${categorySlug ?? ""}:${sort}`}
categorySlug={categorySlug} categorySlug={categorySlug}
categoryId={category?.id} categoryId={category?.id}
sort={sort}
/> />
); );
} }
/** /**
* Renders the public playlists, from `GET /api/playlists`, a page at a time * Renders the public playlists, from `GET /api/playlists`, as a grid of
* as the list scrolls; with `categorySlug`, only those in that playlist * covers, a page at a time as the list scrolls; with `categorySlug`, only
* category, which the menu in the title row switches. The socket keeps them * those in that playlist category, which the menu before the tabs switches,
* live, a reconnection fetches the pages loaded again, and going back * keeping the order, while a tab changes the order in place of the current
* restores the list and the scroll. * 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( function PlaylistsList(
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string }, { categorySlug, categoryId, sort }: {
categorySlug?: string;
categoryId?: string;
sort: PlaylistSort;
},
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, authFetch } = useAuth(); const { user, authFetch } = useAuth();
const { cached, saveState } = useFeedCache<Playlist>( const { cached, saveState } = useFeedCache<Playlist>(
`feed:playlists:${categorySlug ?? "all"}`, `feed:playlists:${categorySlug ?? "all"}:${sort}`,
hydratePlaylist, hydratePlaylist,
); );
const url = `${API_URL}/api/playlists${ const url = `${API_URL}/api/playlists${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`; }`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList< const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist Playlist
@@ -97,6 +114,7 @@ function PlaylistsList(
usePlaylistListSync( usePlaylistListSync(
loaded?.items ?? [], loaded?.items ?? [],
useCategoryFilter(setAllItems, categoryId), useCategoryFilter(setAllItems, categoryId),
{ sort },
); );
useOnReconnect(resync); useOnReconnect(resync);
@@ -110,12 +128,19 @@ function PlaylistsList(
useScrollRestore(cached?.scrollY, !!loaded); useScrollRestore(cached?.scrollY, !!loaded);
return ( return (
<PageShell narrow> <PageShell>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}> <PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<CategorySwitcher <PlaylistSortTabs
kind="playlist" sort={sort}
categorySlug={categorySlug} onChange={(s) =>
onSelect={(slug) => navigate(playlistsPath(slug))} navigate(playlistsPath(categorySlug, s), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug, sort))}
/>
}
/> />
</PageHeader> </PageHeader>
<ListState <ListState
@@ -129,7 +154,7 @@ function PlaylistsList(
> >
{({ data }) => ( {({ data }) => (
<> <>
<PlaylistCardList playlists={data.items} /> <PlaylistCardList playlists={data.items} grid privateBadgeOnly />
<FeedFooter <FeedFooter
hasMore={data.hasMore} hasMore={data.hasMore}
loadingMore={data.loadingMore} loadingMore={data.loadingMore}

View File

@@ -166,7 +166,7 @@ export function Search(): React.JSX.Element {
setData((d) => ({ ...d, playlists: fn(d.playlists) })), setData((d) => ({ ...d, playlists: fn(d.playlists) })),
[setData], [setData],
), ),
{ isOwner: true, noNewEntries: true }, { isOwner: true },
); );
// What changed while the socket was down: every page of dumps loaded is // 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"; const OVERVIEW = "overview";
type Tab = ProfileTab | typeof OVERVIEW; type Tab = ProfileTab | typeof OVERVIEW;
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS]; 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 * Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
@@ -168,7 +172,12 @@ export function UserPublicProfile(): React.JSX.Element {
]} ]}
activeTab={tab} activeTab={tab}
onChange={(key) => onChange={(key) =>
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")} setTab(
key,
DUMP_LISTS.includes(key) && DUMP_LISTS.includes(tab)
? undefined
: "",
)}
className="profile-tabs-scroller" className="profile-tabs-scroller"
innerClassName="profile-tabs" innerClassName="profile-tabs"
/> />

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest"; 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 { userEvent } from "@testing-library/user-event";
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx"; import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
import { makePlaylist } from "../../fixtures.ts"; import { makePlaylist } from "../../fixtures.ts";
@@ -38,6 +38,50 @@ describe("PlaylistCard", () => {
expect(currentPath()).toBe("/@ann/playlists/mix"); 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", () => { test("the description shows as text, without its Markdown or its images", () => {
const withText = renderWithContexts( const withText = renderWithContexts(
<ul> <ul>

View File

@@ -26,7 +26,9 @@ describe("usePlaylistListSync", () => {
} }
test("prepends a created playlist once", () => { 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" }); const p = makePlaylist({ id: "b" });
send("created", p); send("created", p);
send("created", { ...p }); send("created", { ...p });
@@ -36,28 +38,83 @@ describe("usePlaylistListSync", () => {
test("a playlist made public goes where its date puts it", () => { test("a playlist made public goes where its date puts it", () => {
const at = (id: string, day: number) => const at = (id: string, day: number) =>
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) }); 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)); send("created", at("b", 20));
// Older than every one loaded: a later page holds it. // Older than every one loaded: a later page holds it.
send("created", at("old", 1)); send("created", at("old", 1));
expect(ids(items())).toEqual(["c", "b", "a"]); 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 p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" }); const byOwner = setup([], { ownerId: "me", sort: "new" });
byOwner.send("created", p); byOwner.send("created", p);
expect(byOwner.items()).toEqual([]); expect(byOwner.items()).toEqual([]);
const priv = setup([]); const priv = setup([], { sort: "new" });
priv.send("created", { ...p, isPublic: false }); priv.send("created", { ...p, isPublic: false });
expect(priv.items()).toEqual([]); 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); followed.send("created", p);
expect(followed.items()).toEqual([]); 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", () => { test("updated: in place, removed when private, never added by itself", () => {
const p = makePlaylist({ id: "p", title: "one" }); const p = makePlaylist({ id: "p", title: "one" });
const other = makePlaylist({ id: "q" }); 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", () => { test("one that left comes back where it was, even to a followed list", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id })); 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. // Gone private for others, as a deletion.
set({ deletedPlaylistIds: new Set(["b"]) }); set({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(items())).toEqual(["a", "c"]); expect(ids(items())).toEqual(["a", "c"]);

View File

@@ -85,6 +85,61 @@ describe("Playlists", () => {
expect(screen.queryByText("Filed")).toBeNull(); 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 () => { test("a slug that names no playlist category is not found", async () => {
route("GET /api/playlists", () => page([])); route("GET /api/playlists", () => page([]));
open("/playlists/music"); open("/playlists/music");

View File

@@ -292,6 +292,42 @@ describe("UserPublicProfile", () => {
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument(); 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 () => { test("the dumps tab loads more as its end scrolls into view", async () => {
open({ url: "/@ann/dumps" }); open({ url: "/@ann/dumps" });
// After open's, so that it wins. // After open's, so that it wins.

View File

@@ -478,7 +478,6 @@
[data-style="nyt"] .notifications-empty-icon, [data-style="nyt"] .notifications-empty-icon,
[data-style="nyt"] .page-title-icon, [data-style="nyt"] .page-title-icon,
[data-style="nyt"] .dump-card-preview-icon, [data-style="nyt"] .dump-card-preview-icon,
[data-style="nyt"] .playlist-card-icon,
[data-style="nyt"] .rich-content-compact-icon, [data-style="nyt"] .rich-content-compact-icon,
[data-style="nyt"] .journal-card-glyph, [data-style="nyt"] .journal-card-glyph,
[data-style="nyt"] .fdz__file-icon, [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 * Returns `items`, in the order of `compare` as the API pages them, with
* `createdAt` puts it: before the first item no newer than it. An item older * `item` where that order puts it: before the first item that doesn't come
* than every one loaded is left to a later page, and `items` returned as they * before it. An item that comes after every one loaded is left to a later
* are, as they are when `item` is already there. The list sync hooks put a * page, and `items` returned as they are, as they are when `item` is already
* new or newly visible dump or playlist in a list with it. * 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 }>( export function withByDate<T extends { id: string; createdAt: Date }>(
items: T[], items: T[],
item: T, item: T,
): T[] { ): T[] {
if (items.some((i) => i.id === item.id)) return items; return withInOrder(
if (items.length === 0) return [item]; items,
const time = item.createdAt.getTime(); item,
const idx = items.findIndex((i) => i.createdAt.getTime() <= time); (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
if (idx === -1) return items; );
return [...items.slice(0, idx), item, ...items.slice(idx)];
} }
/** /**

View File

@@ -95,7 +95,10 @@ export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
/** Returns `raw` as a {@link Playlist}. */ /** Returns `raw` as a {@link Playlist}. */
export function deserializePlaylist(raw: Wire<Playlist>): 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( export function deserializePlaylistWithDumps(
raw: Wire<PlaylistWithDumps>, raw: Wire<PlaylistWithDumps>,
): PlaylistWithDumps { ): PlaylistWithDumps {
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) }; return {
...deserializePlaylist(raw),
dumps: raw.dumps.map(deserializeDump),
};
} }
/** Returns `raw` as a {@link PlaylistMembership}. */ /** Returns `raw` as a {@link PlaylistMembership}. */