From 561cdf98b0d53089981f478137b7745b384ffbc3 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 10 Oct 2026 08:51:37 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20playlists=20sort=20by=20their=20last=20du?= =?UTF-8?q?mp,=20their=20creation=20or=20their=20size,=20and=20/playlists?= =?UTF-8?q?=20is=20a=20grid=20of=20covers=20=E2=80=94=20every=20list=20of?= =?UTF-8?q?=20playlists=20came=20newest=20first,=20so=20one=20fed=20every?= =?UTF-8?q?=20week=20sank=20under=20those=20made=20since=20and=20left=20em?= =?UTF-8?q?pty.=20GET=20/api/playlists=20and=20/api/users/:key/playlists?= =?UTF-8?q?=20take=20=3Fsort=3Dupdated|new|dumps=20(PLAYLIST=5FSORTS,=20sh?= =?UTF-8?q?ared/playlist.ts),=20updated=20being=20the=20default:=20the=20l?= =?UTF-8?q?ast=20to=20take=20a=20dump=20first,=20or=20its=20creation=20whi?= =?UTF-8?q?le=20it=20holds=20none;=20a=20new=20title,=20cover=20or=20descr?= =?UTF-8?q?iption=20doesn't=20bring=20it=20back=20up.=20A=20playlist=20car?= =?UTF-8?q?ries=20that=20date=20as=20lastDumpAt,=20read=20with=20its=20dum?= =?UTF-8?q?pCount=20by=20isMemberVisibleTo's=20rule=20(VISIBLE=5FDUMP=5FFI?= =?UTF-8?q?GURES,=20the=20viewer's=20id=20as=20=3F1),=20so=20the=20owner's?= =?UTF-8?q?=20private=20dumps=20date=20and=20count=20their=20playlists=20f?= =?UTF-8?q?or=20them=20alone,=20in=20the=20lists=20and=20over=20the=20sock?= =?UTF-8?q?et:=20playlist=5Fcreated,=20playlist=5Fupdated=20and=20playlist?= =?UTF-8?q?=5Fdumps=5Fupdated=20carry=20each=20socket's=20own.=20The=20/pl?= =?UTF-8?q?aylists=20page=20and=20a=20profile's=20playlists=20tab=20show?= =?UTF-8?q?=20the=20orders=20as=20tabs=20beside=20the=20category=20menu=20?= =?UTF-8?q?and=20keep=20the=20one=20picked=20in=20=3Fsort=3D,=20the=20defa?= =?UTF-8?q?ult=20being=20the=20bare=20path;=20a=20tab=20takes=20the=20curr?= =?UTF-8?q?ent=20history=20entry's=20place,=20and=20picking=20a=20category?= =?UTF-8?q?=20keeps=20the=20order.=20Live,=20usePlaylistListSync=20takes?= =?UTF-8?q?=20the=20list's=20order:=20a=20new=20playlist=20goes=20where=20?= =?UTF-8?q?it=20puts=20it,=20one=20that=20rises=20moves=20up=20to=20its=20?= =?UTF-8?q?place,=20as=20in=20the=20updated=20order=20when=20it=20takes=20?= =?UTF-8?q?a=20dump,=20and=20one=20that=20falls=20stays=20until=20the=20li?= =?UTF-8?q?st=20is=20fetched=20again,=20its=20place=20being=20perhaps=20on?= =?UTF-8?q?=20a=20page=20not=20loaded.=20The=20lists=20in=20an=20order=20o?= =?UTF-8?q?f=20their=20own,=20the=20followed=20playlists=20and=20the=20sea?= =?UTF-8?q?rch's,=20pass=20none,=20and=20take=20no=20new=20playlist=20as?= =?UTF-8?q?=20before=20(noNewEntries=20goes).=20comparePlaylists=20is=20th?= =?UTF-8?q?e=20order=20on=20the=20client,=20which=20a=20test=20holds=20to?= =?UTF-8?q?=20the=20API's.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- API.md | 19 +-- api/lib/playlist-members.ts | 53 +++++--- api/model/playlist.ts | 9 +- api/routes/playlists.ts | 15 ++- api/routes/users.ts | 22 +++- api/services/follow-service.ts | 4 +- api/services/playlist-service.ts | 72 +++++++---- api/services/ws-service.ts | 19 ++- api/test/routes/playlists.test.ts | 119 +++++++++++++++++- api/test/routes/ws.test.ts | 41 ++++++ locales/en.mjs | 2 +- locales/en.po | 115 +++++++++-------- locales/fr.mjs | 2 +- locales/fr.po | 115 +++++++++-------- shared/paths.ts | 32 ++++- shared/playlist.ts | 60 +++++++++ shared/test/paths.test.ts | 15 +++ shared/ws.ts | 9 +- src/App.css | 99 +++++++++++++-- src/App.tsx | 2 +- src/components/layout/PageHeader.tsx | 5 +- src/components/playlist/PlaylistCard.tsx | 48 ++++--- src/components/playlist/PlaylistCardList.tsx | 14 ++- src/components/playlist/PlaylistSortTabs.tsx | 43 +++++++ src/components/profile/ProfileFollowTab.tsx | 1 - .../profile/ProfilePlaylistsTab.tsx | 63 ++++++---- src/contexts/ws/WSContext.ts | 5 + src/contexts/ws/WSProvider.tsx | 3 +- src/hooks/dump/useDumpCardLink.ts | 83 ++---------- src/hooks/playlist/usePlaylistListSync.ts | 61 +++++---- src/hooks/shared/useCardLink.ts | 91 ++++++++++++++ src/pages/Playlists.tsx | 69 ++++++---- src/pages/Search.tsx | 2 +- src/pages/UserPublicProfile.tsx | 15 ++- .../components/playlist/PlaylistCard.test.tsx | 46 ++++++- .../playlist/usePlaylistListSync.test.tsx | 71 +++++++++-- src/test/pages/Playlists.test.tsx | 55 ++++++++ src/test/pages/UserPublicProfile.test.tsx | 36 ++++++ src/themes/nyt.css | 1 - src/utils/feed/pagedList.ts | 39 ++++-- src/utils/shared/deserialize.ts | 10 +- 41 files changed, 1198 insertions(+), 387 deletions(-) create mode 100644 src/components/playlist/PlaylistSortTabs.tsx create mode 100644 src/hooks/shared/useCardLink.ts diff --git a/API.md b/API.md index 9dd015eb..50caec9b 100644 --- a/API.md +++ b/API.md @@ -15,9 +15,10 @@ default view is the bare path, with no redirect. A category is a segment where the page is the category's own, a dump category's feed at the root (`/music`) and a playlist category's under `/playlists` (`/playlists/mixes`); where it narrows a list on another page, a profile's or a search's, it is `?category=`, -of the kind that list holds. Switching tabs, or a tab's views, takes the place -of the current history entry; picking a category, or opening another page or an -editor, adds one. +of the kind that list holds. The order of a list of playlists is `?sort=`, the +default order being the bare path. Switching tabs, a tab's views or a list's +order takes the place of the current history entry; picking a category, or +opening another page or an editor, adds one. **API.** A resource is `/api//[/]`. Its key is its id, except a user's, which may also be `@name`. The `@` keeps the two apart, so no regex @@ -33,14 +34,14 @@ has one word, the same as in pages. | `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` | | `/` | `//hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` | | `/@` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` | -| `/@/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists | anyone | `UserPublicProfile` | +| `/@/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` | | `/@/follow` | `/@/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` | | `/@/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` | | `/@/dumps/` | | anyone; a private dump, its poster | `Dump` | | `/@/dumps//edit` | | its poster, `dump:moderate` | `DumpEdit` | | `/@/playlists/` | | anyone; a private playlist, its owner | `PlaylistDetail` | | `/@/playlists//edit` | | its owner, `playlist:moderate` | `PlaylistDetail` | -| `/playlists` | `/playlists/`, a playlist category's | anyone | `Playlists` | +| `/playlists` | `/playlists/`, a playlist category's; `?sort=new`, `?sort=dumps` order either | anyone | `Playlists` | | `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` | | `/notifications` | | signed in | `Notifications` | | `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` | @@ -135,7 +136,7 @@ there: those endpoints answer it 400. - `GET /api/users/:key/avatar` — anyone. The avatar. - `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items to their owner only. Their lists; `?category=` narrows one to a category of - its kind. + its kind, and `?sort=` orders the playlists as `GET /api/playlists` does. - `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items to their owner only. An item by its page path, by an alias too. - `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they @@ -174,8 +175,10 @@ there: those endpoints answer it 400. - `POST /api/playlists` — signed in. Creates a playlist, filed under the playlist categories it names. -- `GET /api/playlists` — anyone. Every public playlist, newest first; - `?category=` narrows it to a playlist category. +- `GET /api/playlists` — anyone. Every public 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. - `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title, slug, description, visibility, categories; deletion. diff --git a/api/lib/playlist-members.ts b/api/lib/playlist-members.ts index 595d4c25..4abb9487 100644 --- a/api/lib/playlist-members.ts +++ b/api/lib/playlist-members.ts @@ -37,26 +37,51 @@ export function isMemberVisibleTo( } /** - * The `dump_count` column of a query on `playlists p`: how many of the - * playlist's dumps the viewer may see, by {@link isMemberVisibleTo}'s rule. - * Its `?` takes the viewer's id, or `""` for a signed-out one, and comes - * before the query's other parameters, the column being in its select list. + * The fields of a playlist's dump that {@link dumpFiguresFor} needs: those + * that tell who may see it, and when it was added. The playlist service reads + * them for its broadcasts. */ -export const VISIBLE_DUMP_COUNT = `(SELECT COUNT(*) FROM playlist_dumps pd +export interface DatedPlaylistMember extends PlaylistMember { + addedAt: Date; +} + +/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */ +const visibleDumps = (figure: string) => + `(SELECT ${figure} FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id WHERE pd.playlist_id = p.id - AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?)) - ) as dump_count`; + AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1)) + )`; /** - * Returns how many of `members` the viewer may see in `playlist`, the - * `dumpCount` the playlist has for them. + * The `dump_count` and `last_dump_at` columns of a query on `playlists p`: + * how many of the playlist's dumps the viewer may see, by + * {@link isMemberVisibleTo}'s rule, and when the latest of them was added, + * null without any. Both read the viewer's id, or `""` for a signed-out one, + * as `?1`: it is the query's first parameter, and each `?` after it takes the + * next. */ -export function countVisibleTo( - members: PlaylistMember[], +export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count, + ${visibleDumps("MAX(pd.added_at)")} as last_dump_at`; + +/** + * Returns what `playlist` says of its dumps to the viewer, `members` being + * all of them: the `dumpCount` and `lastDumpAt` that + * {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts + * them on the playlist each socket is sent. + */ +export function dumpFiguresFor( + members: DatedPlaylistMember[], playlist: Pick, viewerId: string | null | undefined, -): number { - return members.filter((m) => isMemberVisibleTo(m, playlist, viewerId)) - .length; +): Required> & Pick { + const visible = members.filter((m) => + isMemberVisibleTo(m, playlist, viewerId) + ); + return { + dumpCount: visible.length, + lastDumpAt: visible.length + ? new Date(Math.max(...visible.map((m) => m.addedAt.getTime()))) + : undefined, + }; } diff --git a/api/model/playlist.ts b/api/model/playlist.ts index 1a768048..d3ce9455 100644 --- a/api/model/playlist.ts +++ b/api/model/playlist.ts @@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts"; /** * Returned by the playlist and follow services' `SELECT`s on `playlists`, - * which join the owner's name as `owner_username`. A query may add a - * `dump_count` column, which {@link playlistRowToApi} carries over when - * present. + * which join the owner's name as `owner_username`. A query may add the + * `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which + * {@link playlistRowToApi} carries over when present. */ export interface PlaylistRow { id: string; @@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist { updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, imageMime: row.image_mime ?? undefined, dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, + lastDumpAt: typeof row.last_dump_at === "string" + ? new Date(row.last_dump_at) + : undefined, ownerUsername: row.owner_username, }; } diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index e120e15b..c2d076c7 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -3,9 +3,11 @@ * playlist's id; its page reads it by its path, through * `GET /api/users/:key/playlists/:slug`. * - * - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of - * every public playlist, newest first, or only those in the playlist - * category at `category`, which 404s when there is none. + * - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a + * page of every public playlist, in the order `sort` names + * (`PLAYLIST_SORTS`, the last to take a dump first by default), or only + * those in the playlist category at `category`, which 404s when there is + * none. * - `POST /api/playlists` — signed in. Creates a playlist, filed under the * playlist categories it names. * - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist @@ -44,7 +46,11 @@ import { isReorderPlaylistRequest, isUpdatePlaylistRequest, } from "../validators/playlist.ts"; -import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts"; +import { + parsePlaylistSort, + type Playlist, + type PlaylistWithDumps, +} from "../../shared/playlist.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { @@ -87,6 +93,7 @@ router.get("/", async (ctx) => { requestingUserId, page, limit, + parsePlaylistSort(params.get("sort")), categoryId, ); const responseBody: APIResponse> = { diff --git a/api/routes/users.ts b/api/routes/users.ts index c66b2695..735975e3 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -19,9 +19,10 @@ * - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in. * Answers a page of the dumps they upvoted, private ones only to * themselves, optionally in one category. - * - `GET /api/users/:key/playlists?page=&limit=&category=` — optional + * - `GET /api/users/:key/playlists?page=&limit=&category=&sort=` — optional * sign-in. Answers a page of the playlists they made, the private ones only - * to themselves, optionally in one playlist category. + * to themselves, optionally in one playlist category, in the order `sort` + * names, as `GET /api/playlists` does. * - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their * playlist whose slug is or was `slug`, with its current slug and the dumps * the caller may see; a private one 404s but for its owner. @@ -56,7 +57,11 @@ import { type UserSuggestion, } from "../../shared/user.ts"; import type { Dump } from "../../shared/dump.ts"; -import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts"; +import { + parsePlaylistSort, + type Playlist, + type PlaylistWithDumps, +} from "../../shared/playlist.ts"; import { fieldOf, isRegisterUserRequest, @@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => { const categoryId = resolveCategoryFilter("playlist", params); const { items, hasMore } = categoryId === "missing" ? { items: [], hasMore: false } - : listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId); + : listPlaylistsByUser( + user.id, + requestingUserId, + page, + limit, + parsePlaylistSort(params.get("sort")), + categoryId, + ); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, @@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => { const responseBody: APIResponse = { success: true, data: getPlaylist( - getPlaylistByPath(user.id, ctx.params.slug), + getPlaylistByPath(user.id, ctx.params.slug, requestingUserId), requestingUserId, ), }; diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 6c418682..6c2f3ac0 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -23,7 +23,7 @@ import { } from "../model/dump.ts"; import { isFollowRow } from "../model/follow.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; -import { VISIBLE_DUMP_COUNT } from "../lib/playlist-members.ts"; +import { VISIBLE_DUMP_FIGURES } from "../lib/playlist-members.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds, categoryJoin } from "./category-service.ts"; import { sendToUser } from "./ws-service.ts"; @@ -248,7 +248,7 @@ export function getFollowedPlaylistsByUser( const rawRows = db.prepare( `SELECT p.*, u.username as owner_username, - ${VISIBLE_DUMP_COUNT} + ${VISIBLE_DUMP_FIGURES} FROM playlists p LEFT JOIN users u ON u.id = p.user_id INNER JOIN follows f ON f.followed_playlist_id = p.id diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index b352887b..bffa590d 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -12,6 +12,7 @@ import type { CreatePlaylistRequest, Playlist, PlaylistMembership, + PlaylistSort, PlaylistWithDumps, UpdatePlaylistRequest, } from "../../shared/playlist.ts"; @@ -42,25 +43,36 @@ import { setCategories, } from "./category-service.ts"; import { + type DatedPlaylistMember, isMemberVisibleTo, - type PlaylistMember, - VISIBLE_DUMP_COUNT, + VISIBLE_DUMP_FIGURES, } from "../lib/playlist-members.ts"; import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; /** * The select list and `FROM` clause of a playlist query, adding the owner's - * name and the count of the dumps the viewer may see. Callers append the - * `WHERE`, and pass the viewer's id first, as {@link viewerParam} gives it. + * name, the count of the dumps the viewer may see and when the latest was + * added. Callers append the `WHERE`, and pass the viewer's id first, as + * {@link viewerParam} gives it. */ const PLAYLIST_SELECT = `p.*, u.username as owner_username, - ${VISIBLE_DUMP_COUNT} + ${VISIBLE_DUMP_FIGURES} FROM playlists p JOIN users u ON u.id = p.user_id`; /** - * Returns what {@link VISIBLE_DUMP_COUNT}'s parameter takes for `viewerId`: a - * signed-out viewer matches no user. + * The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}: + * `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it. + */ +const PLAYLIST_ORDER: Record = { + updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC", + new: "p.created_at DESC", + dumps: "dump_count DESC, p.created_at DESC", +}; + +/** + * Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`: + * a signed-out viewer matches no user. */ function viewerParam(viewerId: string | null | undefined): string { return viewerId ?? ""; @@ -79,7 +91,8 @@ function playlistsOf(rows: unknown[]): Playlist[] { /** * Returns playlist `playlistId`, private or not. A caller that shows it to * someone must check `isPublic` first, as the OG middleware does. Its - * `dumpCount` is `viewerId`'s, or a signed-out viewer's without one. + * `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's + * without one. * * @throws {APIException} 404 when there is no such playlist. */ @@ -99,13 +112,18 @@ export function getPlaylistById( /** * Returns the playlist of `userId`'s whose slug is or was `slug`, private or - * not, or throws a 404, as {@link getPlaylistById} does. Reached by a former - * slug, it comes with its current one. + * not, or throws a 404, as {@link getPlaylistById} does, counting its dumps + * for `viewerId` likewise. Reached by a former slug, it comes with its current + * one. */ -export function getPlaylistByPath(userId: string, slug: string): Playlist { +export function getPlaylistByPath( + userId: string, + slug: string, + viewerId: string | null = null, +): Playlist { const playlistId = resolveSlug("playlist", userId, slug); if (!playlistId) throw new APIException(404, "Playlist not found"); - return getPlaylistById(playlistId); + return getPlaylistById(playlistId, viewerId); } /** @@ -157,8 +175,8 @@ export function createPlaylist( * Returns a playlist with the dumps `requestingUserId` may see in it, in * order, for `GET /api/playlists/:playlistId`, and for * `GET /api/users/:key/playlists/:slug` once it has found the playlist by its - * path. A null `requestingUserId` is a signed-out viewer. Its `dumpCount` is - * the number of those dumps. + * path, for the same viewer. A null `requestingUserId` is a signed-out viewer. + * Its `dumpCount` is the number of those dumps. * * @throws {APIException} 404 when there is no such playlist, or it is private * and not theirs. @@ -168,7 +186,7 @@ export function getPlaylist( requestingUserId: string | null, ): PlaylistWithDumps { const playlist = typeof playlistId === "string" - ? getPlaylistById(playlistId) + ? getPlaylistById(playlistId, requestingUserId) : playlistId; if (!playlist.isPublic && requestingUserId !== playlist.userId) { @@ -228,15 +246,17 @@ export function searchPlaylists( } /** - * Returns a page of every public playlist, newest first, for + * Returns a page of every public playlist, in the order `sort` names, for * `GET /api/playlists`, optionally only those in the playlist category * `categoryId`. The client's `/playlists` page lists them. Their dumps are - * counted for `requestingUserId`, null being a signed-out viewer. + * counted, and so ordered, for `requestingUserId`, null being a signed-out + * viewer. */ export function listPublicPlaylists( requestingUserId: string | null, page: number, limit: number, + sort: PlaylistSort, categoryId?: string, ): Page { const offset = (page - 1) * limit; @@ -244,7 +264,7 @@ export function listPublicPlaylists( const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 - ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`, + ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`, ).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset); return pageOf(playlistsOf(rows), limit); } @@ -259,13 +279,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] { const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? - ORDER BY dump_count DESC, p.created_at DESC;`, + ORDER BY ${PLAYLIST_ORDER.dumps};`, ).all(viewerParam(null), from.toISOString(), to.toISOString()); return playlistsOf(rows); } /** - * Returns a page of `userId`'s playlists, newest first, for + * Returns a page of `userId`'s playlists, in the order `sort` names, for * `GET /api/users/:key/playlists`, optionally only those in the playlist * category `categoryId`. The private ones are listed only when * `requestingUserId` is `userId`. @@ -275,6 +295,7 @@ export function listPlaylistsByUser( requestingUserId: string | null, page: number, limit: number, + sort: PlaylistSort, categoryId?: string, ): Page { const isOwner = requestingUserId === userId; @@ -285,7 +306,7 @@ export function listPlaylistsByUser( const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.user_id = ?${privacyFilter} - ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`, + ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`, ).all( viewerParam(requestingUserId), ...catParams, @@ -620,7 +641,7 @@ export function getPlaylistMembershipsForDump( ): PlaylistMembership[] { const rows = db.prepare( `SELECT p.*, u.username as owner_username, - ${VISIBLE_DUMP_COUNT}, + ${VISIBLE_DUMP_FIGURES}, EXISTS (SELECT 1 FROM playlist_dumps pd WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump FROM playlists p JOIN users u ON u.id = p.user_id @@ -690,18 +711,19 @@ export function getPlaylistImageInfo( * Returns a playlist's dumps in order, all of them, for the callers to filter * per viewer. */ -function getCurrentMembers(playlistId: string): PlaylistMember[] { +function getCurrentMembers(playlistId: string): DatedPlaylistMember[] { const rows = db.prepare( - `SELECT pd.dump_id, d.is_private, d.user_id + `SELECT pd.dump_id, pd.added_at, d.is_private, d.user_id FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`, ).all(playlistId) as Array< - { dump_id: string; is_private: number; user_id: string } + { dump_id: string; added_at: string; is_private: number; user_id: string } >; return rows.map((r) => ({ id: r.dump_id, isPrivate: Boolean(r.is_private), userId: r.user_id, + addedAt: new Date(r.added_at), })); } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 38a61792..a8afc1f6 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -18,9 +18,9 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser, Role } from "../../shared/user.ts"; import { - countVisibleTo, + type DatedPlaylistMember, + dumpFiguresFor, isMemberVisibleTo, - type PlaylistMember, } from "../lib/playlist-members.ts"; import type { Job } from "../lib/scheduler.ts"; @@ -298,21 +298,17 @@ export function broadcastCommentLikeUpdate( * {@link broadcastChange} says, for the playlist service after every write to * its details. A playlist turned private thus leaves the others' views without * their learning what it became. `members` are the dumps of `after`: each - * socket gets the playlist with the count of those its user may see, the - * owner's private dumps being theirs alone to count. + * socket gets the playlist with the count of those its user may see and the + * date of the latest, the owner's private dumps being theirs alone to count. */ export function broadcastPlaylistChange( before: Playlist | null, after: Playlist | null, - members: PlaylistMember[], + members: DatedPlaylistMember[], ): void { const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({ ...playlist, - dumpCount: countVisibleTo( - members, - playlist, - toOwner ? playlist.userId : null, - ), + ...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null), }); broadcastChange(before, after, (playlist) => !playlist.isPublic, { created: (playlist, toOwner) => ({ @@ -339,7 +335,7 @@ export function broadcastPlaylistChange( */ export function broadcastPlaylistDumpsUpdated( playlist: Pick, - members: PlaylistMember[], + members: DatedPlaylistMember[], ): void { for (const client of clients) { if (!playlist.isPublic && client.userId !== playlist.userId) continue; @@ -349,6 +345,7 @@ export function broadcastPlaylistDumpsUpdated( dumpIds: members .filter((m) => isMemberVisibleTo(m, playlist, client.userId)) .map((m) => m.id), + lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt, }); } } diff --git a/api/test/routes/playlists.test.ts b/api/test/routes/playlists.test.ts index 8bf36e26..036cc380 100644 --- a/api/test/routes/playlists.test.ts +++ b/api/test/routes/playlists.test.ts @@ -16,10 +16,12 @@ import { TEST_DIR, } from "../helpers.ts"; import type { PaginatedData } from "../../../shared/api.ts"; -import type { - Playlist, - PlaylistMembership, - PlaylistWithDumps, +import { + comparePlaylists, + type Playlist, + PLAYLIST_SORTS, + type PlaylistMembership, + type PlaylistWithDumps, } from "../../../shared/playlist.ts"; import { assert, assertEquals, assertMatch } from "@std/assert"; @@ -665,7 +667,7 @@ test("playlist categories: filed on creation, replaced on update, dump categorie assertEquals(cleared.data.categoryIds, []); }); -test("GET /api/playlists: every public playlist, newest first, by category", async () => { +test("GET /api/playlists: every public playlist, by category", async () => { const owner = await createUser(); const other = await createUser(); const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); @@ -729,3 +731,110 @@ test("GET /api/users/:key/playlists?category=: their playlists in one category", [], ); }); + +test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => { + const owner = await createUser(); + const made = (title: string, createdAt: string) => { + const { id } = insertPlaylist({ userId: owner.id, title }); + db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`) + .run(createdAt, id); + return id; + }; + const add = (playlistId: string, addedAt: string, isPrivate = false) => + db.prepare( + `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) + VALUES (?, ?, 0, ?);`, + ).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt); + const a = made("A", "2026-01-01T00:00:00.000Z"); + const b = made("B", "2026-02-01T00:00:00.000Z"); + const c = made("C", "2026-03-01T00:00:00.000Z"); + add(a, "2026-04-01T00:00:00.000Z"); + add(b, "2026-02-10T00:00:00.000Z"); + add(b, "2026-02-11T00:00:00.000Z"); + // Only the owner sees it: to anyone else, C is empty and as it was made. + add(c, "2026-05-01T00:00:00.000Z", true); + + type Auth = typeof owner.auth; + const list = async (query: string, auth?: Auth) => + (await get>(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(`/api/playlists/${c}`, { auth: owner.auth })) + .data.lastDumpAt as unknown, + "2026-05-01T00:00:00.000Z", + ); + assertEquals( + (await get(`/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>( + `/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, + ); + } +}); diff --git a/api/test/routes/ws.test.ts b/api/test/routes/ws.test.ts index 427e132d..177b7387 100644 --- a/api/test/routes/ws.test.ts +++ b/api/test/routes/ws.test.ts @@ -744,6 +744,47 @@ wsTest("a password change closes the user's sockets", async (connect) => { assertFalse(res.data.token === user.token); }); +wsTest( + "playlist_dumps_updated dates the latest dump each viewer can see", + async (connect) => { + const owner = await createUser(); + const pl = insertPlaylist({ userId: owner.id }); + const o = await connect(owner); + const anon = await connect(); + + const add = async (isPrivate: boolean) => { + const dump = insertDump({ userId: owner.id, isPrivate }); + await post(`/api/playlists/${pl.id}/dumps/${dump.id}`, { + auth: owner.auth, + }); + return [ + (await o.next("playlist_dumps_updated")).lastDumpAt, + (await anon.next("playlist_dumps_updated")).lastDumpAt, + ]; + }; + const [first, firstToAnon] = await add(false); + assert(first); + assertEquals(firstToAnon, first); + // The owner's private dump moves the date for them alone. + const [second, secondToAnon] = await add(true); + assert(second && second >= first); + assertEquals(secondToAnon, first); + + await put(`/api/playlists/${pl.id}`, { + auth: owner.auth, + json: { title: "Renamed" }, + }); + assertEquals( + (await o.next("playlist_updated")).playlist.lastDumpAt, + second, + ); + assertEquals( + (await anon.next("playlist_updated")).playlist.lastDumpAt, + first, + ); + }, +); + wsTest( "playlist_dumps_updated lists only what each viewer can see", async (connect) => { diff --git a/locales/en.mjs b/locales/en.mjs index f58eda5d..8f42ada9 100644 --- a/locales/en.mjs +++ b/locales/en.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Disconnect\"],\"-K9EZb\":[\"Add email…\"],\"-Ya-b9\":[\"Failed to save\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2rwzb1\":[\"You haven't signed in to another gerbeur with this account yet.\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"32TndD\":[\"Blocked\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Edit email\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5NZ3tg\":[\"Turning off…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6BV5h6\":[\"This dump was deleted, or made private.\"],\"6C4kKS\":[\"Other instances\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Nothing happened this week.\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8rPWOT\":[\"Can't sign in there\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9eEYEq\":[\"This sign-in link is incomplete.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"AnRu_j\":[\"Cancel reply\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"CJkEfx\":[\"Block\"],\"CL-DkX\":[\"An instance's URL\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"ClFfRL\":[\"Playlist categories\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"Czya-u\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite.\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"E417RG\":[\"You won't get the weekly digest any more. You can turn it back on in your settings.\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"EwVrWF\":[\"The dump couldn't be removed. Try again.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"Gv5nA5\":[\"Unexpected response format\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"JrFTcr\":[\"Connecting…\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"MbkoRn\":[\"Or join with your account on another gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"MrIc3x\":[\"Or log in with your account on another gerbeur\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" new\"]}]],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"QctOgc\":[\"Could not connect to the server. Check your connection and try again.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RLe7Vk\":[\"Checking…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"SLaP1b\":[\"Not from here\"],\"SMaFdc\":[\"Unsubscribe\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Your account lives on \",[\"home\"],\": sign in to other instances from there.\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") asks to sign you in with your account here, <1>@\",[\"username\"],\". It will receive your username, your email address and your avatar.\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOV0wi\":[\"Signing you in…\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"V38P77\":[\"Only its poster or a moderator can edit this dump.\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"Your gerbeur's URL\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W5A0Ly\":[\"An unexpected error occurred.\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WMw63e\":[\"Couldn't reach that instance\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"XWbTfu\":[\"last used \",[\"0\"]],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"YK1Dhc\":[\"a post\"],\"YWnSuq\":[\"This unsubscribe link is missing or malformed.\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_0oJwI\":[\"Trust\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_I1_HN\":[\"Lowercase letters, numbers and single hyphens only\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Sign in elsewhere\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"b_1P1D\":[\"Renaming…\"],\"b_VouM\":[\"Your account lives on \",[\"home\"],\": your password, email address, avatar and username change there.\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"d2gzT1\":[\"My playlists\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"digest.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" mentioned everyone in \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"fn1ZQX\":[\"Sign in to \",[\"name\"],\"?\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gIpLuy\":[\"A link is required\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Back to the login page\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i3uTFh\":[\"Dump categories\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jTGs8A\":[\"This site doesn't send email, so no digest goes out.\"],\"jZNg18\":[\"Account on <0>\",[\"host\"],\"\"],\"jbernk\":[\"Loading profile…\"],\"jnrIbz\":[\"Upvoted (\",[\"0\"],\")\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"m9Uf3I\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite.\"],\"mDJo8W\":[\"Dumps (\",[\"0\"],\")\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" mentioned everyone in <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"q018gJ\":[\"Couldn't add that instance\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"qVdjD5\":[\"Trusted\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qnUJBI\":[\"The new order couldn't be saved. Try again.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rn6SBY\":[\"Unmute\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"tOHgQw\":[\"Continue as @\",[\"username\"]],\"tXNIqW\":[\"This sign-in was started elsewhere. Start again from the login page.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"u1lDX2\":[\"Fetching preview…\"],\"u8pttd\":[\"Change it on \",[\"home\"]],\"uACFmj\":[\"Log in with \",[\"0\"]],\"uD0qXQ\":[\"Drop a file here\"],\"uISnsY\":[\"Could not delete\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"v-Wp--\":[\"Open source\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"w61W3L\":[\"Remove \",[\"0\"]],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wrFDaD\":[\"Users here can't sign in to a blocked instance.\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xGVfLh\":[\"Continue\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Disconnect\"],\"-K9EZb\":[\"Add email…\"],\"-Ya-b9\":[\"Failed to save\"],\"-b7T3G\":[\"Updated\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2rwzb1\":[\"You haven't signed in to another gerbeur with this account yet.\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"32TndD\":[\"Blocked\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Edit email\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5NZ3tg\":[\"Turning off…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6BV5h6\":[\"This dump was deleted, or made private.\"],\"6C4kKS\":[\"Other instances\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Nothing happened this week.\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8rPWOT\":[\"Can't sign in there\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9eEYEq\":[\"This sign-in link is incomplete.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"AnRu_j\":[\"Cancel reply\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BKVDJg\":[\"created \",[\"ago\"]],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"CJkEfx\":[\"Block\"],\"CL-DkX\":[\"An instance's URL\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"ClFfRL\":[\"Playlist categories\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"Czya-u\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite.\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"E417RG\":[\"You won't get the weekly digest any more. You can turn it back on in your settings.\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"EwVrWF\":[\"The dump couldn't be removed. Try again.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"Gv5nA5\":[\"Unexpected response format\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"JrFTcr\":[\"Connecting…\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"MbkoRn\":[\"Or join with your account on another gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"MrIc3x\":[\"Or log in with your account on another gerbeur\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" new\"]}]],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"QctOgc\":[\"Could not connect to the server. Check your connection and try again.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RLe7Vk\":[\"Checking…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"SLaP1b\":[\"Not from here\"],\"SMaFdc\":[\"Unsubscribe\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Your account lives on \",[\"home\"],\": sign in to other instances from there.\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") asks to sign you in with your account here, <1>@\",[\"username\"],\". It will receive your username, your email address and your avatar.\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOV0wi\":[\"Signing you in…\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"V38P77\":[\"Only its poster or a moderator can edit this dump.\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"Your gerbeur's URL\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W5A0Ly\":[\"An unexpected error occurred.\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WMw63e\":[\"Couldn't reach that instance\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"XWbTfu\":[\"last used \",[\"0\"]],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"YK1Dhc\":[\"a post\"],\"YWnSuq\":[\"This unsubscribe link is missing or malformed.\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_0oJwI\":[\"Trust\"],\"_3O5R_\":[\"Request failed\"],\"_DneUO\":[\"updated \",[\"ago\"]],\"_DwR-n\":[\"Creating…\"],\"_I1_HN\":[\"Lowercase letters, numbers and single hyphens only\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Sign in elsewhere\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"aFOsPT\":[\"Most dumps\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"b_1P1D\":[\"Renaming…\"],\"b_VouM\":[\"Your account lives on \",[\"home\"],\": your password, email address, avatar and username change there.\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"d2gzT1\":[\"My playlists\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"digest.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" mentioned everyone in \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"fn1ZQX\":[\"Sign in to \",[\"name\"],\"?\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gIpLuy\":[\"A link is required\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Back to the login page\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i3uTFh\":[\"Dump categories\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jTGs8A\":[\"This site doesn't send email, so no digest goes out.\"],\"jZNg18\":[\"Account on <0>\",[\"host\"],\"\"],\"jbernk\":[\"Loading profile…\"],\"jnrIbz\":[\"Upvoted (\",[\"0\"],\")\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"m9Uf3I\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite.\"],\"mDJo8W\":[\"Dumps (\",[\"0\"],\")\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" mentioned everyone in <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"q018gJ\":[\"Couldn't add that instance\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"qVdjD5\":[\"Trusted\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qnUJBI\":[\"The new order couldn't be saved. Try again.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rn6SBY\":[\"Unmute\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"tOHgQw\":[\"Continue as @\",[\"username\"]],\"tXNIqW\":[\"This sign-in was started elsewhere. Start again from the login page.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"u1lDX2\":[\"Fetching preview…\"],\"u8pttd\":[\"Change it on \",[\"home\"]],\"uACFmj\":[\"Log in with \",[\"0\"]],\"uD0qXQ\":[\"Drop a file here\"],\"uISnsY\":[\"Could not delete\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"v-Wp--\":[\"Open source\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"w61W3L\":[\"Remove \",[\"0\"]],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wrFDaD\":[\"Users here can't sign in to a blocked instance.\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xGVfLh\":[\"Continue\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file diff --git a/locales/en.po b/locales/en.po index 85492d9d..c6536f7a 100644 --- a/locales/en.po +++ b/locales/en.po @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:125 +#: src/components/playlist/PlaylistCard.tsx:131 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" @@ -207,13 +207,13 @@ msgstr "# Welcome to {site}!\n" #: src/components/dump/create/DumpCreateModal.tsx:736 #: src/pages/PlaylistDetail.tsx:98 -#: src/pages/UserPublicProfile.tsx:125 +#: src/pages/UserPublicProfile.tsx:129 msgid "← Back" msgstr "← Back" -#: src/pages/Dump.tsx:354 -#: src/pages/Dump.tsx:379 -#: src/pages/Dump.tsx:606 +#: src/pages/Dump.tsx:375 +#: src/pages/Dump.tsx:400 +#: src/pages/Dump.tsx:627 #: src/pages/DumpEdit.tsx:234 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -226,7 +226,7 @@ msgstr "+ Invite someone" msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:453 +#: src/pages/Dump.tsx:474 msgid "+ Playlist" msgstr "+ Playlist" @@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta" msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}" #: src/contexts/ws/WSProvider.tsx:377 -#: src/contexts/ws/WSProvider.tsx:765 +#: src/contexts/ws/WSProvider.tsx:770 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." @@ -436,7 +436,7 @@ msgstr "Can't sign in there" #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:495 +#: src/pages/Dump.tsx:516 #: src/pages/DumpEdit.tsx:561 msgid "Cancel" msgstr "Cancel" @@ -551,7 +551,7 @@ msgstr "Confirm new password" msgid "Connecting…" msgstr "Connecting…" -#: src/contexts/ws/WSProvider.tsx:758 +#: src/contexts/ws/WSProvider.tsx:763 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" @@ -579,7 +579,7 @@ msgstr "Could not change password" msgid "Could not connect to the server. Check your connection and try again." msgstr "Could not connect to the server. Check your connection and try again." -#: src/components/profile/ProfilePlaylistsTab.tsx:171 +#: src/components/profile/ProfilePlaylistsTab.tsx:192 #: src/pages/DumpEdit.tsx:555 msgid "Could not delete" msgstr "Could not delete" @@ -628,6 +628,10 @@ msgstr "Create" msgid "Create & Add" msgstr "Create & Add" +#: src/components/playlist/PlaylistCard.tsx:142 +msgid "created {ago}" +msgstr "created {ago}" + #: src/components/playlist/PlaylistCreateForm.tsx:121 msgid "Creating…" msgstr "Creating…" @@ -675,9 +679,9 @@ msgstr "Delete dump" msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/playlist/PlaylistCard.tsx:154 +#: src/components/playlist/PlaylistCard.tsx:162 #: src/components/playlist/PlaylistEditForm.tsx:213 -#: src/components/profile/ProfilePlaylistsTab.tsx:201 +#: src/components/profile/ProfilePlaylistsTab.tsx:222 #: src/pages/PlaylistDetail.tsx:172 msgid "Delete playlist" msgstr "Delete playlist" @@ -694,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/components/profile/ProfilePlaylistsTab.tsx:200 +#: src/components/profile/ProfilePlaylistsTab.tsx:221 #: src/pages/PlaylistDetail.tsx:171 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -767,7 +771,7 @@ msgstr "Dumped!" #: src/components/profile/ProfileDumpsTab.tsx:46 #: src/pages/Search.tsx:211 -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:164 msgid "Dumps" msgstr "Dumps" @@ -785,7 +789,7 @@ msgstr "Earlier" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 #: src/components/playlist/PlaylistHeader.tsx:60 -#: src/pages/Dump.tsx:602 +#: src/pages/Dump.tsx:623 msgid "Edit" msgstr "Edit" @@ -803,7 +807,7 @@ msgid "Edit email" msgstr "Edit email" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:512 +#: src/pages/Dump.tsx:533 msgid "Edit title" msgstr "Edit title" @@ -817,7 +821,7 @@ msgstr "edited" #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 #: src/components/playlist/PlaylistHeader.tsx:95 -#: src/pages/Dump.tsx:555 +#: src/pages/Dump.tsx:576 msgid "edited {0}" msgstr "edited {0}" @@ -827,7 +831,7 @@ msgstr "edited {0}" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:553 +#: src/pages/Dump.tsx:574 msgid "Edited {0}" msgstr "Edited {0}" @@ -948,9 +952,9 @@ msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." #: src/components/feed/FeedTabBar.tsx:61 -#: src/components/profile/ProfileFollowTab.tsx:144 +#: src/components/profile/ProfileFollowTab.tsx:143 #: src/pages/Settings.tsx:57 -#: src/pages/UserPublicProfile.tsx:163 +#: src/pages/UserPublicProfile.tsx:167 msgid "Followed" msgstr "Followed" @@ -1021,7 +1025,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s msgid "image" msgstr "image" -#: src/pages/Dump.tsx:622 +#: src/pages/Dump.tsx:643 msgid "In collections" msgstr "In collections" @@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou" msgstr "invited by you" #: src/components/profile/ProfileInviteesTab.tsx:77 -#: src/pages/UserPublicProfile.tsx:164 +#: src/pages/UserPublicProfile.tsx:168 msgid "Invitees" msgstr "Invitees" @@ -1106,7 +1110,7 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/ws/WSProvider.tsx:764 +#: src/contexts/ws/WSProvider.tsx:769 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" @@ -1122,7 +1126,7 @@ msgstr "Load more" msgid "Load older messages" msgstr "Load older messages" -#: src/pages/Dump.tsx:338 +#: src/pages/Dump.tsx:359 #: src/pages/DumpEdit.tsx:210 msgid "Loading dump…" msgstr "Loading dump…" @@ -1139,7 +1143,7 @@ msgstr "Loading more…" msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:108 +#: src/pages/UserPublicProfile.tsx:112 msgid "Loading profile…" msgstr "Loading profile…" @@ -1177,7 +1181,7 @@ msgid "Log in with {0}" msgstr "Log in with {0}" #: src/components/layout/UserMenu.tsx:128 -#: src/pages/UserPublicProfile.tsx:133 +#: src/pages/UserPublicProfile.tsx:137 msgid "Log out" msgstr "Log out" @@ -1197,7 +1201,7 @@ msgstr "Lowercase letters, numbers and single hyphens only" #: src/components/layout/UserMenu.tsx:115 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 -#: src/pages/UserPublicProfile.tsx:166 +#: src/pages/UserPublicProfile.tsx:170 msgid "Manage" msgstr "Manage" @@ -1214,6 +1218,10 @@ msgstr "Moderator" msgid "digest.monthly" msgstr "Monthly recap" +#: src/components/playlist/PlaylistSortTabs.tsx:27 +msgid "Most dumps" +msgstr "Most dumps" + #: src/components/player/MediaPlayer.tsx:299 msgid "Mute" msgstr "Mute" @@ -1229,6 +1237,7 @@ msgstr "Name" #: src/components/chat/ChatModal.tsx:692 #: src/components/feed/FeedTabBar.tsx:58 +#: src/components/playlist/PlaylistSortTabs.tsx:26 #: src/pages/Settings.tsx:55 msgid "New" msgstr "New" @@ -1285,7 +1294,7 @@ msgstr "No dumps yet. Be the first!" msgid "No emoji found." msgstr "No emoji found." -#: src/components/profile/ProfileFollowTab.tsx:117 +#: src/components/profile/ProfileFollowTab.tsx:116 msgid "No followed playlists yet." msgstr "No followed playlists yet." @@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:178 -#: src/pages/Playlists.tsx:126 +#: src/components/profile/ProfilePlaylistsTab.tsx:199 +#: src/pages/Playlists.tsx:151 msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/ws/WSProvider.tsx:934 +#: src/contexts/ws/WSProvider.tsx:939 msgid "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent" @@ -1408,7 +1417,7 @@ msgstr "Or log in with your account on another gerbeur" msgid "Other instances" msgstr "Other instances" -#: src/pages/UserPublicProfile.tsx:159 +#: src/pages/UserPublicProfile.tsx:163 msgid "Overview" msgstr "Overview" @@ -1469,12 +1478,12 @@ msgstr "Playlist categories" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:169 -#: src/components/profile/ProfileFollowTab.tsx:148 -#: src/components/profile/ProfilePlaylistsTab.tsx:149 -#: src/pages/Playlists.tsx:53 -#: src/pages/Playlists.tsx:114 +#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/components/profile/ProfilePlaylistsTab.tsx:161 +#: src/pages/Playlists.tsx:60 +#: src/pages/Playlists.tsx:132 #: src/pages/Search.tsx:214 -#: src/pages/UserPublicProfile.tsx:162 +#: src/pages/UserPublicProfile.tsx:166 msgid "Playlists" msgstr "Playlists" @@ -1498,10 +1507,10 @@ msgstr "Previous track" #: src/components/dump/DumpCard.tsx:144 #: src/components/dump/JournalCard.tsx:145 -#: src/components/playlist/PlaylistCard.tsx:111 +#: src/components/playlist/PlaylistCard.tsx:117 #: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:561 +#: src/pages/Dump.tsx:582 msgid "private" msgstr "private" @@ -1509,7 +1518,7 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/playlist/PlaylistCard.tsx:110 +#: src/components/playlist/PlaylistCard.tsx:116 #: src/components/playlist/PlaylistHeader.tsx:77 msgid "public" msgstr "public" @@ -1544,7 +1553,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:613 +#: src/pages/Dump.tsx:634 msgid "Related" msgstr "Related" @@ -1631,7 +1640,7 @@ msgid "email.reset.title" msgstr "Reset your password" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:372 +#: src/pages/Dump.tsx:393 #: src/pages/DumpEdit.tsx:227 msgid "Retry" msgstr "Retry" @@ -1646,7 +1655,7 @@ msgstr "Role" #: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:487 +#: src/pages/Dump.tsx:508 #: src/pages/DumpEdit.tsx:564 msgid "Save" msgstr "Save" @@ -1656,7 +1665,7 @@ msgstr "Save" #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:486 +#: src/pages/Dump.tsx:507 #: src/pages/ResetPassword.tsx:191 msgid "Saving…" msgstr "Saving…" @@ -1765,7 +1774,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:733 +#: src/contexts/ws/WSProvider.tsx:738 #: src/pages/PlaylistDetail.tsx:155 msgid "Something went wrong" msgstr "Something went wrong" @@ -1830,7 +1839,7 @@ msgstr "The new order couldn't be saved. Try again." msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." -#: src/pages/Dump.tsx:347 +#: src/pages/Dump.tsx:368 msgid "This dump was deleted, or made private." msgstr "This dump was deleted, or made private." @@ -1971,6 +1980,14 @@ msgstr "Unsubscribe" msgid "Unsubscribed" msgstr "Unsubscribed" +#: src/components/playlist/PlaylistSortTabs.tsx:25 +msgid "Updated" +msgstr "Updated" + +#: src/components/playlist/PlaylistCard.tsx:141 +msgid "updated {ago}" +msgstr "updated {ago}" + #. js-lingui-explicit-id #: api/mail/digest.ts:237 msgid "email.digest.upload" @@ -1998,7 +2015,7 @@ msgid "Upvote" msgstr "Upvote" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/UserPublicProfile.tsx:161 +#: src/pages/UserPublicProfile.tsx:165 msgid "Upvoted" msgstr "Upvoted" @@ -2039,7 +2056,7 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/components/profile/ProfileFollowTab.tsx:146 #: src/pages/Search.tsx:213 msgid "Users" msgstr "Users" @@ -2150,7 +2167,7 @@ msgstr "You're getting this email because a password reset was requested for thi msgid "email.welcome.reason" msgstr "You're getting this email because an account was created on {site} with this address." -#: src/contexts/ws/WSProvider.tsx:915 +#: src/contexts/ws/WSProvider.tsx:920 msgid "You're offline — not sent" msgstr "You're offline — not sent" diff --git a/locales/fr.mjs b/locales/fr.mjs index e18a263d..d7e17dcd 100644 --- a/locales/fr.mjs +++ b/locales/fr.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Déconnecter\"],\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2rwzb1\":[\"Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte.\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"32TndD\":[\"Bloquée\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Modifier l'e-mail\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5NZ3tg\":[\"Désactivation…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6BV5h6\":[\"Cette reco a été supprimée, ou rendue privée.\"],\"6C4kKS\":[\"Autres instances\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6_dCYd\":[\"Aperçu\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8rPWOT\":[\"Connexion impossible\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9eEYEq\":[\"Ce lien de connexion est incomplet.\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Position\"],\"AnRu_j\":[\"Annuler la réponse\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Validé\"],\"CJkEfx\":[\"Bloquer\"],\"CL-DkX\":[\"URL d'une instance\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Ne plus recevoir le résumé hebdomadaire ?\"],\"Cj24wt\":[\"Inscription…\"],\"ClFfRL\":[\"Catégories de collections\"],\"CnqayG\":[\"La mise à jour a échoué. Réessayez.\"],\"Czya-u\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas, et les utilisateurs d'ici ne peuvent pas s'y connecter. Ceux de toute autre instance ont besoin d'une invitation.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a validé <1>\",[\"1\"],\"\"],\"E417RG\":[\"Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres.\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"EwVrWF\":[\"Le dump n'a pas pu être retiré. Réessayez.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"Gv5nA5\":[\"Format de réponse inattendu\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"JrFTcr\":[\"Connexion…\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"MbkoRn\":[\"Ou rejoignez-nous avec votre compte d'un autre gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"MrIc3x\":[\"Ou connectez-vous avec votre compte d'un autre gerbeur\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"QctOgc\":[\"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RLe7Vk\":[\"Vérification…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"SLaP1b\":[\"Compte d'ailleurs\"],\"SMaFdc\":[\"Se désabonner\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Votre compte est sur \",[\"home\"],\" : connectez-vous aux autres instances depuis là-bas.\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") demande à vous connecter avec votre compte d'ici, <1>@\",[\"username\"],\". Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar.\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOV0wi\":[\"Connexion…\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour valider\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"URL de votre gerbeur\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W5A0Ly\":[\"Une erreur inattendue s'est produite.\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WMw63e\":[\"Impossible de joindre cette instance\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"XWbTfu\":[\"utilisée \",[\"0\"]],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"YK1Dhc\":[\"une publication\"],\"YWnSuq\":[\"Ce lien de désabonnement est absent ou mal formé.\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_0oJwI\":[\"Approuver\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_I1_HN\":[\"Minuscules, chiffres et tirets simples seulement\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Se connecter ailleurs\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"b_1P1D\":[\"Renommage…\"],\"b_VouM\":[\"Votre compte est sur \",[\"home\"],\" : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas.\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"d2gzT1\":[\"Mes collections\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"digest.community\":[\"Communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus validées du mois\"],\"digest.topWeek\":[\"Les plus validées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" a mentionné tout le monde dans \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus validées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus validées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus validées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres.\"],\"email.welcome.preheader\":[\"Votre compte est prêt.\"],\"email.welcome.reason\":[\"Vous recevez cet e-mail car un compte \",[\"site\"],\" a été créé avec cette adresse.\"],\"email.welcome.subject\":[\"Bienvenue sur \",[\"site\"]],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"f3cEDO\":[\"Changer l'image\"],\"fC6mXb\":[\"Valider\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"fn1ZQX\":[\"Se connecter à \",[\"name\"],\" ?\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gIpLuy\":[\"Il faut un lien\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Retour à la page de connexion\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i3uTFh\":[\"Catégories de recos\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jTGs8A\":[\"Ce site n'envoie pas d'e-mails, aucun résumé ne part donc.\"],\"jZNg18\":[\"Compte sur <0>\",[\"host\"],\"\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Validé (\",[\"0\"],\")\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, valide vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"m9Uf3I\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation.\"],\"mDJo8W\":[\"Recos (\",[\"0\"],\")\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" a mentionné tout le monde dans <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"q018gJ\":[\"Impossible d'ajouter cette instance\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"qVdjD5\":[\"Approuvée\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qnUJBI\":[\"Le nouvel ordre n'a pas pu être enregistré. Réessayez.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rn6SBY\":[\"Rétablir le son\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"tOHgQw\":[\"Continuer en tant que @\",[\"username\"]],\"tXNIqW\":[\"Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"u8pttd\":[\"Le changer sur \",[\"home\"]],\"uACFmj\":[\"Se connecter avec \",[\"0\"]],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uISnsY\":[\"Suppression impossible\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"v-Wp--\":[\"Ouvrir la source\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"w61W3L\":[\"Retirer \",[\"0\"]],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wrFDaD\":[\"Les utilisateurs d'ici ne peuvent pas se connecter à une instance bloquée.\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xGVfLh\":[\"Continuer\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Déconnecter\"],\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-b7T3G\":[\"Mises à jour\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2rwzb1\":[\"Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte.\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"32TndD\":[\"Bloquée\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Modifier l'e-mail\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5NZ3tg\":[\"Désactivation…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6BV5h6\":[\"Cette reco a été supprimée, ou rendue privée.\"],\"6C4kKS\":[\"Autres instances\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6_dCYd\":[\"Aperçu\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8rPWOT\":[\"Connexion impossible\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9eEYEq\":[\"Ce lien de connexion est incomplet.\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Position\"],\"AnRu_j\":[\"Annuler la réponse\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BKVDJg\":[\"créée \",[\"ago\"]],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Validé\"],\"CJkEfx\":[\"Bloquer\"],\"CL-DkX\":[\"URL d'une instance\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Ne plus recevoir le résumé hebdomadaire ?\"],\"Cj24wt\":[\"Inscription…\"],\"ClFfRL\":[\"Catégories de collections\"],\"CnqayG\":[\"La mise à jour a échoué. Réessayez.\"],\"Czya-u\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas, et les utilisateurs d'ici ne peuvent pas s'y connecter. Ceux de toute autre instance ont besoin d'une invitation.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a validé <1>\",[\"1\"],\"\"],\"E417RG\":[\"Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres.\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"EwVrWF\":[\"Le dump n'a pas pu être retiré. Réessayez.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"Gv5nA5\":[\"Format de réponse inattendu\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"JrFTcr\":[\"Connexion…\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"MbkoRn\":[\"Ou rejoignez-nous avec votre compte d'un autre gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"MrIc3x\":[\"Ou connectez-vous avec votre compte d'un autre gerbeur\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"QctOgc\":[\"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RLe7Vk\":[\"Vérification…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"SLaP1b\":[\"Compte d'ailleurs\"],\"SMaFdc\":[\"Se désabonner\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Votre compte est sur \",[\"home\"],\" : connectez-vous aux autres instances depuis là-bas.\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") demande à vous connecter avec votre compte d'ici, <1>@\",[\"username\"],\". Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar.\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOV0wi\":[\"Connexion…\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour valider\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"URL de votre gerbeur\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W5A0Ly\":[\"Une erreur inattendue s'est produite.\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WMw63e\":[\"Impossible de joindre cette instance\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"XWbTfu\":[\"utilisée \",[\"0\"]],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"YK1Dhc\":[\"une publication\"],\"YWnSuq\":[\"Ce lien de désabonnement est absent ou mal formé.\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_0oJwI\":[\"Approuver\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DneUO\":[\"mise à jour \",[\"ago\"]],\"_DwR-n\":[\"Création…\"],\"_I1_HN\":[\"Minuscules, chiffres et tirets simples seulement\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Se connecter ailleurs\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"aFOsPT\":[\"Les plus fournies\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"b_1P1D\":[\"Renommage…\"],\"b_VouM\":[\"Votre compte est sur \",[\"home\"],\" : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas.\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"d2gzT1\":[\"Mes collections\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"digest.community\":[\"Communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus validées du mois\"],\"digest.topWeek\":[\"Les plus validées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" a mentionné tout le monde dans \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus validées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus validées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus validées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres.\"],\"email.welcome.preheader\":[\"Votre compte est prêt.\"],\"email.welcome.reason\":[\"Vous recevez cet e-mail car un compte \",[\"site\"],\" a été créé avec cette adresse.\"],\"email.welcome.subject\":[\"Bienvenue sur \",[\"site\"]],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"f3cEDO\":[\"Changer l'image\"],\"fC6mXb\":[\"Valider\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"fn1ZQX\":[\"Se connecter à \",[\"name\"],\" ?\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gIpLuy\":[\"Il faut un lien\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Retour à la page de connexion\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i3uTFh\":[\"Catégories de recos\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jTGs8A\":[\"Ce site n'envoie pas d'e-mails, aucun résumé ne part donc.\"],\"jZNg18\":[\"Compte sur <0>\",[\"host\"],\"\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Validé (\",[\"0\"],\")\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, valide vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"m9Uf3I\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation.\"],\"mDJo8W\":[\"Recos (\",[\"0\"],\")\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" a mentionné tout le monde dans <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"q018gJ\":[\"Impossible d'ajouter cette instance\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"qVdjD5\":[\"Approuvée\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qnUJBI\":[\"Le nouvel ordre n'a pas pu être enregistré. Réessayez.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rn6SBY\":[\"Rétablir le son\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"tOHgQw\":[\"Continuer en tant que @\",[\"username\"]],\"tXNIqW\":[\"Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"u8pttd\":[\"Le changer sur \",[\"home\"]],\"uACFmj\":[\"Se connecter avec \",[\"0\"]],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uISnsY\":[\"Suppression impossible\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"v-Wp--\":[\"Ouvrir la source\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"w61W3L\":[\"Retirer \",[\"0\"]],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wrFDaD\":[\"Les utilisateurs d'ici ne peuvent pas se connecter à une instance bloquée.\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xGVfLh\":[\"Continuer\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file diff --git a/locales/fr.po b/locales/fr.po index 86592f9d..214e64c7 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:125 +#: src/components/playlist/PlaylistCard.tsx:131 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" @@ -207,13 +207,13 @@ msgstr "# Bienvenue sur {site} !\n" #: src/components/dump/create/DumpCreateModal.tsx:736 #: src/pages/PlaylistDetail.tsx:98 -#: src/pages/UserPublicProfile.tsx:125 +#: src/pages/UserPublicProfile.tsx:129 msgid "← Back" msgstr "← Retour" -#: src/pages/Dump.tsx:354 -#: src/pages/Dump.tsx:379 -#: src/pages/Dump.tsx:606 +#: src/pages/Dump.tsx:375 +#: src/pages/Dump.tsx:400 +#: src/pages/Dump.tsx:627 #: src/pages/DumpEdit.tsx:234 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un" msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:453 +#: src/pages/Dump.tsx:474 msgid "+ Playlist" msgstr "+ Collection" @@ -416,7 +416,7 @@ msgid "email.digest.playlistMeta" msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}" #: src/contexts/ws/WSProvider.tsx:377 -#: src/contexts/ws/WSProvider.tsx:765 +#: src/contexts/ws/WSProvider.tsx:770 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." @@ -436,7 +436,7 @@ msgstr "Connexion impossible" #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:495 +#: src/pages/Dump.tsx:516 #: src/pages/DumpEdit.tsx:561 msgid "Cancel" msgstr "Annuler" @@ -551,7 +551,7 @@ msgstr "Confirmer le nouveau mot de passe" msgid "Connecting…" msgstr "Connexion…" -#: src/contexts/ws/WSProvider.tsx:758 +#: src/contexts/ws/WSProvider.tsx:763 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" @@ -579,7 +579,7 @@ msgstr "Impossible de changer le mot de passe" msgid "Could not connect to the server. Check your connection and try again." msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez." -#: src/components/profile/ProfilePlaylistsTab.tsx:171 +#: src/components/profile/ProfilePlaylistsTab.tsx:192 #: src/pages/DumpEdit.tsx:555 msgid "Could not delete" msgstr "Suppression impossible" @@ -628,6 +628,10 @@ msgstr "Créer" msgid "Create & Add" msgstr "Créer et ajouter" +#: src/components/playlist/PlaylistCard.tsx:142 +msgid "created {ago}" +msgstr "créée {ago}" + #: src/components/playlist/PlaylistCreateForm.tsx:121 msgid "Creating…" msgstr "Création…" @@ -675,9 +679,9 @@ msgstr "Supprimer la reco" msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/playlist/PlaylistCard.tsx:154 +#: src/components/playlist/PlaylistCard.tsx:162 #: src/components/playlist/PlaylistEditForm.tsx:213 -#: src/components/profile/ProfilePlaylistsTab.tsx:201 +#: src/components/profile/ProfilePlaylistsTab.tsx:222 #: src/pages/PlaylistDetail.tsx:172 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -694,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/components/profile/ProfilePlaylistsTab.tsx:200 +#: src/components/profile/ProfilePlaylistsTab.tsx:221 #: src/pages/PlaylistDetail.tsx:171 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -767,7 +771,7 @@ msgstr "Recommandé !" #: src/components/profile/ProfileDumpsTab.tsx:46 #: src/pages/Search.tsx:211 -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:164 msgid "Dumps" msgstr "Recos" @@ -785,7 +789,7 @@ msgstr "Plus tôt" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 #: src/components/playlist/PlaylistHeader.tsx:60 -#: src/pages/Dump.tsx:602 +#: src/pages/Dump.tsx:623 msgid "Edit" msgstr "Modifier" @@ -803,7 +807,7 @@ msgid "Edit email" msgstr "Modifier l'e-mail" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:512 +#: src/pages/Dump.tsx:533 msgid "Edit title" msgstr "Modifier le titre" @@ -817,7 +821,7 @@ msgstr "modifié" #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 #: src/components/playlist/PlaylistHeader.tsx:95 -#: src/pages/Dump.tsx:555 +#: src/pages/Dump.tsx:576 msgid "edited {0}" msgstr "modifié {0}" @@ -827,7 +831,7 @@ msgstr "modifié {0}" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:553 +#: src/pages/Dump.tsx:574 msgid "Edited {0}" msgstr "Modifié le {0}" @@ -948,9 +952,9 @@ msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." #: src/components/feed/FeedTabBar.tsx:61 -#: src/components/profile/ProfileFollowTab.tsx:144 +#: src/components/profile/ProfileFollowTab.tsx:143 #: src/pages/Settings.tsx:57 -#: src/pages/UserPublicProfile.tsx:163 +#: src/pages/UserPublicProfile.tsx:167 msgid "Followed" msgstr "Suivi" @@ -1021,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V msgid "image" msgstr "image" -#: src/pages/Dump.tsx:622 +#: src/pages/Dump.tsx:643 msgid "In collections" msgstr "Dans les collections" @@ -1067,7 +1071,7 @@ msgid "digest.invitedByYou" msgstr "sur votre invitation" #: src/components/profile/ProfileInviteesTab.tsx:77 -#: src/pages/UserPublicProfile.tsx:164 +#: src/pages/UserPublicProfile.tsx:168 msgid "Invitees" msgstr "Invités" @@ -1106,7 +1110,7 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/ws/WSProvider.tsx:764 +#: src/contexts/ws/WSProvider.tsx:769 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" @@ -1122,7 +1126,7 @@ msgstr "Charger plus" msgid "Load older messages" msgstr "Charger les messages plus anciens" -#: src/pages/Dump.tsx:338 +#: src/pages/Dump.tsx:359 #: src/pages/DumpEdit.tsx:210 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -1139,7 +1143,7 @@ msgstr "Chargement…" msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:108 +#: src/pages/UserPublicProfile.tsx:112 msgid "Loading profile…" msgstr "Chargement du profil…" @@ -1177,7 +1181,7 @@ msgid "Log in with {0}" msgstr "Se connecter avec {0}" #: src/components/layout/UserMenu.tsx:128 -#: src/pages/UserPublicProfile.tsx:133 +#: src/pages/UserPublicProfile.tsx:137 msgid "Log out" msgstr "Se déconnecter" @@ -1197,7 +1201,7 @@ msgstr "Minuscules, chiffres et tirets simples seulement" #: src/components/layout/UserMenu.tsx:115 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 -#: src/pages/UserPublicProfile.tsx:166 +#: src/pages/UserPublicProfile.tsx:170 msgid "Manage" msgstr "Administration" @@ -1214,6 +1218,10 @@ msgstr "Modérateur" msgid "digest.monthly" msgstr "Récap mensuel" +#: src/components/playlist/PlaylistSortTabs.tsx:27 +msgid "Most dumps" +msgstr "Les plus fournies" + #: src/components/player/MediaPlayer.tsx:299 msgid "Mute" msgstr "Couper le son" @@ -1229,6 +1237,7 @@ msgstr "Nom" #: src/components/chat/ChatModal.tsx:692 #: src/components/feed/FeedTabBar.tsx:58 +#: src/components/playlist/PlaylistSortTabs.tsx:26 #: src/pages/Settings.tsx:55 msgid "New" msgstr "Nouveau" @@ -1285,7 +1294,7 @@ msgstr "Pas encore de recos. Soyez le premier !" msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/components/profile/ProfileFollowTab.tsx:117 +#: src/components/profile/ProfileFollowTab.tsx:116 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." @@ -1306,12 +1315,12 @@ msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:178 -#: src/pages/Playlists.tsx:126 +#: src/components/profile/ProfilePlaylistsTab.tsx:199 +#: src/pages/Playlists.tsx:151 msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/ws/WSProvider.tsx:934 +#: src/contexts/ws/WSProvider.tsx:939 msgid "No reply from the server — this message may not have been sent" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" @@ -1408,7 +1417,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur" msgid "Other instances" msgstr "Autres instances" -#: src/pages/UserPublicProfile.tsx:159 +#: src/pages/UserPublicProfile.tsx:163 msgid "Overview" msgstr "Aperçu" @@ -1469,12 +1478,12 @@ msgstr "Catégories de collections" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:169 -#: src/components/profile/ProfileFollowTab.tsx:148 -#: src/components/profile/ProfilePlaylistsTab.tsx:149 -#: src/pages/Playlists.tsx:53 -#: src/pages/Playlists.tsx:114 +#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/components/profile/ProfilePlaylistsTab.tsx:161 +#: src/pages/Playlists.tsx:60 +#: src/pages/Playlists.tsx:132 #: src/pages/Search.tsx:214 -#: src/pages/UserPublicProfile.tsx:162 +#: src/pages/UserPublicProfile.tsx:166 msgid "Playlists" msgstr "Collections" @@ -1498,10 +1507,10 @@ msgstr "Piste précédente" #: src/components/dump/DumpCard.tsx:144 #: src/components/dump/JournalCard.tsx:145 -#: src/components/playlist/PlaylistCard.tsx:111 +#: src/components/playlist/PlaylistCard.tsx:117 #: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:561 +#: src/pages/Dump.tsx:582 msgid "private" msgstr "privé" @@ -1509,7 +1518,7 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/playlist/PlaylistCard.tsx:110 +#: src/components/playlist/PlaylistCard.tsx:116 #: src/components/playlist/PlaylistHeader.tsx:77 msgid "public" msgstr "public" @@ -1544,7 +1553,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:613 +#: src/pages/Dump.tsx:634 msgid "Related" msgstr "Connexe" @@ -1631,7 +1640,7 @@ msgid "email.reset.title" msgstr "Réinitialiser votre mot de passe" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:372 +#: src/pages/Dump.tsx:393 #: src/pages/DumpEdit.tsx:227 msgid "Retry" msgstr "Réessayer" @@ -1646,7 +1655,7 @@ msgstr "Rôle" #: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:487 +#: src/pages/Dump.tsx:508 #: src/pages/DumpEdit.tsx:564 msgid "Save" msgstr "Enregistrer" @@ -1656,7 +1665,7 @@ msgstr "Enregistrer" #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:486 +#: src/pages/Dump.tsx:507 #: src/pages/ResetPassword.tsx:191 msgid "Saving…" msgstr "Enregistrement…" @@ -1765,7 +1774,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte { #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:733 +#: src/contexts/ws/WSProvider.tsx:738 #: src/pages/PlaylistDetail.tsx:155 msgid "Something went wrong" msgstr "Une erreur est survenue" @@ -1830,7 +1839,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." -#: src/pages/Dump.tsx:347 +#: src/pages/Dump.tsx:368 msgid "This dump was deleted, or made private." msgstr "Cette reco a été supprimée, ou rendue privée." @@ -1971,6 +1980,14 @@ msgstr "Se désabonner" msgid "Unsubscribed" msgstr "Désabonné" +#: src/components/playlist/PlaylistSortTabs.tsx:25 +msgid "Updated" +msgstr "Mises à jour" + +#: src/components/playlist/PlaylistCard.tsx:141 +msgid "updated {ago}" +msgstr "mise à jour {ago}" + #. js-lingui-explicit-id #: api/mail/digest.ts:237 msgid "email.digest.upload" @@ -1998,7 +2015,7 @@ msgid "Upvote" msgstr "Valider" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/UserPublicProfile.tsx:161 +#: src/pages/UserPublicProfile.tsx:165 msgid "Upvoted" msgstr "Validé" @@ -2039,7 +2056,7 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/components/profile/ProfileFollowTab.tsx:146 #: src/pages/Search.tsx:213 msgid "Users" msgstr "Utilisateurs" @@ -2150,7 +2167,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét msgid "email.welcome.reason" msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse." -#: src/contexts/ws/WSProvider.tsx:915 +#: src/contexts/ws/WSProvider.tsx:920 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" diff --git a/shared/paths.ts b/shared/paths.ts index 9d8d1f19..5278bdd0 100644 --- a/shared/paths.ts +++ b/shared/paths.ts @@ -6,7 +6,9 @@ * categories. Under a user each segment is a fixed tab name, and under `dumps` * or `playlists` a slug. A view of a page is one more segment, and a default * view is the bare path. A category narrowing a list on another page, a - * profile's or a search's, is a query, `?category=`, not a segment. + * profile's or a search's, is a query, `?category=`, not a segment, and so is + * the order of a list of playlists, `?sort=`, the default order being the + * bare path. * * The client links every page through the builders (`src/utils/shared/urls.ts` * wraps the dump and playlist ones), the emails prefix them with the @@ -19,6 +21,7 @@ */ import type { CategoryKind } from "./category.ts"; +import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts"; import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts"; import { HANDLE_RE, splitHandle } from "./validation.ts"; @@ -142,10 +145,31 @@ export function feedPath( /** * Returns the path of the page of every public playlist, `/playlists`, or of a - * playlist category's at `/playlists/`. + * playlist category's at `/playlists/`, in the order `sort` names, the + * default one without it. */ -export function playlistsPath(categorySlug?: string): string { - return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists"; +export function playlistsPath( + categorySlug?: string, + sort?: PlaylistSort, +): string { + const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists"; + return `${path}${playlistSortQuery(sort)}`; +} + +/** + * Returns the query string that puts a list of playlists in the order `sort` + * names, with its `?`, or `""` for the default order. `extra` holds the + * list's other parameters, such as a profile's `category`. + */ +export function playlistSortQuery( + sort: PlaylistSort = PLAYLIST_SORTS[0], + extra?: URLSearchParams, +): string { + const params = new URLSearchParams(extra); + if (sort === PLAYLIST_SORTS[0]) params.delete("sort"); + else params.set("sort", sort); + const query = params.toString(); + return query ? `?${query}` : ""; } /** diff --git a/shared/playlist.ts b/shared/playlist.ts index 95ad215f..b0372787 100644 --- a/shared/playlist.ts +++ b/shared/playlist.ts @@ -8,6 +8,26 @@ import type { Dump } from "./dump.ts"; +/** + * The orders a list of playlists comes in, the first being the default one: + * `updated`, by {@link playlistActivity}, the latest first; `new`, by + * creation, the newest first; `dumps`, the fullest first. `GET /api/playlists` + * and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists` + * page and a profile's playlists tab show them as tabs and keep the one + * picked in their URL's `?sort=`. + */ +export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const; +/** One of the {@link PLAYLIST_SORTS}. */ +export type PlaylistSort = (typeof PLAYLIST_SORTS)[number]; + +/** + * Returns the order a `?sort=` names, the default one when it is missing or + * names none: like a page number, an order never fails a list. + */ +export function parsePlaylistSort(value: string | null): PlaylistSort { + return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0]; +} + /** * Returned by the playlist routes, and by the search, users and dumps routes * that list playlists. The `playlist_created` and `playlist_updated` WebSocket @@ -44,6 +64,12 @@ export interface Playlist { * is undefined when the query didn't count them. */ dumpCount?: number; + /** + * When the latest of the dumps the viewer may see was added to it, which + * {@link playlistActivity} reads. It is undefined when it holds none, or + * when the query didn't count them. + */ + lastDumpAt?: Date; /** The owner's current name, which the playlist's path starts with. */ ownerUsername: string; /** @@ -53,6 +79,40 @@ export interface Playlist { categoryIds?: string[]; } +/** + * Returns when `playlist` last took a dump, or was created if it holds none: + * what the `updated` order sorts by and the cards show. Its title, cover and + * description may change after without bringing it back up a list. + */ +export function playlistActivity( + playlist: Pick, +): Date { + return playlist.lastDumpAt ?? playlist.createdAt; +} + +/** + * Returns the comparison that puts playlists in the order `sort` names, as + * the API pages them, the newer first on a tie. `usePlaylistListSync` finds + * with it where a playlist the socket announces goes in a list. + */ +export function comparePlaylists( + sort: PlaylistSort, +): (a: Playlist, b: Playlist) => number { + const byCreation = (a: Playlist, b: Playlist) => + b.createdAt.getTime() - a.createdAt.getTime(); + switch (sort) { + case "updated": + return (a, b) => + playlistActivity(b).getTime() - playlistActivity(a).getTime() || + byCreation(a, b); + case "dumps": + return (a, b) => + (b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b); + case "new": + return byCreation; + } +} + /** * Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and * by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in diff --git a/shared/test/paths.test.ts b/shared/test/paths.test.ts index b37ef3e5..26cc63fd 100644 --- a/shared/test/paths.test.ts +++ b/shared/test/paths.test.ts @@ -4,6 +4,8 @@ import { feedPath, parsePagePath, playlistPath, + playlistSortQuery, + playlistsPath, searchPath, userPath, } from "../paths.ts"; @@ -37,6 +39,19 @@ Deno.test("builders: one hierarchy, default views bare", () => { assertEquals(searchPath(), "/search"); assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b"); assertEquals(searchPath("dumps", "x"), "/search?q=x"); + assertEquals(playlistsPath(), "/playlists"); + assertEquals(playlistsPath("mixes"), "/playlists/mixes"); + assertEquals(playlistsPath(undefined, "updated"), "/playlists"); + assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new"); + // The query of a list that has other parameters: the default order takes + // an old `sort` out, and leaves the rest. + const category = new URLSearchParams({ category: "mixes", sort: "new" }); + assertEquals( + playlistSortQuery("dumps", category), + "?category=mixes&sort=dumps", + ); + assertEquals(playlistSortQuery("updated", category), "?category=mixes"); + assertEquals(playlistSortQuery("updated"), ""); }); Deno.test("parsePagePath: what a path under a user names", () => { diff --git a/shared/ws.ts b/shared/ws.ts index d50e30a4..d58db62f 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -311,8 +311,8 @@ export interface PlaylistCreatedMessage { /** * Sent by the server when a playlist changes, to every socket if it is public, - * else to its owner's, as when it has just gone private. Its `dumpCount` is - * the socket's user's, as in `playlist_created`. + * else to its owner's, as when it has just gone private. Its `dumpCount` and + * `lastDumpAt` are the socket's user's, as in `playlist_created`. */ export interface PlaylistUpdatedMessage { type: "playlist_updated"; @@ -342,6 +342,11 @@ export interface PlaylistDumpsUpdatedMessage { * private dump is left out. */ dumpIds: string[]; + /** + * When the latest of them was added, the playlist's `lastDumpAt` for this + * socket's user. It is missing when `dumpIds` is empty. + */ + lastDumpAt?: Date; } /** diff --git a/src/App.css b/src/App.css index 73e75e19..06734ca0 100644 --- a/src/App.css +++ b/src/App.css @@ -2289,6 +2289,14 @@ body.has-player .chat-fab { display: flex; align-items: center; gap: 0.5rem; + min-width: 0; +} + +/* A list's order tabs, as the playlists', take a line of their own under the + title where the row has no room for both, rather than scroll beside it. */ +.profile-section-header:has(.playlist-sort-tabs) { + flex-wrap: wrap; + row-gap: 0.25rem; } /* ── Profile (own) page ── */ @@ -2761,11 +2769,9 @@ body.has-player .chat-fab { padding: 1rem; } -/* A list of dump or playlist cards in a group, or right under a page's header - as on /playlists, fills the column, its edges in line with the label's rule - or the header. */ -.page-group .dump-feed, -.page-content--narrow > .dump-feed { +/* A list of dump or playlist cards in a group fills the column, its edges in + line with the label's rule. */ +.page-group .dump-feed { padding: 0; max-width: none; } @@ -4064,9 +4070,8 @@ body.has-player .chat-fab { 0.25); } -/* ── Shared card preview icon ── */ -.dump-card-preview-icon, -.playlist-card-icon { +/* ── Dump card preview icon ── */ +.dump-card-preview-icon { font-size: var(--text-3xl); opacity: 0.7; line-height: 1; @@ -4355,6 +4360,82 @@ body.has-player .chat-fab { } } +/* ── Playlist grid ── + The playlists page shows its cards as tiles, the cover across the tile's + top and the text under it: the same card as in a .dump-feed, laid out by + the list it is in, so every theme's card skin carries over. */ +.playlist-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); + gap: 1rem; +} + +.playlist-grid > li { + animation: card-enter 0.2s ease both; +} + +/* The tiles of a row are as tall as the tallest, each one's text starting at + the same height under covers of one size. */ +.playlist-grid .playlist-card { + display: flex; +} + +.playlist-grid .playlist-card-inner { + flex: 1; + flex-direction: column; + align-items: stretch; + gap: 0.65rem; + padding: 0.6rem 0.6rem 0.8rem; +} + +.playlist-grid .playlist-card-preview { + width: 100%; + height: auto; + aspect-ratio: 1; +} + +/* A cover this size fills the tile: grown as a thumb is, it would spill over + the card's edge. */ +.playlist-grid .playlist-card-inner:hover .playlist-card-preview { + transform: none; +} + +.playlist-grid .playlist-card-preview .thumb-placeholder-initials { + font-size: var(--text-display); +} + +.playlist-grid .playlist-card-body { + margin-left: 0; + padding-inline: 0.15rem; +} + +.playlist-grid .playlist-card-title, +.playlist-grid .playlist-card-description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; +} + +/* Owner, count and date wrap whole rather than run past a narrow tile. */ +.playlist-grid .playlist-card-meta { + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0.1rem 0.6rem; +} + +@media (max-width: 512px) { + .playlist-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } +} + .dump-card-vote { flex-shrink: 0; align-self: center; @@ -5723,7 +5804,7 @@ body.has-player .chat-fab { align-self: center; } -.collections-section .playlist-card-icon { +.collections-section .playlist-card-preview .thumb-placeholder-initials { font-size: var(--text-lg); } diff --git a/src/App.tsx b/src/App.tsx index 0384dae0..a618af4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ * - `/community` and `/community/monthly`, the site's week and month * (`Community`), signed in only. * - `/playlists` is every public playlist, and `/playlists/:categorySlug` a - * playlist category's (`Playlists`). + * playlist category's, in the order `?sort=` names (`Playlists`). * - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs, * their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`, * signed in only), and their playlists at `playlists/:slug` diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx index f01556e1..ee4bebba 100644 --- a/src/components/layout/PageHeader.tsx +++ b/src/components/layout/PageHeader.tsx @@ -17,14 +17,15 @@ export interface PageHeaderProps { title: ReactNode; /** * It goes at the end of the row, such as the notifications' unread count or - * the playlists' category menu. + * the playlists' category menu and order tabs. */ children?: ReactNode; } /** * Renders the page's h1 with its icon, then `children`, as the first block of - * a narrow `PageShell`; `PageGroup`s follow it. + * a `PageShell`: a narrow one's `PageGroup`s follow it, or the playlists + * page's grid. */ export function PageHeader( { icon, title, children }: PageHeaderProps, diff --git a/src/components/playlist/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx index e3dfe6a8..10d2952b 100644 --- a/src/components/playlist/PlaylistCard.tsx +++ b/src/components/playlist/PlaylistCard.tsx @@ -5,18 +5,20 @@ * @module */ -import { Link, useNavigate } from "react-router"; +import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; -import type { Playlist } from "../../../shared/playlist.ts"; +import { type Playlist, playlistActivity } from "../../../shared/playlist.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useCardLink } from "../../hooks/shared/useCardLink.ts"; import { isPlaylistVisited, isRecent, markPlaylistVisited, } from "../../utils/shared/visited.ts"; +import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx"; import { Tooltip } from "../shared/Tooltip.tsx"; import { CategoryChips } from "../category/CategoryChips.tsx"; import { userPath } from "../../../shared/paths.ts"; @@ -41,16 +43,21 @@ export interface PlaylistCardProps { } /** - * Renders a playlist as a card that opens the playlist when clicked, with the - * playlist categories it is filed under. Another user's recent playlist shows - * an unread dot until it is opened on this device. The card is an `
  • `, for - * a `
      `. + * Renders a playlist as a card that opens the playlist when clicked, in a new + * tab on a middle click (`useCardLink`), with its cover, or a tile of its own + * colour without one, the playlist categories it is filed under and when it + * last took a dump. Another user's recent playlist shows an unread dot until + * it is opened on this device. The card is an `
    • `, for a `
        `, whose + * class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`. */ export function PlaylistCard( { playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps, ): React.JSX.Element { - const navigate = useNavigate(); const { mediaToken } = useAuth(); + const { cardProps } = useCardLink( + playlistUrl(playlist), + () => markPlaylistVisited(playlist.id), + ); const unread = !isOwner && isRecent(playlist.createdAt) && !isPlaylistVisited(playlist.id); const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic); @@ -58,18 +65,12 @@ export function PlaylistCard( // room for its text only, and none at all when it is a lone image. const description = playlist.description && markdownToPlainText(playlist.description, ""); - - function handleNavigate() { - markPlaylistVisited(playlist.id); - navigate(playlistUrl(playlist)); - } + const activity = playlistActivity(playlist); + const ago = relativeTime(activity); return (
      • -
        +
        {playlist.imageMime ? ( @@ -81,7 +82,12 @@ export function PlaylistCard( decoding="async" /> ) - : 📋} + : ( + + )}
        )} - -
        diff --git a/src/components/playlist/PlaylistCardList.tsx b/src/components/playlist/PlaylistCardList.tsx index 849ac096..6f0ce9ec 100644 --- a/src/components/playlist/PlaylistCardList.tsx +++ b/src/components/playlist/PlaylistCardList.tsx @@ -15,18 +15,24 @@ export interface PlaylistCardListProps { onDelete?: (playlist: Playlist) => void; /** Passed to each {@link PlaylistCard}. */ privateBadgeOnly?: boolean; + /** + * Lays the cards out as tiles, cover first, several to a row, for a page + * that is a list of playlists and nothing else: the playlists page. + */ + grid?: boolean; } /** - * Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being - * its owner's when the playlist is the signed-in user's. + * Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a + * row, or as a `.playlist-grid` of them, a card being its owner's when the + * playlist is the signed-in user's. */ export function PlaylistCardList( - { playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps, + { playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps, ): React.JSX.Element { const { user } = useAuth(); return ( -
          +
            {playlists.map((p) => ( void; + /** Goes before the tabs, as the playlists page's category menu does. */ + leading?: ReactNode; +} + +const LABELS: Record = { + updated: Updated, + new: New, + dumps: Most dumps, +}; + +/** Renders one tab per order of `PLAYLIST_SORTS`, `sort` being the active one. */ +export function PlaylistSortTabs( + { sort, onChange, leading }: PlaylistSortTabsProps, +): React.JSX.Element { + return ( + ({ key, label: LABELS[key] }))} + activeTab={sort} + onChange={onChange} + leading={leading} + className="playlist-sort-tabs" + /> + ); +} diff --git a/src/components/profile/ProfileFollowTab.tsx b/src/components/profile/ProfileFollowTab.tsx index b54108f8..288b246c 100644 --- a/src/components/profile/ProfileFollowTab.tsx +++ b/src/components/profile/ProfileFollowTab.tsx @@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) { usePlaylistListSync( state.status === "loaded" ? state.data.items : [], setItems, - { noNewEntries: true }, ); return ( ); } @@ -74,26 +83,28 @@ export function ProfilePlaylistsTab( * Renders the playlists `user` made, from `GET /api/users/:userId/playlists`, * the private ones to their owner only, a page at a time as the list scrolls; * with `categorySlug`, only those in that playlist category, which the menu in - * the title row switches. The socket keeps them live, and going back restores - * the list and the scroll. The owner can make one, which goes first, and - * delete one. + * the title row switches, before the tabs that change the order. The socket + * keeps them live, and going back restores the list and the scroll. The owner + * can make one, which goes first, and delete one. */ function PlaylistList( - { user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & { + { user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & { categorySlug?: string; + sort: PlaylistSort; }, ): React.JSX.Element { const [, setParams] = useSearchParams(); const { user: me, authFetch } = useAuth(); const { categories } = useCategories("playlist"); const { cached, saveState } = useFeedCache( - `feed:profile-playlists:${user.id}${ - categorySlug ? `:${categorySlug}` : "" - }`, + `feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`, hydratePlaylist, ); const url = `${userApiUrl(user, "playlists")}${ - categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" + playlistSortQuery( + sort, + new URLSearchParams(categorySlug ? { category: categorySlug } : {}), + ) }`; const { state, setItems: setAllItems, loadMore, resync } = usePagedList< Playlist @@ -116,6 +127,7 @@ function PlaylistList( usePlaylistListSync(loaded?.items ?? [], setItems, { isOwner: isOwnProfile, ownerId: user.id, + sort, }); useOnReconnect(resync); @@ -149,15 +161,24 @@ function PlaylistList( title={Playlists} actions={
            - - setParams((p) => { - if (slug) p.set("category", slug); - else p.delete("category"); - return p; - })} + + setParams((p) => playlistSortQuery(s, p), { replace: true })} + leading={ + + setParams((p) => { + if (slug) p.set("category", slug); + else p.delete("category"); + return p; + })} + /> + } /> {isOwnProfile && ( ` and not a link, a link's - * behaviour: a middle or modified click opens the dump in a new tab. + * Makes a dump's card open the dump as a link would, and mark it visited. * * @module */ -import type { MouseEvent } from "react"; -import { useNavigate } from "react-router"; import type { Dump } from "../../../shared/dump.ts"; import { dumpUrl } from "../../utils/shared/urls.ts"; import { markDumpVisited } from "../../utils/shared/visited.ts"; - -/** Checks whether `e` happened over an anchor, which keeps its own target. */ -function isOverLink(e: MouseEvent) { - return (e.target as Element).closest("a") !== null; -} - -function wantsNewTab(e: MouseEvent) { - return e.ctrlKey || e.metaKey || e.shiftKey; -} +import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.ts"; /** - * Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every - * way it opens the dump marks it visited, which clears its unread dot. - */ -export interface UseDumpCardLinkResult { - /** Opens the dump in this tab, for a handler that would rather not play. */ - go: () => void; - /** - * Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or - * shift click away from any anchor. A click handler that plays media calls - * it first, so that such a click opens the dump instead. - */ - handledAsNewTab: (e: MouseEvent) => boolean; - /** Spread onto the card element. */ - cardProps: { - onClick: (e: MouseEvent) => void; - onMouseDown: (e: MouseEvent) => void; - onAuxClick: (e: MouseEvent) => void; - }; -} - -/** - * Returns the handlers that make the card of `dump` open it: in this tab on a - * click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors - * inside the card, such as the title or a category, keep their own target. + * Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and + * `JournalCard`: every way they open the dump marks it visited, which clears + * its unread dot. */ export function useDumpCardLink( dump: Pick, -): UseDumpCardLinkResult { - const navigate = useNavigate(); - - function openInNewTab() { - markDumpVisited(dump.id); - globalThis.open(dumpUrl(dump), "_blank", "noopener"); - } - - function go() { - markDumpVisited(dump.id); - navigate(dumpUrl(dump)); - } - - return { - go, - handledAsNewTab(e: MouseEvent) { - if (!wantsNewTab(e) || isOverLink(e)) return false; - e.preventDefault(); - e.stopPropagation(); - openInNewTab(); - return true; - }, - cardProps: { - onClick(e: MouseEvent) { - if (wantsNewTab(e)) openInNewTab(); - else go(); - }, - // Windows and Linux browsers start autoscroll on a middle press, which - // eats the auxclick that follows. - onMouseDown(e: MouseEvent) { - if (e.button === 1 && !isOverLink(e)) e.preventDefault(); - }, - onAuxClick(e: MouseEvent) { - if (e.button !== 1 || isOverLink(e)) return; - e.preventDefault(); - openInNewTab(); - }, - }, - }; +): UseCardLinkResult { + return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id)); } diff --git a/src/hooks/playlist/usePlaylistListSync.ts b/src/hooks/playlist/usePlaylistListSync.ts index d5eb2b33..406efe46 100644 --- a/src/hooks/playlist/usePlaylistListSync.ts +++ b/src/hooks/playlist/usePlaylistListSync.ts @@ -7,10 +7,14 @@ */ import { useEffect, useLayoutEffect, useRef } from "react"; -import type { Playlist } from "../../../shared/playlist.ts"; +import { + comparePlaylists, + type Playlist, + type PlaylistSort, +} from "../../../shared/playlist.ts"; import { useWS, useWSEvent } from "../ws/useWS.ts"; import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts"; -import { withByDate } from "../../utils/feed/pagedList.ts"; +import { withInOrder } from "../../utils/feed/pagedList.ts"; /** Passed to {@link usePlaylistListSync} by each page, to fit its list. */ export interface PlaylistListSyncOptions { @@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions { */ ownerId?: string; /** - * Adds no new playlist, for a list of followed playlists, since the socket - * doesn't say what the user follows, or of search results. Playlists - * already listed still change, go, and come back where they were. + * The order the list is in, for one the API sorts: the playlists page's and + * a profile's. A list in an order of its own leaves it out, the followed + * playlists being by the date they were followed and a search's results + * the search's: it takes no new playlist, since the socket says neither + * what the user follows nor what a search matches, and moves none. + * Playlists already listed still change, go, and come back where they + * were. */ - noNewEntries?: boolean; + sort?: PlaylistSort; } /** - * Applies the socket's playlist events to `items`, the loaded list, newest - * first, which `setPlaylists` updates. A created playlist goes where its date - * puts it, first when new, an updated one changes in place or leaves when it - * turns private (unless `isOwner`), one that left comes back where it was - * when it returns, a deleted one leaves, and a playlist's `dumpCount` follows - * its dumps. A renamed user's playlists take their new name. + * Applies the socket's playlist events to `items`, the loaded list, which + * `setPlaylists` updates. A created playlist goes where the list's order puts + * it, an updated one changes in place or leaves when it turns private (unless + * `isOwner`), one that left comes back where it was when it returns, a + * deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow + * its dumps. One that a change brings up the order moves up to its place, as + * a playlist does in the `updated` order when it takes a dump; one that falls + * stays where it is, its place being perhaps on a page not loaded. A renamed + * user's playlists take their new name. */ export function usePlaylistListSync( items: Playlist[], @@ -53,7 +64,14 @@ export function usePlaylistListSync( }); useWSEvent("playlist", (ev) => { - const { isOwner, ownerId, noNewEntries } = options ?? {}; + const { isOwner, ownerId, sort } = options ?? {}; + const compare = sort && comparePlaylists(sort); + // Returns `list` with the playlist at `idx` before the ones it now comes + // before, if it rose. + const risen = (list: Playlist[], idx: number) => + compare && idx > 0 && compare(list[idx], list[idx - 1]) < 0 + ? withInOrder(list.toSpliced(idx, 1), list[idx], compare) + : list; const shown = ev.playlist && (ev.playlist.isPublic || isOwner) && (!ownerId || ev.playlist.userId === ownerId); @@ -66,15 +84,13 @@ export function usePlaylistListSync( const idx = prev.findIndex((p) => p.id === playlist.id); if (idx !== -1) { if (!shown) return prev.filter((p) => p.id !== playlist.id); - const next = [...prev]; - next[idx] = playlist; - return next; + return risen(prev.with(idx, playlist), idx); } if (!shown) return prev; if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx); // An update never adds a playlist the list doesn't hold. - if (ev.type === "updated" || noNewEntries) return prev; - return withByDate(prev, playlist); + if (ev.type === "updated" || !compare) return prev; + return withInOrder(prev, playlist, compare); } if (ev.type === "deleted") { @@ -84,10 +100,13 @@ export function usePlaylistListSync( if (ev.type === "dumps_updated" && ev.dumpIds) { const idx = prev.findIndex((p) => p.id === ev.playlistId); if (idx === -1) return prev; - const next = [...prev]; // The dumps this user may see, which are those the playlist counts. - next[idx] = { ...next[idx], dumpCount: ev.dumpIds.length }; - return next; + const playlist = { + ...prev[idx], + dumpCount: ev.dumpIds.length, + lastDumpAt: ev.lastDumpAt, + }; + return risen(prev.with(idx, playlist), idx); } return prev; diff --git a/src/hooks/shared/useCardLink.ts b/src/hooks/shared/useCardLink.ts new file mode 100644 index 00000000..9860a18a --- /dev/null +++ b/src/hooks/shared/useCardLink.ts @@ -0,0 +1,91 @@ +/** + * Gives a dump's or a playlist's card, which is a clickable `
          • ` 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(); + }, + }, + }; +} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 87c9a56a..8a90572e 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,17 +1,21 @@ /** * Renders every public playlist, `/playlists`, and those of one playlist - * category, `/playlists/:categorySlug`, newest first. + * category, `/playlists/:categorySlug`, in the order `?sort=` names. * * @module */ import { useCallback } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; -import type { Playlist } from "../../shared/playlist.ts"; -import { playlistsPath } from "../../shared/paths.ts"; +import { + parsePlaylistSort, + type Playlist, + type PlaylistSort, +} from "../../shared/playlist.ts"; +import { playlistSortQuery, playlistsPath } from "../../shared/paths.ts"; import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts"; import { deserializePlaylist, @@ -34,18 +38,21 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx"; import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; +import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ListState } from "../components/shared/ListState.tsx"; import { NotFound } from "./NotFound.tsx"; /** * Renders the list for the route's category, or every public playlist - * without one. Each category's list is another, which the page mounts - * afresh. A slug that names no playlist category, once the categories have - * loaded, is a page not found. + * without one, in the order of the URL's `?sort=`. Each category's list in + * each order is another, which the page mounts afresh. A slug that names no + * playlist category, once the categories have loaded, is a page not found. */ export function Playlists(): React.JSX.Element { const { categorySlug } = useParams(); + const [params] = useSearchParams(); + const sort = parsePlaylistSort(params.get("sort")); const { categories, isLoaded } = useCategories("playlist"); const category = categorySlug ? categories.find((c) => c.slug === categorySlug) @@ -55,31 +62,41 @@ export function Playlists(): React.JSX.Element { if (categorySlug && isLoaded && !category) return ; return ( ); } /** - * Renders the public playlists, from `GET /api/playlists`, a page at a time - * as the list scrolls; with `categorySlug`, only those in that playlist - * category, which the menu in the title row switches. The socket keeps them - * live, a reconnection fetches the pages loaded again, and going back - * restores the list and the scroll. + * Renders the public playlists, from `GET /api/playlists`, as a grid of + * covers, a page at a time as the list scrolls; with `categorySlug`, only + * those in that playlist category, which the menu before the tabs switches, + * keeping the order, while a tab changes the order in place of the current + * history entry, as a feed's tabs do. The socket keeps them live, a + * reconnection fetches the pages loaded again, and going back restores the + * list and the scroll. */ function PlaylistsList( - { categorySlug, categoryId }: { categorySlug?: string; categoryId?: string }, + { categorySlug, categoryId, sort }: { + categorySlug?: string; + categoryId?: string; + sort: PlaylistSort; + }, ) { const navigate = useNavigate(); const { user, authFetch } = useAuth(); const { cached, saveState } = useFeedCache( - `feed:playlists:${categorySlug ?? "all"}`, + `feed:playlists:${categorySlug ?? "all"}:${sort}`, hydratePlaylist, ); const url = `${API_URL}/api/playlists${ - categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" + playlistSortQuery( + sort, + new URLSearchParams(categorySlug ? { category: categorySlug } : {}), + ) }`; const { state, setItems: setAllItems, loadMore, resync } = usePagedList< Playlist @@ -97,6 +114,7 @@ function PlaylistsList( usePlaylistListSync( loaded?.items ?? [], useCategoryFilter(setAllItems, categoryId), + { sort }, ); useOnReconnect(resync); @@ -110,12 +128,19 @@ function PlaylistsList( useScrollRestore(cached?.scrollY, !!loaded); return ( - + Playlists}> - navigate(playlistsPath(slug))} + + navigate(playlistsPath(categorySlug, s), { replace: true })} + leading={ + navigate(playlistsPath(slug, sort))} + /> + } /> {({ data }) => ( <> - + ({ ...d, playlists: fn(d.playlists) })), [setData], ), - { isOwner: true, noNewEntries: true }, + { isOwner: true }, ); // What changed while the socket was down: every page of dumps loaded is diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 963b5c69..deb930c2 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -44,8 +44,12 @@ import { NotFound } from "./NotFound.tsx"; const OVERVIEW = "overview"; type Tab = ProfileTab | typeof OVERVIEW; const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS]; -/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */ -const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"]; +/** + * The lists of dumps, which one `?category=` narrows, so it passes from one to + * the other. Any other change of tab drops the query: a tab's is its own, as + * the playlists' category, of another kind, and their `?sort=`. + */ +const DUMP_LISTS: readonly (Tab | null)[] = ["dumps", "upvoted"]; /** * Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow @@ -168,7 +172,12 @@ export function UserPublicProfile(): React.JSX.Element { ]} activeTab={tab} onChange={(key) => - setTab(key, DUMP_LISTS.includes(key) ? undefined : "")} + setTab( + key, + DUMP_LISTS.includes(key) && DUMP_LISTS.includes(tab) + ? undefined + : "", + )} className="profile-tabs-scroller" innerClassName="profile-tabs" /> diff --git a/src/test/components/playlist/PlaylistCard.test.tsx b/src/test/components/playlist/PlaylistCard.test.tsx index f2b7304b..7b07a872 100644 --- a/src/test/components/playlist/PlaylistCard.test.tsx +++ b/src/test/components/playlist/PlaylistCard.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx"; import { makePlaylist } from "../../fixtures.ts"; @@ -38,6 +38,50 @@ describe("PlaylistCard", () => { expect(currentPath()).toBe("/@ann/playlists/mix"); }); + test("a middle click anywhere on the card opens it in a new tab", () => { + const opened = vi.spyOn(globalThis, "open").mockReturnValue(null); + const p = makePlaylist({ slug: "mix", ownerUsername: "ann" }); + const { container } = renderWithContexts( +
              + +
            , + ); + 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( +
              + +
            , + ); + expect(screen.getByText("updated 2 days ago")).toBeInTheDocument(); + filled.unmount(); + renderWithContexts( +
              + +
            , + ); + expect(screen.getByText("created last month")).toBeInTheDocument(); + }); + test("the description shows as text, without its Markdown or its images", () => { const withText = renderWithContexts(
              diff --git a/src/test/hooks/playlist/usePlaylistListSync.test.tsx b/src/test/hooks/playlist/usePlaylistListSync.test.tsx index f49f1289..5c3380d6 100644 --- a/src/test/hooks/playlist/usePlaylistListSync.test.tsx +++ b/src/test/hooks/playlist/usePlaylistListSync.test.tsx @@ -26,7 +26,9 @@ describe("usePlaylistListSync", () => { } test("prepends a created playlist once", () => { - const { send, items } = setup([makePlaylist({ id: "a" })]); + const { send, items } = setup([makePlaylist({ id: "a" })], { + sort: "new", + }); const p = makePlaylist({ id: "b" }); send("created", p); send("created", { ...p }); @@ -36,28 +38,83 @@ describe("usePlaylistListSync", () => { test("a playlist made public goes where its date puts it", () => { const at = (id: string, day: number) => makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) }); - const { send, items } = setup([at("c", 30), at("a", 10)]); + const { send, items } = setup([at("c", 30), at("a", 10)], { + sort: "new", + }); send("created", at("b", 20)); // Older than every one loaded: a later page holds it. send("created", at("old", 1)); expect(ids(items())).toEqual(["c", "b", "a"]); }); - test("created: filtered by owner, privacy and noNewEntries", () => { + test("created: filtered by owner and privacy, and added to a sorted list only", () => { const p = makePlaylist({ id: "p", userId: "them" }); - const byOwner = setup([], { ownerId: "me" }); + const byOwner = setup([], { ownerId: "me", sort: "new" }); byOwner.send("created", p); expect(byOwner.items()).toEqual([]); - const priv = setup([]); + const priv = setup([], { sort: "new" }); priv.send("created", { ...p, isPublic: false }); expect(priv.items()).toEqual([]); - const followed = setup([], { noNewEntries: true }); + // A list in an order of its own, as the followed playlists'. + const followed = setup([]); followed.send("created", p); expect(followed.items()).toEqual([]); }); + test("a created playlist goes where the list's order puts it", () => { + const at = (id: string, day: number, dumpCount: number, added?: number) => + makePlaylist({ + id, + dumpCount, + createdAt: new Date(Date.UTC(2026, 0, day)), + lastDumpAt: added ? new Date(Date.UTC(2026, 1, added)) : undefined, + }); + const loaded = [at("a", 1, 3, 20), at("b", 2, 2, 10), at("c", 3, 0)]; + // Made between b and c, it took a dump after b did and before a did. + const made = at("new", 2.5, 1, 15); + + const updated = setup(loaded, { sort: "updated" }); + updated.send("created", made); + expect(ids(updated.items())).toEqual(["a", "new", "b", "c"]); + + const dumps = setup(loaded, { sort: "dumps" }); + dumps.send("created", made); + expect(ids(dumps.items())).toEqual(["a", "b", "new", "c"]); + }); + + test("a playlist that rises in the order moves up, one that falls stays", () => { + const at = (id: string, day: number) => + makePlaylist({ + id, + dumpCount: 1, + lastDumpAt: new Date(Date.UTC(2026, 0, day)), + }); + const loaded = [at("a", 30), at("b", 20), at("c", 10)]; + const dumped = (playlistId: string, day?: number) => ({ + type: "dumps_updated" as const, + playlistId, + dumpIds: day ? ["x", "y"] : [], + lastDumpAt: day ? new Date(Date.UTC(2026, 0, day)) : undefined, + }); + + const sorted = setup(loaded, { sort: "updated" }); + sorted.emit("playlist", dumped("c", 25)); + expect(ids(sorted.items())).toEqual(["a", "c", "b"]); + expect(sorted.items()[1].dumpCount).toBe(2); + sorted.send("updated", { ...at("b", 31), title: "Risen" }); + expect(ids(sorted.items())).toEqual(["b", "a", "c"]); + // Emptied, it comes after every other, perhaps on a page not loaded. + sorted.emit("playlist", dumped("b")); + expect(ids(sorted.items())).toEqual(["b", "a", "c"]); + expect(sorted.items()[0].lastDumpAt).toBeUndefined(); + + const unsorted = setup(loaded); + unsorted.emit("playlist", dumped("c", 25)); + expect(ids(unsorted.items())).toEqual(["a", "b", "c"]); + }); + test("updated: in place, removed when private, never added by itself", () => { const p = makePlaylist({ id: "p", title: "one" }); const other = makePlaylist({ id: "q" }); @@ -72,7 +129,7 @@ describe("usePlaylistListSync", () => { test("one that left comes back where it was, even to a followed list", () => { const initial = ["a", "b", "c"].map((id) => makePlaylist({ id })); - const { set, send, items } = setup(initial, { noNewEntries: true }); + const { set, send, items } = setup(initial); // Gone private for others, as a deletion. set({ deletedPlaylistIds: new Set(["b"]) }); expect(ids(items())).toEqual(["a", "c"]); diff --git a/src/test/pages/Playlists.test.tsx b/src/test/pages/Playlists.test.tsx index 58b95072..e5584dad 100644 --- a/src/test/pages/Playlists.test.tsx +++ b/src/test/pages/Playlists.test.tsx @@ -85,6 +85,61 @@ describe("Playlists", () => { expect(screen.queryByText("Filed")).toBeNull(); }); + test("the tabs change the order, in the URL, which picking a category keeps", async () => { + route( + "GET /api/playlists", + (req) => + page([ + makePlaylist({ + title: `By ${req.url.searchParams.get("sort") ?? "default"}`, + }), + ]), + ); + const { user } = open("/playlists"); + expect(await screen.findByText("By default")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Most dumps" })); + expect(currentPath()).toBe("/playlists?sort=dumps"); + expect(await screen.findByText("By dumps")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "All" })); + await user.click(screen.getByRole("option", { name: "Mixes" })); + expect(currentPath()).toBe("/playlists/mixes?sort=dumps"); + const asked = calls("GET /api/playlists").at(-1)!.url.searchParams; + expect([asked.get("category"), asked.get("sort")]).toEqual([ + "mixes", + "dumps", + ]); + // The default order is the bare path, and so is one the URL misnames. + await user.click(screen.getByRole("button", { name: "Updated" })); + expect(currentPath()).toBe("/playlists/mixes"); + }); + + test("a playlist that takes a dump goes first in the updated order", async () => { + const at = (id: string, day: number) => + makePlaylist({ + id, + title: `Playlist ${id}`, + dumpCount: 1, + lastDumpAt: new Date(Date.UTC(2026, 0, day)), + }); + route("GET /api/playlists", () => page([at("a", 20), at("b", 10)])); + const { socket } = open("/playlists"); + await screen.findByText("Playlist a"); + socket.welcome(); + socket.receive({ + type: "playlist_dumps_updated", + playlistId: "b", + dumpIds: ["x", "y"], + lastDumpAt: "2026-01-25T00:00:00.000Z", + }); + expect( + screen.getAllByRole("link", { name: /^Playlist \w$/ }).map((a) => + a.textContent + ), + ).toEqual(["Playlist b", "Playlist a"]); + expect(screen.getByText("2 dumps")).toBeInTheDocument(); + }); + test("a slug that names no playlist category is not found", async () => { route("GET /api/playlists", () => page([])); open("/playlists/music"); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index c9efa95a..24781261 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -292,6 +292,42 @@ describe("UserPublicProfile", () => { expect(screen.queryByText("Filed mix")).not.toBeInTheDocument(); }); + test("the playlists tab's order is in its URL, beside the category, and no other tab's", async () => { + const mixes = makeCategory({ + id: "pc1", + kind: "playlist", + slug: "mixes", + name: "Mixes", + }); + const { user } = open({ url: "/@ann/playlists", categories: [mixes] }); + route( + "GET /api/users/:u/playlists", + () => page([makePlaylist({ title: "Mix", userId: "ann" })]), + ); + const asked = () => + calls("GET /api/users/:u/playlists").at(-1)!.url.searchParams; + await screen.findByText("Mix"); + expect(asked().get("sort")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "New" })); + expect(currentPath()).toBe("/@ann/playlists?sort=new"); + await waitFor(() => expect(asked().get("sort")).toBe("new")); + await user.click(screen.getByRole("button", { name: "All" })); + await user.click(screen.getByRole("option", { name: "Mixes" })); + expect(currentPath()).toBe("/@ann/playlists?sort=new&category=mixes"); + await waitFor(() => expect(asked().get("category")).toBe("mixes")); + expect(asked().get("sort")).toBe("new"); + // The default order is the bare tab's. + await user.click(screen.getByRole("button", { name: "Updated" })); + expect(currentPath()).toBe("/@ann/playlists?category=mixes"); + + // Neither the order nor a playlist category means anything to the dumps. + await user.click(screen.getByRole("button", { name: "Most dumps" })); + expect(currentPath()).toBe("/@ann/playlists?category=mixes&sort=dumps"); + await user.click(screen.getByRole("button", { name: "Dumps" })); + expect(currentPath()).toBe("/@ann/dumps"); + }); + test("the dumps tab loads more as its end scrolls into view", async () => { open({ url: "/@ann/dumps" }); // After open's, so that it wins. diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 565dbf43..a2e4d022 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -478,7 +478,6 @@ [data-style="nyt"] .notifications-empty-icon, [data-style="nyt"] .page-title-icon, [data-style="nyt"] .dump-card-preview-icon, -[data-style="nyt"] .playlist-card-icon, [data-style="nyt"] .rich-content-compact-icon, [data-style="nyt"] .journal-card-glyph, [data-style="nyt"] .fdz__file-icon, diff --git a/src/utils/feed/pagedList.ts b/src/utils/feed/pagedList.ts index 1d92071e..4b4929a8 100644 --- a/src/utils/feed/pagedList.ts +++ b/src/utils/feed/pagedList.ts @@ -105,22 +105,39 @@ export function withFirst(items: T[], item: T): T[] { } /** - * Returns `items`, newest first as the API pages them, with `item` where its - * `createdAt` puts it: before the first item no newer than it. An item older - * than every one loaded is left to a later page, and `items` returned as they - * are, as they are when `item` is already there. The list sync hooks put a - * new or newly visible dump or playlist in a list with it. + * Returns `items`, in the order of `compare` as the API pages them, with + * `item` where that order puts it: before the first item that doesn't come + * before it. An item that comes after every one loaded is left to a later + * page, and `items` returned as they are, as they are when `item` is already + * there. `usePlaylistListSync` puts a new, newly visible or risen playlist in + * a list with it, whatever its order. + */ +export function withInOrder( + 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( items: T[], item: T, ): T[] { - if (items.some((i) => i.id === item.id)) return items; - if (items.length === 0) return [item]; - const time = item.createdAt.getTime(); - const idx = items.findIndex((i) => i.createdAt.getTime() <= time); - if (idx === -1) return items; - return [...items.slice(0, idx), item, ...items.slice(idx)]; + return withInOrder( + items, + item, + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); } /** diff --git a/src/utils/shared/deserialize.ts b/src/utils/shared/deserialize.ts index d0413f04..0d2f9deb 100644 --- a/src/utils/shared/deserialize.ts +++ b/src/utils/shared/deserialize.ts @@ -95,7 +95,10 @@ export function deserializeChatMessage(raw: Wire): ChatMessage { /** Returns `raw` as a {@link Playlist}. */ export function deserializePlaylist(raw: Wire): Playlist { - return dates(raw); + return { + ...dates(raw), + lastDumpAt: raw.lastDumpAt ? new Date(raw.lastDumpAt) : undefined, + }; } /** @@ -110,7 +113,10 @@ export function hydratePlaylist(raw: unknown): Playlist { export function deserializePlaylistWithDumps( raw: Wire, ): PlaylistWithDumps { - return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) }; + return { + ...deserializePlaylist(raw), + dumps: raw.dumps.map(deserializeDump), + }; } /** Returns `raw` as a {@link PlaylistMembership}. */