From 53515ce46f8735d9fe59f4d4e1d37e7198aeda50 Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 9 Oct 2026 21:53:16 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20playlists=20have=20categories=20of=20thei?= =?UTF-8?q?r=20own=20=E2=80=94=20a=20set=20apart=20from=20the=20dumps',=20?= =?UTF-8?q?which=20admins=20manage=20on=20/manage=20under=20"Playlist=20ca?= =?UTF-8?q?tegories",=20next=20to=20"Dump=20categories".=20Each=20category?= =?UTF-8?q?=20now=20has=20a=20kind,=20dump=20or=20playlist,=20its=20slug?= =?UTF-8?q?=20and=20name=20unique=20within=20its=20kind=20only;=20migratio?= =?UTF-8?q?n=200025=20rebuilds=20categories=20with=20the=20column,=20every?= =?UTF-8?q?=20existing=20one=20becoming=20a=20dump=20category=20with=20its?= =?UTF-8?q?=20dumps,=20and=20adds=20playlist=5Fcategories.=20A=20playlist'?= =?UTF-8?q?s=20owner=20files=20it=20under=20any=20of=20them=20in=20the=20c?= =?UTF-8?q?reate=20and=20edit=20forms,=20and=20it=20shows=20them=20as=20ch?= =?UTF-8?q?ips=20on=20its=20card=20and=20its=20page,=20each=20leading=20to?= =?UTF-8?q?=20/playlists/.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/ a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 --- api/db/migrate.ts | 2 + api/db/migrations/0025_playlist_categories.ts | 110 +++++++ api/db/schema.sql | 18 +- api/lib/reserved-slugs.ts | 21 +- api/model/category.ts | 9 +- api/routes/categories.ts | 14 +- api/routes/follows.ts | 2 +- api/routes/playlists.ts | 27 +- api/routes/search.ts | 26 +- api/routes/users.ts | 22 +- api/services/category-service.ts | 197 ++++++++---- api/services/dump-service.ts | 102 +++--- api/services/follow-service.ts | 20 +- api/services/playlist-service.ts | 130 ++++++-- api/test/db/migrate.test.ts | 4 +- .../0025_playlist_categories.test.ts | 108 +++++++ api/test/helpers.ts | 12 +- api/test/lib/reserved-slugs.test.ts | 3 +- api/test/routes/categories.test.ts | 44 ++- api/test/routes/dumps.test.ts | 12 + api/test/routes/playlists.test.ts | 124 ++++++++ api/test/routes/search.test.ts | 42 +++ api/test/validators/category.test.ts | 25 +- api/validators/category.ts | 8 +- api/validators/playlist.ts | 14 +- locales/en.mjs | 2 +- locales/en.po | 294 +++++++++--------- locales/fr.mjs | 2 +- locales/fr.po | 294 +++++++++--------- shared/category.ts | 42 ++- shared/paths.ts | 21 +- shared/playlist.ts | 9 + src/App.css | 35 ++- src/App.tsx | 10 +- src/components/category/CategoryChips.tsx | 62 ++++ src/components/category/CategoryManager.tsx | Bin 7657 -> 8093 bytes src/components/category/CategorySelect.tsx | 19 +- src/components/category/CategorySwitcher.tsx | 24 +- src/components/dump/DumpCard.tsx | 28 +- .../dump/create/DumpCreateModal.tsx | 14 +- src/components/feed/FeedTabBar.tsx | 1 + src/components/layout/PageHeader.tsx | 12 +- src/components/layout/UserMenu.tsx | 14 +- src/components/playlist/PlaylistCard.tsx | 17 +- .../playlist/PlaylistCreateForm.tsx | 12 +- src/components/playlist/PlaylistEditForm.tsx | 30 +- src/components/playlist/PlaylistHeader.tsx | 5 +- src/components/profile/ProfileDumpsTab.tsx | 1 + .../profile/ProfilePlaylistsTab.tsx | 81 ++++- src/contexts/category/CategoriesContext.ts | 16 +- src/contexts/category/CategoriesProvider.tsx | 16 +- src/hooks/category/useCategories.ts | 47 ++- src/hooks/category/useCategoryFilter.ts | 30 ++ src/hooks/category/useCategorySelection.ts | 26 ++ src/hooks/dump/useUserDumpFeed.ts | 14 +- src/pages/Dump.tsx | 20 +- src/pages/DumpEdit.tsx | 13 +- src/pages/Index.tsx | 2 +- src/pages/Manage.tsx | 25 +- src/pages/Playlists.tsx | 135 ++++++++ src/pages/Search.tsx | 59 +++- .../category/CategorySelect.test.tsx | 8 +- .../category/CategorySwitcher.test.tsx | 8 +- .../category/CategoriesProvider.test.tsx | 22 +- src/test/fixtures.ts | 1 + src/test/pages/Manage.test.tsx | 58 +++- src/test/pages/PlaylistDetail.test.tsx | 60 +++- src/test/pages/Playlists.test.tsx | 94 ++++++ src/test/pages/Search.test.tsx | 50 ++- src/test/pages/UserPublicProfile.test.tsx | 41 +++ src/test/render.tsx | 1 - 71 files changed, 2166 insertions(+), 705 deletions(-) create mode 100644 api/db/migrations/0025_playlist_categories.ts create mode 100644 api/test/db/migrations/0025_playlist_categories.test.ts create mode 100644 src/components/category/CategoryChips.tsx create mode 100644 src/hooks/category/useCategoryFilter.ts create mode 100644 src/hooks/category/useCategorySelection.ts create mode 100644 src/pages/Playlists.tsx create mode 100644 src/test/pages/Playlists.test.tsx diff --git a/api/db/migrate.ts b/api/db/migrate.ts index d00433ce..4b97aa39 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -31,6 +31,7 @@ import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts"; import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts"; import { up as up0023MentionEveryone } from "./migrations/0023_mention_everyone.ts"; import { up as up0024Federation } from "./migrations/0024_federation.ts"; +import { up as up0025PlaylistCategories } from "./migrations/0025_playlist_categories.ts"; interface Migration { name: string; @@ -74,6 +75,7 @@ const MIGRATIONS: Migration[] = [ { name: "0022_reserve_community", up: up0022ReserveCommunity }, { name: "0023_mention_everyone", up: up0023MentionEveryone }, { name: "0024_federation", up: up0024Federation }, + { name: "0025_playlist_categories", up: up0025PlaylistCategories }, ]; /** diff --git a/api/db/migrations/0025_playlist_categories.ts b/api/db/migrations/0025_playlist_categories.ts new file mode 100644 index 00000000..8931ba17 --- /dev/null +++ b/api/db/migrations/0025_playlist_categories.ts @@ -0,0 +1,110 @@ +/** + * Gives playlists categories of their own, a set apart from the dumps': each + * category gets a `kind`, `dump` or `playlist`, its slug and name now unique + * within its kind only, and `playlist_categories` files playlists under them. + * + * `/playlists` becomes the page of every public playlist, so its first segment + * is reserved. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; +import { renameReservedCategories } from "../../lib/reserved-slugs.ts"; + +/** + * Run once by `runMigrations`. Every existing category becomes a dump + * category, filing the same dumps. + * + * SQLite can't change a table's UNIQUE constraints in place, so `categories` + * is rebuilt. `foreign_keys` can't be switched off inside the runner's + * transaction, and dropping `categories` would then empty `dump_categories` + * through its cascade, so both are read, dropped child first, made anew as in + * schema.sql, and refilled. A database made from schema.sql already has the + * `kind` column and is left as it is. + */ +export function up(db: DatabaseSync): void { + const columns = new Set( + (db.prepare(`PRAGMA table_info(categories);`).all() as { name: string }[]) + .map((c) => c.name), + ); + + if (!columns.has("kind")) { + const categories = db.prepare( + `SELECT id, slug, name, position, created_at, updated_at FROM categories;`, + ).all() as { + id: string; + slug: string; + name: string; + position: number; + created_at: string; + updated_at: string | null; + }[]; + const links = db.prepare( + `SELECT dump_id, category_id FROM dump_categories;`, + ).all() as { dump_id: string; category_id: string }[]; + + db.exec(`DROP TABLE dump_categories;`); + db.exec(`DROP TABLE categories;`); + + db.exec( + `CREATE TABLE categories ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')), + slug TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT, + UNIQUE (kind, slug), + UNIQUE (kind, name) +);`, + ); + db.exec( + `CREATE TABLE dump_categories ( + dump_id TEXT NOT NULL, + category_id TEXT NOT NULL, + PRIMARY KEY (dump_id, category_id), + FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +);`, + ); + db.exec( + `CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);`, + ); + + const insertCategory = db.prepare( + `INSERT INTO categories (id, kind, slug, name, position, created_at, updated_at) + VALUES (?, 'dump', ?, ?, ?, ?, ?);`, + ); + for (const c of categories) { + insertCategory.run( + c.id, + c.slug, + c.name, + c.position, + c.created_at, + c.updated_at, + ); + } + const insertLink = db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ); + for (const l of links) insertLink.run(l.dump_id, l.category_id); + } + + db.exec( + `CREATE TABLE IF NOT EXISTS playlist_categories ( + playlist_id TEXT NOT NULL, + category_id TEXT NOT NULL, + PRIMARY KEY (playlist_id, category_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +);`, + ); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_playlist_categories_category ON playlist_categories(category_id);`, + ); + + renameReservedCategories(db); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 42d55c9b..35b6d2e3 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -69,11 +69,14 @@ CREATE TABLE playlist_dumps ( CREATE TABLE categories ( id TEXT PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')), + slug TEXT NOT NULL, + name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, - updated_at TEXT + updated_at TEXT, + UNIQUE (kind, slug), + UNIQUE (kind, name) ); CREATE TABLE dump_categories ( @@ -84,6 +87,14 @@ CREATE TABLE dump_categories ( FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ); +CREATE TABLE playlist_categories ( + playlist_id TEXT NOT NULL, + category_id TEXT NOT NULL, + PRIMARY KEY (playlist_id, category_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + CREATE TABLE comments ( id TEXT PRIMARY KEY, dump_id TEXT NOT NULL, @@ -136,6 +147,7 @@ CREATE INDEX idx_comment_likes_user ON comment_likes(user_id); CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id); CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id); CREATE INDEX idx_dump_categories_category ON dump_categories(category_id); +CREATE INDEX idx_playlist_categories_category ON playlist_categories(category_id); CREATE TABLE follows ( id TEXT PRIMARY KEY, diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index a222c9f9..3230e05e 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -1,9 +1,10 @@ /** - * Checks which slugs a category may take. A category's feed lives at the URL - * root (`/music`), so its slug must not shadow another top-level page. The - * category service runs this check when a category is created or renamed, and - * the migrations that reserve more words rename the categories that had one - * with {@link renameReservedCategories}. + * Checks which slugs a dump category may take. A dump category's feed lives at + * the URL root (`/music`), so its slug must not shadow another top-level page. + * The category service runs this check when a dump category is created or + * renamed, and the migrations that reserve more words rename the categories + * that had one with {@link renameReservedCategories}. A playlist category's + * page is `/playlists/`, under a reserved word, so it reserves nothing. * * Dump and playlist slugs reserve nothing: they live under their owner's * `dumps/` and `playlists/`, where nothing else is named by a user. A user @@ -32,6 +33,7 @@ export const RESERVED_SLUGS: readonly string[] = [ "settings", "manage", "community", + "playlists", "authorize", "federation", "api", @@ -48,8 +50,13 @@ export function isReservedSlug(slug: string): boolean { /** * Renames each category whose slug is now reserved, such as `hot`, to the * first of `-2`, `-3`… that is free, and logs it. Migration 0021 - * runs it for v4's words, 0022 for `community`, and 0024 for `authorize` and - * `federation`. + * runs it for v4's words, 0022 for `community`, 0024 for `authorize` and + * `federation`, and 0025 for `playlists`. + * + * It reads every category: until 0025 gave categories a `kind`, each was a + * dump category, and 0025 makes no other. A later migration that reserves + * more words must leave the playlist categories alone, and limit it to + * `kind = 'dump'`, which the earlier ones can't, having no such column. */ export function renameReservedCategories(db: DatabaseSync): void { const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as { diff --git a/api/model/category.ts b/api/model/category.ts index c74c59f0..098b3b5c 100644 --- a/api/model/category.ts +++ b/api/model/category.ts @@ -6,11 +6,16 @@ */ import type { SQLOutputValue } from "node:sqlite"; -import type { Category } from "../../shared/category.ts"; +import { + type Category, + type CategoryKind, + isCategoryKind, +} from "../../shared/category.ts"; /** Returned by the category service's `SELECT`s on `categories`. */ export interface CategoryRow { id: string; + kind: CategoryKind; slug: string; name: string; /** Sorts the categories, lowest first. */ @@ -24,6 +29,7 @@ export interface CategoryRow { export function isCategoryRow(obj: unknown): obj is CategoryRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && + "kind" in obj && isCategoryKind(obj.kind) && "slug" in obj && typeof obj.slug === "string" && "name" in obj && typeof obj.name === "string" && "position" in obj && typeof obj.position === "number" && @@ -36,6 +42,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow { export function categoryRowToApi(row: CategoryRow): Category { return { id: row.id, + kind: row.kind, slug: row.slug, name: row.name, position: row.position, diff --git a/api/routes/categories.ts b/api/routes/categories.ts index 7eed285f..c9c54b03 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -1,13 +1,17 @@ /** - * Serves the categories dumps are filed under, and changes them for those with - * `category:manage`. A category's feed is `GET /api/dumps?category=:slug`. + * Serves the categories dumps and playlists are filed under, a set for each + * kind, and changes them for those with `category:manage`. A dump category's + * feed is `GET /api/dumps?category=:slug`, a playlist category's + * `GET /api/playlists?category=:slug`. * - * - `GET /api/categories` — public. Answers every category, in order. - * - `POST /api/categories` — permission `category:manage`. Creates a category. + * - `GET /api/categories` — public. Answers every category of both kinds, by + * kind, then in order. + * - `POST /api/categories` — permission `category:manage`. Creates a category + * of the kind it names. * - `PUT /api/categories/:id` — permission `category:manage`. Updates a * category. * - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a - * category; its dumps stay. + * category; its dumps or playlists stay. * * @module */ diff --git a/api/routes/follows.ts b/api/routes/follows.ts index 6597b817..cfb14480 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -79,7 +79,7 @@ router.get("/feed", authMiddleware, (ctx) => { throw new APIException(400, "from must be users or playlists"); } const { page, limit } = parsePagination(params); - const categoryId = resolveCategoryFilter(params); + const categoryId = resolveCategoryFilter("dump", params); const userId = ctx.state.user.userId; const { items, hasMore } = categoryId === "missing" ? EMPTY_FEED diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index 1d989ad0..5de684e8 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -3,12 +3,17 @@ * playlist's id; its page reads it by its path, through * `GET /api/users/:key/playlists/:slug`. * - * - `POST /api/playlists` — signed in. Creates a playlist. + * - `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. + * - `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 * with the dumps the caller may see in it. A private one 404s but for its * owner. * - `PUT /api/playlists/:playlistId` — signed in, as its owner or with - * `playlist:moderate`. Updates its title, slug, description and visibility. + * `playlist:moderate`. Updates its title, slug, description, visibility and + * categories. * A new slug keeps the old one as an alias, 3 times a day at most but for * those with `playlist:moderate`. * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with @@ -32,7 +37,7 @@ import { Router } from "@oak/oak"; import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts"; -import { type APIResponse } from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isCreatePlaylistRequest, @@ -49,6 +54,7 @@ import { getEditablePlaylist, getPlaylist, getPlaylistImageInfo, + listPublicPlaylists, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, @@ -60,6 +66,8 @@ import { validateImageUpload, } from "../lib/upload.ts"; import { PLAYLIST_IMAGES_DIR } from "../config.ts"; +import { parsePagination } from "../lib/pagination.ts"; +import { getCategoryBySlug } from "../services/category-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -69,6 +77,19 @@ const router: Router = new Router({ prefix: "/api/playlists", }); +router.get("/", (ctx) => { + const params = ctx.request.url.searchParams; + const { page, limit } = parsePagination(params); + const slug = params.get("category"); + const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined; + const { items, hasMore } = listPublicPlaylists(page, limit, categoryId); + const responseBody: APIResponse> = { + success: true, + data: { items, hasMore }, + }; + ctx.response.body = responseBody; +}); + router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { diff --git a/api/routes/search.ts b/api/routes/search.ts index fe125c37..f3396d5f 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -3,9 +3,11 @@ * Only the dumps are paged. Private dumps and playlists are found only by * their owner. * - * - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the - * matching dumps with their total, and up to 20 matching users and - * playlists. A blank `q` finds nothing. + * - `GET /api/search?q=&page=&limit=&category=&playlistCategory=` — optional + * sign-in. Answers a page of the matching dumps with their total, and up to + * 20 matching users and playlists. A blank `q` finds nothing. `category` + * keeps the dumps in that dump category, `playlistCategory` the playlists in + * that playlist category; a slug that names none finds nothing. * * @module */ @@ -16,6 +18,7 @@ import { parsePagination } from "../lib/pagination.ts"; import { searchDumps } from "../services/dump-service.ts"; import { searchUsersGlobal } from "../services/user-service.ts"; import { searchPlaylists } from "../services/playlist-service.ts"; +import { resolveCategoryFilter } from "../services/category-service.ts"; import type { APIResponse } from "../../shared/api.ts"; import type { SearchResults } from "../../shared/search.ts"; @@ -44,14 +47,19 @@ router.get("/", async (ctx) => { return; } - const { items: dumpItems, total: dumpTotal } = searchDumps( - q, - page, - limit, - requestingUserId, + const dumpCategoryId = resolveCategoryFilter("dump", params); + const playlistCategoryId = resolveCategoryFilter( + "playlist", + params, + "playlistCategory", ); + const { items: dumpItems, total: dumpTotal } = dumpCategoryId === "missing" + ? { items: [], total: 0 } + : searchDumps(q, page, limit, requestingUserId, dumpCategoryId); const users = searchUsersGlobal(q, 20); - const playlists = searchPlaylists(q, 20, requestingUserId); + const playlists = playlistCategoryId === "missing" + ? [] + : searchPlaylists(q, 20, requestingUserId, playlistCategoryId); const responseBody: APIResponse = { success: true, diff --git a/api/routes/users.ts b/api/routes/users.ts index bf3ef1ea..eee49c0f 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -19,8 +19,9 @@ * - `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=` — optional sign-in. Answers a - * page of the playlists they made, the private ones only to themselves. + * - `GET /api/users/:key/playlists?page=&limit=&category=` — optional + * sign-in. Answers a page of the playlists they made, the private ones only + * to themselves, optionally in one playlist category. * - `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. @@ -163,7 +164,7 @@ router.get("/:key/dumps", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); const params = ctx.request.url.searchParams; const { page, limit } = parsePagination(params); - const categoryId = resolveCategoryFilter(params); + const categoryId = resolveCategoryFilter("dump", params); const includePrivate = requestingUserId === user.id; const { items, hasMore } = categoryId === "missing" ? { items: [], hasMore: false } @@ -190,7 +191,7 @@ router.get("/:key/upvoted", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); const params = ctx.request.url.searchParams; const { page, limit } = parsePagination(params); - const categoryId = resolveCategoryFilter(params); + const categoryId = resolveCategoryFilter("dump", params); const { items, hasMore } = categoryId === "missing" ? { items: [], hasMore: false } : getVotedDumpsByUser(user.id, page, limit, requestingUserId, categoryId); @@ -204,13 +205,12 @@ router.get("/:key/upvoted", async (ctx) => { router.get("/:key/playlists", async (ctx) => { const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = listPlaylistsByUser( - user.id, - requestingUserId, - page, - limit, - ); + const params = ctx.request.url.searchParams; + const { page, limit } = parsePagination(params); + const categoryId = resolveCategoryFilter("playlist", params); + const { items, hasMore } = categoryId === "missing" + ? { items: [], hasMore: false } + : listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 365960eb..e7b126db 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -1,7 +1,10 @@ /** - * Stores the categories and which dumps belong to them. The category routes - * manage and list them, the dump service files each dump it writes under its - * categories, and every service that lists dumps fills in their `categoryIds`. + * Stores the categories and which dumps and playlists belong to them, a set + * of categories for each {@link CategoryKind}. The category routes manage and + * list them, the dump and playlist services file each dump or playlist they + * write under its categories, and every service that lists dumps or playlists + * fills in their `categoryIds` and filters them by category with + * {@link categoryJoin}. * * @module */ @@ -9,6 +12,7 @@ import { APIException } from "../lib/api-exception.ts"; import type { Category, + CategoryKind, CreateCategoryRequest, UpdateCategoryRequest, } from "../../shared/category.ts"; @@ -27,13 +31,23 @@ import { VALIDATION } from "../../shared/validation.ts"; import { parseRows } from "../model/rows.ts"; /** - * Throws a 400 unless `slug` is a valid category slug: a slug within a - * category's length, and none of the top-level paths other pages hold - * (reserved-slugs.ts), since a category's feed lives at the URL root. + * Names, for each kind, the table that files things under its categories and + * the column there of the thing filed. */ -function validateCategorySlug(slug: string): void { +const LINKS: Record = { + dump: { table: "dump_categories", column: "dump_id" }, + playlist: { table: "playlist_categories", column: "playlist_id" }, +}; + +/** + * Throws a 400 unless `slug` is a valid slug for a category of `kind`: a slug + * within a category's length, and for a dump category none of the top-level + * paths other pages hold (reserved-slugs.ts), since its feed lives at the URL + * root. A playlist category's lives under `/playlists/`. + */ +function validateCategorySlug(slug: string, kind: CategoryKind): void { validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX); - if (isReservedSlug(slug)) { + if (kind === "dump" && isReservedSlug(slug)) { throw new APIException(400, `Slug "${slug}" is reserved`); } } @@ -82,12 +96,15 @@ export function getCategoryById(id: string): Category { } /** - * Returns the category at `slug`, for {@link listDumpsByCategory}. + * Returns the category of `kind` at `slug`, for {@link listDumpsByCategory} + * and the list of every public playlist. * * @throws {APIException} 404 when there is no such category. */ -export function getCategoryBySlug(slug: string): Category { - const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug); +export function getCategoryBySlug(kind: CategoryKind, slug: string): Category { + const row = db.prepare( + `SELECT * FROM categories WHERE kind = ? AND slug = ?;`, + ).get(kind, slug); if (!row || !isCategoryRow(row)) { throw new APIException(404, "Category not found"); } @@ -95,35 +112,64 @@ export function getCategoryBySlug(slug: string): Category { } /** - * Returns the id of the category at `slug`, or undefined when there is none. + * Returns the id of the category of `kind` at `slug`, or undefined when there + * is none. */ -export function getCategoryIdBySlug(slug: string): string | undefined { - const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); +export function getCategoryIdBySlug( + kind: CategoryKind, + slug: string, +): string | undefined { + const row = db.prepare( + `SELECT id FROM categories WHERE kind = ? AND slug = ?;`, + ).get(kind, slug); return row && typeof (row as { id?: unknown }).id === "string" ? (row as { id: string }).id : undefined; } /** - * Returns the id of the category the `?category=` slug of `params` names, - * undefined without one, or "missing" when it names none, so that the list - * comes back empty rather than unfiltered. The follows routes filter the - * index's "followed" tab with it, and the users routes a profile's lists. + * Returns the id of the category of `kind` whose slug the `name` parameter of + * `params`, `?category=` by default, gives, undefined without one, or + * "missing" when it names none, so that the list comes back empty rather than + * unfiltered. The follows routes filter the index's "followed" tab with it, + * the users routes a profile's lists, and the search route its results. */ export function resolveCategoryFilter( + kind: CategoryKind, params: URLSearchParams, + name = "category", ): string | "missing" | undefined { - const slug = params.get("category"); + const slug = params.get(name); if (!slug) return undefined; - return getCategoryIdBySlug(slug) ?? "missing"; + return getCategoryIdBySlug(kind, slug) ?? "missing"; } /** - * Returns every category, by position then slug, for `GET /api/categories`. + * Returns the join that keeps only the rows of `alias`, a dump or playlist as + * `kind` says, filed under `categoryId`, with its parameters, or nothing + * without one. A query puts the join after its `FROM` and the parameters + * before its own. + */ +export function categoryJoin( + kind: CategoryKind, + alias: string, + categoryId?: string, +): [string, string[]] { + if (!categoryId) return ["", []]; + const { table, column } = LINKS[kind]; + return [ + `INNER JOIN ${table} cat ON cat.${column} = ${alias}.id AND cat.category_id = ?`, + [categoryId], + ]; +} + +/** + * Returns every category, of both kinds, by kind, position then slug, for + * `GET /api/categories`. */ export function listCategories(): Category[] { const rows = db.prepare( - `SELECT * FROM categories ORDER BY position ASC, slug ASC;`, + `SELECT * FROM categories ORDER BY kind ASC, position ASC, slug ASC;`, ).all(); return parseRows(rows, isCategoryRow, categoryRowToApi); } @@ -133,20 +179,21 @@ export function listCategories(): Category[] { * and the position defaults to 0. * * @throws {APIException} 400 when the slug or name is invalid, 409 when - * either is taken. + * either is taken within its kind. */ export function createCategory(req: CreateCategoryRequest): Category { - validateCategorySlug(req.slug); + validateCategorySlug(req.slug, req.kind); const name = requireName(req.name); const id = crypto.randomUUID(); const createdAt = new Date(); try { db.prepare( - `INSERT INTO categories (id, slug, name, position, created_at) - VALUES (?, ?, ?, ?, ?);`, + `INSERT INTO categories (id, kind, slug, name, position, created_at) + VALUES (?, ?, ?, ?, ?, ?);`, ).run( id, + req.kind, req.slug, name, req.position ?? 0, @@ -158,6 +205,7 @@ export function createCategory(req: CreateCategoryRequest): Category { return { id, + kind: req.kind, slug: req.slug, name, position: req.position ?? 0, @@ -170,7 +218,7 @@ export function createCategory(req: CreateCategoryRequest): Category { * the category as it now is. Fields `req` leaves out keep their value. * * @throws {APIException} 404 when there is no such category, 400 when the new - * slug or name is invalid, 409 when either is taken. + * slug or name is invalid, 409 when either is taken within its kind. */ export function updateCategory( id: string, @@ -180,7 +228,7 @@ export function updateCategory( const newSlug = req.slug ?? existing.slug; if (req.slug !== undefined && req.slug !== existing.slug) { - validateCategorySlug(req.slug); + validateCategorySlug(req.slug, existing.kind); } const newName = req.name !== undefined ? requireName(req.name) @@ -212,8 +260,8 @@ export function updateCategory( } /** - * Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and - * lose it from their `categoryIds`. + * Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps or + * playlists stay, and lose it from their `categoryIds`. * * @throws {APIException} 404 when there is no such category. */ @@ -225,20 +273,25 @@ export function deleteCategory(id: string): void { } /** - * Checks that every id in `categoryIds` names a category. The dump service - * runs it before it writes a dump row or stores an uploaded file, so a bad id - * fails the request before anything is written. + * Checks that every id in `categoryIds` names a category of `kind`, so that a + * dump is never filed under a playlist category, nor the other way round. The + * dump and playlist services run it before they write a row or store an + * uploaded file, so a bad id fails the request before anything is written. * - * @throws {APIException} 400 when an id names no category. + * @throws {APIException} 400 when an id names no category of `kind`. */ -export function assertCategoriesExist(categoryIds: string[]): void { +export function assertCategoriesExist( + kind: CategoryKind, + categoryIds: string[], +): void { const unique = [...new Set(categoryIds)]; if (unique.length > 0) { const placeholders = unique.map(() => "?").join(", "); const found = db.prepare( - `SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`, - ).get(...unique) as { count: number } | undefined; + `SELECT COUNT(*) as count FROM categories + WHERE kind = ? AND id IN (${placeholders});`, + ).get(kind, ...unique) as { count: number } | undefined; if ((found?.count ?? 0) !== unique.length) { throw new APIException(400, "One or more categories do not exist"); } @@ -246,56 +299,64 @@ export function assertCategoriesExist(categoryIds: string[]): void { } /** - * Replaces the categories of dump `dumpId` with `categoryIds`, duplicates - * dropped. An empty list takes the dump out of every category. Called inside - * the dump service's transaction, it joins that one. + * Replaces the categories of the dump or playlist `id`, as `kind` says, with + * `categoryIds`, duplicates dropped. An empty list takes it out of every + * category. Called inside the dump or playlist service's transaction, it + * joins that one. * - * @throws {APIException} 400 when an id names no category. + * @throws {APIException} 400 when an id names no category of `kind`. */ -export function setDumpCategories( - dumpId: string, +export function setCategories( + kind: CategoryKind, + id: string, categoryIds: string[], ): void { - assertCategoriesExist(categoryIds); + assertCategoriesExist(kind, categoryIds); const unique = [...new Set(categoryIds)]; + const { table, column } = LINKS[kind]; transaction(() => { - db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId); + db.prepare(`DELETE FROM ${table} WHERE ${column} = ?;`).run(id); const insert = db.prepare( - `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + `INSERT INTO ${table} (${column}, category_id) VALUES (?, ?);`, ); for (const categoryId of unique) { - insert.run(dumpId, categoryId); + insert.run(id, categoryId); } }); } /** - * Sets `categoryIds` on each of `dumps` in place, in one query. The dump row - * doesn't carry them, so every service that returns dumps calls this first. + * Sets `categoryIds` on each of `items`, dumps or playlists as `kind` says, in + * place, in one query. Their rows don't carry them, so every service that + * returns dumps or playlists calls this first. */ -export function attachCategoryIds(dumps: Dump[]): void { - if (dumps.length === 0) return; - const ids = dumps.map((d) => d.id); +export function attachCategoryIds( + kind: CategoryKind, + items: { id: string; categoryIds?: string[] }[], +): void { + if (items.length === 0) return; + const { table, column } = LINKS[kind]; + const ids = items.map((d) => d.id); const placeholders = ids.map(() => "?").join(", "); const rows = db.prepare( - `SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`, - ).all(...ids) as Array<{ dump_id: string; category_id: string }>; + `SELECT ${column} as id, category_id FROM ${table} WHERE ${column} IN (${placeholders});`, + ).all(...ids) as Array<{ id: string; category_id: string }>; - const byDump = new Map(); - for (const { dump_id, category_id } of rows) { - const list = byDump.get(dump_id) ?? []; + const byItem = new Map(); + for (const { id, category_id } of rows) { + const list = byItem.get(id) ?? []; list.push(category_id); - byDump.set(dump_id, list); + byItem.set(id, list); } - for (const dump of dumps) { - dump.categoryIds = byDump.get(dump.id) ?? []; + for (const item of items) { + item.categoryIds = byItem.get(item.id) ?? []; } } /** - * Returns a page of the dumps in the category at `slug`, newest first, for - * `GET /api/dumps?category=`. Private dumps are left out, except + * Returns a page of the dumps in the dump category at `slug`, newest first, + * for `GET /api/dumps?category=`. Private dumps are left out, except * `requestingUserId`'s own. * * @throws {APIException} 404 when there is no such category. @@ -306,7 +367,7 @@ export function listDumpsByCategory( limit: number, requestingUserId?: string, ): Page { - const category = getCategoryBySlug(slug); + const category = getCategoryBySlug("dump", slug); const offset = (page - 1) * limit; const privacyClause = requestingUserId @@ -314,15 +375,15 @@ export function listDumpsByCategory( : "d.is_private = 0"; const privacyParams = requestingUserId ? [requestingUserId] : []; + const [catJoin, catParams] = categoryJoin("dump", "d", category.id); const rows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} - FROM dumps d - INNER JOIN dump_categories dc ON dc.dump_id = d.id - WHERE dc.category_id = ? AND ${privacyClause} + FROM dumps d ${catJoin} + WHERE ${privacyClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, - ).all(category.id, ...privacyParams, limit + 1, offset); + ).all(...catParams, ...privacyParams, limit + 1, offset); const items = parseRows(rows, isDumpRow, dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 8d388eab..ac6a004f 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -56,7 +56,8 @@ import { relinkBacklinks } from "./backlink-service.ts"; import { assertCategoriesExist, attachCategoryIds, - setDumpCategories, + categoryJoin, + setCategories, } from "./category-service.ts"; import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; @@ -114,7 +115,7 @@ export async function createUrlDump( if (!isValidHttpUrl(request.url)) { throw new APIException(400, "Invalid URL"); } - assertCategoriesExist(request.categoryIds ?? []); + assertCategoriesExist("dump", request.categoryIds ?? []); const dumpId = crypto.randomUUID(); const createdAt = new Date(); @@ -144,7 +145,7 @@ export async function createUrlDump( richContent ? JSON.stringify(richContent) : null, isPrivate ? 1 : 0, ); - setDumpCategories(dumpId, categoryIds); + setCategories("dump", dumpId, categoryIds); }); const dump: Dump = { ...fetchDump(dumpId), categoryIds }; @@ -184,7 +185,7 @@ export async function createFileDump( if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { throw new APIException(400, "File too large (max 50 MB)"); } - assertCategoriesExist(categoryIds); + assertCategoriesExist("dump", categoryIds); const dumpId = crypto.randomUUID(); const createdAt = new Date(); @@ -215,7 +216,7 @@ export async function createFileDump( file.size, isPrivate ? 1 : 0, ); - setDumpCategories(dumpId, dedupedCategoryIds); + setCategories("dump", dumpId, dedupedCategoryIds); }); } catch (err) { // Leave no file behind when the write or the insert fails. @@ -262,7 +263,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { if (dump.isPrivate && dump.userId !== requestingUserId) { throw new APIException(404, "Dump not found"); } - attachCategoryIds([dump]); + attachCategoryIds("dump", [dump]); return dump; } @@ -288,13 +289,15 @@ export function getDumpByPath( /** * Returns a page of the dumps `requestingUserId` may see whose title, note, or * previewed title or description contains `query`, newest first, with how many - * match in all. `GET /api/search` calls it. + * match in all, optionally only those in the dump category `categoryId`. + * `GET /api/search` calls it. */ export function searchDumps( query: string, page: number, limit: number, requestingUserId?: string, + categoryId?: string, ): { items: Dump[]; total: number } { if (!query.trim()) return { items: [], total: 0 }; const pattern = likePattern(query); @@ -302,29 +305,28 @@ export function searchDumps( const searchClause = `(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`; - const searchParams = [pattern, pattern, pattern, pattern] as const; + const privacyClause = requestingUserId + ? "(is_private = 0 OR user_id = ?)" + : "is_private = 0"; + const [catJoin, catParams] = categoryJoin("dump", "dumps", categoryId); + const params = [ + ...catParams, + ...(requestingUserId ? [requestingUserId] : []), + pattern, + pattern, + pattern, + pattern, + ]; - if (requestingUserId) { - const rows = db.prepare( - `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(requestingUserId, ...searchParams, limit, offset); - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, - ).get(requestingUserId, ...searchParams) as { count: number } | undefined; - const items = parseRows(rows, isDumpRow, dumpRowToApi); - attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; - } else { - const rows = db.prepare( - `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(...searchParams, limit, offset); - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, - ).get(...searchParams) as { count: number } | undefined; - const items = parseRows(rows, isDumpRow, dumpRowToApi); - attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; - } + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(...params, limit, offset); + const totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause};`, + ).get(...params) as { count: number } | undefined; + const items = parseRows(rows, isDumpRow, dumpRowToApi); + attachCategoryIds("dump", items); + return { items, total: totalRow?.count ?? 0 }; } /** @@ -352,7 +354,7 @@ export function listDumps( } const items = rows.map(dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } @@ -372,7 +374,7 @@ export function listTopDumps(from: Date, to: Date, limit: number): Dump[] { LIMIT ?;`, ).all(from.toISOString(), to.toISOString(), limit); const items = parseRows(rows, isDumpRow, dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return items; } @@ -441,7 +443,7 @@ export async function updateDump( ): Promise { const dump = fetchDump(dumpId); if (request.categoryIds !== undefined) { - assertCategoriesExist(request.categoryIds); + assertCategoriesExist("dump", request.categoryIds); } if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) { throw new APIException(400, "Invalid URL"); @@ -479,10 +481,10 @@ export async function updateDump( ); if (request.categoryIds !== undefined) { const ids = [...new Set(request.categoryIds)]; - setDumpCategories(dumpId, ids); + setCategories("dump", dumpId, ids); updatedDump.categoryIds = ids; } else { - attachCategoryIds([updatedDump]); + attachCategoryIds("dump", [updatedDump]); } broadcastDumpChange(dump, updatedDump); notifyIfWentPublic(dump, updatedDump); @@ -550,10 +552,10 @@ export async function updateDump( if (request.categoryIds !== undefined) { const ids = [...new Set(request.categoryIds)]; - setDumpCategories(dumpId, ids); + setCategories("dump", dumpId, ids); updatedDump.categoryIds = ids; } else { - attachCategoryIds([updatedDump]); + attachCategoryIds("dump", [updatedDump]); } broadcastDumpChange(dump, updatedDump); @@ -610,7 +612,7 @@ export async function replaceFileDump( if (dump.kind !== "file") { throw new APIException(400, "Not a file dump"); } - if (categoryIds !== undefined) assertCategoriesExist(categoryIds); + if (categoryIds !== undefined) assertCategoriesExist("dump", categoryIds); if (slug !== undefined) changeSlug("dump", dump, slug, exempt); // The new file is written beside the old one and renamed over it (atomic: a @@ -650,7 +652,7 @@ export async function replaceFileDump( dumpId, ); if (categoryIds !== undefined) { - setDumpCategories(dumpId, [...new Set(categoryIds)]); + setCategories("dump", dumpId, [...new Set(categoryIds)]); } }); } catch (err) { @@ -664,7 +666,7 @@ export async function replaceFileDump( if (categoryIds !== undefined) { dump.categoryIds = [...new Set(categoryIds)]; } else { - attachCategoryIds([dump]); + attachCategoryIds("dump", [dump]); } if (comment) { @@ -690,20 +692,6 @@ export async function replaceFileDump( return updatedDump; } -/** - * Returns the join that keeps only the dumps `d` filed under `categoryId`, - * with its parameters, or nothing without one. The lists of a user's dumps - * filter by it. - */ -function categoryJoin(categoryId?: string): [string, string[]] { - return categoryId - ? [ - "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?", - [categoryId], - ] - : ["", []]; -} - /** * Returns a page of `userId`'s dumps, newest first, for * `GET /api/users/:key/dumps`, optionally only those in `categoryId`. The @@ -720,7 +708,7 @@ export function getDumpsByUser( ): Page { const offset = (page - 1) * limit; const privacyFilter = includePrivate ? "" : " AND d.is_private = 0"; - const [catJoin, catParams] = categoryJoin(categoryId); + const [catJoin, catParams] = categoryJoin("dump", "d", categoryId); const rows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin} WHERE d.user_id = ?${privacyFilter} @@ -730,7 +718,7 @@ export function getDumpsByUser( throw new APIException(500, "Malformed dump data"); } const items = rows.map(dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } @@ -753,7 +741,7 @@ export function getVotedDumpsByUser( const privacyFilter = isOwn ? "(d.is_private = 0 OR d.user_id = ?)" : "d.is_private = 0"; - const [catJoin, catParams] = categoryJoin(categoryId); + const [catJoin, catParams] = categoryJoin("dump", "d", categoryId); const rows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d @@ -773,7 +761,7 @@ export function getVotedDumpsByUser( throw new APIException(500, "Malformed dump data"); } const items = rows.map(dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index ed8a2e07..0bed65e1 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -24,7 +24,7 @@ import { import { isFollowRow } from "../model/follow.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; -import { attachCategoryIds } from "./category-service.ts"; +import { attachCategoryIds, categoryJoin } from "./category-service.ts"; import { sendToUser } from "./ws-service.ts"; /** @@ -167,10 +167,7 @@ export function getFollowedUsersDumpFeed( ): Page { const offset = (page - 1) * limit; - const catJoin = categoryId - ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" - : ""; - const catParams = categoryId ? [categoryId] : []; + const [catJoin, catParams] = categoryJoin("dump", "d", categoryId); const rawRows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} @@ -188,7 +185,7 @@ export function getFollowedUsersDumpFeed( throw new APIException(500, "Malformed dump data"); } const items = userFeedRows.map(dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } @@ -208,10 +205,7 @@ export function getFollowedPlaylistsDumpFeed( ): Page { const offset = (page - 1) * limit; - const catJoin = categoryId - ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" - : ""; - const catParams = categoryId ? [categoryId] : []; + const [catJoin, catParams] = categoryJoin("dump", "d", categoryId); const rawRows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} @@ -232,7 +226,7 @@ export function getFollowedPlaylistsDumpFeed( throw new APIException(500, "Malformed dump data"); } const items = rawRows.map(dumpRowToApi); - attachCategoryIds(items); + attachCategoryIds("dump", items); return pageOf(items, limit); } @@ -264,7 +258,9 @@ export function getFollowedPlaylistsByUser( if (!rawRows.every(isPlaylistRow)) { throw new APIException(500, "Malformed playlist data"); } - return pageOf(rawRows.map(playlistRowToApi), limit); + const items = rawRows.map(playlistRowToApi); + attachCategoryIds("playlist", items); + return pageOf(items, limit); } /** diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index dbd3181b..e670c693 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -37,6 +37,12 @@ import { } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { linkAttachments } from "./attachment-service.ts"; +import { + assertCategoriesExist, + attachCategoryIds, + categoryJoin, + setCategories, +} from "./category-service.ts"; import { isMemberVisibleTo, type PlaylistMember, @@ -53,6 +59,16 @@ const PLAYLIST_SELECT = `p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p JOIN users u ON u.id = p.user_id`; +/** + * Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their + * categories filled in. + */ +function playlistsOf(rows: unknown[]): Playlist[] { + const playlists = parseRows(rows, isPlaylistRow, playlistRowToApi); + attachCategoryIds("playlist", playlists); + return playlists; +} + /** * Returns playlist `playlistId`, private or not. A caller that shows it to * someone must check `isPublic` first, as the OG middleware does. @@ -65,7 +81,9 @@ export function getPlaylistById(playlistId: string): Playlist { if (!row || !isPlaylistRow(row)) { throw new APIException(404, "Playlist not found"); } - return playlistRowToApi(row); + const playlist = playlistRowToApi(row); + attachCategoryIds("playlist", [playlist]); + return playlist; } /** @@ -80,9 +98,12 @@ export function getPlaylistByPath(userId: string, slug: string): Playlist { } /** - * Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is - * public, it notifies the users its description mentions. It links the - * attachments the description embeds and broadcasts `playlist_created`. + * Creates a playlist owned by `userId`, for `POST /api/playlists`, filed under + * the playlist categories `req` names. If it is public, it notifies the users + * its description mentions. It links the attachments the description embeds + * and broadcasts `playlist_created`. + * + * @throws {APIException} 400 when a category id names no playlist category. */ export function createPlaylist( req: CreatePlaylistRequest, @@ -91,6 +112,7 @@ export function createPlaylist( const id = crypto.randomUUID(); const createdAt = new Date(); const title = req.title.trim(); + assertCategoriesExist("playlist", req.categoryIds ?? []); transaction(() => { db.prepare( `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) @@ -104,6 +126,9 @@ export function createPlaylist( req.isPublic ? 1 : 0, createdAt.toISOString(), ); + if (req.categoryIds?.length) { + setCategories("playlist", id, req.categoryIds); + } }); const playlist = getPlaylistById(id); if (req.description) { @@ -157,27 +182,50 @@ export function getPlaylist( /** * Returns up to `limit` playlists whose title or description contains - * `query`, newest first, for `GET /api/search`. Private playlists are left - * out, except `requestingUserId`'s own. A blank `query` finds nothing. + * `query`, newest first, for `GET /api/search`, optionally only those in the + * playlist category `categoryId`. Private playlists are left out, except + * `requestingUserId`'s own. A blank `query` finds nothing. */ export function searchPlaylists( query: string, limit: number, requestingUserId?: string, + categoryId?: string, ): Playlist[] { if (!query.trim()) return []; const pattern = likePattern(query); const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`; + const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); const rows = requestingUserId ? db.prepare( - `SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, - ).all(requestingUserId, pattern, pattern, limit) + `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, + ).all(...catParams, requestingUserId, pattern, pattern, limit) : db.prepare( - `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, - ).all(pattern, pattern, limit); + `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, + ).all(...catParams, pattern, pattern, limit); - return parseRows(rows, isPlaylistRow, playlistRowToApi); + return playlistsOf(rows); +} + +/** + * Returns a page of every public playlist, newest first, for + * `GET /api/playlists`, optionally only those in the playlist category + * `categoryId`. The client's `/playlists` page lists them. + */ +export function listPublicPlaylists( + page: number, + limit: number, + categoryId?: string, +): Page { + const offset = (page - 1) * limit; + const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); + const rows = db.prepare( + `SELECT ${PLAYLIST_SELECT} ${catJoin} + WHERE p.is_public = 1 + ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`, + ).all(...catParams, limit + 1, offset); + return pageOf(playlistsOf(rows), limit); } /** @@ -191,12 +239,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] { WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? ORDER BY dump_count DESC, p.created_at DESC;`, ).all(from.toISOString(), to.toISOString()); - return parseRows(rows, isPlaylistRow, playlistRowToApi); + return playlistsOf(rows); } /** * Returns a page of `userId`'s playlists, newest first, for - * `GET /api/users/:key/playlists`. The private ones are listed only when + * `GET /api/users/:key/playlists`, optionally only those in the playlist + * category `categoryId`. The private ones are listed only when * `requestingUserId` is `userId`. */ export function listPlaylistsByUser( @@ -204,16 +253,19 @@ export function listPlaylistsByUser( requestingUserId: string | null, page: number, limit: number, + categoryId?: string, ): Page { const isOwner = requestingUserId === userId; const offset = (page - 1) * limit; + const privacyFilter = isOwner ? "" : " AND p.is_public = 1"; + const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); - const sql = isOwner - ? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;` - : `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`; - - const rows = db.prepare(sql).all(userId, limit + 1, offset); - return pageOf(parseRows(rows, isPlaylistRow, playlistRowToApi), limit); + const rows = db.prepare( + `SELECT ${PLAYLIST_SELECT} ${catJoin} + WHERE p.user_id = ?${privacyFilter} + ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`, + ).all(...catParams, userId, limit + 1, offset); + return pageOf(playlistsOf(rows), limit); } /** @@ -227,8 +279,9 @@ export function listPlaylistsByUser( * attachments and broadcasts `playlist_updated`. * * @throws {APIException} 404 when there is no such playlist, 403 when the user - * is neither its owner nor a moderator, 400 when the slug isn't valid, 409 - * when it is taken, 429 when it changed too often lately. + * is neither its owner nor a moderator, 400 when the slug isn't valid or a + * category id names no playlist category, 409 when the slug is taken, 429 + * when it changed too often lately. */ export function updatePlaylist( playlistId: string, @@ -250,20 +303,28 @@ export function updatePlaylist( ? req.isPublic : playlist.isPublic; + if (req.categoryIds !== undefined) { + assertCategoriesExist("playlist", req.categoryIds); + } if (req.slug !== undefined) { changeSlug("playlist", playlist, req.slug, canModerate); } const now = new Date(); - db.prepare( - `UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, - ).run( - newTitle, - newDescription, - newIsPublic ? 1 : 0, - now.toISOString(), - playlist.id, - ); + transaction(() => { + db.prepare( + `UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, + ).run( + newTitle, + newDescription, + newIsPublic ? 1 : 0, + now.toISOString(), + playlist.id, + ); + if (req.categoryIds !== undefined) { + setCategories("playlist", playlist.id, req.categoryIds); + } + }); const updated: Playlist = { ...playlist, @@ -272,6 +333,9 @@ export function updatePlaylist( description: newDescription ?? undefined, isPublic: newIsPublic, updatedAt: now, + categoryIds: req.categoryIds !== undefined + ? [...new Set(req.categoryIds)] + : playlist.categoryIds, }; if (newDescription) { if (newIsPublic) { @@ -536,7 +600,7 @@ export function getPlaylistMembershipsForDump( ORDER BY p.created_at DESC;`, ).all(dumpId, userId); - return rows.map((row) => { + const memberships = rows.map((row) => { if (!isPlaylistRow(row)) { throw new APIException(500, "Malformed playlist data"); } @@ -545,6 +609,8 @@ export function getPlaylistMembershipsForDump( hasDump: Boolean(row.has_dump), }; }); + attachCategoryIds("playlist", memberships.map((m) => m.playlist)); + return memberships; } /** @@ -564,7 +630,7 @@ export function getPlaylistsForDump( ORDER BY p.created_at DESC LIMIT 20;`, ).all(dumpId, requestingUserId ?? ""); - return parseRows(rows, isPlaylistRow, playlistRowToApi); + return playlistsOf(rows); } /** diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index cd9fd6b9..ec4974fb 100644 --- a/api/test/db/migrate.test.ts +++ b/api/test/db/migrate.test.ts @@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => .get() as { n: number; }; - assertEquals(applied.n, 24); + assertEquals(applied.n, 25); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 24, + 25, ); db.close(); }); diff --git a/api/test/db/migrations/0025_playlist_categories.test.ts b/api/test/db/migrations/0025_playlist_categories.test.ts new file mode 100644 index 00000000..148674df --- /dev/null +++ b/api/test/db/migrations/0025_playlist_categories.test.ts @@ -0,0 +1,108 @@ +import "../../setup.ts"; +import { assertEquals, assertThrows } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0025 } from "../../../db/migrations/0025_playlist_categories.ts"; + +/** Returns a database with the tables 0025 changes, as 0006 made them. */ +function oldDb(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(`PRAGMA foreign_keys = ON;`); + db.exec(` + CREATE TABLE dumps (id TEXT PRIMARY KEY); + CREATE TABLE playlists (id TEXT PRIMARY KEY); + CREATE TABLE categories ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT + ); + CREATE TABLE dump_categories ( + dump_id TEXT NOT NULL, + category_id TEXT NOT NULL, + PRIMARY KEY (dump_id, category_id), + FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE + ); + `); + return db; +} + +Deno.test("migration 0025 keeps the categories as dump categories, with their dumps", () => { + const db = oldDb(); + db.exec(`INSERT INTO dumps (id) VALUES ('d1'), ('d2');`); + db.exec( + `INSERT INTO categories (id, slug, name, position, created_at) VALUES + ('c1', 'music', 'Music', 2, '2026-01-01T00:00:00.000Z'), + ('c2', 'playlists', 'Playlists', 1, '2026-01-02T00:00:00.000Z');`, + ); + db.exec( + `INSERT INTO dump_categories (dump_id, category_id) VALUES + ('d1', 'c1'), ('d2', 'c1'), ('d2', 'c2');`, + ); + + db.exec("BEGIN;"); + up0025(db); + db.exec("COMMIT;"); + up0025(db); // idempotent + + assertEquals( + db.prepare( + `SELECT id, kind, slug, name, position FROM categories ORDER BY id;`, + ).all().map((r) => ({ ...r })), + [ + { id: "c1", kind: "dump", slug: "music", name: "Music", position: 2 }, + // `playlists` is now the page of every playlist. + { + id: "c2", + kind: "dump", + slug: "playlists-2", + name: "Playlists", + position: 1, + }, + ], + ); + assertEquals( + db.prepare( + `SELECT dump_id, category_id FROM dump_categories ORDER BY dump_id, category_id;`, + ).all().map((r) => ({ ...r })), + [ + { dump_id: "d1", category_id: "c1" }, + { dump_id: "d2", category_id: "c1" }, + { dump_id: "d2", category_id: "c2" }, + ], + ); + + // A slug or name is unique within its kind only. + db.exec( + `INSERT INTO categories (id, kind, slug, name, created_at) + VALUES ('p1', 'playlist', 'music', 'Music', '2026-01-03T00:00:00.000Z');`, + ); + assertThrows(() => + db.exec( + `INSERT INTO categories (id, kind, slug, name, created_at) + VALUES ('p2', 'playlist', 'music', 'Other', '2026-01-03T00:00:00.000Z');`, + ) + ); + + // The links still cascade. + db.exec(`INSERT INTO playlists (id) VALUES ('pl1');`); + db.exec( + `INSERT INTO playlist_categories (playlist_id, category_id) VALUES ('pl1', 'p1');`, + ); + db.exec(`DELETE FROM categories WHERE id IN ('c1', 'p1');`); + assertEquals( + (db.prepare(`SELECT COUNT(*) as n FROM dump_categories;`).get() as { + n: number; + }).n, + 1, + ); + assertEquals( + (db.prepare(`SELECT COUNT(*) as n FROM playlist_categories;`).get() as { + n: number; + }).n, + 0, + ); + db.close(); +}); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 7a9bfaa8..163a59b6 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -26,6 +26,7 @@ import { } from "../services/email-service.ts"; import type { APIError, APIResponse } from "../../shared/api.ts"; import type { Role } from "../../shared/user.ts"; +import type { CategoryKind } from "../../shared/category.ts"; export { app, db, TEST_DIR }; @@ -40,6 +41,7 @@ const TABLES = [ "federation_outbox", "dump_backlinks", "dump_categories", + "playlist_categories", "playlist_dumps", "comment_likes", "comments", @@ -268,13 +270,19 @@ export function insertComment( } export function insertCategory( - opts: { slug: string; name?: string; position?: number }, + opts: { + slug: string; + name?: string; + position?: number; + kind?: CategoryKind; + }, ): string { const id = crypto.randomUUID(); db.prepare( - `INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`, + `INSERT INTO categories (id, kind, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?, ?);`, ).run( id, + opts.kind ?? "dump", opts.slug, opts.name ?? opts.slug, opts.position ?? 0, diff --git a/api/test/lib/reserved-slugs.test.ts b/api/test/lib/reserved-slugs.test.ts index 99de3402..0c9eba76 100644 --- a/api/test/lib/reserved-slugs.test.ts +++ b/api/test/lib/reserved-slugs.test.ts @@ -10,6 +10,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve "settings", "manage", "community", + "playlists", "api", "ws", ] @@ -17,7 +18,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve assert(isReservedSlug(word), word); } // v3's words are free again: nothing lives at /dumps or /users now. - for (const word of ["music", "dumps", "users", "playlists", "~"]) { + for (const word of ["music", "dumps", "users", "~"]) { assertFalse(isReservedSlug(word), word); } }); diff --git a/api/test/routes/categories.test.ts b/api/test/routes/categories.test.ts index 64325928..52a6c11e 100644 --- a/api/test/routes/categories.test.ts +++ b/api/test/routes/categories.test.ts @@ -29,7 +29,7 @@ test("categories: listed by position, then slug", async () => { test("categories: only category:manage may create, update or delete", async () => { const admin = await createUser({ role: "admin" }); const mod = await createUser({ role: "moderator" }); - const json = { slug: "music", name: "Music" }; + const json = { kind: "dump", slug: "music", name: "Music" }; assertEquals((await post("/api/categories", { json })).status, 401); assertEquals( (await post("/api/categories", { auth: mod.auth, json })).status, @@ -64,13 +64,17 @@ test("categories: only category:manage may create, update or delete", async () = test("categories: slug and name rules, and friendly conflicts", async () => { const admin = await createUser({ role: "admin" }); - const create = (json: unknown) => - post("/api/categories", { auth: admin.auth, json }); + const create = (json: Record) => + post("/api/categories", { + auth: admin.auth, + json: { kind: "dump", ...json }, + }); for ( const [json, message] of [ [{ slug: "Bad Slug", name: "x" }, "lowercase"], [{ slug: "hot", name: "x" }, "reserved"], [{ slug: "settings", name: "x" }, "reserved"], + [{ slug: "playlists", name: "x" }, "reserved"], [{ slug: "a".repeat(51), name: "x" }, "at most 50"], [{ slug: "ok", name: " " }, "Name is required"], [{ slug: "ok", name: "x".repeat(51) }, "at most 50"], @@ -144,3 +148,37 @@ test("GET /api/dumps?category=: members only, private for the owner", async () = [], ); }); + +test("categories: each kind is a set apart", async () => { + const admin = await createUser({ role: "admin" }); + const create = (json: Record) => + post("/api/categories", { auth: admin.auth, json }); + + assertEquals((await create({ slug: "music", name: "Music" })).status, 400); + const dump = await create({ kind: "dump", slug: "music", name: "Music" }); + assertEquals(dump.data.kind, "dump"); + // The same slug and name are free in the other kind, taken in the same. + const playlist = await create({ + kind: "playlist", + slug: "music", + name: "Music", + }); + assertEquals(playlist.status, 201); + assertEquals(playlist.data.kind, "playlist"); + assertEquals( + (await create({ kind: "playlist", slug: "music", name: "Other" })).status, + 409, + ); + // A playlist category's page lives under /playlists/: nothing is reserved. + assertEquals( + (await create({ kind: "playlist", slug: "hot", name: "Hot" })).status, + 201, + ); + + const list = await get("/api/categories"); + assertEquals(list.data.map((c) => [c.kind, c.slug]), [ + ["dump", "music"], + ["playlist", "hot"], + ["playlist", "music"], + ]); +}); diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index d25b0df7..5f856680 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -130,6 +130,18 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); }); +test("POST /api/dumps: a playlist category is refused", async () => { + const user = await createUser(); + const lists = insertCategory({ slug: "mixes", kind: "playlist" }); + using _ = stubNetwork(() => PAGE.clone()); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://example.com/e", categoryIds: [lists] }, + }); + assertEquals(res.status, 400); + assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); +}); + test("POST /api/dumps: a file dump is stored and served back", async () => { const user = await createUser(); const res = await post("/api/dumps", { diff --git a/api/test/routes/playlists.test.ts b/api/test/routes/playlists.test.ts index a40b0e39..2cf2c8b5 100644 --- a/api/test/routes/playlists.test.ts +++ b/api/test/routes/playlists.test.ts @@ -6,6 +6,7 @@ import { del, formWithFile, get, + insertCategory, insertDump, insertPlaylist, PNG, @@ -539,3 +540,126 @@ test("reorder: a moderator orders around the owner's private dumps", async () => b.id, ]); }); + +test("playlist categories: filed on creation, replaced on update, dump categories refused", async () => { + const user = await createUser(); + const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); + const live = insertCategory({ slug: "live", kind: "playlist" }); + const music = insertCategory({ slug: "music" }); + + const refused = await post("/api/playlists", { + auth: user.auth, + json: { title: "Wrong", isPublic: true, categoryIds: [music] }, + }); + assertEquals(refused.status, 400); + assertEquals(count(`SELECT COUNT(*) as n FROM playlists;`), 0); + assertEquals( + (await post("/api/playlists", { + auth: user.auth, + json: { title: "Bad", isPublic: true, categoryIds: "mixes" }, + })).status, + 400, + ); + + const created = await post("/api/playlists", { + auth: user.auth, + json: { title: "Mix", isPublic: true, categoryIds: [mixes, mixes] }, + }); + assertEquals(created.status, 201); + assertEquals(created.data.categoryIds, [mixes]); + assertEquals( + (await get(`/api/playlists/${created.data.id}`)).data + .categoryIds, + [mixes], + ); + + const id = created.data.id; + assertEquals( + (await put(`/api/playlists/${id}`, { + auth: user.auth, + json: { categoryIds: [music] }, + })).status, + 400, + ); + const updated = await put(`/api/playlists/${id}`, { + auth: user.auth, + json: { categoryIds: [live] }, + }); + assertEquals(updated.data.categoryIds, [live]); + // A change of anything else keeps them. + const renamed = await put(`/api/playlists/${id}`, { + auth: user.auth, + json: { title: "Live mix" }, + }); + assertEquals(renamed.data.categoryIds, [live]); + const cleared = await put(`/api/playlists/${id}`, { + auth: user.auth, + json: { categoryIds: [] }, + }); + assertEquals(cleared.data.categoryIds, []); +}); + +test("GET /api/playlists: every public playlist, newest first, by category", async () => { + const owner = await createUser(); + const other = await createUser(); + const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); + insertCategory({ slug: "music" }); + const a = insertPlaylist({ userId: owner.id, title: "A" }); + insertPlaylist({ userId: other.id, title: "B" }); + const hidden = insertPlaylist({ + userId: owner.id, + title: "Hidden", + isPublic: false, + }); + db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`) + .run("2026-01-01T00:00:00.000Z", a.id); + for (const p of [a, hidden]) { + db.prepare( + `INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`, + ).run(p.id, mixes); + } + + const all = await get>("/api/playlists", { + auth: owner.auth, + }); + assertEquals(all.data.items.map((p) => p.title), ["B", "A"]); + assertEquals(all.data.items[1].categoryIds, [mixes]); + assertEquals( + (await get>("/api/playlists?limit=1")).data + .hasMore, + true, + ); + assertEquals( + (await get>("/api/playlists?category=mixes")).data + .items.map((p) => p.id), + [a.id], + ); + // A dump category's slug names no playlist category. + assertEquals((await get("/api/playlists?category=music")).status, 404); + assertEquals((await get("/api/playlists?category=nope")).status, 404); +}); + +test("GET /api/users/:key/playlists?category=: their playlists in one category", async () => { + const owner = await createUser(); + const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); + const a = insertPlaylist({ userId: owner.id, title: "A" }); + insertPlaylist({ userId: owner.id, title: "B" }); + db.prepare( + `INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`, + ).run(a.id, mixes); + + const url = `/api/users/${owner.id}/playlists`; + assertEquals( + (await get>(url)).data.items.length, + 2, + ); + assertEquals( + (await get>(`${url}?category=mixes`)).data.items + .map((p) => p.id), + [a.id], + ); + assertEquals( + (await get>(`${url}?category=nope`)).data.items, + [], + ); +}); diff --git a/api/test/routes/search.test.ts b/api/test/routes/search.test.ts index 64099fee..db0caa89 100644 --- a/api/test/routes/search.test.ts +++ b/api/test/routes/search.test.ts @@ -2,6 +2,7 @@ import { createUser, db, get, + insertCategory, insertDump, insertPlaylist, test, @@ -62,3 +63,44 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () = playlists: [], }); }); + +test("GET /api/search: category and playlistCategory narrow each list", async () => { + const owner = await createUser(); + const music = insertCategory({ slug: "music" }); + const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); + const filed = insertDump({ userId: owner.id, title: "Banana song" }); + insertDump({ userId: owner.id, title: "Banana bread" }); + const list = insertPlaylist({ userId: owner.id, title: "Banana mix" }); + insertPlaylist({ userId: owner.id, title: "Banana bin" }); + db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ).run(filed.id, music); + db.prepare( + `INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`, + ).run(list.id, mixes); + + const all = (await get("/api/search?q=banana")).data; + assertEquals(all.dumps.total, 2); + assertEquals(all.playlists.length, 2); + + const dumps = (await get( + "/api/search?q=banana&category=music", + )).data; + assertEquals(dumps.dumps.items.map((d) => d.id), [filed.id]); + assertEquals(dumps.dumps.total, 1); + assertEquals(dumps.playlists.length, 2); + + const playlists = (await get( + "/api/search?q=banana&playlistCategory=mixes", + )).data; + assertEquals(playlists.dumps.total, 2); + assertEquals(playlists.playlists.map((p) => p.id), [list.id]); + assertEquals(playlists.playlists[0].categoryIds, [mixes]); + + // Each names a category of its own kind only. + const crossed = (await get( + "/api/search?q=banana&category=mixes&playlistCategory=music", + )).data; + assertEquals(crossed.dumps.total, 0); + assertEquals(crossed.playlists, []); +}); diff --git a/api/test/validators/category.test.ts b/api/test/validators/category.test.ts index 16dc61d6..7c3afaae 100644 --- a/api/test/validators/category.test.ts +++ b/api/test/validators/category.test.ts @@ -6,10 +6,27 @@ import { } from "../../validators/category.ts"; Deno.test("category requests", () => { - assert(isCreateCategoryRequest({ slug: "a", name: "A" })); - assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 })); - assertFalse(isCreateCategoryRequest({ slug: "a" })); - assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" })); + assert(isCreateCategoryRequest({ kind: "dump", slug: "a", name: "A" })); + assert(isCreateCategoryRequest({ kind: "playlist", slug: "a", name: "A" })); + assert( + isCreateCategoryRequest({ + kind: "dump", + slug: "a", + name: "A", + position: 2, + }), + ); + assertFalse(isCreateCategoryRequest({ slug: "a", name: "A" })); + assertFalse(isCreateCategoryRequest({ kind: "user", slug: "a", name: "A" })); + assertFalse(isCreateCategoryRequest({ kind: "dump", slug: "a" })); + assertFalse( + isCreateCategoryRequest({ + kind: "dump", + slug: "a", + name: "A", + position: "2", + }), + ); assert(isUpdateCategoryRequest({})); assertFalse(isUpdateCategoryRequest({ name: 1 })); }); diff --git a/api/validators/category.ts b/api/validators/category.ts index fc40bc10..7cd49134 100644 --- a/api/validators/category.ts +++ b/api/validators/category.ts @@ -5,9 +5,10 @@ * @module */ -import type { - CreateCategoryRequest, - UpdateCategoryRequest, +import { + type CreateCategoryRequest, + isCategoryKind, + type UpdateCategoryRequest, } from "../../shared/category.ts"; /** Checks whether `obj` has the fields of a {@link CreateCategoryRequest}. */ @@ -16,6 +17,7 @@ export function isCreateCategoryRequest( ): obj is CreateCategoryRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; + if (!isCategoryKind(o.kind)) return false; if (typeof o.slug !== "string") return false; if (typeof o.name !== "string") return false; if ("position" in o && typeof o.position !== "number") return false; diff --git a/api/validators/playlist.ts b/api/validators/playlist.ts index cf4be684..22c0f274 100644 --- a/api/validators/playlist.ts +++ b/api/validators/playlist.ts @@ -11,6 +11,13 @@ import type { } from "../../shared/playlist.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** Checks that `categoryIds`, when `o` has it, is a list of strings. */ +function hasValidCategoryIds(o: Record): boolean { + return !("categoryIds" in o) || + (Array.isArray(o.categoryIds) && + o.categoryIds.every((id) => typeof id === "string")); +} + /** Checks that `title` isn't blank and, trimmed, fits the limit. */ function isValidTitle(title: string): boolean { const trimmed = title.trim(); @@ -20,7 +27,8 @@ function isValidTitle(title: string): boolean { /** * Checks whether `obj` is a {@link CreatePlaylistRequest} with a title that * isn't blank, and a title and description within the `VALIDATION` limits. A - * null description passes. + * null description passes. Whether its category ids name playlist categories + * is the playlist service's to check. */ export function isCreatePlaylistRequest( obj: unknown, @@ -40,7 +48,7 @@ export function isCreatePlaylistRequest( typeof o.description === "string" && (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX ) return false; - return true; + return hasValidCategoryIds(o); } /** @@ -68,7 +76,7 @@ export function isUpdatePlaylistRequest( (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX ) return false; if ("isPublic" in o && typeof o.isPublic !== "boolean") return false; - return true; + return hasValidCategoryIds(o); } /** diff --git a/locales/en.mjs b/locales/en.mjs index 70632037..7dbcd4a1 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\"],\"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…\"],\"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\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"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\"],\"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\"],\"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\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"All playlists\"],\"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\"],\"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\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"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\"],\"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 afb30d6f..30fbc2c6 100644 --- a/locales/en.po +++ b/locales/en.po @@ -18,13 +18,13 @@ msgid "[deleted]" msgstr "[deleted]" #. placeholder {0}: dump.commentCount -#: src/components/dump/DumpCard.tsx:139 +#: src/components/dump/DumpCard.tsx:135 #: src/components/dump/JournalCard.tsx:136 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:118 +#: src/components/playlist/PlaylistCard.tsx:120 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" @@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "email.digest.totals" msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}." -#: src/pages/Search.tsx:124 +#: src/pages/Search.tsx:152 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -205,15 +205,15 @@ msgstr "# Welcome to {site}!\n" "\n" "Your account has been created successfully. Welcome aboard!" -#: src/components/dump/create/DumpCreateModal.tsx:742 +#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/pages/PlaylistDetail.tsx:80 #: src/pages/UserPublicProfile.tsx:125 msgid "← Back" msgstr "← Back" -#: src/pages/Dump.tsx:326 -#: src/pages/Dump.tsx:568 -#: src/pages/DumpEdit.tsx:232 +#: src/pages/Dump.tsx:325 +#: src/pages/Dump.tsx:552 +#: src/pages/DumpEdit.tsx:233 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -225,7 +225,7 @@ msgstr "+ Invite someone" msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:403 +#: src/pages/Dump.tsx:399 msgid "+ Playlist" msgstr "+ Playlist" @@ -312,8 +312,8 @@ msgstr "Account on <0>{host}" msgid "Add a comment…" msgstr "Add a comment…" -#: src/components/category/CategoryManager.tsx:104 -#: src/components/category/CategoryManager.tsx:105 +#: src/components/category/CategoryManager.tsx:115 +#: src/components/category/CategoryManager.tsx:116 msgid "Add category" msgstr "Add category" @@ -325,7 +325,7 @@ msgstr "Add email…" msgid "Add image" msgstr "Add image" -#: src/components/dump/create/DumpCreateModal.tsx:465 +#: src/components/dump/create/DumpCreateModal.tsx:458 #: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Add to playlist" @@ -339,11 +339,15 @@ msgstr "added to {playlist}" msgid "Admin" msgstr "Admin" -#: src/components/category/CategorySwitcher.tsx:100 -#: src/components/category/CategorySwitcher.tsx:103 +#: src/components/category/CategorySwitcher.tsx:104 +#: src/components/category/CategorySwitcher.tsx:107 msgid "All" msgstr "All" +#: src/components/layout/UserMenu.tsx:108 +msgid "All playlists" +msgstr "All playlists" + #. placeholder {0}: first.ownerUsername #. placeholder {1}: relativeTime(first.createdAt) #: src/components/dump/create/DuplicateNotice.tsx:39 @@ -424,19 +428,19 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may msgid "Can't sign in there" msgstr "Can't sign in there" -#: src/components/category/CategoryManager.tsx:225 -#: src/components/category/CategoryManager.tsx:226 +#: src/components/category/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:242 #: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:752 +#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/form/FormActions.tsx:42 -#: src/components/playlist/PlaylistEditForm.tsx:200 +#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:445 -#: src/pages/DumpEdit.tsx:565 +#: src/pages/Dump.tsx:441 +#: src/pages/DumpEdit.tsx:560 msgid "Cancel" msgstr "Cancel" @@ -453,12 +457,11 @@ msgstr "Cancel reply" msgid "Cancel search" msgstr "Cancel search" -#: src/components/category/CategorySelect.tsx:33 -#: src/pages/Manage.tsx:42 +#: src/components/category/CategorySelect.tsx:38 msgid "Categories" msgstr "Categories" -#: src/components/category/CategorySwitcher.tsx:123 +#: src/components/category/CategorySwitcher.tsx:127 msgid "Category" msgstr "Category" @@ -513,7 +516,7 @@ msgstr "Checking…" msgid "Close" msgstr "Close" -#: src/components/dump/create/DumpCreateModal.tsx:496 +#: src/components/dump/create/DumpCreateModal.tsx:489 msgid "Close anyway" msgstr "Close anyway" @@ -525,7 +528,7 @@ msgstr "Close emoji picker" msgid "Close player" msgstr "Close player" -#: src/components/dump/create/DumpCreateModal.tsx:486 +#: src/components/dump/create/DumpCreateModal.tsx:479 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will." @@ -579,8 +582,8 @@ 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:126 -#: src/pages/DumpEdit.tsx:559 +#: src/components/profile/ProfilePlaylistsTab.tsx:177 +#: src/pages/DumpEdit.tsx:554 msgid "Could not delete" msgstr "Could not delete" @@ -588,7 +591,7 @@ msgstr "Could not delete" msgid "Could not rename" msgstr "Could not rename" -#: src/pages/DumpEdit.tsx:512 +#: src/pages/DumpEdit.tsx:506 msgid "Could not save" msgstr "Could not save" @@ -596,7 +599,7 @@ msgstr "Could not save" msgid "Couldn't add that instance" msgstr "Couldn't add that instance" -#: src/components/dump/create/DumpCreateModal.tsx:607 +#: src/components/dump/create/DumpCreateModal.tsx:600 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." @@ -616,19 +619,19 @@ msgstr "Couldn't unsubscribe" msgid "Couldn't update. Try again." msgstr "Couldn't update. Try again." -#: src/components/playlist/PlaylistEditForm.tsx:135 +#: src/components/playlist/PlaylistEditForm.tsx:150 msgid "Cover" msgstr "Cover" -#: src/components/playlist/PlaylistCreateForm.tsx:112 +#: src/components/playlist/PlaylistCreateForm.tsx:122 msgid "Create" msgstr "Create" -#: src/components/playlist/PlaylistCreateForm.tsx:112 +#: src/components/playlist/PlaylistCreateForm.tsx:122 msgid "Create & Add" msgstr "Create & Add" -#: src/components/playlist/PlaylistCreateForm.tsx:111 +#: src/components/playlist/PlaylistCreateForm.tsx:121 msgid "Creating…" msgstr "Creating…" @@ -644,8 +647,8 @@ msgstr "Dark" msgid "Default tab" msgstr "Default tab" -#: src/components/category/CategoryManager.tsx:236 -#: src/components/category/CategoryManager.tsx:237 +#: src/components/category/CategoryManager.tsx:252 +#: src/components/category/CategoryManager.tsx:253 #: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:220 @@ -656,28 +659,28 @@ msgstr "Default tab" msgid "Delete" msgstr "Delete" -#: src/components/category/CategoryManager.tsx:253 +#: src/components/category/CategoryManager.tsx:269 msgid "Delete category" msgstr "Delete category" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:252 +#: src/components/category/CategoryManager.tsx:268 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone." -#: src/pages/DumpEdit.tsx:314 -#: src/pages/DumpEdit.tsx:562 +#: src/pages/DumpEdit.tsx:315 +#: src/pages/DumpEdit.tsx:557 msgid "Delete dump" msgstr "Delete dump" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:193 +#: src/pages/DumpEdit.tsx:194 msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/playlist/PlaylistCard.tsx:142 -#: src/components/playlist/PlaylistEditForm.tsx:191 -#: src/components/profile/ProfilePlaylistsTab.tsx:156 +#: src/components/playlist/PlaylistCard.tsx:149 +#: src/components/playlist/PlaylistEditForm.tsx:213 +#: src/components/profile/ProfilePlaylistsTab.tsx:207 #: src/pages/PlaylistDetail.tsx:141 msgid "Delete playlist" msgstr "Delete playlist" @@ -686,7 +689,7 @@ msgstr "Delete playlist" msgid "Delete this comment?" msgstr "Delete this comment?" -#: src/pages/DumpEdit.tsx:313 +#: src/pages/DumpEdit.tsx:314 msgid "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone." @@ -694,7 +697,7 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/components/profile/ProfilePlaylistsTab.tsx:155 +#: src/components/profile/ProfilePlaylistsTab.tsx:206 #: src/pages/PlaylistDetail.tsx:140 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -703,11 +706,11 @@ msgstr "Delete this playlist? This cannot be undone." msgid "deleted message" msgstr "deleted message" -#: src/components/playlist/PlaylistEditForm.tsx:176 +#: src/components/playlist/PlaylistEditForm.tsx:191 msgid "Description" msgstr "Description" -#: src/components/playlist/PlaylistCreateForm.tsx:104 +#: src/components/playlist/PlaylistCreateForm.tsx:108 msgid "Description (optional)" msgstr "Description (optional)" @@ -728,7 +731,7 @@ msgstr "Disconnected — reconnecting…" msgid "Dismiss" msgstr "Dismiss" -#: src/components/dump/create/DumpCreateModal.tsx:546 +#: src/components/dump/create/DumpCreateModal.tsx:539 msgid "Done" msgstr "Done" @@ -741,11 +744,11 @@ msgstr "Download {0}" msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/dump/create/DumpCreateModal.tsx:653 +#: src/components/dump/create/DumpCreateModal.tsx:646 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" -#: src/pages/DumpEdit.tsx:503 +#: src/pages/DumpEdit.tsx:497 msgid "Drop a replacement here" msgstr "Drop a replacement here" @@ -753,16 +756,20 @@ msgstr "Drop a replacement here" msgid "Dump" msgstr "Dump" -#: src/components/dump/create/DumpCreateModal.tsx:765 +#: src/pages/Manage.tsx:43 +msgid "Dump categories" +msgstr "Dump categories" + +#: src/components/dump/create/DumpCreateModal.tsx:759 msgid "Dump it" msgstr "Dump it" -#: src/components/dump/create/DumpCreateModal.tsx:524 +#: src/components/dump/create/DumpCreateModal.tsx:517 msgid "Dumped!" msgstr "Dumped!" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/Search.tsx:120 +#: src/pages/Search.tsx:148 #: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Dumps" @@ -780,13 +787,13 @@ msgstr "Earlier" #: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 -#: src/components/playlist/PlaylistHeader.tsx:59 -#: src/pages/Dump.tsx:564 +#: src/components/playlist/PlaylistHeader.tsx:60 +#: src/pages/Dump.tsx:548 msgid "Edit" msgstr "Edit" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:84 +#: src/components/category/CategoryManager.tsx:94 msgid "Edit {0}" msgstr "Edit {0}" @@ -799,7 +806,7 @@ msgid "Edit email" msgstr "Edit email" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:462 +#: src/pages/Dump.tsx:458 msgid "Edit title" msgstr "Edit title" @@ -812,8 +819,8 @@ msgstr "edited" #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 -#: src/components/playlist/PlaylistHeader.tsx:94 -#: src/pages/Dump.tsx:505 +#: src/components/playlist/PlaylistHeader.tsx:95 +#: src/pages/Dump.tsx:501 msgid "edited {0}" msgstr "edited {0}" @@ -822,17 +829,17 @@ msgstr "edited {0}" #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 -#: src/components/playlist/PlaylistHeader.tsx:92 -#: src/pages/Dump.tsx:503 +#: src/components/playlist/PlaylistHeader.tsx:93 +#: src/pages/Dump.tsx:499 msgid "Edited {0}" msgstr "Edited {0}" -#: src/pages/DumpEdit.tsx:257 +#: src/pages/DumpEdit.tsx:258 msgid "Editing" msgstr "Editing" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:80 +#: src/pages/DumpEdit.tsx:81 msgid "Editing {0}" msgstr "Editing {0}" @@ -844,7 +851,7 @@ msgstr "Email" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:155 +#: src/pages/Search.tsx:204 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -852,15 +859,15 @@ msgstr "Enter a query to search." msgid "Expand player" msgstr "Expand player" -#: src/components/category/CategoryManager.tsx:246 +#: src/components/category/CategoryManager.tsx:262 msgid "Failed to create category" msgstr "Failed to create category" -#: src/components/playlist/PlaylistCreateForm.tsx:109 +#: src/components/playlist/PlaylistCreateForm.tsx:119 msgid "Failed to create playlist" msgstr "Failed to create playlist" -#: src/components/category/CategoryManager.tsx:169 +#: src/components/category/CategoryManager.tsx:185 msgid "Failed to delete" msgstr "Failed to delete" @@ -874,7 +881,7 @@ msgstr "Failed to generate invite" msgid "Failed to load" msgstr "Failed to load" -#: src/components/dump/create/DumpCreateModal.tsx:555 +#: src/components/dump/create/DumpCreateModal.tsx:548 msgid "Failed to post" msgstr "Failed to post" @@ -888,13 +895,13 @@ msgstr "Failed to post reply" #: src/components/federation/GrantList.tsx:98 #: src/components/federation/PeerManager.tsx:158 -#: src/components/playlist/PlaylistEditForm.tsx:185 +#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 msgid "Failed to save" msgstr "Failed to save" -#: src/components/category/CategoryManager.tsx:247 +#: src/components/category/CategoryManager.tsx:263 msgid "Failed to save category" msgstr "Failed to save category" @@ -914,11 +921,11 @@ msgstr "Failed to update role" msgid "Feeds" msgstr "Feeds" -#: src/components/dump/create/DumpCreateModal.tsx:594 +#: src/components/dump/create/DumpCreateModal.tsx:587 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/dump/create/DumpCreateModal.tsx:417 +#: src/components/dump/create/DumpCreateModal.tsx:410 msgid "File too large (max {max})." msgstr "File too large (max {max})." @@ -1017,7 +1024,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:584 +#: src/pages/Dump.tsx:568 msgid "In collections" msgstr "In collections" @@ -1031,7 +1038,7 @@ msgstr "In the chat" msgid "Insert emoji" msgstr "Insert emoji" -#: src/pages/Manage.tsx:49 +#: src/pages/Manage.tsx:56 msgid "Instances" msgstr "Instances" @@ -1073,7 +1080,7 @@ msgstr "Invitees" msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:493 +#: src/components/dump/create/DumpCreateModal.tsx:486 msgid "Keep editing" msgstr "Keep editing" @@ -1094,7 +1101,7 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/components/dump/create/DumpCreateModal.tsx:569 +#: src/components/dump/create/DumpCreateModal.tsx:562 #: src/components/form/SlugField.tsx:69 msgid "Link" msgstr "Link" @@ -1119,8 +1126,8 @@ msgstr "Load more" msgid "Load older messages" msgstr "Load older messages" -#: src/pages/Dump.tsx:302 -#: src/pages/DumpEdit.tsx:208 +#: src/pages/Dump.tsx:301 +#: src/pages/DumpEdit.tsx:209 msgid "Loading dump…" msgstr "Loading dump…" @@ -1140,7 +1147,7 @@ msgstr "Loading playlist…" msgid "Loading profile…" msgstr "Loading profile…" -#: src/components/category/CategoryManager.tsx:60 +#: src/components/category/CategoryManager.tsx:69 #: src/components/chat/ChatModal.tsx:763 #: src/components/editor/EmojiPickerPopup.tsx:125 #: src/components/federation/PeerManager.tsx:120 @@ -1173,7 +1180,7 @@ msgstr "Log in to vote" msgid "Log in with {0}" msgstr "Log in with {0}" -#: src/components/layout/UserMenu.tsx:127 +#: src/components/layout/UserMenu.tsx:139 #: src/pages/UserPublicProfile.tsx:133 msgid "Log out" msgstr "Log out" @@ -1191,7 +1198,7 @@ msgstr "Login failed" msgid "Lowercase letters, numbers and single hyphens only" msgstr "Lowercase letters, numbers and single hyphens only" -#: src/components/layout/UserMenu.tsx:114 +#: src/components/layout/UserMenu.tsx:126 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 #: src/pages/UserPublicProfile.tsx:166 @@ -1215,8 +1222,8 @@ msgstr "Monthly recap" msgid "Mute" msgstr "Mute" -#: src/components/category/CategoryManager.tsx:192 -#: src/components/category/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:208 +#: src/components/category/CategoryManager.tsx:209 msgid "Name" msgstr "Name" @@ -1226,7 +1233,7 @@ msgstr "Name" msgid "New" msgstr "New" -#: src/components/dump/create/DumpCreateModal.tsx:465 +#: src/components/dump/create/DumpCreateModal.tsx:458 #: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 @@ -1253,7 +1260,7 @@ msgstr "New playlist" msgid "digest.newPlaylists" msgstr "New public playlists" -#: src/components/dump/create/DumpCreateModal.tsx:764 +#: src/components/dump/create/DumpCreateModal.tsx:758 msgid "Next →" msgstr "Next →" @@ -1265,7 +1272,7 @@ msgstr "Next track" msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:171 +#: src/pages/Search.tsx:220 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." @@ -1294,12 +1301,13 @@ msgstr "No messages yet. Say hello!" msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:212 +#: src/pages/Search.tsx:261 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:133 +#: src/components/profile/ProfilePlaylistsTab.tsx:184 +#: src/pages/Playlists.tsx:118 msgid "No playlists yet." msgstr "No playlists yet." @@ -1307,7 +1315,7 @@ msgstr "No playlists yet." 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" -#: src/pages/Search.tsx:186 +#: src/pages/Search.tsx:235 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." @@ -1327,7 +1335,7 @@ msgstr "Nothing happened this month." msgid "Nothing happened this week." msgstr "Nothing happened this week." -#: src/components/profile/ProfileDumpsTab.tsx:96 +#: src/components/profile/ProfileDumpsTab.tsx:97 #: src/components/profile/ProfileOverviewTab.tsx:47 #: src/pages/Notifications.tsx:522 msgid "Nothing here yet." @@ -1361,7 +1369,7 @@ msgstr "Once a week you'll get a digest of what happened here, and once a month msgid "Only images can be dropped here." msgstr "Only images can be dropped here." -#: src/pages/DumpEdit.tsx:118 +#: src/pages/DumpEdit.tsx:119 msgid "Only its poster or a moderator can edit this dump." msgstr "Only its poster or a moderator can edit this dump." @@ -1432,7 +1440,7 @@ msgstr "Password updated" msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/dump/create/DumpCreateModal.tsx:587 +#: src/components/dump/create/DumpCreateModal.tsx:580 msgid "Paste a link…" msgstr "Paste a link…" @@ -1443,7 +1451,7 @@ msgstr "Paste a link…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:505 +#: src/components/dump/create/DumpCreateModal.tsx:498 msgid "Picked up where you left off." msgstr "Picked up where you left off." @@ -1455,12 +1463,18 @@ msgstr "Picked up where you left off." msgid "Play" msgstr "Play" +#: src/pages/Manage.tsx:48 +msgid "Playlist categories" +msgstr "Playlist categories" + #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 -#: src/components/layout/UserMenu.tsx:88 +#: src/components/layout/UserMenu.tsx:92 #: src/components/profile/ProfileFollowTab.tsx:144 -#: src/components/profile/ProfilePlaylistsTab.tsx:118 -#: src/pages/Search.tsx:123 +#: src/components/profile/ProfilePlaylistsTab.tsx:155 +#: src/pages/Playlists.tsx:52 +#: src/pages/Playlists.tsx:106 +#: src/pages/Search.tsx:151 #: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Playlists" @@ -1475,7 +1489,7 @@ msgstr "Post reply" #: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:758 +#: src/components/dump/create/DumpCreateModal.tsx:752 msgid "Posting…" msgstr "Posting…" @@ -1483,12 +1497,12 @@ msgstr "Posting…" msgid "Previous track" msgstr "Previous track" -#: src/components/dump/DumpCard.tsx:148 +#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/JournalCard.tsx:145 -#: src/components/playlist/PlaylistCard.tsx:104 -#: src/components/playlist/PlaylistHeader.tsx:76 +#: src/components/playlist/PlaylistCard.tsx:106 +#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:511 +#: src/pages/Dump.tsx:507 msgid "private" msgstr "private" @@ -1496,8 +1510,8 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/playlist/PlaylistCard.tsx:103 -#: src/components/playlist/PlaylistHeader.tsx:76 +#: src/components/playlist/PlaylistCard.tsx:105 +#: src/components/playlist/PlaylistHeader.tsx:77 msgid "public" msgstr "public" @@ -1505,7 +1519,7 @@ msgstr "public" msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:290 +#: src/pages/DumpEdit.tsx:291 msgid "Refresh metadata" msgstr "Refresh metadata" @@ -1513,7 +1527,7 @@ msgstr "Refresh metadata" msgid "Refreshing stream…" msgstr "Refreshing stream…" -#: src/pages/DumpEdit.tsx:289 +#: src/pages/DumpEdit.tsx:290 msgid "Refreshing…" msgstr "Refreshing…" @@ -1531,7 +1545,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:575 +#: src/pages/Dump.tsx:559 msgid "Related" msgstr "Related" @@ -1565,7 +1579,7 @@ msgstr "Rename" msgid "Renaming…" msgstr "Renaming…" -#: src/pages/DumpEdit.tsx:495 +#: src/pages/DumpEdit.tsx:489 msgid "Replace file" msgstr "Replace file" @@ -1596,8 +1610,8 @@ msgstr "Reset password" msgid "Reset password" msgstr "Reset password" -#: src/pages/DumpEdit.tsx:471 -#: src/pages/DumpEdit.tsx:526 +#: src/pages/DumpEdit.tsx:465 +#: src/pages/DumpEdit.tsx:520 msgid "Reset to default" msgstr "Reset to default" @@ -1617,8 +1631,8 @@ msgid "email.reset.title" msgstr "Reset your password" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:319 -#: src/pages/DumpEdit.tsx:225 +#: src/pages/Dump.tsx:318 +#: src/pages/DumpEdit.tsx:226 msgid "Retry" msgstr "Retry" @@ -1626,30 +1640,30 @@ msgstr "Retry" msgid "Role" msgstr "Role" -#: src/components/category/CategoryManager.tsx:216 +#: src/components/category/CategoryManager.tsx:232 #: src/components/chat/ChatEditBox.tsx:85 #: src/components/comment/CommentNode.tsx:216 -#: src/components/playlist/PlaylistEditForm.tsx:207 +#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:437 -#: src/pages/DumpEdit.tsx:568 +#: src/pages/Dump.tsx:433 +#: src/pages/DumpEdit.tsx:563 msgid "Save" msgstr "Save" #: src/components/comment/CommentNode.tsx:217 -#: src/components/playlist/PlaylistEditForm.tsx:204 +#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:436 +#: src/pages/Dump.tsx:432 #: src/pages/ResetPassword.tsx:139 msgid "Saving…" msgstr "Saving…" #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/SearchBar.tsx:83 -#: src/pages/Search.tsx:69 +#: src/pages/Search.tsx:87 msgid "Search" msgstr "Search" @@ -1665,11 +1679,11 @@ msgstr "Search emoji" msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:166 +#: src/pages/Search.tsx:215 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:161 +#: src/pages/Search.tsx:210 msgid "Searching…" msgstr "Searching…" @@ -1709,7 +1723,7 @@ msgstr "Set new password" msgid "email.settings" msgstr "Settings" -#: src/components/layout/UserMenu.tsx:104 +#: src/components/layout/UserMenu.tsx:116 #: src/pages/Settings.tsx:75 #: src/pages/Settings.tsx:88 msgid "Settings" @@ -1736,7 +1750,7 @@ msgstr "Signing you in…" msgid "Single use, valid for 7 days." msgstr "Single use, valid for 7 days." -#: src/components/category/CategoryManager.tsx:205 +#: src/components/category/CategoryManager.tsx:221 msgid "slug" msgstr "slug" @@ -1760,7 +1774,7 @@ msgstr "Something went wrong" msgid "Sounds" msgstr "Sounds" -#: src/components/dump/create/DumpCreateModal.tsx:512 +#: src/components/dump/create/DumpCreateModal.tsx:505 msgid "Start fresh" msgstr "Start fresh" @@ -1856,18 +1870,18 @@ msgstr "This unsubscribe link is missing or malformed." msgid "This week" msgstr "This week" -#: src/pages/DumpEdit.tsx:455 +#: src/pages/DumpEdit.tsx:449 msgid "Thumbnail" msgstr "Thumbnail" #: src/components/dump/create/TitleField.tsx:45 -#: src/components/playlist/PlaylistCreateForm.tsx:96 -#: src/components/playlist/PlaylistEditForm.tsx:160 -#: src/pages/DumpEdit.tsx:516 +#: src/components/playlist/PlaylistCreateForm.tsx:100 +#: src/components/playlist/PlaylistEditForm.tsx:175 +#: src/pages/DumpEdit.tsx:510 msgid "Title" msgstr "Title" -#: src/components/dump/create/DumpCreateModal.tsx:414 +#: src/components/dump/create/DumpCreateModal.tsx:407 msgid "Title is required." msgstr "Title is required." @@ -1967,7 +1981,7 @@ msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:449 +#: src/components/dump/create/DumpCreateModal.tsx:442 msgid "Uploading {0}%" msgstr "Uploading {0}%" @@ -1975,7 +1989,7 @@ msgstr "Uploading {0}%" msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/dump/create/DumpCreateModal.tsx:761 +#: src/components/dump/create/DumpCreateModal.tsx:755 msgid "Uploading…" msgstr "Uploading…" @@ -1993,15 +2007,15 @@ msgstr "Upvoted" msgid "Upvoted ({0})" msgstr "Upvoted ({0})" -#: src/pages/DumpEdit.tsx:487 +#: src/pages/DumpEdit.tsx:481 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:398 +#: src/components/dump/create/DumpCreateModal.tsx:391 msgid "URL is required." msgstr "URL is required." -#: src/components/category/CategoryManager.tsx:206 +#: src/components/category/CategoryManager.tsx:222 msgid "URL slug" msgstr "URL slug" @@ -2013,7 +2027,7 @@ msgstr "User" msgid "User management" msgstr "User management" -#: src/components/layout/UserMenu.tsx:63 +#: src/components/layout/UserMenu.tsx:67 msgid "User menu" msgstr "User menu" @@ -2026,7 +2040,7 @@ msgid "Username" msgstr "Username" #: src/components/profile/ProfileFollowTab.tsx:143 -#: src/pages/Search.tsx:122 +#: src/pages/Search.tsx:150 msgid "Users" msgstr "Users" @@ -2046,7 +2060,7 @@ msgstr "Users of a trusted instance sign in here with their account there. Those msgid "View all →" msgstr "View all →" -#: src/components/dump/create/DumpCreateModal.tsx:526 +#: src/components/dump/create/DumpCreateModal.tsx:519 msgid "View dump →" msgstr "View dump →" @@ -2064,8 +2078,8 @@ msgstr "Weekly digest" msgid "email.welcome.subject" msgstr "Welcome to {site}" -#: src/components/dump/create/DumpCreateModal.tsx:712 -#: src/pages/DumpEdit.tsx:544 +#: src/components/dump/create/DumpCreateModal.tsx:705 +#: src/pages/DumpEdit.tsx:538 msgid "What makes it worth it?" msgstr "What makes it worth it?" @@ -2074,7 +2088,7 @@ msgstr "What makes it worth it?" msgid "Who am I?" msgstr "Who am I?" -#: src/components/dump/create/DumpCreateModal.tsx:727 +#: src/components/dump/create/DumpCreateModal.tsx:721 msgid "Who can see it" msgstr "Who can see it" @@ -2082,8 +2096,8 @@ msgstr "Who can see it" msgid "Why & where" msgstr "Why & where" -#: src/components/dump/create/DumpCreateModal.tsx:711 -#: src/pages/DumpEdit.tsx:543 +#: src/components/dump/create/DumpCreateModal.tsx:704 +#: src/pages/DumpEdit.tsx:537 msgid "Why?" msgstr "Why?" diff --git a/locales/fr.mjs b/locales/fr.mjs index 3d9b2466..df4878de 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\"],\"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…\"],\"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\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"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\"],\"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\"],\"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é\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"Toutes les collections\"],\"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\"],\"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\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"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\"],\"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 e1134c1f..863efee2 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -18,13 +18,13 @@ msgid "[deleted]" msgstr "[supprimé]" #. placeholder {0}: dump.commentCount -#: src/components/dump/DumpCard.tsx:139 +#: src/components/dump/DumpCard.tsx:135 #: src/components/dump/JournalCard.tsx:136 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:118 +#: src/components/playlist/PlaylistCard.tsx:120 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" @@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "email.digest.totals" msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}." -#: src/pages/Search.tsx:124 +#: src/pages/Search.tsx:152 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -205,15 +205,15 @@ msgstr "# Bienvenue sur {site} !\n" "\n" "Votre compte a bien été créé. Bienvenue à bord !" -#: src/components/dump/create/DumpCreateModal.tsx:742 +#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/pages/PlaylistDetail.tsx:80 #: src/pages/UserPublicProfile.tsx:125 msgid "← Back" msgstr "← Retour" -#: src/pages/Dump.tsx:326 -#: src/pages/Dump.tsx:568 -#: src/pages/DumpEdit.tsx:232 +#: src/pages/Dump.tsx:325 +#: src/pages/Dump.tsx:552 +#: src/pages/DumpEdit.tsx:233 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -225,7 +225,7 @@ msgstr "+ Inviter quelqu'un" msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:403 +#: src/pages/Dump.tsx:399 msgid "+ Playlist" msgstr "+ Collection" @@ -312,8 +312,8 @@ msgstr "Compte sur <0>{host}" msgid "Add a comment…" msgstr "Ajouter un commentaire…" -#: src/components/category/CategoryManager.tsx:104 -#: src/components/category/CategoryManager.tsx:105 +#: src/components/category/CategoryManager.tsx:115 +#: src/components/category/CategoryManager.tsx:116 msgid "Add category" msgstr "Ajouter une catégorie" @@ -325,7 +325,7 @@ msgstr "Ajouter un e-mail…" msgid "Add image" msgstr "Ajouter une image" -#: src/components/dump/create/DumpCreateModal.tsx:465 +#: src/components/dump/create/DumpCreateModal.tsx:458 #: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Ajouter à la collection" @@ -339,11 +339,15 @@ msgstr "ajoutée à {playlist}" msgid "Admin" msgstr "Administrateur" -#: src/components/category/CategorySwitcher.tsx:100 -#: src/components/category/CategorySwitcher.tsx:103 +#: src/components/category/CategorySwitcher.tsx:104 +#: src/components/category/CategorySwitcher.tsx:107 msgid "All" msgstr "Tout" +#: src/components/layout/UserMenu.tsx:108 +msgid "All playlists" +msgstr "Toutes les collections" + #. placeholder {0}: first.ownerUsername #. placeholder {1}: relativeTime(first.createdAt) #: src/components/dump/create/DuplicateNotice.tsx:39 @@ -424,19 +428,19 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo msgid "Can't sign in there" msgstr "Connexion impossible" -#: src/components/category/CategoryManager.tsx:225 -#: src/components/category/CategoryManager.tsx:226 +#: src/components/category/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:242 #: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:752 +#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/form/FormActions.tsx:42 -#: src/components/playlist/PlaylistEditForm.tsx:200 +#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:445 -#: src/pages/DumpEdit.tsx:565 +#: src/pages/Dump.tsx:441 +#: src/pages/DumpEdit.tsx:560 msgid "Cancel" msgstr "Annuler" @@ -453,12 +457,11 @@ msgstr "Annuler la réponse" msgid "Cancel search" msgstr "Annuler la recherche" -#: src/components/category/CategorySelect.tsx:33 -#: src/pages/Manage.tsx:42 +#: src/components/category/CategorySelect.tsx:38 msgid "Categories" msgstr "Catégories" -#: src/components/category/CategorySwitcher.tsx:123 +#: src/components/category/CategorySwitcher.tsx:127 msgid "Category" msgstr "Catégorie" @@ -513,7 +516,7 @@ msgstr "Vérification…" msgid "Close" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:496 +#: src/components/dump/create/DumpCreateModal.tsx:489 msgid "Close anyway" msgstr "Fermer quand même" @@ -525,7 +528,7 @@ msgstr "Fermer le sélecteur d'emoji" msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/dump/create/DumpCreateModal.tsx:486 +#: src/components/dump/create/DumpCreateModal.tsx:479 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." @@ -579,8 +582,8 @@ 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:126 -#: src/pages/DumpEdit.tsx:559 +#: src/components/profile/ProfilePlaylistsTab.tsx:177 +#: src/pages/DumpEdit.tsx:554 msgid "Could not delete" msgstr "Suppression impossible" @@ -588,7 +591,7 @@ msgstr "Suppression impossible" msgid "Could not rename" msgstr "Renommage impossible" -#: src/pages/DumpEdit.tsx:512 +#: src/pages/DumpEdit.tsx:506 msgid "Could not save" msgstr "Sauvegarde impossible" @@ -596,7 +599,7 @@ msgstr "Sauvegarde impossible" msgid "Couldn't add that instance" msgstr "Impossible d'ajouter cette instance" -#: src/components/dump/create/DumpCreateModal.tsx:607 +#: src/components/dump/create/DumpCreateModal.tsx:600 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." @@ -616,19 +619,19 @@ msgstr "Désabonnement impossible" msgid "Couldn't update. Try again." msgstr "La mise à jour a échoué. Réessayez." -#: src/components/playlist/PlaylistEditForm.tsx:135 +#: src/components/playlist/PlaylistEditForm.tsx:150 msgid "Cover" msgstr "Couverture" -#: src/components/playlist/PlaylistCreateForm.tsx:112 +#: src/components/playlist/PlaylistCreateForm.tsx:122 msgid "Create" msgstr "Créer" -#: src/components/playlist/PlaylistCreateForm.tsx:112 +#: src/components/playlist/PlaylistCreateForm.tsx:122 msgid "Create & Add" msgstr "Créer et ajouter" -#: src/components/playlist/PlaylistCreateForm.tsx:111 +#: src/components/playlist/PlaylistCreateForm.tsx:121 msgid "Creating…" msgstr "Création…" @@ -644,8 +647,8 @@ msgstr "Sombre" msgid "Default tab" msgstr "Onglet par défaut" -#: src/components/category/CategoryManager.tsx:236 -#: src/components/category/CategoryManager.tsx:237 +#: src/components/category/CategoryManager.tsx:252 +#: src/components/category/CategoryManager.tsx:253 #: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:220 @@ -656,28 +659,28 @@ msgstr "Onglet par défaut" msgid "Delete" msgstr "Supprimer" -#: src/components/category/CategoryManager.tsx:253 +#: src/components/category/CategoryManager.tsx:269 msgid "Delete category" msgstr "Supprimer la catégorie" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:252 +#: src/components/category/CategoryManager.tsx:268 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." -#: src/pages/DumpEdit.tsx:314 -#: src/pages/DumpEdit.tsx:562 +#: src/pages/DumpEdit.tsx:315 +#: src/pages/DumpEdit.tsx:557 msgid "Delete dump" msgstr "Supprimer la reco" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:193 +#: src/pages/DumpEdit.tsx:194 msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/playlist/PlaylistCard.tsx:142 -#: src/components/playlist/PlaylistEditForm.tsx:191 -#: src/components/profile/ProfilePlaylistsTab.tsx:156 +#: src/components/playlist/PlaylistCard.tsx:149 +#: src/components/playlist/PlaylistEditForm.tsx:213 +#: src/components/profile/ProfilePlaylistsTab.tsx:207 #: src/pages/PlaylistDetail.tsx:141 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -686,7 +689,7 @@ msgstr "Supprimer la collection" msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" -#: src/pages/DumpEdit.tsx:313 +#: src/pages/DumpEdit.tsx:314 msgid "Delete this dump? This cannot be undone." msgstr "Supprimer cette reco ? Cette action est irréversible." @@ -694,7 +697,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/components/profile/ProfilePlaylistsTab.tsx:155 +#: src/components/profile/ProfilePlaylistsTab.tsx:206 #: src/pages/PlaylistDetail.tsx:140 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -703,11 +706,11 @@ msgstr "Supprimer cette collection ? Cette action est irréversible." msgid "deleted message" msgstr "message supprimé" -#: src/components/playlist/PlaylistEditForm.tsx:176 +#: src/components/playlist/PlaylistEditForm.tsx:191 msgid "Description" msgstr "Description" -#: src/components/playlist/PlaylistCreateForm.tsx:104 +#: src/components/playlist/PlaylistCreateForm.tsx:108 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -728,7 +731,7 @@ msgstr "Déconnecté — reconnexion…" msgid "Dismiss" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:546 +#: src/components/dump/create/DumpCreateModal.tsx:539 msgid "Done" msgstr "Terminé" @@ -741,11 +744,11 @@ msgstr "Télécharger {0}" msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/dump/create/DumpCreateModal.tsx:653 +#: src/components/dump/create/DumpCreateModal.tsx:646 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" -#: src/pages/DumpEdit.tsx:503 +#: src/pages/DumpEdit.tsx:497 msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" @@ -753,16 +756,20 @@ msgstr "Déposez un fichier de remplacement ici" msgid "Dump" msgstr "Reco" -#: src/components/dump/create/DumpCreateModal.tsx:765 +#: src/pages/Manage.tsx:43 +msgid "Dump categories" +msgstr "Catégories de recos" + +#: src/components/dump/create/DumpCreateModal.tsx:759 msgid "Dump it" msgstr "Recommander" -#: src/components/dump/create/DumpCreateModal.tsx:524 +#: src/components/dump/create/DumpCreateModal.tsx:517 msgid "Dumped!" msgstr "Recommandé !" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/Search.tsx:120 +#: src/pages/Search.tsx:148 #: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Recos" @@ -780,13 +787,13 @@ msgstr "Plus tôt" #: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 -#: src/components/playlist/PlaylistHeader.tsx:59 -#: src/pages/Dump.tsx:564 +#: src/components/playlist/PlaylistHeader.tsx:60 +#: src/pages/Dump.tsx:548 msgid "Edit" msgstr "Modifier" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:84 +#: src/components/category/CategoryManager.tsx:94 msgid "Edit {0}" msgstr "Modifier {0}" @@ -799,7 +806,7 @@ msgid "Edit email" msgstr "Modifier l'e-mail" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:462 +#: src/pages/Dump.tsx:458 msgid "Edit title" msgstr "Modifier le titre" @@ -812,8 +819,8 @@ msgstr "modifié" #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 -#: src/components/playlist/PlaylistHeader.tsx:94 -#: src/pages/Dump.tsx:505 +#: src/components/playlist/PlaylistHeader.tsx:95 +#: src/pages/Dump.tsx:501 msgid "edited {0}" msgstr "modifié {0}" @@ -822,17 +829,17 @@ msgstr "modifié {0}" #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 -#: src/components/playlist/PlaylistHeader.tsx:92 -#: src/pages/Dump.tsx:503 +#: src/components/playlist/PlaylistHeader.tsx:93 +#: src/pages/Dump.tsx:499 msgid "Edited {0}" msgstr "Modifié le {0}" -#: src/pages/DumpEdit.tsx:257 +#: src/pages/DumpEdit.tsx:258 msgid "Editing" msgstr "Modification" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:80 +#: src/pages/DumpEdit.tsx:81 msgid "Editing {0}" msgstr "Édition de {0}" @@ -844,7 +851,7 @@ msgstr "E-mail" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:155 +#: src/pages/Search.tsx:204 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -852,15 +859,15 @@ msgstr "Saisissez une recherche." msgid "Expand player" msgstr "Agrandir le lecteur" -#: src/components/category/CategoryManager.tsx:246 +#: src/components/category/CategoryManager.tsx:262 msgid "Failed to create category" msgstr "Échec de la création de la catégorie" -#: src/components/playlist/PlaylistCreateForm.tsx:109 +#: src/components/playlist/PlaylistCreateForm.tsx:119 msgid "Failed to create playlist" msgstr "Impossible de créer la collection" -#: src/components/category/CategoryManager.tsx:169 +#: src/components/category/CategoryManager.tsx:185 msgid "Failed to delete" msgstr "Suppression échouée" @@ -874,7 +881,7 @@ msgstr "Impossible de générer une invitation" msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/dump/create/DumpCreateModal.tsx:555 +#: src/components/dump/create/DumpCreateModal.tsx:548 msgid "Failed to post" msgstr "Publication échouée" @@ -888,13 +895,13 @@ msgstr "Impossible de publier la réponse" #: src/components/federation/GrantList.tsx:98 #: src/components/federation/PeerManager.tsx:158 -#: src/components/playlist/PlaylistEditForm.tsx:185 +#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 msgid "Failed to save" msgstr "Enregistrement échoué" -#: src/components/category/CategoryManager.tsx:247 +#: src/components/category/CategoryManager.tsx:263 msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" @@ -914,11 +921,11 @@ msgstr "Erreur lors de la mise à jour du rôle" msgid "Feeds" msgstr "Flux" -#: src/components/dump/create/DumpCreateModal.tsx:594 +#: src/components/dump/create/DumpCreateModal.tsx:587 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/dump/create/DumpCreateModal.tsx:417 +#: src/components/dump/create/DumpCreateModal.tsx:410 msgid "File too large (max {max})." msgstr "Fichier trop volumineux (max {max})." @@ -1017,7 +1024,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:584 +#: src/pages/Dump.tsx:568 msgid "In collections" msgstr "Dans les collections" @@ -1031,7 +1038,7 @@ msgstr "Dans la tribune" msgid "Insert emoji" msgstr "Insérer un emoji" -#: src/pages/Manage.tsx:49 +#: src/pages/Manage.tsx:56 msgid "Instances" msgstr "Instances" @@ -1073,7 +1080,7 @@ msgstr "Invités" msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:493 +#: src/components/dump/create/DumpCreateModal.tsx:486 msgid "Keep editing" msgstr "Continuer l'édition" @@ -1094,7 +1101,7 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/components/dump/create/DumpCreateModal.tsx:569 +#: src/components/dump/create/DumpCreateModal.tsx:562 #: src/components/form/SlugField.tsx:69 msgid "Link" msgstr "Lien" @@ -1119,8 +1126,8 @@ msgstr "Charger plus" msgid "Load older messages" msgstr "Charger les messages plus anciens" -#: src/pages/Dump.tsx:302 -#: src/pages/DumpEdit.tsx:208 +#: src/pages/Dump.tsx:301 +#: src/pages/DumpEdit.tsx:209 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -1140,7 +1147,7 @@ msgstr "Chargement de la collection…" msgid "Loading profile…" msgstr "Chargement du profil…" -#: src/components/category/CategoryManager.tsx:60 +#: src/components/category/CategoryManager.tsx:69 #: src/components/chat/ChatModal.tsx:763 #: src/components/editor/EmojiPickerPopup.tsx:125 #: src/components/federation/PeerManager.tsx:120 @@ -1173,7 +1180,7 @@ msgstr "Se connecter pour valider" msgid "Log in with {0}" msgstr "Se connecter avec {0}" -#: src/components/layout/UserMenu.tsx:127 +#: src/components/layout/UserMenu.tsx:139 #: src/pages/UserPublicProfile.tsx:133 msgid "Log out" msgstr "Se déconnecter" @@ -1191,7 +1198,7 @@ msgstr "Connexion échouée" msgid "Lowercase letters, numbers and single hyphens only" msgstr "Minuscules, chiffres et tirets simples seulement" -#: src/components/layout/UserMenu.tsx:114 +#: src/components/layout/UserMenu.tsx:126 #: src/pages/Manage.tsx:32 #: src/pages/Manage.tsx:40 #: src/pages/UserPublicProfile.tsx:166 @@ -1215,8 +1222,8 @@ msgstr "Récap mensuel" msgid "Mute" msgstr "Couper le son" -#: src/components/category/CategoryManager.tsx:192 -#: src/components/category/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:208 +#: src/components/category/CategoryManager.tsx:209 msgid "Name" msgstr "Nom" @@ -1226,7 +1233,7 @@ msgstr "Nom" msgid "New" msgstr "Nouveau" -#: src/components/dump/create/DumpCreateModal.tsx:465 +#: src/components/dump/create/DumpCreateModal.tsx:458 #: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 @@ -1253,7 +1260,7 @@ msgstr "Nouvelle collection" msgid "digest.newPlaylists" msgstr "Nouvelles collections publiques" -#: src/components/dump/create/DumpCreateModal.tsx:764 +#: src/components/dump/create/DumpCreateModal.tsx:758 msgid "Next →" msgstr "Suivant →" @@ -1265,7 +1272,7 @@ msgstr "Piste suivante" msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:171 +#: src/pages/Search.tsx:220 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." @@ -1294,12 +1301,13 @@ msgstr "Aucun message pour l'instant. Dites bonjour !" msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:212 +#: src/pages/Search.tsx:261 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:133 +#: src/components/profile/ProfilePlaylistsTab.tsx:184 +#: src/pages/Playlists.tsx:118 msgid "No playlists yet." msgstr "Pas encore de collections." @@ -1307,7 +1315,7 @@ msgstr "Pas encore de collections." 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é" -#: src/pages/Search.tsx:186 +#: src/pages/Search.tsx:235 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." @@ -1327,7 +1335,7 @@ msgstr "Rien de neuf ce mois-ci." msgid "Nothing happened this week." msgstr "Rien de neuf cette semaine." -#: src/components/profile/ProfileDumpsTab.tsx:96 +#: src/components/profile/ProfileDumpsTab.tsx:97 #: src/components/profile/ProfileOverviewTab.tsx:47 #: src/pages/Notifications.tsx:522 msgid "Nothing here yet." @@ -1361,7 +1369,7 @@ msgstr "Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et msgid "Only images can be dropped here." msgstr "Seules les images peuvent être déposées ici." -#: src/pages/DumpEdit.tsx:118 +#: src/pages/DumpEdit.tsx:119 msgid "Only its poster or a moderator can edit this dump." msgstr "Seuls son auteur et les modérateurs peuvent modifier ce dump." @@ -1432,7 +1440,7 @@ msgstr "Mot de passe mis à jour" msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/dump/create/DumpCreateModal.tsx:587 +#: src/components/dump/create/DumpCreateModal.tsx:580 msgid "Paste a link…" msgstr "Collez un lien…" @@ -1443,7 +1451,7 @@ msgstr "Collez un lien…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:505 +#: src/components/dump/create/DumpCreateModal.tsx:498 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." @@ -1455,12 +1463,18 @@ msgstr "Reprise de votre brouillon." msgid "Play" msgstr "Lecture" +#: src/pages/Manage.tsx:48 +msgid "Playlist categories" +msgstr "Catégories de collections" + #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 -#: src/components/layout/UserMenu.tsx:88 +#: src/components/layout/UserMenu.tsx:92 #: src/components/profile/ProfileFollowTab.tsx:144 -#: src/components/profile/ProfilePlaylistsTab.tsx:118 -#: src/pages/Search.tsx:123 +#: src/components/profile/ProfilePlaylistsTab.tsx:155 +#: src/pages/Playlists.tsx:52 +#: src/pages/Playlists.tsx:106 +#: src/pages/Search.tsx:151 #: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Collections" @@ -1475,7 +1489,7 @@ msgstr "Publier la réponse" #: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:758 +#: src/components/dump/create/DumpCreateModal.tsx:752 msgid "Posting…" msgstr "Publication…" @@ -1483,12 +1497,12 @@ msgstr "Publication…" msgid "Previous track" msgstr "Piste précédente" -#: src/components/dump/DumpCard.tsx:148 +#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/JournalCard.tsx:145 -#: src/components/playlist/PlaylistCard.tsx:104 -#: src/components/playlist/PlaylistHeader.tsx:76 +#: src/components/playlist/PlaylistCard.tsx:106 +#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:511 +#: src/pages/Dump.tsx:507 msgid "private" msgstr "privé" @@ -1496,8 +1510,8 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/playlist/PlaylistCard.tsx:103 -#: src/components/playlist/PlaylistHeader.tsx:76 +#: src/components/playlist/PlaylistCard.tsx:105 +#: src/components/playlist/PlaylistHeader.tsx:77 msgid "public" msgstr "public" @@ -1505,7 +1519,7 @@ msgstr "public" msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:290 +#: src/pages/DumpEdit.tsx:291 msgid "Refresh metadata" msgstr "Actualiser les métadonnées" @@ -1513,7 +1527,7 @@ msgstr "Actualiser les métadonnées" msgid "Refreshing stream…" msgstr "Actualisation du flux…" -#: src/pages/DumpEdit.tsx:289 +#: src/pages/DumpEdit.tsx:290 msgid "Refreshing…" msgstr "Actualisation…" @@ -1531,7 +1545,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:575 +#: src/pages/Dump.tsx:559 msgid "Related" msgstr "Connexe" @@ -1565,7 +1579,7 @@ msgstr "Renommer" msgid "Renaming…" msgstr "Renommage…" -#: src/pages/DumpEdit.tsx:495 +#: src/pages/DumpEdit.tsx:489 msgid "Replace file" msgstr "Remplacer le fichier" @@ -1596,8 +1610,8 @@ msgstr "Choisir un nouveau mot de passe" msgid "Reset password" msgstr "Réinitialiser le mot de passe" -#: src/pages/DumpEdit.tsx:471 -#: src/pages/DumpEdit.tsx:526 +#: src/pages/DumpEdit.tsx:465 +#: src/pages/DumpEdit.tsx:520 msgid "Reset to default" msgstr "Réinitialiser par défaut" @@ -1617,8 +1631,8 @@ msgid "email.reset.title" msgstr "Réinitialiser votre mot de passe" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:319 -#: src/pages/DumpEdit.tsx:225 +#: src/pages/Dump.tsx:318 +#: src/pages/DumpEdit.tsx:226 msgid "Retry" msgstr "Réessayer" @@ -1626,30 +1640,30 @@ msgstr "Réessayer" msgid "Role" msgstr "Rôle" -#: src/components/category/CategoryManager.tsx:216 +#: src/components/category/CategoryManager.tsx:232 #: src/components/chat/ChatEditBox.tsx:85 #: src/components/comment/CommentNode.tsx:216 -#: src/components/playlist/PlaylistEditForm.tsx:207 +#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:437 -#: src/pages/DumpEdit.tsx:568 +#: src/pages/Dump.tsx:433 +#: src/pages/DumpEdit.tsx:563 msgid "Save" msgstr "Enregistrer" #: src/components/comment/CommentNode.tsx:217 -#: src/components/playlist/PlaylistEditForm.tsx:204 +#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:436 +#: src/pages/Dump.tsx:432 #: src/pages/ResetPassword.tsx:139 msgid "Saving…" msgstr "Enregistrement…" #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/SearchBar.tsx:83 -#: src/pages/Search.tsx:69 +#: src/pages/Search.tsx:87 msgid "Search" msgstr "Rechercher" @@ -1665,11 +1679,11 @@ msgstr "Rechercher un emoji" msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:166 +#: src/pages/Search.tsx:215 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:161 +#: src/pages/Search.tsx:210 msgid "Searching…" msgstr "Recherche…" @@ -1709,7 +1723,7 @@ msgstr "Définir un nouveau mot de passe" msgid "email.settings" msgstr "Paramètres" -#: src/components/layout/UserMenu.tsx:104 +#: src/components/layout/UserMenu.tsx:116 #: src/pages/Settings.tsx:75 #: src/pages/Settings.tsx:88 msgid "Settings" @@ -1736,7 +1750,7 @@ msgstr "Connexion…" msgid "Single use, valid for 7 days." msgstr "Usage unique, valable 7 jours." -#: src/components/category/CategoryManager.tsx:205 +#: src/components/category/CategoryManager.tsx:221 msgid "slug" msgstr "identifiant" @@ -1760,7 +1774,7 @@ msgstr "Une erreur est survenue" msgid "Sounds" msgstr "Sons" -#: src/components/dump/create/DumpCreateModal.tsx:512 +#: src/components/dump/create/DumpCreateModal.tsx:505 msgid "Start fresh" msgstr "Repartir de zéro" @@ -1856,18 +1870,18 @@ msgstr "Ce lien de désabonnement est absent ou mal formé." msgid "This week" msgstr "Cette semaine" -#: src/pages/DumpEdit.tsx:455 +#: src/pages/DumpEdit.tsx:449 msgid "Thumbnail" msgstr "Miniature" #: src/components/dump/create/TitleField.tsx:45 -#: src/components/playlist/PlaylistCreateForm.tsx:96 -#: src/components/playlist/PlaylistEditForm.tsx:160 -#: src/pages/DumpEdit.tsx:516 +#: src/components/playlist/PlaylistCreateForm.tsx:100 +#: src/components/playlist/PlaylistEditForm.tsx:175 +#: src/pages/DumpEdit.tsx:510 msgid "Title" msgstr "Titre" -#: src/components/dump/create/DumpCreateModal.tsx:414 +#: src/components/dump/create/DumpCreateModal.tsx:407 msgid "Title is required." msgstr "Un titre est requis." @@ -1967,7 +1981,7 @@ msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:449 +#: src/components/dump/create/DumpCreateModal.tsx:442 msgid "Uploading {0}%" msgstr "Envoi {0} %" @@ -1975,7 +1989,7 @@ msgstr "Envoi {0} %" msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/dump/create/DumpCreateModal.tsx:761 +#: src/components/dump/create/DumpCreateModal.tsx:755 msgid "Uploading…" msgstr "Envoi…" @@ -1993,15 +2007,15 @@ msgstr "Validé" msgid "Upvoted ({0})" msgstr "Validé ({0})" -#: src/pages/DumpEdit.tsx:487 +#: src/pages/DumpEdit.tsx:481 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:398 +#: src/components/dump/create/DumpCreateModal.tsx:391 msgid "URL is required." msgstr "L'URL est obligatoire." -#: src/components/category/CategoryManager.tsx:206 +#: src/components/category/CategoryManager.tsx:222 msgid "URL slug" msgstr "Identifiant d’URL" @@ -2013,7 +2027,7 @@ msgstr "Utilisateur" msgid "User management" msgstr "Gestion utilisateur" -#: src/components/layout/UserMenu.tsx:63 +#: src/components/layout/UserMenu.tsx:67 msgid "User menu" msgstr "Menu utilisateur" @@ -2026,7 +2040,7 @@ msgid "Username" msgstr "Nom d'utilisateur" #: src/components/profile/ProfileFollowTab.tsx:143 -#: src/pages/Search.tsx:122 +#: src/pages/Search.tsx:150 msgid "Users" msgstr "Utilisateurs" @@ -2046,7 +2060,7 @@ msgstr "Les utilisateurs d'une instance approuvée se connectent ici avec leur c msgid "View all →" msgstr "Tout voir →" -#: src/components/dump/create/DumpCreateModal.tsx:526 +#: src/components/dump/create/DumpCreateModal.tsx:519 msgid "View dump →" msgstr "Voir la reco →" @@ -2064,8 +2078,8 @@ msgstr "Résumé hebdomadaire" msgid "email.welcome.subject" msgstr "Bienvenue sur {site}" -#: src/components/dump/create/DumpCreateModal.tsx:712 -#: src/pages/DumpEdit.tsx:544 +#: src/components/dump/create/DumpCreateModal.tsx:705 +#: src/pages/DumpEdit.tsx:538 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" @@ -2074,7 +2088,7 @@ msgstr "Pourquoi on en voudrait ?" msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/dump/create/DumpCreateModal.tsx:727 +#: src/components/dump/create/DumpCreateModal.tsx:721 msgid "Who can see it" msgstr "Qui peut la voir" @@ -2082,8 +2096,8 @@ msgstr "Qui peut la voir" msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/dump/create/DumpCreateModal.tsx:711 -#: src/pages/DumpEdit.tsx:543 +#: src/components/dump/create/DumpCreateModal.tsx:704 +#: src/pages/DumpEdit.tsx:537 msgid "Why?" msgstr "Pourquoi ?" diff --git a/shared/category.ts b/shared/category.ts index 4230e34b..313ae981 100644 --- a/shared/category.ts +++ b/shared/category.ts @@ -1,18 +1,40 @@ /** - * Categories file dumps by topic, and give each topic its own feed on the - * index. This module types them for the server and the client, with the bodies - * that create and change them. Only admins manage categories, through the API, - * since no page does it. The client lists them in its `CategoriesProvider`. + * Categories file dumps and playlists by topic, in two sets apart, one of each + * {@link CategoryKind}, and give each topic its own page. This module types + * them for the server and the client, with the bodies that create and change + * them. Those who hold `category:manage` manage both sets on `/manage`. The + * client lists them in its `CategoriesProvider`. * * @module */ /** - * Returned by the category routes. A category's feed lives at `/` on the - * client, and a dump names its categories by `id` in `categoryIds`. + * Lists what a category files: a dump category files dumps, a playlist + * category playlists. Each kind is a set of its own, whose slugs and names are + * unique within it. + */ +export const CATEGORY_KINDS = ["dump", "playlist"] as const; + +/** One of the {@link CATEGORY_KINDS}. */ +export type CategoryKind = typeof CATEGORY_KINDS[number]; + +/** + * Checks whether `value` is one of the {@link CATEGORY_KINDS}, for the + * category validator and the rows the category service reads. + */ +export function isCategoryKind(value: unknown): value is CategoryKind { + return (CATEGORY_KINDS as readonly unknown[]).includes(value); +} + +/** + * Returned by the category routes. A dump category's feed lives at `/` + * on the client, a playlist category's at `/playlists/`, and a dump or + * playlist names its categories by `id` in `categoryIds`. */ export interface Category { id: string; + /** What it files. It never changes. */ + kind: CategoryKind; slug: string; name: string; /** Sorts the categories, lowest first. */ @@ -22,10 +44,11 @@ export interface Category { } /** - * Passed to `POST /api/categories`. The slug must be free and not reserved by - * a route. + * Passed to `POST /api/categories`. The slug must be free within its kind, and + * a dump category's not reserved by a route. */ export interface CreateCategoryRequest { + kind: CategoryKind; slug: string; name: string; /** 0 when left out. */ @@ -33,7 +56,8 @@ export interface CreateCategoryRequest { } /** - * Passed to `PUT /api/categories/:id`. A field left out keeps its value. + * Passed to `PUT /api/categories/:id`. A field left out keeps its value, and + * the kind is kept. */ export interface UpdateCategoryRequest { slug?: string; diff --git a/shared/paths.ts b/shared/paths.ts index 957acbf1..cc53b1b0 100644 --- a/shared/paths.ts +++ b/shared/paths.ts @@ -2,7 +2,8 @@ * Builds and parses the site's page paths, by the one rule they all follow: * the path is a hierarchy, `@name` is a user (`@name@host` one whose account * lives on another instance), and any other word under the - * root is a category or an app page. Under a user each segment is a fixed tab + * root is a dump category or an app page, `/playlists` holding the playlist + * 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. * @@ -16,6 +17,7 @@ * @module */ +import type { CategoryKind } from "./category.ts"; import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts"; import { HANDLE_RE, splitHandle } from "./validation.ts"; @@ -117,6 +119,23 @@ export function feedPath(categorySlug?: string, tab?: FeedTab): string { return tab ? `${base}/${tab}` : base || "/"; } +/** + * Returns the path of the page of every public playlist, `/playlists`, or of a + * playlist category's at `/playlists/`. + */ +export function playlistsPath(categorySlug?: string): string { + return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists"; +} + +/** + * Returns the path of the page of the category of `kind` at `slug`: a dump + * category's feed ({@link feedPath}), or a playlist category's playlists + * ({@link playlistsPath}). The category chips link to it. + */ +export function categoryPath(kind: CategoryKind, slug: string): string { + return kind === "dump" ? feedPath(slug) : playlistsPath(slug); +} + /** * Returns the path of the search page on `tab`, searching `query` when it is * given. diff --git a/shared/playlist.ts b/shared/playlist.ts index a70b7435..84c42b27 100644 --- a/shared/playlist.ts +++ b/shared/playlist.ts @@ -46,6 +46,11 @@ export interface Playlist { dumpCount?: number; /** The owner's current name, which the playlist's path starts with. */ ownerUsername: string; + /** + * The ids of the playlist categories it is filed under. The services fill + * them in on every playlist they return. + */ + categoryIds?: string[]; } /** @@ -72,6 +77,8 @@ export interface CreatePlaylistRequest { title: string; description?: string; isPublic: boolean; + /** The playlist categories to file it under, none when left out. */ + categoryIds?: string[]; } /** @@ -88,6 +95,8 @@ export interface UpdatePlaylistRequest { /** `null` removes the description. */ description?: string | null; isPublic?: boolean; + /** Replaces its playlist categories; `[]` takes it out of every one. */ + categoryIds?: string[]; } /** diff --git a/src/App.css b/src/App.css index 8250a3df..b477bcba 100644 --- a/src/App.css +++ b/src/App.css @@ -2237,11 +2237,11 @@ body.has-player .chat-fab { font-size: var(--text-2xs); } - .profile-columns .dump-card-categories { + .profile-columns .category-links { gap: 0.25rem; } - .profile-columns .dump-card-category-chip { + .profile-columns .category-link { padding: 0.05rem 0.4rem; font-size: var(--text-2xs); } @@ -2761,9 +2761,11 @@ body.has-player .chat-fab { padding: 1rem; } -/* A list of dump or playlist cards in a group fills the group's column, its - edges in line with the label's rule. */ -.page-group .dump-feed { +/* 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 { padding: 0; max-width: none; } @@ -7249,6 +7251,21 @@ body.has-player .chat-fab { box-sizing: border-box; } +/* The category menu of the dumps and playlists tabs, at the end of the + results' column, as at the end of a profile list's title row. */ +.search-filter { + display: flex; + justify-content: flex-end; + width: 100%; + max-width: 860px; + padding: 0.75rem 1.25rem 0; + box-sizing: border-box; +} + +.search-filter:empty { + display: none; +} + .search-page-empty { padding: 3rem 1.25rem; color: var(--color-text-secondary); @@ -7532,15 +7549,15 @@ body.has-player .chat-fab { color: var(--color-on-accent); } -/* Read-only category chips on dump cards */ -.dump-card-categories { +/* Read-only category chips on dump and playlist cards and pages */ +.category-links { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; } -.dump-card-category-chip { +.category-link { padding: 0.1rem 0.5rem; border-radius: var(--radius-full); background: var(--color-header-user-bg); @@ -7549,7 +7566,7 @@ body.has-player .chat-fab { font-size: var(--text-xs); } -.dump-card-category-chip:hover { +.category-link:hover { background: var(--color-accent); color: var(--color-on-accent); } diff --git a/src/App.tsx b/src/App.tsx index a782d86f..b7d4f7a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,11 +21,13 @@ * `federation:manage` (`Manage`). * - `/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`). * - `/@: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` * (`PlaylistDetail`). - * - `/:categorySlug` and `/:categorySlug/:feedTab` are a category's feed + * - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed * (`Index`). * - Any other path is `NotFound`. * @@ -68,6 +70,9 @@ const Index = lazy(() => const Dump = lazy(() => import("./pages/Dump.tsx").then((m) => ({ default: m.Dump })) ); +const Playlists = lazy(() => + import("./pages/Playlists.tsx").then((m) => ({ default: m.Playlists })) +); const DumpEdit = lazy(() => import("./pages/DumpEdit.tsx").then((m) => ({ default: m.DumpEdit })) ); @@ -240,6 +245,9 @@ function AppRoutes() { } /> ))} + {["/playlists", "/playlists/:categorySlug"].map((path) => ( + } /> + ))} void; +} + +/** + * Renders each category `ids` names as a chip linking to its page, a dump + * category's feed or a playlist category's playlists, in the categories' + * order. Ids of categories since deleted are skipped, and it renders nothing + * when none is left. + */ +export function CategoryChips( + { kind, ids, className, onClick }: CategoryChipsProps, +): React.JSX.Element | null { + const { categories } = useCategories(kind); + if (!ids?.length) return null; + const filed = new Set(ids); + const shown = categories.filter((c) => filed.has(c.id)); + if (shown.length === 0) return null; + + return ( +
+ {shown.map((category) => ( + + {category.name} + + ))} +
+ ); +} diff --git a/src/components/category/CategoryManager.tsx b/src/components/category/CategoryManager.tsx index 51b8116cb4f4ee461f2ea1ee214b47e66b984f3b..9abf452debd2ac47e87ab52f0dcf9f65b6ab98f8 100644 GIT binary patch delta 621 zcma)3!AiqG5T(@?jV)AA#EXpe)`k>4TB?E|hzJ$Dn{BeS!I+J^D;h%4yB>rEZ-T!d zIriYe5AiJifG2S_4Q=VkIV?M~^WK{``_+8eecEaO>jDcXTzG+Nf_r@#0^<<+B=S8W zO#^E940hv^1mv9BZ)XR&1tRG=QLhJQfUin4TIi>j7#tWNqLuG%M?qSvrs^605obfIPFmo6x-z?P}h^f z1QOs3Sk`8=F@1N2lXz5MCpes9E>by2`?g0a=tt$st4k9IP(s)!lFy1!{sYPj&cpx! delta 220 zcmbPh|I&Jbk*fi6tpNrE+q* LkizE4LR`E6NdZZ@ diff --git a/src/components/category/CategorySelect.tsx b/src/components/category/CategorySelect.tsx index bde7686f..e108ae63 100644 --- a/src/components/category/CategorySelect.tsx +++ b/src/components/category/CategorySelect.tsx @@ -1,30 +1,35 @@ /** - * Renders the category picker of the dump creation dialog and the dump editor. + * Renders the category picker of the dump creation dialog and the dump editor, + * and of the playlist forms. * * @module */ import { Trans } from "@lingui/react/macro"; import { useCategories } from "../../hooks/category/useCategories.ts"; +import type { CategoryKind } from "../../../shared/category.ts"; /** - * Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, which - * hold the selection and send it as the dump's `categoryIds`. + * Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, and by + * `PlaylistCreateForm` and `PlaylistEditForm`, which hold the selection and + * send it as the dump's or playlist's `categoryIds`. */ export interface CategorySelectProps { + /** The set it offers: dump categories for a dump, playlist ones for a playlist. */ + kind: CategoryKind; selected: Set; onToggle: (id: string) => void; disabled?: boolean; } /** - * Renders the categories as toggle chips, any number of which may be on. It - * renders nothing while the site has no categories. + * Renders the categories of `kind` as toggle chips, any number of which may be + * on. It renders nothing while the site has none. */ export function CategorySelect( - { selected, onToggle, disabled }: CategorySelectProps, + { kind, selected, onToggle, disabled }: CategorySelectProps, ): React.JSX.Element | null { - const { categories } = useCategories(); + const { categories } = useCategories(kind); if (categories.length === 0) return null; return ( diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx index 7f9cf694..81bc2621 100644 --- a/src/components/category/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -1,6 +1,6 @@ /** - * Renders the menu that narrows a list of dumps to a category, on the index - * page and on a profile's lists. + * Renders the menu that narrows a list of dumps or playlists to a category, on + * the index and playlists pages, on a profile's lists and on the search page. * * @module */ @@ -15,13 +15,17 @@ import { import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { useCategories } from "../../hooks/category/useCategories.ts"; +import type { CategoryKind } from "../../../shared/category.ts"; /** - * Passed to {@link CategorySwitcher} by `FeedTabBar` and `ProfileDumpsTab`, - * which keep the category in their URL. + * Passed to {@link CategorySwitcher} by `FeedTabBar`, `ProfileDumpsTab`, + * `ProfilePlaylistsTab`, `Playlists` and `Search`, which keep the category in + * their URL. */ export interface CategorySwitcherProps { - /** The category shown, or none for every dump. */ + /** The set it offers: dump categories for dumps, playlist ones for playlists. */ + kind: CategoryKind; + /** The category shown, or none for every dump or playlist. */ categorySlug?: string; /** Called with the slug of the category picked, or undefined for "All". */ onSelect: (slug: string | undefined) => void; @@ -29,13 +33,13 @@ export interface CategorySwitcherProps { /** * Renders a button naming the category shown, "All" without one, that opens - * the menu of every category. It renders nothing while the site has no - * categories. + * the menu of every category of `kind`. It renders nothing while the site has + * none. */ export function CategorySwitcher( - { categorySlug, onSelect }: CategorySwitcherProps, + { kind, categorySlug, onSelect }: CategorySwitcherProps, ): React.JSX.Element | null { - const { categories } = useCategories(); + const { categories } = useCategories(kind); const [open, setOpen] = useState(false); const [menuStyle, setMenuStyle] = useState({}); @@ -93,7 +97,7 @@ export function CategorySwitcher( if (categories.length === 0) return null; - // "" stands for every dump, which no category slug can be. + // "" stands for every dump or playlist, which no category slug can be. const value = categorySlug ?? ""; const currentLabel = categorySlug ? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug diff --git a/src/components/dump/DumpCard.tsx b/src/components/dump/DumpCard.tsx index 6dd65783..92f1fe1c 100644 --- a/src/components/dump/DumpCard.tsx +++ b/src/components/dump/DumpCard.tsx @@ -12,7 +12,7 @@ import type { Dump } from "../../../shared/dump.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; -import { useCategories } from "../../hooks/category/useCategories.ts"; +import { CategoryChips } from "../category/CategoryChips.tsx"; import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts"; import { isDumpVisited, @@ -57,10 +57,6 @@ export const DumpCard: React.MemoExoticComponent< ) { const { cardProps, handledAsNewTab } = useDumpCardLink(dump); const { mediaToken } = useAuth(); - const { byId } = useCategories(); - const dumpCategories = (dump.categoryIds ?? []) - .map((id) => byId.get(id)) - .filter((c) => c !== undefined); // Posted in the last 7 days and never opened on this device. const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); @@ -149,22 +145,12 @@ export const DumpCard: React.MemoExoticComponent< )} - {dumpCategories.length > 0 && ( -
e.stopPropagation()} - > - {dumpCategories.map((category) => ( - - {category.name} - - ))} -
- )} + e.stopPropagation()} + /> diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx index 3cd3fc43..2657f2b0 100644 --- a/src/components/dump/create/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -28,6 +28,7 @@ import { deserializeDumpUrlMatch, } from "../../../utils/shared/deserialize.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts"; +import { useCategorySelection } from "../../../hooks/category/useCategorySelection.ts"; import { useWS } from "../../../hooks/ws/useWS.ts"; import { usePlaylistMemberships } from "../../../hooks/playlist/usePlaylistMemberships.ts"; import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts"; @@ -172,16 +173,8 @@ export function DumpCreateModal( // link dump, and clearing either offers both again. const mode: "url" | "file" = file ? "file" : "url"; - const [selectedCategoryIds, setSelectedCategoryIds] = useState>( - () => new Set(draft.categoryIds), - ); - const toggleCategory = (id: string) => - setSelectedCategoryIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + const [selectedCategoryIds, toggleCategory, setSelectedCategoryIds] = + useCategorySelection(draft.categoryIds); const selectFile = useCallback((f: File | null) => { setValue("file", f); @@ -717,6 +710,7 @@ export function DumpCreateModal( /> navigate(feedPath(slug, tab === defaultTab ? undefined : tab))} diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx index 82d70b7d..f01556e1 100644 --- a/src/components/layout/PageHeader.tsx +++ b/src/components/layout/PageHeader.tsx @@ -1,18 +1,24 @@ /** * Renders the title row of the pages the user menu and the bell lead to: - * settings, manage and notifications. + * settings, manage, notifications and every playlist. * * @module */ import type { ReactNode } from "react"; -/** Passed to {@link PageHeader} by `Settings`, `Manage` and `Notifications`. */ +/** + * Passed to {@link PageHeader} by `Settings`, `Manage`, `Notifications` and + * `Playlists`. + */ export interface PageHeaderProps { /** An emoji before the title, hidden from screen readers. */ icon: string; title: ReactNode; - /** It goes at the end of the row, such as the notifications' unread count. */ + /** + * It goes at the end of the row, such as the notifications' unread count or + * the playlists' category menu. + */ children?: ReactNode; } diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index 3bded325..f692c893 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -15,7 +15,11 @@ import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts"; import { Avatar } from "../shared/Avatar.tsx"; import type { SelfUser } from "../../../shared/user.ts"; import { can } from "../../utils/auth/permissions.ts"; -import { communityPath, userPath } from "../../../shared/paths.ts"; +import { + communityPath, + playlistsPath, + userPath, +} from "../../../shared/paths.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; /** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */ @@ -95,6 +99,14 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element { > {i18n._(DIGEST_MESSAGES.community)} + setOpen(false)} + > + All playlists + `, for a `
    `. + * 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 `
      `. */ export function PlaylistCard( { playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps, @@ -128,6 +130,11 @@ export function PlaylistCard( + e.stopPropagation()} + /> {onDelete && ( diff --git a/src/components/playlist/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx index 5c24d3e8..2cf554b3 100644 --- a/src/components/playlist/PlaylistCreateForm.tsx +++ b/src/components/playlist/PlaylistCreateForm.tsx @@ -1,6 +1,6 @@ /** * Renders the form that creates a playlist, in NewPlaylistButton's modal and in - * PlaylistMembershipPanel. + * PlaylistMembershipPanel, with the playlist categories to file it under. * * @module */ @@ -25,6 +25,8 @@ import { SubmitButton } from "../form/SubmitButton.tsx"; import { TextField } from "../form/TextField.tsx"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { VisibilityToggle } from "../form/SegmentedField.tsx"; +import { CategorySelect } from "../category/CategorySelect.tsx"; +import { useCategorySelection } from "../../hooks/category/useCategorySelection.ts"; /** * Passed to {@link PlaylistCreateForm} by NewPlaylistButton and @@ -57,6 +59,7 @@ export function PlaylistCreateForm( const form = useApiForm({ defaultValues: { title: "", description: "", isPublic: true }, }); + const [categoryIds, toggleCategory] = useCategorySelection(); const onSubmit = form.submit(async ({ title, description, isPublic }) => { const playlist = deserializePlaylist( @@ -69,6 +72,7 @@ export function PlaylistCreateForm( title: title.trim(), description: description.trim() || undefined, isPublic, + categoryIds: [...categoryIds], } satisfies CreatePlaylistRequest, ), }), @@ -106,6 +110,12 @@ export function PlaylistCreateForm( maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX} /> name="isPublic" /> + Creating…}> diff --git a/src/components/playlist/PlaylistEditForm.tsx b/src/components/playlist/PlaylistEditForm.tsx index 554d8feb..671676bb 100644 --- a/src/components/playlist/PlaylistEditForm.tsx +++ b/src/components/playlist/PlaylistEditForm.tsx @@ -1,6 +1,6 @@ /** - * Renders the form that edits a playlist's details and cover, in place of the - * header of its page. + * Renders the form that edits a playlist's details, categories and cover, in + * place of the header of its page. * * @module */ @@ -31,6 +31,15 @@ import { FormError } from "../form/FormError.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx"; import { SlugField } from "../form/SlugField.tsx"; import { userPath } from "../../../shared/paths.ts"; +import { CategorySelect } from "../category/CategorySelect.tsx"; +import { useCategorySelection } from "../../hooks/category/useCategorySelection.ts"; + +/** Checks whether `picked` holds exactly the ids of `saved`. */ +function sameIds(picked: Set, saved: string[] = []): boolean { + const savedSet = new Set(saved); + return picked.size === savedSet.size && + [...picked].every((id) => savedSet.has(id)); +} /** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */ export interface PlaylistEditFormProps { @@ -53,8 +62,8 @@ interface EditValues { /** * Renders the form that edits the playlist, in place of the page's header. It * saves the details with `PUT /api/playlists/:playlistId`, sending only the - * title, slug and description that changed, then a new cover, if one was - * picked, with `PUT /api/playlists/:playlistId/cover`. + * title, slug, description and categories that changed, then a new cover, if + * one was picked, with `PUT /api/playlists/:playlistId/cover`. */ export function PlaylistEditForm( { playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps, @@ -77,6 +86,9 @@ export function PlaylistEditForm( }, }); const title = form.watch("title"); + const [categoryIds, toggleCategory] = useCategorySelection( + playlist.categoryIds, + ); const onSubmit = form.submit( async ({ title, slug, description, isPublic, image }) => { @@ -92,6 +104,9 @@ export function PlaylistEditForm( ...(description !== (playlist.description ?? "") ? { description: description || null } : {}), + ...(!sameIds(categoryIds, playlist.categoryIds) + ? { categoryIds: [...categoryIds] } + : {}), isPublic, } satisfies UpdatePlaylistRequest, ), @@ -182,6 +197,13 @@ export function PlaylistEditForm( name="isPublic" /> + + diff --git a/src/components/playlist/PlaylistHeader.tsx b/src/components/playlist/PlaylistHeader.tsx index f3a59fc5..7f6467de 100644 --- a/src/components/playlist/PlaylistHeader.tsx +++ b/src/components/playlist/PlaylistHeader.tsx @@ -1,6 +1,6 @@ /** * Renders the header of a playlist's page, with its cover, title, description, - * visibility, owner and dates. + * visibility, owner, dates and categories. * * @module */ @@ -15,6 +15,7 @@ import { useAuth } from "../../hooks/auth/useAuth.ts"; import { Markdown } from "../editor/Markdown.tsx"; import { FollowPlaylistButton } from "../social/FollowButton.tsx"; import { Tooltip } from "../shared/Tooltip.tsx"; +import { CategoryChips } from "../category/CategoryChips.tsx"; import { userPath } from "../../../shared/paths.ts"; /** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */ @@ -96,6 +97,8 @@ export function PlaylistHeader( )} + + ); diff --git a/src/components/profile/ProfileDumpsTab.tsx b/src/components/profile/ProfileDumpsTab.tsx index a38da9fb..71a5ed98 100644 --- a/src/components/profile/ProfileDumpsTab.tsx +++ b/src/components/profile/ProfileDumpsTab.tsx @@ -47,6 +47,7 @@ export function ProfileDumpsTab( actions={
      setParams((p) => { diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx index 808a0a62..e9b9b439 100644 --- a/src/components/profile/ProfilePlaylistsTab.tsx +++ b/src/components/profile/ProfilePlaylistsTab.tsx @@ -1,11 +1,12 @@ /** - * Renders a profile's "playlists" tab, the playlists the user made. Those they - * follow are the "follow" tab's. + * Renders a profile's "playlists" tab, the playlists the user made, which a + * playlist category may narrow. Those they follow are the "follow" tab's. * * @module */ import { useCallback, useState } from "react"; +import { useSearchParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../../config/api.ts"; @@ -33,6 +34,9 @@ import { useScrollSave, } from "../../hooks/feed/useScrollSave.ts"; import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts"; +import { useCategories } from "../../hooks/category/useCategories.ts"; +import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts"; +import { CategorySwitcher } from "../category/CategorySwitcher.tsx"; import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx"; import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; import { FeedFooter } from "../feed/FeedFooter.tsx"; @@ -48,22 +52,51 @@ export interface ProfilePlaylistsTabProps { } /** - * Renders the playlists `user` made, from `GET /api/users/:userId/playlists`, - * the private ones to their owner only, a page at a time as the list scrolls. - * The socket keeps them live, and going back restores the list and the - * scroll. The owner can make one, which goes first, and delete one. + * Renders the tab's list for the playlist category the URL's `?category=` + * picks, or every playlist without one. Each category's list is another, + * which the tab mounts afresh. */ export function ProfilePlaylistsTab( - { user, isOwnProfile }: ProfilePlaylistsTabProps, + props: ProfilePlaylistsTabProps, ): React.JSX.Element { + const [params] = useSearchParams(); + const categorySlug = params.get("category") || undefined; + return ( + + ); +} + +/** + * 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. + */ +function PlaylistList( + { user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & { + categorySlug?: string; + }, +): React.JSX.Element { + const [, setParams] = useSearchParams(); const { user: me, authFetch } = useAuth(); const { lastPlaylistEvent } = useWS(); + const { categories } = useCategories("playlist"); const { cached, saveState } = useFeedCache( - `feed:profile-playlists:${user.id}`, + `feed:profile-playlists:${user.id}${ + categorySlug ? `:${categorySlug}` : "" + }`, hydratePlaylist, ); - const url = `${API_URL}/api/users/${user.id}/playlists`; - const { state, setItems, loadMore } = usePagedList( + const url = `${API_URL}/api/users/${user.id}/playlists${ + categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" + }`; + const { state, setItems: setAllItems, loadMore } = usePagedList( `${url}:${me?.id ?? "guest"}`, useCallback( (page: number, signal?: AbortSignal) => @@ -74,6 +107,10 @@ export function ProfilePlaylistsTab( cached, ); const loaded = state.status === "loaded" ? state.data : null; + // A slug the categories don't know yet lets everything through. + const categoryId = categorySlug && + categories.find((c) => c.slug === categorySlug)?.id; + const setItems = useCategoryFilter(setAllItems, categoryId || undefined); const lastPlaylistItem = lastPlaylistEvent?.type === "updated" ? (lastPlaylistEvent.playlist ?? null) @@ -116,11 +153,25 @@ export function ProfilePlaylistsTab( return ( Playlists} - actions={isOwnProfile && ( - setItems((items) => withFirst(items, p))} - /> - )} + actions={ +
      + + setParams((p) => { + if (slug) p.set("category", slug); + else p.delete("category"); + return p; + })} + /> + {isOwnProfile && ( + setItems((items) => withFirst(items, p))} + /> + )} +
      + } > {deleteError && ( diff --git a/src/contexts/category/CategoriesContext.ts b/src/contexts/category/CategoriesContext.ts index d6bce39c..2ead909c 100644 --- a/src/contexts/category/CategoriesContext.ts +++ b/src/contexts/category/CategoriesContext.ts @@ -1,6 +1,7 @@ /** - * Shares the site's categories with every component. `CategoriesProvider` fills - * this context and `useCategories` reads it. + * Shares the site's categories, of both kinds, with every component. + * `CategoriesProvider` fills this context and `useCategories` reads one kind's + * from it. * * @module */ @@ -10,13 +11,15 @@ import type { Category } from "../../../shared/category.ts"; /** * Provided by `CategoriesProvider` to `useCategories`, for the category - * switcher and picker, the dump pages and cards, and the category manager. + * switchers and pickers, the dump and playlist pages and cards, and the + * category manager. */ export interface CategoriesContextValue { - /** Every category, in the server's order, by position then slug. */ + /** + * Every category, dump and playlist ones, in the server's order, by kind, + * position then slug. + */ categories: Category[]; - /** Resolves a dump's `categoryIds` to its category chips. */ - byId: Map; /** * Whether the first fetch has ended. It is also true after a network error, * with no categories. @@ -35,7 +38,6 @@ export const CategoriesContext: React.Context = CategoriesContextValue >({ categories: [], - byId: new Map(), isLoaded: false, refresh: async () => {}, }); diff --git a/src/contexts/category/CategoriesProvider.tsx b/src/contexts/category/CategoriesProvider.tsx index f6e0502a..f9af8ec2 100644 --- a/src/contexts/category/CategoriesProvider.tsx +++ b/src/contexts/category/CategoriesProvider.tsx @@ -1,6 +1,7 @@ /** - * Spares every page fetching the site's categories: it loads them once for the - * whole app, and again when the category manager changes them. `App` mounts it. + * Spares every page fetching the site's categories: it loads both kinds once + * for the whole app, and again when the category manager changes them. `App` + * mounts it. * * @module */ @@ -26,7 +27,7 @@ import { expectOk } from "../../utils/shared/api.ts"; const RETRY_MS = 10_000; /** - * Returns every category. + * Returns every category, of both kinds. * * @throws {Error} When the request fails or the API answers a failure. */ @@ -81,14 +82,9 @@ export function CategoriesProvider( const refresh = useCallback(() => load().catch(() => {}), [load]); - const byId = useMemo( - () => new Map(categories.map((c) => [c.id, c])), - [categories], - ); - const value: CategoriesContextValue = useMemo( - () => ({ categories, byId, isLoaded, refresh }), - [categories, byId, isLoaded, refresh], + () => ({ categories, isLoaded, refresh }), + [categories, isLoaded, refresh], ); return ( diff --git a/src/hooks/category/useCategories.ts b/src/hooks/category/useCategories.ts index fbcf5325..49d765e9 100644 --- a/src/hooks/category/useCategories.ts +++ b/src/hooks/category/useCategories.ts @@ -1,20 +1,45 @@ /** - * Gives components the categories that `CategoriesProvider` loads once for the - * whole app. + * Gives components the categories of one kind, of those `CategoriesProvider` + * loads once for the whole app. * * @module */ -import { useContext } from "react"; -import { - CategoriesContext, - type CategoriesContextValue, -} from "../../contexts/category/CategoriesContext.ts"; +import { useContext, useMemo } from "react"; +import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts"; +import type { Category, CategoryKind } from "../../../shared/category.ts"; + +/** Returned by {@link useCategories}: one kind's categories. */ +export interface KindCategories { + /** The categories of the kind, by position then slug. */ + categories: Category[]; + /** Resolves a dump's or playlist's `categoryIds` to its category chips. */ + byId: Map; + /** + * Whether the first fetch has ended. It is also true after a network error, + * with no categories. + */ + isLoaded: boolean; + /** Refetches both kinds. The category manager calls it after each change. */ + refresh: () => Promise; +} /** - * Returns every category and a function that refetches them. Under no - * `CategoriesProvider`, the list is empty. + * Returns the categories of `kind`, dump or playlist, and a function that + * refetches them. Under no `CategoriesProvider`, the list is empty. */ -export function useCategories(): CategoriesContextValue { - return useContext(CategoriesContext); +export function useCategories(kind: CategoryKind): KindCategories { + const { categories: all, isLoaded, refresh } = useContext(CategoriesContext); + const categories = useMemo( + () => all.filter((c) => c.kind === kind), + [all, kind], + ); + const byId = useMemo( + () => new Map(categories.map((c) => [c.id, c])), + [categories], + ); + return useMemo( + () => ({ categories, byId, isLoaded, refresh }), + [categories, byId, isLoaded, refresh], + ); } diff --git a/src/hooks/category/useCategoryFilter.ts b/src/hooks/category/useCategoryFilter.ts new file mode 100644 index 00000000..786a777e --- /dev/null +++ b/src/hooks/category/useCategoryFilter.ts @@ -0,0 +1,30 @@ +/** + * Keeps a list narrowed to one category, whatever the socket puts in it. + * + * @module + */ + +import { useCallback } from "react"; + +/** + * Returns `setItems` wrapped so that every list it sets keeps only the items + * filed under `categoryId`, or `setItems` as it is without one. A list of one + * category's dumps or playlists passes it to the hooks that apply the + * socket's messages, so that one created outside the category isn't added, + * and one edited out of it leaves. + */ +export function useCategoryFilter( + setItems: (fn: (prev: T[]) => T[]) => void, + categoryId?: string, +): (fn: (prev: T[]) => T[]) => void { + return useCallback( + (fn: (prev: T[]) => T[]) => + setItems((prev) => { + const next = fn(prev); + return categoryId + ? next.filter((item) => item.categoryIds?.includes(categoryId)) + : next; + }), + [setItems, categoryId], + ); +} diff --git a/src/hooks/category/useCategorySelection.ts b/src/hooks/category/useCategorySelection.ts new file mode 100644 index 00000000..0c1e85a8 --- /dev/null +++ b/src/hooks/category/useCategorySelection.ts @@ -0,0 +1,26 @@ +/** + * Holds the categories picked in a form's `CategorySelect`. + * + * @module + */ + +import { useCallback, useState } from "react"; + +/** + * Returns the ids picked, starting from `initial`, a function that turns one + * on or off, and one that replaces them all. The dump creation dialog, the + * dump editor and the playlist forms send `[...selected]` as `categoryIds`. + */ +export function useCategorySelection( + initial: Iterable = [], +): [Set, (id: string) => void, (ids: Set) => void] { + const [selected, setSelected] = useState>(() => new Set(initial)); + const toggle = useCallback((id: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }), []); + return [selected, toggle, setSelected]; +} diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts index 6d4f2bb2..4f1bd5c9 100644 --- a/src/hooks/dump/useUserDumpFeed.ts +++ b/src/hooks/dump/useUserDumpFeed.ts @@ -16,6 +16,7 @@ import { } from "../../utils/shared/deserialize.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useCategories } from "../category/useCategories.ts"; +import { useCategoryFilter } from "../category/useCategoryFilter.ts"; import { useWS } from "../ws/useWS.ts"; import { useFeedCache } from "../feed/useFeedCache.ts"; import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts"; @@ -64,7 +65,7 @@ export function useUserDumpFeed( ): UseUserDumpFeedResult { const { user, authFetch } = useAuth(); const { lastDumpEvent } = useWS(); - const { categories } = useCategories(); + const { categories } = useCategories("dump"); const { cached, saveState } = useFeedCache( `feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`, hydrateDump, @@ -91,16 +92,7 @@ export function useUserDumpFeed( // the categories don't know yet lets everything through. const categoryId = categorySlug && categories.find((c) => c.slug === categorySlug)?.id; - const setItems = useCallback( - (fn: (prev: Dump[]) => Dump[]) => - setAllItems((prev) => { - const next = fn(prev); - return categoryId - ? next.filter((d) => d.categoryIds?.includes(categoryId)) - : next; - }), - [setAllItems, categoryId], - ); + const setItems = useCategoryFilter(setAllItems, categoryId || undefined); const upvoted = useUpvotedList({ profileUserId: list === "upvoted" ? userId : null, diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 59a4bd6f..fd8ca196 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -11,6 +11,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts"; import { can } from "../utils/auth/permissions.ts"; +import { CategoryChips } from "../components/category/CategoryChips.tsx"; import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx"; import { API_URL } from "../config/api.ts"; @@ -30,7 +31,6 @@ import { import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; -import { useCategories } from "../hooks/category/useCategories.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { relativeTime } from "../utils/shared/relativeTime.ts"; import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts"; @@ -116,7 +116,6 @@ export function Dump(): React.JSX.Element { const [titleError, setTitleError] = useState(null); const { user, mediaToken, authFetch } = useAuth(); - const { byId } = useCategories(); const { voteCounts, myVotes, @@ -334,9 +333,6 @@ export function Dump(): React.JSX.Element { const { dump } = dumpState; const canEdit = !!user && (dump.userId === user.id || can(user, "dump:moderate")); - const dumpCategories = (dump.categoryIds ?? []) - .map((id) => byId.get(id)) - .filter((c) => c !== undefined); const startTitleEdit = () => { setTitleDraft(dump.title); @@ -512,19 +508,7 @@ export function Dump(): React.JSX.Element { )}
      - {dumpCategories.length > 0 && ( -
      - {dumpCategories.map((category) => ( - - {category.name} - - ))} -
      - )} + diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index f08a5380..cd7ec46d 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -39,6 +39,7 @@ import { RichContentCard } from "../components/dump/RichContentCard.tsx"; import { FilePreview } from "../components/dump/FilePreview.tsx"; import { ImagePicker } from "../components/shared/ImagePicker.tsx"; import { CategorySelect } from "../components/category/CategorySelect.tsx"; +import { useCategorySelection } from "../hooks/category/useCategorySelection.ts"; import { FileField } from "../components/form/FileField.tsx"; import { FormError } from "../components/form/FormError.tsx"; import { FormProvider } from "react-hook-form"; @@ -362,16 +363,9 @@ function DumpEditForm({ onSaved, }: DumpEditFormProps) { const { authFetch } = useAuth(); - const [selectedCategoryIds, setSelectedCategoryIds] = useState>( - () => new Set(dump.categoryIds ?? []), + const [selectedCategoryIds, toggleCategory] = useCategorySelection( + dump.categoryIds ?? [], ); - const toggleCategory = (id: string) => - setSelectedCategoryIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); const form = useApiForm({ defaultValues: { title: dump.title, @@ -547,6 +541,7 @@ function DumpEditForm({ /> diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 7a96e274..c1b28408 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -65,7 +65,7 @@ export function Index( const { user, authFetch } = useAuth(); const { recentDumps, deletedDumpIds, connectionEpoch } = useWS(); - const { categories, isLoaded: categoriesLoaded } = useCategories(); + const { categories, isLoaded: categoriesLoaded } = useCategories("dump"); const category = categorySlug ? categories.find((c) => c.slug === categorySlug) : undefined; diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx index 1f168977..85069cf1 100644 --- a/src/pages/Manage.tsx +++ b/src/pages/Manage.tsx @@ -23,10 +23,10 @@ import { import { NotFound } from "./NotFound.tsx"; /** - * Renders the category manager and the instances the site trusts or blocks, - * each to those who hold its permission, which the API checks again, the - * instances only while federated sign-in goes at least one way; without - * either, the page isn't found. + * Renders the managers of the dump and the playlist categories, and the + * instances the site trusts or blocks, each to those who hold its permission, + * which the API checks again, the instances only while federated sign-in goes + * at least one way; without either, the page isn't found. */ export function Manage(): React.JSX.Element { useDocumentTitle(t`Manage`); @@ -39,11 +39,18 @@ export function Manage(): React.JSX.Element { Manage} /> {categories && ( - Categories}> -
      - -
      -
      + <> + Dump categories}> +
      + +
      +
      + Playlist categories}> +
      + +
      +
      + )} {instances && ( Instances}> diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx new file mode 100644 index 00000000..77762786 --- /dev/null +++ b/src/pages/Playlists.tsx @@ -0,0 +1,135 @@ +/** + * Renders every public playlist, `/playlists`, and those of one playlist + * category, `/playlists/:categorySlug`, newest first. + * + * @module + */ + +import { useCallback } from "react"; +import { useNavigate, useParams } 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 { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts"; +import { + deserializePlaylist, + hydratePlaylist, +} from "../utils/shared/deserialize.ts"; +import { useAuth } from "../hooks/auth/useAuth.ts"; +import { useCategories } from "../hooks/category/useCategories.ts"; +import { useCategoryFilter } from "../hooks/category/useCategoryFilter.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; +import { usePagedList } from "../hooks/feed/usePagedList.ts"; +import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; +import { + useScrollRestore, + useScrollSave, +} from "../hooks/feed/useScrollSave.ts"; +import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; +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 { 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. + */ +export function Playlists(): React.JSX.Element { + const { categorySlug } = useParams(); + const { categories, isLoaded } = useCategories("playlist"); + const category = categorySlug + ? categories.find((c) => c.slug === categorySlug) + : undefined; + useDocumentTitle(category ? category.name : t`Playlists`); + + 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, and going back restores the list and the scroll. + */ +function PlaylistsList( + { categorySlug, categoryId }: { categorySlug?: string; categoryId?: string }, +) { + const navigate = useNavigate(); + const { user, authFetch } = useAuth(); + const { cached, saveState } = useFeedCache( + `feed:playlists:${categorySlug ?? "all"}`, + hydratePlaylist, + ); + const url = `${API_URL}/api/playlists${ + categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" + }`; + const { state, setItems: setAllItems, loadMore } = usePagedList( + `${url}:${user?.id ?? "guest"}`, + useCallback( + (page: number, signal?: AbortSignal) => + authFetch(pageUrl(url, page), { signal }) + .then((res) => readPage(res, deserializePlaylist)), + [authFetch, url], + ), + cached, + ); + const loaded = state.status === "loaded" ? state.data : null; + usePlaylistListSync(useCategoryFilter(setAllItems, categoryId)); + + const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded)); + useScrollSave( + !!loaded, + useCallback((y) => { + if (loaded) saveState(loaded, y); + }, [loaded, saveState]), + ); + useScrollRestore(cached?.scrollY, !!loaded); + + return ( + + Playlists}> + navigate(playlistsPath(slug))} + /> + + data.items.length === 0} + empty={ +

      + No playlists yet. +

      + } + > + {({ data }) => ( + <> + + + + )} +
      +
      + ); +} diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 29d67875..10619aee 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,6 +1,7 @@ /** * Renders the results of a search, in a tab for dumps, one for users and one - * for playlists. + * for playlists, the dumps and the playlists narrowed to a category of their + * own. * * @module */ @@ -14,6 +15,7 @@ import { TabBar } from "../components/shared/TabBar.tsx"; import { DumpCardList } from "../components/dump/DumpCardList.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; +import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; @@ -29,6 +31,7 @@ import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import type { Wire } from "../../shared/api.ts"; import type { SearchResults } from "../../shared/search.ts"; +import type { CategoryKind } from "../../shared/category.ts"; import { SEARCH_URL } from "../config/api.ts"; import { expectOk } from "../utils/shared/api.ts"; import { @@ -59,29 +62,54 @@ interface Results { playlists: Playlist[]; } +/** + * Names, for each tab a category narrows, the kind of its categories and the + * parameter of `GET /api/search` that takes one. + */ +const TAB_CATEGORIES: Partial< + Record +> = { + dumps: { kind: "dump", param: "category" }, + playlists: { kind: "playlist", param: "playlistCategory" }, +}; + /** * Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come * from `GET /api/search`, signed in when the viewer is, so they find their own * private dumps and playlists too. The dumps load a page at a time as the * "dumps" tab scrolls. A new `q` aborts the search under way. + * + * On the "dumps" and "playlists" tabs, a menu narrows the results to a + * category of their kind, kept in the URL's `?category=`, which another tab + * drops. */ export function Search(): React.JSX.Element { useDocumentTitle(t`Search`); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const q = searchParams.get("q") ?? ""; + const categorySlug = searchParams.get("category") || undefined; const [tab, setTab] = useTabParam(searchPath(), SEARCH_TABS, "dumps"); + const filter = tab ? TAB_CATEGORIES[tab] : undefined; + const categoryParam = filter && categorySlug ? filter.param : undefined; const { user, authFetch } = useAuth(); const search = useCallback(async (page: number, signal?: AbortSignal) => { const url = new URL(SEARCH_URL, location.origin); url.searchParams.set("q", q); + if (categoryParam && categorySlug) { + url.searchParams.set(categoryParam, categorySlug); + } return expectOk>( await authFetch(pageUrl(url.toString(), page), { signal }), ); - }, [authFetch, q]); + }, [authFetch, q, categoryParam, categorySlug]); const { state, setData } = useFetchState( - q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined, + q.trim() + ? `${q}:${user?.id ?? "guest"}:${categoryParam ?? ""}:${ + categorySlug ?? "" + }` + : undefined, (signal) => search(1, signal).then(({ dumps, users, playlists }) => ({ dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore), @@ -144,12 +172,33 @@ export function Search(): React.JSX.Element { ), }))} activeTab={tab} - onChange={setTab} + onChange={(next) => { + // Each tab's categories are its own. + const params = new URLSearchParams(searchParams); + params.delete("category"); + const query = params.toString(); + setTab(next, query ? `?${query}` : ""); + }} className="search-tabs" innerClassName="search-tabs-inner" /> )} + {q.trim() && filter && ( +
      + + setSearchParams((p) => { + if (slug) p.set("category", slug); + else p.delete("category"); + return p; + })} + /> +
      + )} + {!q.trim() && (

      Enter a query to search. diff --git a/src/test/components/category/CategorySelect.test.tsx b/src/test/components/category/CategorySelect.test.tsx index bce48241..201c2983 100644 --- a/src/test/components/category/CategorySelect.test.tsx +++ b/src/test/components/category/CategorySelect.test.tsx @@ -16,7 +16,11 @@ describe("CategorySelect", () => { const onToggle = vi.fn(); const user = userEvent.setup(); renderWithContexts( - , + , { categories, }, @@ -35,7 +39,7 @@ describe("CategorySelect", () => { test("nothing without categories", () => { renderWithContexts( - {}} />, + {}} />, ); expect(screen.queryByText("Categories")).not.toBeInTheDocument(); }); diff --git a/src/test/components/category/CategorySwitcher.test.tsx b/src/test/components/category/CategorySwitcher.test.tsx index a03ab336..baaa8f0f 100644 --- a/src/test/components/category/CategorySwitcher.test.tsx +++ b/src/test/components/category/CategorySwitcher.test.tsx @@ -13,7 +13,7 @@ const categories: Category[] = [ describe("CategorySwitcher", () => { test("hidden with no categories", () => { - renderWithContexts( {}} />); + renderWithContexts( {}} />); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -21,7 +21,7 @@ describe("CategorySwitcher", () => { const user = userEvent.setup(); const onSelect = vi.fn(); renderWithContexts( - , + , { categories }, ); await user.click(screen.getByRole("button", { name: "Music" })); @@ -38,7 +38,7 @@ describe("CategorySwitcher", () => { const user = userEvent.setup(); const onSelect = vi.fn(); renderWithContexts( - , + , { categories }, ); await user.click(screen.getByRole("button", { name: "gone" })); @@ -48,7 +48,7 @@ describe("CategorySwitcher", () => { test("Escape closes the menu", async () => { const user = userEvent.setup(); - renderWithContexts( {}} />, { + renderWithContexts( {}} />, { categories, }); await user.click(screen.getByRole("button", { name: "All" })); diff --git a/src/test/contexts/category/CategoriesProvider.test.tsx b/src/test/contexts/category/CategoriesProvider.test.tsx index ae88820a..f812ee05 100644 --- a/src/test/contexts/category/CategoriesProvider.test.tsx +++ b/src/test/contexts/category/CategoriesProvider.test.tsx @@ -14,16 +14,32 @@ describe("CategoriesProvider", () => { test("loads and indexes categories", async () => { const music = makeCategory({ id: "c1", slug: "music" }); route("GET /api/categories", () => wire([music])); - const { result } = renderHook(useCategories, { wrapper }); + const { result } = renderHook(() => useCategories("dump"), { wrapper }); await waitFor(() => expect(result.current.isLoaded).toBe(true)); expect(result.current.byId.get("c1")?.slug).toBe("music"); expect(result.current.categories[0].createdAt).toBeInstanceOf(Date); }); + test("gives each kind its own set", async () => { + route("GET /api/categories", () => + wire([ + makeCategory({ id: "d", slug: "music" }), + makeCategory({ id: "p", slug: "music", kind: "playlist" }), + ])); + const { result } = renderHook( + () => ({ dump: useCategories("dump"), list: useCategories("playlist") }), + { wrapper }, + ); + await waitFor(() => expect(result.current.dump.isLoaded).toBe(true)); + expect(result.current.dump.categories.map((c) => c.id)).toEqual(["d"]); + expect(result.current.list.categories.map((c) => c.id)).toEqual(["p"]); + expect(result.current.dump.byId.has("p")).toBe(false); + }); + test("refresh refetches", async () => { let list = [makeCategory({ id: "a" })]; route("GET /api/categories", () => wire(list)); - const { result } = renderHook(useCategories, { wrapper }); + const { result } = renderHook(() => useCategories("dump"), { wrapper }); await waitFor(() => expect(result.current.categories).toHaveLength(1)); list = [...list, makeCategory({ id: "b" })]; await act(() => result.current.refresh()); @@ -34,7 +50,7 @@ describe("CategoriesProvider", () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { route("GET /api/categories", () => fail("x", 500)); - const { result } = renderHook(useCategories, { wrapper }); + const { result } = renderHook(() => useCategories("dump"), { wrapper }); await act(async () => {}); expect(result.current.isLoaded).toBe(false); route("GET /api/categories", () => wire([makeCategory({ id: "a" })])); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 6d847670..a9621f97 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -119,6 +119,7 @@ export function makeCategory(over: Partial = {}): Category { const id = over.id ?? nextId("cat"); return { id, + kind: "dump", slug: over.slug ?? `cat${seq}`, name: over.name ?? `Category ${seq}`, position: 0, diff --git a/src/test/pages/Manage.test.tsx b/src/test/pages/Manage.test.tsx index 4d9068a5..66f07658 100644 --- a/src/test/pages/Manage.test.tsx +++ b/src/test/pages/Manage.test.tsx @@ -22,6 +22,12 @@ vi.mock("../../config/instance.ts", async (importOriginal) => ({ const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); +/** Queries within the `/manage` group headed `label`. */ +async function group(label: string) { + const heading = await screen.findByRole("heading", { name: label }); + return within(heading.closest("section")!); +} + function open(session = admin) { route("GET /api/federation/peers", () => [], { fallback: true }); const user = userEvent.setup(); @@ -49,8 +55,9 @@ describe("Manage", () => { return null; }); const user = open(); + const dumps = await group("Dump categories"); await user.click( - await screen.findByRole("button", { name: "Add category" }), + await dumps.findByRole("button", { name: "Add category" }), ); await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema"); await user.type( @@ -61,6 +68,7 @@ describe("Manage", () => { expect(await screen.findByRole("button", { name: "Cinema" })) .toBeInTheDocument(); expect(calls("POST /api/categories")[0].body).toEqual({ + kind: "dump", name: "Cinema", slug: "cinema", }); @@ -82,8 +90,9 @@ describe("Manage", () => { route("GET /api/categories", () => []); route("POST /api/categories", () => fail("Slug already taken", 409)); const user = open(); + const dumps = await group("Dump categories"); await user.click( - await screen.findByRole("button", { name: "Add category" }), + await dumps.findByRole("button", { name: "Add category" }), ); await user.type(screen.getByRole("textbox", { name: "Name" }), "X"); await user.type( @@ -94,10 +103,51 @@ describe("Manage", () => { "Slug already taken", ); await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}"); - expect(screen.getByRole("button", { name: "Add category" })) + expect(dumps.getByRole("button", { name: "Add category" })) .toBeInTheDocument(); }); + test("the playlist categories are a set of their own", async () => { + let list = [ + makeCategory({ id: "d1", slug: "music", name: "Music" }), + makeCategory({ + id: "p1", + kind: "playlist", + slug: "mixes", + name: "Mixes", + }), + ]; + route("GET /api/categories", () => wire(list)); + route("POST /api/categories", (req) => { + const c = makeCategory({ id: "p2", ...(req.body as object) }); + list = [...list, c]; + return wire(c); + }); + const user = open(); + const dumps = await group("Dump categories"); + const playlists = await group("Playlist categories"); + expect(await dumps.findByRole("button", { name: "Music" })) + .toBeInTheDocument(); + expect(dumps.queryByRole("button", { name: "Mixes" })).toBeNull(); + expect(playlists.getByRole("button", { name: "Mixes" })) + .toBeInTheDocument(); + expect(playlists.queryByRole("button", { name: "Music" })).toBeNull(); + + await user.click(playlists.getByRole("button", { name: "Add category" })); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Live"); + await user.type( + screen.getByRole("textbox", { name: "URL slug" }), + "live{Enter}", + ); + expect(await playlists.findByRole("button", { name: "Live" })) + .toBeInTheDocument(); + expect(calls("POST /api/categories")[0].body).toEqual({ + kind: "playlist", + name: "Live", + slug: "live", + }); + }); + test("an admin trusts, blocks and removes other instances", async () => { route("GET /api/categories", () => []); const music = { @@ -189,7 +239,7 @@ describe("Manage", () => { instance.federationOutgoing = false; route("GET /api/categories", () => []); open(); - expect(await screen.findByText("Categories")).toBeInTheDocument(); + expect(await screen.findByText("Dump categories")).toBeInTheDocument(); expect(screen.queryByText("Instances")).not.toBeInTheDocument(); }); diff --git a/src/test/pages/PlaylistDetail.test.tsx b/src/test/pages/PlaylistDetail.test.tsx index 627a1ff1..7174afd0 100644 --- a/src/test/pages/PlaylistDetail.test.tsx +++ b/src/test/pages/PlaylistDetail.test.tsx @@ -12,7 +12,13 @@ import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx"; import type { Dump } from "../../../shared/dump.ts"; import type { Playlist } from "../../../shared/playlist.ts"; import { calls, fail, route } from "../fetch.ts"; -import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts"; +import { + makeCategory, + makeDump, + makePlaylist, + makeSession, + wire, +} from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeWebSocket } from "../ws.ts"; @@ -31,6 +37,7 @@ function open( playlist = {} as Partial, items = dumps() as Dump[], url = "/@owner/playlists/mix", + categories = [] as ReturnType[], } = {}, ) { const pl = makePlaylist({ @@ -51,6 +58,7 @@ function open( route: url, path: "/:handle/playlists/:slug", session, + categories, extraRoutes: home

      } />, }); const socket = FakeWebSocket.last; @@ -142,6 +150,56 @@ describe("PlaylistDetail", () => { expect(currentPath()).toBe("/@owner/playlists/mix"); }); + test("its categories show, and editing them sends the new set", async () => { + const mixes = makeCategory({ + id: "pc1", + kind: "playlist", + slug: "mixes", + name: "Mixes", + }); + const live = makeCategory({ + id: "pc2", + kind: "playlist", + slug: "live", + name: "Live", + }); + const music = makeCategory({ id: "dc1", slug: "music", name: "Music" }); + route( + "PUT /api/playlists/:id", + (req) => + wire( + makePlaylist({ + id: "p1", + slug: "mix", + title: "Mix", + userId: "owner", + ...(req.body as object), + }), + ), + ); + const { user } = open({ + playlist: { categoryIds: ["pc1"] }, + categories: [music, mixes, live], + }); + expect(await screen.findByRole("link", { name: "Mixes" })) + .toHaveAttribute("href", "/playlists/mixes"); + + await user.click(screen.getByRole("button", { name: "Edit" })); + // The dump categories aren't offered. + expect(screen.queryByRole("button", { name: "Music" })).toBeNull(); + expect(screen.getByRole("button", { name: "Mixes" })) + .toHaveAttribute("aria-pressed", "true"); + await user.click(screen.getByRole("button", { name: "Mixes" })); + await user.click(screen.getByRole("button", { name: "Live" })); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("link", { name: "Live" })) + .toHaveAttribute("href", "/playlists/live"); + expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ + categoryIds: ["pc2"], + isPublic: true, + }); + }); + test("a new title proposes a new link, which saving moves to", async () => { route( "PUT /api/playlists/:id", diff --git a/src/test/pages/Playlists.test.tsx b/src/test/pages/Playlists.test.tsx new file mode 100644 index 00000000..58b95072 --- /dev/null +++ b/src/test/pages/Playlists.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Playlists } from "../../pages/Playlists.tsx"; +import { calls, route } from "../fetch.ts"; +import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const mixes = makeCategory({ + id: "pc1", + kind: "playlist", + slug: "mixes", + name: "Mixes", +}); +const music = makeCategory({ id: "dc1", slug: "music", name: "Music" }); + +function open(url: string) { + const user = userEvent.setup(); + renderApp(, { + route: url, + path: "/playlists/:categorySlug?", + categories: [music, mixes], + }); + return { user, socket: FakeWebSocket.last }; +} + +describe("Playlists", () => { + test("every public playlist; the menu offers the playlist categories only", async () => { + route( + "GET /api/playlists", + () => page([makePlaylist({ title: "Summer", categoryIds: ["pc1"] })]), + ); + const { user } = open("/playlists"); + expect(await screen.findByText("Summer")).toBeInTheDocument(); + expect(calls("GET /api/playlists")[0].url.searchParams.get("category")) + .toBeNull(); + // Its chip leads to its category's playlists. + expect(screen.getByRole("link", { name: "Mixes" })).toHaveAttribute( + "href", + "/playlists/mixes", + ); + + await user.click(screen.getByRole("button", { name: "All" })); + expect(screen.getByRole("option", { name: "Mixes" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Music" })).toBeNull(); + await user.click(screen.getByRole("option", { name: "Mixes" })); + expect(currentPath()).toBe("/playlists/mixes"); + }); + + test("a category's playlists, kept to it as the socket brings more", async () => { + route( + "GET /api/playlists", + () => + page([ + makePlaylist({ id: "p1", title: "Filed", categoryIds: ["pc1"] }), + ]), + ); + const { socket } = open("/playlists/mixes"); + expect(await screen.findByText("Filed")).toBeInTheDocument(); + expect(calls("GET /api/playlists")[0].url.searchParams.get("category")) + .toBe("mixes"); + + socket.welcome(); + socket.receive({ + type: "playlist_created", + playlist: wire(makePlaylist({ title: "Elsewhere", categoryIds: [] })), + }); + socket.receive({ + type: "playlist_created", + playlist: wire( + makePlaylist({ title: "Also filed", categoryIds: ["pc1"] }), + ), + }); + expect(screen.getByText("Also filed")).toBeInTheDocument(); + expect(screen.queryByText("Elsewhere")).toBeNull(); + + // Taken out of the category, it leaves. + socket.receive({ + type: "playlist_updated", + playlist: wire( + makePlaylist({ id: "p1", title: "Filed", categoryIds: [] }), + ), + }); + expect(screen.queryByText("Filed")).toBeNull(); + }); + + test("a slug that names no playlist category is not found", async () => { + route("GET /api/playlists", () => page([])); + open("/playlists/music"); + expect(await screen.findByText("This page does not exist.")) + .toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/Search.test.tsx b/src/test/pages/Search.test.tsx index d0394932..39699e65 100644 --- a/src/test/pages/Search.test.tsx +++ b/src/test/pages/Search.test.tsx @@ -1,9 +1,10 @@ import { describe, expect, test } from "vitest"; -import { act, screen } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { Search } from "../../pages/Search.tsx"; import { calls, fail, route } from "../fetch.ts"; import { + makeCategory, makeDump, makePlaylist, makeSession, @@ -35,9 +36,10 @@ function results( function open( url: string, session: ReturnType | null = null, + categories: ReturnType[] = [], ) { const user = userEvent.setup(); - renderApp(, { route: url, path: "/search/*", session }); + renderApp(, { route: url, path: "/search/*", session, categories }); return user; } @@ -70,6 +72,50 @@ describe("Search", () => { expect(screen.getByText("Cats")).toBeInTheDocument(); }); + test("each tab narrows to a category of its own kind", async () => { + route("GET /api/search", () => + results({ + dumps: [makeDump({ title: "Cat song" })], + playlists: [makePlaylist({ title: "Cat mix" })], + })); + const user = open("/search?q=cat", null, [ + makeCategory({ id: "dc1", slug: "music", name: "Music" }), + makeCategory({ + id: "pc1", + kind: "playlist", + slug: "mixes", + name: "Mixes", + }), + ]); + await screen.findByText("Cat song"); + + await user.click(screen.getByRole("button", { name: "All" })); + expect(screen.queryByRole("option", { name: "Mixes" })).toBeNull(); + await user.click(screen.getByRole("option", { name: "Music" })); + expect(currentPath()).toBe("/search?q=cat&category=music"); + await waitFor(() => + expect( + calls("GET /api/search").at(-1)!.url.searchParams.get("category"), + ).toBe("music") + ); + + // Another tab drops it, and offers its own. + await user.click(screen.getByRole("button", { name: /^Playlists/ })); + expect(currentPath()).toBe("/search/playlists?q=cat"); + await user.click(await screen.findByRole("button", { name: "All" })); + expect(screen.queryByRole("option", { name: "Music" })).toBeNull(); + await user.click(screen.getByRole("option", { name: "Mixes" })); + await waitFor(() => { + const req = calls("GET /api/search").at(-1)!; + expect(req.url.searchParams.get("playlistCategory")).toBe("mixes"); + expect(req.url.searchParams.get("category")).toBeNull(); + }); + + // The users tab has none. + await user.click(screen.getByRole("button", { name: /^Users/ })); + expect(screen.queryByRole("button", { name: "All" })).toBeNull(); + }); + test("nothing found", async () => { route("GET /api/search", () => results()); const user = open("/search?q=zzz"); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index 9576652a..fc769827 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -217,6 +217,47 @@ describe("UserPublicProfile", () => { expect(currentPath()).toBe("/@ann/playlists"); }); + test("the playlists tab narrows to a playlist category, in its URL", async () => { + const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); + const mixes = makeCategory({ + id: "pc1", + kind: "playlist", + slug: "mixes", + name: "Mixes", + }); + const filed = makePlaylist({ + id: "p1", + title: "Filed mix", + userId: "ann", + categoryIds: ["pc1"], + }); + const { user, socket } = open({ + url: "/@ann/playlists", + categories: [music, mixes], + }); + route("GET /api/users/:u/playlists", (req) => + page( + req.url.searchParams.get("category") === "mixes" + ? [filed] + : [filed, makePlaylist({ id: "p2", title: "Loose", userId: "ann" })], + )); + await screen.findByText("Loose"); + await user.click(screen.getByRole("button", { name: "All" })); + expect(screen.queryByRole("option", { name: "Music" })).toBeNull(); + await user.click(screen.getByRole("option", { name: "Mixes" })); + expect(currentPath()).toBe("/@ann/playlists?category=mixes"); + await waitFor(() => + expect(screen.queryByText("Loose")).not.toBeInTheDocument() + ); + expect(screen.getByText("Filed mix")).toBeInTheDocument(); + socket.welcome(); + socket.receive({ + type: "playlist_updated", + playlist: wire({ ...filed, categoryIds: [] }), + }); + expect(screen.queryByText("Filed mix")).not.toBeInTheDocument(); + }); + 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/test/render.tsx b/src/test/render.tsx index 18ff3608..640e132f 100644 --- a/src/test/render.tsx +++ b/src/test/render.tsx @@ -130,7 +130,6 @@ function categoriesValue( const list = Array.isArray(c) ? c : c?.categories ?? []; return { categories: list, - byId: new Map(list.map((x) => [x.id, x])), isLoaded: true, refresh: async () => {}, ...(Array.isArray(c) ? {} : c),