diff --git a/API.md b/API.md new file mode 100644 index 00000000..6de5f5c4 --- /dev/null +++ b/API.md @@ -0,0 +1,202 @@ +# gerbeur's URLs + +Every page and every API endpoint follows one of two rules. This file +summarizes them. Each router's module doc in `api/routes/` lists its own +endpoints in full, and a route change updates both. `shared/paths.ts` builds +and parses the page paths. + +## The two rules + +**Pages.** The path is a hierarchy. `@name` is a user. Any other word under +the root is a category or an app page. 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. A default view is the bare path, with no redirect. + +**API.** A resource is `/api//[/]`. Its key is its id, +except a user's, which may also be `@name`. The `@` keeps the two apart, so no +regex sniffing is needed. A read by page path mirrors the page: +`/api/users/@bob/dumps/`. The caller's own things live under `/api/me`. +Media is a sub-resource. A lookup by another key is a query filter. Each +concept has one word, the same as in pages. + +## Pages + +| Path | Views | Access | Page | +|---|---|---|---| +| `/` | `/hot`, `/new`, `/journal`, `/follow`; the bare `/` is the viewer's default tab | anyone, `/follow` signed in | `Index` | +| `/` | `//hot`, `/new`, `/journal`, `/follow` | anyone | `Index` | +| `/@` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` | +| `/@/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` | +| `/@/follow` | `/@/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` | +| `/@/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` | +| `/@/dumps/` | | anyone; a private dump, its poster | `Dump` | +| `/@/dumps//edit` | | its poster, `dump:moderate` | `DumpEdit` | +| `/@/playlists/` | | anyone; a private playlist, its owner | `PlaylistDetail` | +| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` | +| `/notifications` | | signed in | `Notifications` | +| `/settings` | | signed in | `Settings` | +| `/manage` | | `category:manage` | `Manage` | +| `/login`, `/register?token=` | | signed out | `UserLogin`, `UserRegister` | +| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` | + +`/?share_url=` opens the create form on a link shared from Android, +`/?chat=open` opens the chat over any page, and `#comment-` scrolls a dump's +page to a comment. + +## Names + +- **Slugs** of categories, dumps and playlists are lowercase letters and + digits in words joined by single hyphens (`shared/slug.ts`). A category's is + at most 50 characters, a dump's or a playlist's at most 60. +- **A dump's or a playlist's slug** is made from its title when it is created, + unique among its owner's items of that kind: a clash gets `-2`, `-3`. It + changes only when someone edits it: the edit forms propose + `slugify(newTitle)` when the title changes. Its owner may change it 3 times + a day; moderators (`dump:moderate`, `playlist:moderate`) may edit any slug, + at any time. +- **A username** is 1 to 32 letters, digits and underscores, unique in any + case. A page writes it in its stored case, and another case still resolves. + A user may rename themselves once every 30 days from `/settings`; those + with `user:manage` rename anyone, at any time, from `/@/manage`. +- **Aliases.** A slug or a name left behind stays an alias for good, which + always names the item or user itself, so redirects never chain. Nobody else + can take one. An item can take back one of its own. Deleting an item frees + its aliases. +- **Redirects.** A page reached by an alias, or by a name in another case, + gets the item from the API under its current path and replaces its URL. + The server answers the same path with a 301 to crawlers and full page loads. +- **Reserved words.** A category can't take a word the root already uses: + `hot new journal follow search login register notifications reset-password + unsubscribe settings manage api ws assets emojibase` + (`api/lib/reserved-slugs.ts`). Dump and playlist slugs reserve nothing, and + no slug has an `@`. + +## API + +Every JSON endpoint answers the envelope of `shared/api.ts`: +`{ "success": true, "data": … }`, or `{ "success": false, "error": { "code", +"message" } }` with the matching HTTP status. A list answers +`{ "items", "hasMore" }` and takes `?page=` (from 1) and `?limit=` (20 by +default, 100 at most). "Signed in" means a session token as +`Authorization: Bearer …`. Media can't send a header, so a private file, +thumbnail, cover or attachment takes the owner's media token as `?token=`. + +### me + +- `GET /api/me` — signed in. The caller's own user. +- `PATCH /api/me` — signed in. Changes the description, email and username. +- `GET /api/me/media-token` — signed in. A media token. +- `GET`, `PATCH /api/me/settings` — signed in. The caller's settings. +- `PUT /api/me/password` — signed in, with the current one. A new password, + and fresh tokens. +- `PUT /api/me/avatar` — signed in. A new avatar (multipart). +- `GET /api/me/playlists?contains=:dumpId` — signed in. The caller's + playlists, and whether each holds the dump. +- `GET /api/me/follow` — signed in. The ids of the users and playlists + followed. +- `PUT`, `DELETE /api/me/follow/users/:id`, `/api/me/follow/playlists/:id` — + signed in. Follows or stops following. +- `GET /api/me/feed?from=users|playlists&category=` — signed in. The dumps of + what the caller follows. +- `GET /api/me/notifications` — signed in. The caller's notifications. +- `POST /api/me/notifications/read` — signed in. Marks them all read. + +### users + +- `POST /api/users` — with an invite. Signs up. +- `GET /api/users?q=` — anyone. Up to 8 users whose name starts with `q`. +- `GET /api/users/:key` — anyone. A profile. +- `GET /api/users/:key/avatar` — anyone. The avatar. +- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private + items to their owner only. Their lists. +- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private + items to their owner only. An item by its page path, by an alias too. +- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they + follow. +- `GET /api/users/:key/invitees` — anyone. The tree of users they invited. +- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's + role, or anyone's name. + +### dumps + +- `POST /api/dumps` — signed in. Posts a link (JSON) or a file (multipart). +- `GET /api/dumps` — anyone. The feed; `?category=` narrows it, and + `?url=` answers the dumps of the same link instead. +- `GET /api/dumps/:id` — anyone who may see it. A dump. +- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who + upvoted it, the dumps linked to or from it, the playlists holding it. +- `PUT /api/dumps/:id` — its poster, `dump:moderate`. Title, slug, note, + visibility, categories, link. +- `POST /api/dumps/:id/metadata` — the same. Fetches a link's preview again. +- `DELETE /api/dumps/:id` — the same. Deletes it. +- `GET`, `PUT /api/dumps/:id/file` — the file; a new one, by its poster or + `dump:moderate`. +- `GET`, `PUT`, `DELETE /api/dumps/:id/thumbnail` — the thumbnail; a custom + one, set or removed by its poster or `dump:moderate`. +- `GET`, `POST /api/dumps/:id/comments` — anyone who may see the dump; posting + signed in. Its comments. + +### playlists + +- `POST /api/playlists` — signed in. Creates a playlist. +- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps. +- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. + Title, slug, description, visibility; deletion. +- `POST`, `DELETE /api/playlists/:id/dumps/:dumpId` — the same. Adds or + removes a dump. +- `PUT /api/playlists/:id/order` — the same. Reorders its dumps. +- `GET`, `PUT /api/playlists/:id/cover` — the cover; a new one, by its owner + or `playlist:moderate`. + +### comments + +- `PATCH`, `DELETE /api/comments/:id` — its author, `comment:moderate`. Edits + or deletes a comment. +- `GET /api/comments/:id/likers` — anyone. Who likes it. + +### categories + +- `GET /api/categories` — anyone. Every category. +- `POST /api/categories`, `PUT`, `DELETE /api/categories/:id` — + `category:manage`. Changes them. + +### chat + +- `GET /api/chat?before=&after=` — signed in. A page of messages. The chat + itself goes over `/ws`. + +### invites + +- `POST /api/invites` — signed in. A new invite. +- `GET /api/invites/:token` — anyone. Whether it still works. + +### sessions, password-resets, unsubscribe + +- `POST /api/sessions` — anyone. Signs in. +- `POST /api/password-resets` — anyone. Emails a reset link. +- `PUT /api/password-resets/:token` — with the link's token. A new password. +- `POST /api/unsubscribe?token=` — with a digest's token. Turns it off. Its + v3 path, `POST /api/users/unsubscribe`, stays for good, since sent emails' + `List-Unsubscribe` headers name it. + +### search + +- `GET /api/search?q=` — anyone. A page of dumps, and up to 20 users and + playlists. + +### attachments + +- `POST /api/attachments` — signed in. Stores an image. +- `GET /api/attachments/:id` — anyone who may see the text holding it. + +### remote + +- `GET /api/remote/preview?url=` — signed in. A link's preview. +- `GET /api/remote/image?url=` — anyone. A remote image through this site. +- `GET /api/remote/bandcamp?url=` — anyone. A Bandcamp page's tracks. +- `GET /api/remote/youtube?list=` — anyone. A YouTube playlist's tracklist. + +### /ws + +- `GET /ws?token=` — anyone; a session token signs it in. The WebSocket of + live updates and the chat (`shared/ws.ts`). diff --git a/README.md b/README.md index 199c4ca8..4c51d63a 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ api/ main.ts # Entry point — Oak application, middleware, routes config.ts # Environment variables middleware/ # Oak middleware: errors, auth, OG tags - routes/ # One router per URL prefix: HTTP + WebSocket, no SQL + routes/ # One router per API resource: HTTP + WebSocket, no SQL services/ # Business logic and every SQL query remote/ # Everything that fetches from other sites mail/ # One template per email, and the layout they share @@ -150,7 +150,7 @@ api/ gerbeur.db # SQLite database (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) src/ # React frontend (Vite) - config/ # API base URL, feed tabs, player mode, instance settings + config/ # API base URL, default feed tab, player mode, instance settings pages/ # One component per route, nothing else components/ # React components only (.tsx) contexts/ # FooContext.ts + FooProvider.tsx pairs @@ -162,6 +162,16 @@ src/ # React frontend (Vite) public/ # Static assets (favicon, manifest, service worker) ``` +### URLs + +Every page and API path follows one of two rules, which [API.md](API.md) +states, along with every page, the slug and username rules and every +endpoint. Pages are a hierarchy under `@name` for users and plain words for +categories and app pages (`/@bob/dumps/never-gonna`, `/music/hot`), built and +parsed by `shared/paths.ts`. API resources are `/api//`, the +caller's own under `/api/me`. A route change updates its router's module doc +and API.md together. + Whatever crosses the wire is defined once, in `shared/`: the API envelope (`shared/api.ts`), each domain's types and request bodies (`shared/dump.ts`, `shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 75e08cae..72e87da7 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -27,6 +27,7 @@ import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensi import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts"; import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts"; import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts"; +import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts"; interface Migration { name: string; @@ -66,6 +67,7 @@ const MIGRATIONS: Migration[] = [ }, { name: "0019_user_settings", up: up0019UserSettings }, { name: "0020_digest_schedule", up: up0020DigestSchedule }, + { name: "0021_page_urls", up: up0021PageUrls }, ]; /** diff --git a/api/db/migrations/0021_page_urls.ts b/api/db/migrations/0021_page_urls.ts new file mode 100644 index 00000000..d895a581 --- /dev/null +++ b/api/db/migrations/0021_page_urls.ts @@ -0,0 +1,262 @@ +/** + * Moves the site to v4's page URLs, where dumps and playlists live under their + * owner (`/@bob/dumps/`) and are named by an editable slug alone. A + * slug was `slugify(title)-<8 hex of the id>`, unique site-wide; it is now the + * bare `slugify(title)`, unique in its owner's space for its kind, and every + * slug an item or a user leaves behind stays an alias that redirects + * (`dump_slug_aliases`, `playlist_slug_aliases`, `user_aliases`). + * + * v3 URLs aren't redirected. Instead this rewrites the links to this site's + * pages in what users wrote: dump notes, comments, chat messages, and + * playlist and profile descriptions. + * + * SQLite can't make `slug` NOT NULL without rebuilding `dumps` and + * `playlists`, which other tables reference, so the column stays nullable + * and every writer sets it; the unique index is what the services rely on. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; +import { slugify } from "../../../shared/slug.ts"; +import { + dumpPath, + feedPath, + playlistPath, + type ProfileTab, + searchPath, + userPath, +} from "../../../shared/paths.ts"; +import { type SlugKind, uniqueSlug } from "../../lib/slugify.ts"; +import { isReservedSlug } from "../../lib/reserved-slugs.ts"; +import { isSelfHost } from "../../lib/self-url.ts"; + +/** An item's new path, by its v3 slug and by its id. */ +type PathsByKey = Map; + +/** + * Gives every dump or playlist of `kind` the bare slug of its title, unique + * per owner, the oldest first so it keeps the bare one. Returns each one's new + * path under both keys a v3 link may have used: its old slug and its id. + */ +function reslug(db: DatabaseSync, kind: SlugKind): PathsByKey { + const table = kind === "dump" ? "dumps" : "playlists"; + const rows = db.prepare( + `SELECT t.id, t.user_id, t.title, t.slug, u.username + FROM ${table} t JOIN users u ON u.id = t.user_id + ORDER BY t.created_at, t.id;`, + ).all() as { + id: string; + user_id: string; + title: string; + slug: string | null; + username: string; + }[]; + // Cleared first, so that an old slug doesn't take a new one's place. + db.exec(`UPDATE ${table} SET slug = NULL;`); + const set = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`); + const paths: PathsByKey = new Map(); + const build = kind === "dump" ? dumpPath : playlistPath; + for (const row of rows) { + const slug = uniqueSlug(db, kind, row.user_id, slugify(row.title)); + set.run(slug, row.id); + const path = build({ ownerUsername: row.username, slug }); + paths.set(row.id, path); + if (row.slug) paths.set(row.slug, path); + } + return paths; +} + +/** + * Renames each category whose slug v4 reserves at the root, such as `hot`, + * to the first of `-2`, `-3`… that is free, and logs it. + */ +function renameReservedCategories(db: DatabaseSync): void { + const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as { + id: string; + slug: string; + }[]; + const taken = new Set(rows.map((r) => r.slug)); + for (const { id, slug } of rows) { + if (!isReservedSlug(slug)) continue; + let n = 2; + while (taken.has(`${slug}-${n}`) || isReservedSlug(`${slug}-${n}`)) n++; + const renamed = `${slug}-${n}`; + taken.add(renamed); + db.prepare(`UPDATE categories SET slug = ? WHERE id = ?;`).run(renamed, id); + console.log( + `[migrate] category "${slug}" is now "${renamed}": v4 reserves its slug`, + ); + } +} + +/** Where each v3 profile tab went: a tab, the overview, or a page of its own. */ +const PROFILE_TAB_V4: Record = { + dumps: "", + playlists: "playlists", + followed: "follow", + invitees: "invitees", + settings: "/settings", + manage: "manage", +}; +/** The v3 full lists under a profile, which are v4 tabs. */ +const PROFILE_LISTS = new Set(["dumps", "upvoted", "playlists"]); + +/** + * Matches a link to a v3 page, its origin being optional: an absolute one is + * this site's only when its host is, a relative one only as a Markdown link's + * target. The path stops before a query or a fragment, which stay as they are. + */ +const V3_LINK_RE = + /(https?:\/\/([^/\s)\]<>"']+))?(\/(?:dumps|playlists|users|~|search\/~)\/[^\s)\]<>"'?#]+)/g; + +/** + * Returns the v4 path of the v3 page at `path`, or null when it names nothing + * known, which then stays as written: a deleted dump's link is dead either way. + */ +function v4PathOf( + path: string, + dumps: PathsByKey, + playlists: PathsByKey, + username: (name: string) => string | undefined, +): string | null { + let segments: string[]; + try { + segments = path.split("/").slice(1).filter(Boolean).map( + decodeURIComponent, + ); + } catch { + return null; + } + const [root, key, ...rest] = segments; + switch (root) { + case "dumps": { + const target = dumps.get(key); + if (!target) return null; + if (rest.length === 0) return target; + return rest.length === 1 && rest[0] === "edit" ? `${target}/edit` : null; + } + case "playlists": + return rest.length === 0 ? playlists.get(key) ?? null : null; + case "users": { + const name = username(key) ?? key; + if (rest.length === 0) return userPath(name); + if (rest.length === 1 && PROFILE_LISTS.has(rest[0])) { + return userPath(name, rest[0] as ProfileTab); + } + if (rest.length === 2 && rest[0] === "~" && rest[1] in PROFILE_TAB_V4) { + const tab = PROFILE_TAB_V4[rest[1]]; + if (tab === "/settings") return tab; + return tab ? userPath(name, tab) : userPath(name); + } + return null; + } + case "~": + if (rest.length > 0) return null; + return key === "followed" + ? feedPath(undefined, "follow") + : ["hot", "new", "journal"].includes(key) + ? feedPath(undefined, key as "hot") + : null; + case "search": + // `/search/~/users`, split as "search", "~", "users". + return key === "~" && (rest[0] === "users" || rest[0] === "playlists") && + rest.length === 1 + ? searchPath(rest[0]) + : null; + } + return null; +} + +/** + * Rewrites the links to v3 pages of this site in the text column `column` of + * `table`, in every row that has one. + */ +function rewriteLinks( + db: DatabaseSync, + table: string, + column: string, + toV4: (path: string) => string | null, +): void { + const rows = db.prepare( + `SELECT id, ${column} AS text FROM ${table} WHERE ${column} IS NOT NULL;`, + ).all() as { id: string; text: string }[]; + const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE id = ?;`); + for (const { id, text } of rows) { + const rewritten = text.replace( + V3_LINK_RE, + (match, origin: string | undefined, host: string, path: string, at) => { + if (origin ? !isSelfHost(host) : text[at - 1] !== "(") return match; + const v4 = toV4(path); + return v4 ? `${origin ?? ""}${v4}` : match; + }, + ); + if (rewritten !== text) update.run(rewritten, id); + } +} + +/** + * Run once by `runMigrations`. On a database made from schema.sql the tables + * and indexes exist and there is nothing to re-slug or rewrite. + */ +export function up(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS dump_slug_aliases ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, slug) + ); + CREATE INDEX IF NOT EXISTS idx_dump_slug_aliases_dump + ON dump_slug_aliases(dump_id, created_at); + CREATE TABLE IF NOT EXISTS playlist_slug_aliases ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, slug) + ); + CREATE INDEX IF NOT EXISTS idx_playlist_slug_aliases_playlist + ON playlist_slug_aliases(playlist_id, created_at); + CREATE TABLE IF NOT EXISTS user_aliases ( + username TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (username) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_aliases_lower + ON user_aliases(lower(username)); + CREATE INDEX IF NOT EXISTS idx_user_aliases_user + ON user_aliases(user_id, created_at); + DROP INDEX IF EXISTS idx_dumps_slug; + DROP INDEX IF EXISTS idx_playlists_slug; + `); + + const dumps = reslug(db, "dump"); + const playlists = reslug(db, "playlist"); + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_dumps_user_slug + ON dumps(user_id, slug); + CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_user_slug + ON playlists(user_id, slug); + `); + + renameReservedCategories(db); + + const findUser = db.prepare( + `SELECT username FROM users WHERE lower(username) = lower(?);`, + ); + const username = (name: string) => + (findUser.get(name) as { username: string } | undefined)?.username; + const toV4 = (path: string) => v4PathOf(path, dumps, playlists, username); + for ( + const [table, column] of [ + ["dumps", "comment"], + ["comments", "body"], + ["chat_messages", "body"], + ["playlists", "description"], + ["users", "description"], + ] + ) rewriteLinks(db, table, column, toV4); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index c9a701a2..f6104e08 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -121,12 +121,12 @@ CREATE TABLE dump_backlinks ( CREATE INDEX idx_dumps_created ON dumps(created_at); CREATE INDEX idx_dumps_user_created ON dumps(user_id, created_at); -CREATE INDEX idx_dumps_slug ON dumps(slug); +CREATE UNIQUE INDEX idx_dumps_user_slug ON dumps(user_id, slug); CREATE INDEX idx_dumps_url ON dumps(url); CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical); CREATE INDEX idx_votes_user_created ON votes(user_id, created_at); CREATE INDEX idx_playlists_user ON playlists(user_id); -CREATE INDEX idx_playlists_slug ON playlists(slug); +CREATE UNIQUE INDEX idx_playlists_user_slug ON playlists(user_id, slug); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); @@ -231,6 +231,33 @@ CREATE TABLE digest_schedule ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE dump_slug_aliases ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, slug) +); +CREATE INDEX idx_dump_slug_aliases_dump ON dump_slug_aliases(dump_id, created_at); + +CREATE TABLE playlist_slug_aliases ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, slug) +); +CREATE INDEX idx_playlist_slug_aliases_playlist ON playlist_slug_aliases(playlist_id, created_at); + +CREATE TABLE user_aliases ( + username TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (username) +); +CREATE UNIQUE INDEX idx_user_aliases_lower ON user_aliases(lower(username)); +CREATE INDEX idx_user_aliases_user ON user_aliases(user_id, created_at); + CREATE TABLE chat_messages ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 35e248ec..5e9e8067 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -19,6 +19,7 @@ import { } from "../model/auth.ts"; import { JWT_SECRET } from "../config.ts"; import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts"; +import type { AuthTokens, Role } from "../../shared/user.ts"; if (!JWT_SECRET) { throw new Error( @@ -61,9 +62,9 @@ export async function verifyPasswordResetToken( } /** - * Signs a session token carrying `payload`, valid for 7 days. The users routes - * issue it on sign-up, sign-in and password change, and the client sends it as - * a bearer token. + * Signs a session token carrying `payload`, valid for 7 days. + * {@link issueTokens} issues it on sign-up, sign-in and password change, and + * the client sends it as a bearer token. */ export async function createJWT( payload: Omit, @@ -77,6 +78,24 @@ export async function createJWT( .sign(JWT_KEY); } +/** + * Returns a new session token and media token for `user`, as sign-up + * (`POST /api/users`), sign-in (`POST /api/sessions`) and a password change + * (`PUT /api/me/password`) answer them. + */ +export async function issueTokens( + user: { id: string; username: string; role: Role }, +): Promise { + return { + token: await createJWT({ + userId: user.id, + username: user.username, + role: user.role, + }), + mediaToken: await createMediaToken(user.id), + }; +} + /** * Signs a media token for `userId`, valid for 7 days. Media elements can't send * an `Authorization` header, so the URLs of private files, thumbnails and diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index 30b91a8b..d139fa13 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -1,29 +1,33 @@ /** * 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 route. The - * category service runs these checks when a category is created or renamed. + * 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 + * migration 0021 renamed the categories it newly reserved. + * + * 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 + * can't clash with a category either, since `@` is never in a slug. * * @module */ +import { FEED_TABS } from "../../shared/paths.ts"; + /** - * Lists the top-level path segments a category slug may not take. They are - * the SPA's own routes (`src/App.tsx`); `api`, `ws`, `assets` and - * `emojibase`, which the server answers before the SPA; and `manage`, a - * former route whose old links may still be about. Keep it in step with the - * route table and the build's top-level folders. + * Lists the top-level path segments a category slug may not take: the feed + * tabs (`/hot`), the SPA's own pages (`src/App.tsx`), and `api`, `ws`, + * `assets` and `emojibase`, which the server answers before the SPA. Keep it + * in step with the route table and the build's top-level folders. */ export const RESERVED_SLUGS: readonly string[] = [ - "~", - "dumps", - "users", - "playlists", + ...FEED_TABS, "search", "login", "register", "notifications", "reset-password", "unsubscribe", + "settings", "manage", "api", "ws", @@ -31,17 +35,6 @@ export const RESERVED_SLUGS: readonly string[] = [ "emojibase", ]; -/** - * Matches a well-formed category slug, made of lowercase letters and digits - * joined by single hyphens. - */ -export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Checks whether `slug` matches {@link CATEGORY_SLUG_RE}. */ -export function isValidSlugFormat(slug: string): boolean { - return CATEGORY_SLUG_RE.test(slug); -} - /** Checks whether `slug` is one of the {@link RESERVED_SLUGS}. */ export function isReservedSlug(slug: string): boolean { return RESERVED_SLUGS.includes(slug); diff --git a/api/lib/self-url.ts b/api/lib/self-url.ts index 3df2ccad..d1903124 100644 --- a/api/lib/self-url.ts +++ b/api/lib/self-url.ts @@ -1,12 +1,14 @@ /** * Recognises links to this site's own pages. The self provider previews them - * from the database, and remote/fetch.ts accepts them as dump links even on a - * private host such as localhost. + * from the database, remote/fetch.ts accepts them as dump links even on a + * private host such as localhost, and migration 0021 rewrote the v3 ones in + * stored text. * * @module */ import { FRONTEND_URL, PUBLIC_URL } from "../config.ts"; +import { parsePagePath } from "../../shared/paths.ts"; const SELF_HOSTS = new Set( [PUBLIC_URL, FRONTEND_URL].flatMap((u) => { @@ -18,14 +20,22 @@ const SELF_HOSTS = new Set( }), ); -const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/; +/** + * Checks whether `host` (with its port, as `URL.host` gives it) is where this + * site is served, at its public URL or its frontend's. + */ +export function isSelfHost(host: string): boolean { + return SELF_HOSTS.has(host.toLowerCase()); +} /** * Checks whether `url` links to one of this site's own dump, user or playlist - * pages. Matched on the host as well as the path: `/users/bob` on some other - * site is someone else's page, not ours. + * pages, by `parsePagePath`; a dump's editor doesn't count. Matched on the + * host as well as the path: `/@bob` on some other site is someone else's + * page, not ours. */ export function isSelfUrl(url: URL): boolean { - return SELF_HOSTS.has(url.host.toLowerCase()) && - SELF_PATH_RE.test(url.pathname); + if (!isSelfHost(url.host)) return false; + const page = parsePagePath(url.pathname); + return !!page && !(page.kind === "dump" && page.edit); } diff --git a/api/lib/slugify.ts b/api/lib/slugify.ts index c9dd5a7e..592c6a82 100644 --- a/api/lib/slugify.ts +++ b/api/lib/slugify.ts @@ -1,38 +1,85 @@ /** - * Builds the URL slugs of dumps and playlists from their titles. The dump and - * playlist services store the slug when the record is created or retitled. + * Picks and checks the slugs of dumps and playlists, which name them in their + * page URLs under their owner: `/@bob/dumps/`, `/@bob/playlists/`. + * Each kind has its own slug space per owner, shared by the items' current + * slugs and their aliases (the slugs they had before). `services/slug-service.ts` + * changes and resolves slugs with these, and migration 0021 gave every item + * one; both pass the database, since this module doesn't open it. * * @module */ -/** - * Matches a UUID. A dump or playlist route takes either an id or a slug, and - * the services test the parameter with this to know which column to look up. - */ -export const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +import type { DatabaseSync } from "node:sqlite"; +import { APIException } from "./api-exception.ts"; +import { isValidSlug, SLUG_MAX } from "../../shared/slug.ts"; + +/** A kind of item that has a slug under its owner. */ +export type SlugKind = "dump" | "playlist"; + +/** Where each {@link SlugKind}'s slugs live. */ +export const SLUG_TABLES: Record< + SlugKind, + { items: string; aliases: string; itemId: string } +> = { + dump: { items: "dumps", aliases: "dump_slug_aliases", itemId: "dump_id" }, + playlist: { + items: "playlists", + aliases: "playlist_slug_aliases", + itemId: "playlist_id", + }, +}; /** - * Turns `title` into lowercase ASCII words joined by dashes, at most 60 - * characters long. Accents are dropped, and a title with no ASCII letter or - * digit gives `untitled`. + * Returns the id of the item whose current slug or alias is `slug` in + * `userId`'s space for `kind`, or undefined when it is free. */ -export function slugify(title: string): string { - const slug = title - .toLowerCase() - .normalize("NFD") - // NFD splits an accented letter into the letter and a combining mark. - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 60); - return slug || "untitled"; +export function slugHolder( + db: DatabaseSync, + kind: SlugKind, + userId: string, + slug: string, +): string | undefined { + const { items, aliases, itemId } = SLUG_TABLES[kind]; + const row = db.prepare( + `SELECT id FROM ${items} WHERE user_id = ? AND slug = ? + UNION ALL + SELECT ${itemId} FROM ${aliases} WHERE user_id = ? AND slug = ? + LIMIT 1;`, + ).get(userId, slug, userId, slug) as { id: string } | undefined; + return row?.id; } /** - * Returns a record's slug, its title's {@link slugify} followed by the first 8 - * characters of `id`. The suffix keeps two records with the same title apart. + * Returns `base`, or `base-2`, `base-3`… cut to fit, whichever is free in + * `userId`'s space for `kind`. `base` must be a valid slug, as `slugify` + * makes. The services take it on creating an item, the migration on + * re-slugging one, inside the transaction that writes it. */ -export function makeSlug(title: string, id: string): string { - return `${slugify(title)}-${id.substring(0, 8)}`; +export function uniqueSlug( + db: DatabaseSync, + kind: SlugKind, + userId: string, + base: string, +): string { + for (let n = 1;; n++) { + const suffix = n === 1 ? "" : `-${n}`; + const candidate = `${ + base.substring(0, SLUG_MAX - suffix.length).replace(/-+$/, "") + }${suffix}`; + if (!slugHolder(db, kind, userId, candidate)) return candidate; + } +} + +/** + * Throws a 400 unless `slug` is a valid slug, lowercase words of letters and + * digits joined by hyphens, at most `SLUG_MAX` long. Dump and playlist slugs + * reserve no word: nothing else a user names lives beside them. + */ +export function validateSlug(slug: string): void { + if (!isValidSlug(slug)) { + throw new APIException( + 400, + `Slug must be lowercase letters, numbers and hyphens, at most ${SLUG_MAX} characters`, + ); + } } diff --git a/api/mail/digest.ts b/api/mail/digest.ts index 5e82519d..4a1a152b 100644 --- a/api/mail/digest.ts +++ b/api/mail/digest.ts @@ -62,8 +62,11 @@ export interface DigestHighlight { kind: HighlightKind; /** Who did it. */ who: string; - /** What they did it on, absent for a follow of the reader or the chat. */ - target?: { title: string; url: string }; + /** + * What they did it on, absent for a follow of the reader or the chat. Its + * `url` is null once it is deleted, and the title then shows unlinked. + */ + target?: { title: string; url: string | null }; /** The comment or message, flattened and shortened. */ quote?: string; at: Date; @@ -244,8 +247,10 @@ export function renderDigestEmail( i18n._("email.digest.theChat"), content.links.chat, ) - : h.target + : h.target?.url ? mdLink(h.target.title, h.target.url) + : h.target + ? escMd(h.target.title) : ""; const line = `- ${ highlightSentence(h.kind, `**@${escMd(h.who)}**`, target) diff --git a/api/main.ts b/api/main.ts index d02c082f..853cbe5e 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,8 +1,9 @@ /** * Builds the Oak application and, when run directly, starts the digests' - * scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes through the error - * middleware, the OG tags and CORS, then the routers, and last the built - * frontend, which answers every path no router did. + * scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes + * through the error middleware, the OG tags and CORS, then the routers, one + * per resource of the API (`API.md` lists them), and last the built frontend, + * which answers every path no router did. * * @module */ @@ -11,23 +12,25 @@ import { Application } from "@oak/oak"; import { oakCors } from "@tajpouria/cors"; import attachmentsRouter from "./routes/attachments.ts"; +import avatarsRouter from "./routes/avatars.ts"; +import categoriesRouter from "./routes/categories.ts"; +import chatRouter from "./routes/chat.ts"; +import commentsRouter from "./routes/comments.ts"; import dumpsRouter from "./routes/dumps.ts"; import filesRouter from "./routes/files.ts"; -import thumbnailsRouter from "./routes/thumbnails.ts"; -import usersRouter from "./routes/users.ts"; -import avatarsRouter from "./routes/avatars.ts"; -import wsRouter from "./routes/ws.ts"; -import previewRouter from "./routes/preview.ts"; -import bandcampRouter from "./routes/bandcamp.ts"; -import youtubeRouter from "./routes/youtube.ts"; -import playlistsRouter from "./routes/playlists.ts"; -import commentsRouter from "./routes/comments.ts"; -import chatRouter from "./routes/chat.ts"; import followsRouter from "./routes/follows.ts"; -import notificationsRouter from "./routes/notifications.ts"; import invitesRouter from "./routes/invites.ts"; +import meRouter from "./routes/me.ts"; +import notificationsRouter from "./routes/notifications.ts"; +import passwordResetsRouter from "./routes/password-resets.ts"; +import playlistsRouter from "./routes/playlists.ts"; +import remoteRouter from "./routes/remote.ts"; import searchRouter from "./routes/search.ts"; -import categoriesRouter from "./routes/categories.ts"; +import sessionsRouter from "./routes/sessions.ts"; +import thumbnailsRouter from "./routes/thumbnails.ts"; +import unsubscribeRouter from "./routes/unsubscribe.ts"; +import usersRouter from "./routes/users.ts"; +import wsRouter from "./routes/ws.ts"; import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; @@ -55,78 +58,32 @@ app.use(async (ctx, next) => { } await next(); }); -app.use( - dumpsRouter.routes(), - dumpsRouter.allowedMethods(), -); -app.use( - filesRouter.routes(), - filesRouter.allowedMethods(), -); -app.use( - thumbnailsRouter.routes(), - thumbnailsRouter.allowedMethods(), -); -app.use( - attachmentsRouter.routes(), - attachmentsRouter.allowedMethods(), -); -app.use( - usersRouter.routes(), - usersRouter.allowedMethods(), -); -app.use( - avatarsRouter.routes(), - avatarsRouter.allowedMethods(), -); -app.use( - wsRouter.routes(), - wsRouter.allowedMethods(), -); -app.use( - previewRouter.routes(), - previewRouter.allowedMethods(), -); -app.use( - bandcampRouter.routes(), - bandcampRouter.allowedMethods(), -); -app.use( - youtubeRouter.routes(), - youtubeRouter.allowedMethods(), -); -app.use( - playlistsRouter.routes(), - playlistsRouter.allowedMethods(), -); -app.use( - commentsRouter.routes(), - commentsRouter.allowedMethods(), -); -app.use( - chatRouter.routes(), - chatRouter.allowedMethods(), -); -app.use( - followsRouter.routes(), - followsRouter.allowedMethods(), -); -app.use( - notificationsRouter.routes(), - notificationsRouter.allowedMethods(), -); -app.use( - invitesRouter.routes(), - invitesRouter.allowedMethods(), -); -app.use( - searchRouter.routes(), - searchRouter.allowedMethods(), -); -app.use( - categoriesRouter.routes(), - categoriesRouter.allowedMethods(), -); +for ( + const router of [ + meRouter, + followsRouter, + notificationsRouter, + avatarsRouter, + usersRouter, + sessionsRouter, + passwordResetsRouter, + unsubscribeRouter, + dumpsRouter, + filesRouter, + thumbnailsRouter, + commentsRouter, + playlistsRouter, + categoriesRouter, + searchRouter, + chatRouter, + invitesRouter, + attachmentsRouter, + remoteRouter, + wsRouter, + ] +) { + app.use(router.routes(), router.allowedMethods()); +} app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 56e4f4bd..3e6d4310 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -28,7 +28,7 @@ export interface AuthState extends State { /** * The context type of a handler behind {@link authMiddleware}. Pass the route - * path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed. + * path, as in `AuthContext<"/:key/role">`, to keep `ctx.params` typed. */ export type AuthContext< R extends string = string, diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 9974c36c..e0c2365a 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -2,15 +2,22 @@ * Gives links to the site rich previews in chat apps and social sites, which * don't run the app. It answers a link to a dump, a profile or a public * playlist with `index.html` carrying that page's Open Graph and Twitter card - * tags. main.ts installs it before the routers. + * tags, and a link by a former name with a 301 to the current one. main.ts + * installs it before the routers. * * @module */ import { Context, Next } from "@oak/oak"; -import { getDump } from "../services/dump-service.ts"; -import { getUserByUsername } from "../services/user-service.ts"; -import { getPlaylistById } from "../services/playlist-service.ts"; +import { getDumpByPath } from "../services/dump-service.ts"; +import { resolveUsername } from "../services/user-service.ts"; +import { getPlaylistByPath } from "../services/playlist-service.ts"; +import { + dumpPath, + parsePagePath, + playlistPath, + userPath, +} from "../../shared/paths.ts"; import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts"; import { loadIndexHtml } from "../lib/index-html.ts"; @@ -83,17 +90,23 @@ async function findIndexHtml(): Promise { return null; } -const DUMP_RE = /^\/dumps\/([^/]+)$/; -/** Matches a profile, and its tabs under `/~/`. */ -const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; -const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; +/** Answers `canonical` with a 301, keeping the request's query string. */ +function redirect(ctx: Context, canonical: string): void { + ctx.response.status = 301; + // Revalidated each time: a slug taken back would otherwise loop through a + // redirect the browser kept. + ctx.response.headers.set("Cache-Control", "no-cache"); + ctx.response.redirect(`${FRONTEND_URL}${canonical}${ctx.request.url.search}`); +} /** * Serves `index.html` with the tags of the dump, user or public playlist a - * path names, and passes the request on when it names none that exists or - * the visitor may see. The page's URL is on `FRONTEND_URL` and the images' on - * `PUBLIC_URL`, not on the request's origin, which a reverse proxy may have - * rewritten to an internal host or plain http. + * path names (`parsePagePath`), and passes the request on when it names none + * that exists or the visitor may see. A path naming one by a former name, a + * user's or a slug, or in another case, gets a 301 to its current path. The + * page's URL is on `FRONTEND_URL` and the images' on `PUBLIC_URL`, not on the + * request's origin, which a reverse proxy may have rewritten to an internal + * host or plain http. */ export async function ogMiddleware( ctx: Context, @@ -105,21 +118,21 @@ export async function ogMiddleware( return await next(); } + const page = parsePagePath(pathname); + if (!page || (page.kind === "dump" && page.edit)) return await next(); + const origin = PUBLIC_URL; - const pageUrl = `${FRONTEND_URL}${pathname}`; - let meta: OGMeta | null = null; + let canonical: string | null = null; - const dumpMatch = pathname.match(DUMP_RE); - const userMatch = pathname.match(USER_RE); - const playlistMatch = pathname.match(PLAYLIST_RE); - - if (dumpMatch) { - try { - const dump = getDump(dumpMatch[1]); + try { + const user = resolveUsername(page.username); + if (page.kind === "dump") { + const dump = getDumpByPath(user.id, page.slug); + canonical = dumpPath(dump); let imageUrl: string | undefined; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { - imageUrl = `${origin}/api/files/${dump.id}`; + imageUrl = `${origin}/api/dumps/${dump.id}/file`; } else if (dump.richContent?.thumbnailUrl) { imageUrl = dump.richContent.thumbnailUrl; } @@ -130,41 +143,41 @@ export async function ogMiddleware( description: dump.comment, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageIsWide: !!imageUrl, - url: pageUrl, + url: `${FRONTEND_URL}${canonical}`, }; - } catch { /* Unknown, or private. */ } - } else if (userMatch) { - try { - const user = getUserByUsername(userMatch[1]); + } else if (page.kind === "user") { + canonical = [userPath(user.username), ...page.rest].join("/"); const imageUrl = user.avatarMime - ? `${origin}/api/avatars/${user.id}` + ? `${origin}/api/users/${user.id}/avatar` : undefined; meta = { title: user.username, description: user.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, - url: pageUrl, + url: `${FRONTEND_URL}${canonical}`, }; - } catch { /* Unknown. */ } - } else if (playlistMatch) { - try { - const playlist = getPlaylistById(playlistMatch[1]); - // getPlaylistById doesn't check visibility, unlike getDump. + } else { + const playlist = getPlaylistByPath(user.id, page.slug); + // getPlaylistByPath doesn't check visibility, unlike getDumpByPath. if (playlist.isPublic) { + canonical = playlistPath(playlist); const imageUrl = playlist.imageMime - ? `${origin}/api/playlists/${playlist.id}/image` + ? `${origin}/api/playlists/${playlist.id}/cover` : undefined; meta = { title: playlist.title, description: playlist.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, - url: pageUrl, + url: `${FRONTEND_URL}${canonical}`, }; } - } catch { /* Unknown. */ } - } + } + } catch { /* Unknown, or private. */ } - if (!meta) return await next(); + if (!meta || !canonical) return await next(); + if (canonical !== pathname.replace(/\/+$/, "")) { + return redirect(ctx, canonical); + } const html = await findIndexHtml(); if (!html) return await next(); diff --git a/api/model/dump.ts b/api/model/dump.ts index 8adf80cd..6bf27cc0 100644 --- a/api/model/dump.ts +++ b/api/model/dump.ts @@ -23,7 +23,8 @@ export interface DumpRow { user_id: string; created_at: string; updated_at: string | null; - slug: string | null; + /** Unique among its poster's dumps, and set on every row since 0021. */ + slug: string; /** The link as the poster submitted it. */ url: string | null; /** {@link RichContent} as JSON. */ @@ -34,6 +35,8 @@ export interface DumpRow { vote_count: number; /** Computed by a subquery, which counts the comments that aren't deleted. */ comment_count: number; + /** The poster's current name, computed by a subquery. */ + owner_username: string; is_private: number; custom_thumbnail_mime: string | null; /** Lets a query select more columns, such as a join's. */ @@ -53,17 +56,19 @@ const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`) /** * Selects a whole {@link DumpRow}, the stored columns and the computed - * `comment_count`. + * `comment_count` and `owner_username`. */ export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS}, - (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; + (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count, + (SELECT username FROM users WHERE id = dumps.user_id) as owner_username`; /** * Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to * join it with other tables. */ export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED}, - (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`; + (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count, + (SELECT username FROM users WHERE id = d.user_id) as owner_username`; /** * Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks @@ -81,7 +86,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow { "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && - "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && + "slug" in obj && typeof obj.slug === "string" && "url" in obj && (typeof obj.url === "string" || obj.url === null) && "rich_content" in obj && (typeof obj.rich_content === "string" || obj.rich_content === null) && @@ -93,6 +98,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow { (typeof obj.file_size === "number" || obj.file_size === null) && "vote_count" in obj && typeof obj.vote_count === "number" && "comment_count" in obj && typeof obj.comment_count === "number" && + "owner_username" in obj && typeof obj.owner_username === "string" && "is_private" in obj && typeof obj.is_private === "number" && "custom_thumbnail_mime" in obj && (typeof obj.custom_thumbnail_mime === "string" || @@ -108,10 +114,11 @@ export function dumpRowToApi(row: DumpRow): Dump { id: row.id, kind: row.kind as "url" | "file", title: row.title, - slug: row.slug ?? undefined, + slug: row.slug, url: row.url ?? undefined, comment: row.comment ?? undefined, userId: row.user_id, + ownerUsername: row.owner_username, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, richContent: row.rich_content @@ -136,7 +143,7 @@ export function dumpApiToRow(dump: Dump): DumpRow { id: dump.id, kind: dump.kind, title: dump.title, - slug: dump.slug ?? null, + slug: dump.slug, comment: dump.comment ?? null, user_id: dump.userId, created_at: dump.createdAt.toISOString(), @@ -148,6 +155,7 @@ export function dumpApiToRow(dump: Dump): DumpRow { file_size: dump.fileSize ?? null, vote_count: dump.voteCount, comment_count: dump.commentCount, + owner_username: dump.ownerUsername, is_private: dump.isPrivate ? 1 : 0, custom_thumbnail_mime: dump.thumbnailMime ?? null, }; diff --git a/api/model/notification.ts b/api/model/notification.ts index a025e662..6273b990 100644 --- a/api/model/notification.ts +++ b/api/model/notification.ts @@ -1,6 +1,6 @@ /** * Turns the rows of `notifications`, once checked, into the {@link - * Notification} that `GET /api/notifications` answers. + * Notification} that `GET /api/me/notifications` answers. * * @module */ diff --git a/api/model/playlist.ts b/api/model/playlist.ts index 9536aa57..1a768048 100644 --- a/api/model/playlist.ts +++ b/api/model/playlist.ts @@ -9,15 +9,18 @@ import type { SQLOutputValue } from "node:sqlite"; import type { Playlist } from "../../shared/playlist.ts"; /** - * Returned by the playlist and follow services' `SELECT`s on `playlists`. A - * query may add `dump_count` and `owner_username` columns, which - * {@link playlistRowToApi} carries over when present. + * Returned by the playlist and follow services' `SELECT`s on `playlists`, + * which join the owner's name as `owner_username`. A query may add a + * `dump_count` column, which {@link playlistRowToApi} carries over when + * present. */ export interface PlaylistRow { id: string; user_id: string; title: string; - slug: string | null; + /** Unique among its owner's playlists, and set on every row since 0021. */ + slug: string; + owner_username: string; description: string | null; is_public: number; created_at: string; @@ -34,7 +37,8 @@ export function isPlaylistRow( "id" in obj && typeof obj.id === "string" && "user_id" in obj && typeof obj.user_id === "string" && "title" in obj && typeof obj.title === "string" && - "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && + "slug" in obj && typeof obj.slug === "string" && + "owner_username" in obj && typeof obj.owner_username === "string" && "description" in obj && (typeof obj.description === "string" || obj.description === null) && "is_public" in obj && typeof obj.is_public === "number" && @@ -51,15 +55,13 @@ export function playlistRowToApi(row: PlaylistRow): Playlist { id: row.id, userId: row.user_id, title: row.title, - slug: row.slug ?? undefined, + slug: row.slug, description: row.description ?? undefined, isPublic: Boolean(row.is_public), createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, imageMime: row.image_mime ?? undefined, dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, - ownerUsername: typeof row.owner_username === "string" - ? row.owner_username - : undefined, + ownerUsername: row.owner_username, }; } diff --git a/api/remote/bandcamp-streams.ts b/api/remote/bandcamp-streams.ts index dfc44391..94a1e171 100644 --- a/api/remote/bandcamp-streams.ts +++ b/api/remote/bandcamp-streams.ts @@ -1,6 +1,6 @@ /** * Resolves a Bandcamp page to its streamable tracks when the native player - * starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and + * starts it, for `GET /api/remote/bandcamp`. The stream URLs are signed and * expire 24 hours after the page is fetched, so unlike the embed URL they * can't be stored in the dump's preview. The browser plays them from bcbits * directly. diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts index da5f5e73..74bc4130 100644 --- a/api/remote/image-proxy.ts +++ b/api/remote/image-proxy.ts @@ -1,7 +1,7 @@ /** * Shows the thumbnails a browser can't load from their own site: those on plain * http, which an https page would block, and those behind hotlink protection. - * It fetches and caches the images `GET /api/proxy-image` serves, which the + * It fetches and caches the images `GET /api/remote/image` serves, which the * frontend routes a thumbnail through when it is on http or when loading it * directly failed. * @@ -11,7 +11,7 @@ import { fetchWithTimeout, readBytesCapped } from "./fetch.ts"; /** - * Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends + * Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends * it back under `contentType`. */ export interface ProxiedImage { diff --git a/api/remote/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts index 705018f0..65c5232a 100644 --- a/api/remote/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -15,7 +15,7 @@ import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts"; * Claims bandcamp.com and its subdomains, where artists have their pages, and * takes the embed player's URL from the page's `og:video`. The native player * fetches the page's streams itself at play time, through - * `GET /api/bandcamp/tracks`. + * `GET /api/remote/bandcamp`. */ export const bandcampProvider: RichContentProvider = { name: "bandcamp", diff --git a/api/remote/providers/self.ts b/api/remote/providers/self.ts index bf769b33..9694c95f 100644 --- a/api/remote/providers/self.ts +++ b/api/remote/providers/self.ts @@ -1,6 +1,6 @@ /** - * Previews links to this site's own dump, user and playlist pages from the - * database, for `rich-content-service.ts`. No request goes out, so this works + * Previews links to this site's own dump, user and playlist pages, as + * `parsePagePath` reads them, from the database, for `rich-content-service.ts`. No request goes out, so this works * even when the site runs on a private host that `fetchWithTimeout` refuses. * * @module @@ -8,16 +8,13 @@ import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; -import { getDump } from "../../services/dump-service.ts"; -import { getUserByUsername } from "../../services/user-service.ts"; -import { getPlaylistById } from "../../services/playlist-service.ts"; +import { getDumpByPath } from "../../services/dump-service.ts"; +import { resolveUsername } from "../../services/user-service.ts"; +import { getPlaylistByPath } from "../../services/playlist-service.ts"; import { APIException } from "../../lib/api-exception.ts"; import { PUBLIC_URL, THEME_COLOR } from "../../config.ts"; import { isSelfUrl } from "../../lib/self-url.ts"; - -const DUMP_RE = /^\/dumps\/([^/]+)$/; -const USER_RE = /^\/users\/([^/]+)$/; -const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; +import { parsePagePath } from "../../../shared/paths.ts"; /** * Gives a link to this site without a thumbnail a placeholder in the site's @@ -30,10 +27,10 @@ const SELF_BRANDING = { } as const; /** - * Comes before the generic provider, and claims what `isSelfUrl` accepts. Its - * `fetch` rejects with a 404 `APIException` for a missing page, a private dump - * or a private playlist: the preview is stored in the linking dump, which - * others may see. + * Comes before the generic provider, and claims what `isSelfUrl` accepts. A + * profile's tab previews as the profile. Its `fetch` rejects with a 404 + * `APIException` for a missing page, a private dump or a private playlist: + * the preview is stored in the linking dump, which others may see. */ export const selfProvider: RichContentProvider = { name: "self", @@ -47,14 +44,15 @@ export const selfProvider: RichContentProvider = { }, fetch(url: string): Promise { - const { pathname } = new URL(url); + // matches() let only these paths through. + const page = parsePagePath(new URL(url).pathname)!; + const user = resolveUsername(page.username); - const dumpMatch = pathname.match(DUMP_RE); - if (dumpMatch) { - const dump = getDump(dumpMatch[1]); + if (page.kind === "dump") { + const dump = getDumpByPath(user.id, page.slug); let thumbnailUrl: string | undefined; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { - thumbnailUrl = `${PUBLIC_URL}/api/files/${dump.id}`; + thumbnailUrl = `${PUBLIC_URL}/api/dumps/${dump.id}/file`; } else if (dump.richContent?.thumbnailUrl) { thumbnailUrl = dump.richContent.thumbnailUrl; } @@ -68,30 +66,13 @@ export const selfProvider: RichContentProvider = { }); } - const userMatch = pathname.match(USER_RE); - if (userMatch) { - const user = getUserByUsername(userMatch[1]); - const thumbnailUrl = user.avatarMime - ? `${PUBLIC_URL}/api/avatars/${user.id}` - : undefined; - return Promise.resolve({ - type: "generic", - url, - ...SELF_BRANDING, - title: user.username, - description: user.description, - thumbnailUrl, - }); - } - - const playlistMatch = pathname.match(PLAYLIST_RE); - if (playlistMatch) { - const playlist = getPlaylistById(playlistMatch[1]); + if (page.kind === "playlist") { + const playlist = getPlaylistByPath(user.id, page.slug); if (!playlist.isPublic) { throw new APIException(404, "Playlist not found"); } const thumbnailUrl = playlist.imageMime - ? `${PUBLIC_URL}/api/playlists/${playlist.id}/image` + ? `${PUBLIC_URL}/api/playlists/${playlist.id}/cover` : undefined; return Promise.resolve({ type: "generic", @@ -103,7 +84,16 @@ export const selfProvider: RichContentProvider = { }); } - // Unreachable: isSelfUrl accepts only the three paths above. - return Promise.resolve({ type: "generic", url }); + const thumbnailUrl = user.avatarMime + ? `${PUBLIC_URL}/api/users/${user.id}/avatar` + : undefined; + return Promise.resolve({ + type: "generic", + url, + ...SELF_BRANDING, + title: user.username, + description: user.description, + thumbnailUrl, + }); }, }; diff --git a/api/remote/youtube-playlist.ts b/api/remote/youtube-playlist.ts index 377fa827..1599720f 100644 --- a/api/remote/youtube-playlist.ts +++ b/api/remote/youtube-playlist.ts @@ -1,7 +1,7 @@ /** * Gives the global player a tracklist for a YouTube playlist, which the embed * plays but doesn't list. It resolves the playlist to its entries' titles and - * lengths, for `GET /api/youtube/playlist`. + * lengths, for `GET /api/remote/youtube`. * * They are read from the `ytInitialData` JSON of the public playlist page, * whose undocumented shape changes, so both the current `lockupViewModel` rows diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 12b5a5be..abe2f32e 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -3,10 +3,10 @@ * and the client's `Avatar` component loads it, with the user's `updatedAt` as * a cache-busting version. * - * - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart + * - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the * caller's user. - * - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without + * - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without * one. * * @module @@ -14,7 +14,11 @@ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; -import { getUserById, updateUserAvatar } from "../services/user-service.ts"; +import { + getUserById, + resolveUserKey, + updateUserAvatar, +} from "../services/user-service.ts"; import { broadcastUserUpdated, updateClientAvatar, @@ -36,7 +40,7 @@ import { AVATARS_DIR } from "../config.ts"; */ const router: Router = new Router(); -router.post("/api/avatars/me", authMiddleware, async (ctx) => { +router.put("/api/me/avatar", authMiddleware, async (ctx) => { const authPayload = ctx.state.user; const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { @@ -70,12 +74,10 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -router.get("/api/avatars/:userId", async (ctx) => { - const { userId } = ctx.params; - +router.get("/api/users/:key/avatar", async (ctx) => { let user; try { - user = getUserById(userId); + user = resolveUserKey(ctx.params.key); } catch { ctx.response.status = 404; return; @@ -86,7 +88,7 @@ router.get("/api/avatars/:userId", async (ctx) => { return; } - await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime); + await serveUploadedFile(ctx, `${AVATARS_DIR}/${user.id}`, user.avatarMime); }); export default router; diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts deleted file mode 100644 index 8e5540c8..00000000 --- a/api/routes/bandcamp.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Serves the Bandcamp release the native player plays, resolved from its page - * when playback starts. On any failure the player falls back to the embed. - * - * - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's - * tracks and their stream URLs; `force=1` skips the server's cache. - * - * @module - */ - -import { Router } from "@oak/oak"; -import type { APIResponse } from "../../shared/api.ts"; -import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; -import type { Tralbum } from "../../shared/player.ts"; - -/** - * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. - * An `APIException` thrown by a handler becomes the JSON error response there. - */ -const bandcampRouter: Router = new Router(); - -// Public: it reads a public page and answers the URLs that page gives any -// visitor. -bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { - const url = ctx.request.url.searchParams.get("url") ?? ""; - const force = ctx.request.url.searchParams.get("force") === "1"; - - const responseBody: APIResponse = { - success: true, - data: await resolveBandcamp(url, { force }), - }; - - // Not in a shared cache: the signed URLs belong to one page fetch. An hour - // is well inside their 24 hours. - ctx.response.headers.set("Cache-Control", "private, max-age=3600"); - ctx.response.body = responseBody; -}); - -export default bandcampRouter; diff --git a/api/routes/categories.ts b/api/routes/categories.ts index 95d3f1e2..7eed285f 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -1,11 +1,8 @@ /** - * Serves the categories dumps are filed under and their feeds, and changes them - * for those with `category:manage`. + * 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`. * * - `GET /api/categories` — public. Answers every category, in order. - * - `GET /api/categories/:slug` — public. Answers one category. - * - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers - * a page of its dumps, newest first, with the caller's private ones. * - `POST /api/categories` — permission `category:manage`. Creates a category. * - `PUT /api/categories/:id` — permission `category:manage`. Updates a * category. @@ -17,24 +14,19 @@ import { Router } from "@oak/oak"; -import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; +import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Category } from "../../shared/category.ts"; -import type { Dump } from "../../shared/dump.ts"; import { isCreateCategoryRequest, isUpdateCategoryRequest, } from "../validators/category.ts"; import { authMiddleware, requirePermission } from "../middleware/auth.ts"; -import { parseOptionalAuth } from "../lib/auth.ts"; -import { parsePagination } from "../lib/pagination.ts"; import { createCategory, deleteCategory, - getCategoryBySlug, listCategories, - listDumpsByCategory, updateCategory, } from "../services/category-service.ts"; @@ -52,31 +44,6 @@ router.get("/", (ctx) => { ctx.response.body = responseBody; }); -router.get("/:slug", (ctx) => { - const category = getCategoryBySlug(ctx.params.slug); - const responseBody: APIResponse = { - success: true, - data: category, - }; - ctx.response.body = responseBody; -}); - -router.get("/:slug/dumps", async (ctx) => { - const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = listDumpsByCategory( - ctx.params.slug, - page, - limit, - requestingUserId, - ); - const responseBody: APIResponse> = { - success: true, - data: { items, hasMore }, - }; - ctx.response.body = responseBody; -}); - router.post( "/", authMiddleware, diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index b2f53a17..f6827679 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -1,11 +1,18 @@ /** - * Posts, reads, edits and deletes dumps, for the index feed, the dump page, its - * edit page and the create form. `:dumpId` takes an id or a slug. A private - * dump answers 404 to anyone but its poster, moderators included. + * Posts, reads, edits and deletes dumps, for the feeds, the dump page, its + * edit page and the create form. `:dumpId` is a dump's id; the dump page reads + * a dump by its path, through `GET /api/users/:key/dumps/:slug`. A private + * dump answers 404 to anyone but its poster, moderators included. Its file and + * thumbnail are served by `routes/files.ts` and `routes/thumbnails.ts`, its + * comments by `routes/comments.ts`. * * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file * (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). - * - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already + * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the + * dumps the caller may see, newest first. + * - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in + * the category with that slug; 404 when there is none. + * - `GET /api/dumps?url=` — optional sign-in. Answers the dumps already * pointing at the same thing as `url`, for the create form's duplicate hint. * - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump. * - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a @@ -14,21 +21,12 @@ * linked to or from it. * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the * playlists it appears in. - * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the - * dumps the caller may see, newest first. - * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with - * `dump:moderate`. Replaces a file dump's upload and comment, and any of its - * title, categories and visibility (multipart). * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with - * `dump:moderate`. Updates its title, comment, visibility, categories, and a - * link's URL. - * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with - * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 - * MB). - * - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with - * `dump:moderate`. Removes its custom thumbnail. - * - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or - * with `dump:moderate`. Fetches a link's preview again. + * `dump:moderate`. Updates its title, slug, comment, visibility, categories, + * and a link's URL. A new slug keeps the old one as an alias, 3 times a day + * at most but for those with `dump:moderate`. + * - `POST /api/dumps/:dumpId/metadata` — signed in, as its poster or with + * `dump:moderate`. Fetches a link's preview again. * - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with * `dump:moderate`. Deletes it. * @@ -43,6 +41,7 @@ import type { Dump, DumpUrlMatch } from "../../shared/dump.ts"; import { isCreateUrlDumpRequest, isUpdateDumpRequest, + parseCategoryIds, } from "../validators/dump.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; @@ -52,8 +51,6 @@ import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; -import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; -import { THUMBNAILS_DIR } from "../config.ts"; import { createFileDump, createUrlDump, @@ -62,11 +59,9 @@ import { getDump, listDumps, refreshDumpMetadata, - removeDumpThumbnail, - replaceFileDump, - setDumpThumbnail, updateDump, } from "../services/dump-service.ts"; +import { listDumpsByCategory } from "../services/category-service.ts"; import { getDumpVoters } from "../services/vote-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts"; @@ -77,24 +72,8 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts"; */ const router: Router = new Router({ prefix: "/api/dumps" }); -// Each handler resolves `:dumpId` with getDump, which checks privacy and takes -// a slug, and passes `dump.id` on: the services' writes match on the id alone. - -/** - * Parses a multipart form's `categoryIds`, a JSON array sent as a string. A - * missing or malformed value counts as no category. - */ -function parseCategoryIds(value: FormDataEntryValue | null): string[] { - if (typeof value !== "string" || !value) return []; - try { - const parsed: unknown = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((v): v is string => typeof v === "string") - : []; - } catch { - return []; - } -} +// Each handler resolves `:dumpId` with getDump, which checks privacy, before +// acting on the dump. router.post( "/", @@ -140,17 +119,6 @@ router.post( }, ); -// Registered ahead of "/:dumpId" so the literal path wins over the parameter. -router.get("/by-url", async (ctx) => { - const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; - const url = ctx.request.url.searchParams.get("url") ?? ""; - const responseBody: APIResponse = { - success: true, - data: findDumpsByUrl(url, requestingUserId), - }; - ctx.response.body = responseBody; -}); - router.get("/:dumpId", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); @@ -194,8 +162,21 @@ router.get("/:dumpId/playlists", async (ctx) => { router.get("/", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = listDumps(page, limit, requestingUserId); + const params = ctx.request.url.searchParams; + const url = params.get("url"); + if (url !== null) { + const responseBody: APIResponse = { + success: true, + data: findDumpsByUrl(url, requestingUserId), + }; + ctx.response.body = responseBody; + return; + } + const { page, limit } = parsePagination(params); + const category = params.get("category"); + const { items, hasMore } = category + ? listDumpsByCategory(category, page, limit, requestingUserId) + : listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, @@ -203,40 +184,6 @@ router.get("/", async (ctx) => { ctx.response.body = responseBody; }); -router.put("/:dumpId/file", authMiddleware, async (ctx) => { - const dumpId = ctx.params.dumpId; - const userId = ctx.state.user.userId; - - const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException(403, "Not authorized to update dump"); - } - - const formData = await ctx.request.body.formData(); - const file = formData.get("file"); - const comment = formData.get("comment"); - const title = formData.get("title"); - - if (!(file instanceof File)) { - throw new APIException(400, "A file is required"); - } - - const updatedDump = await replaceFileDump( - dump.id, - file, - typeof comment === "string" && comment ? comment : undefined, - typeof title === "string" && title ? title : undefined, - formData.has("categoryIds") - ? parseCategoryIds(formData.get("categoryIds")) - : undefined, - formData.has("isPrivate") - ? formData.get("isPrivate") === "true" - : undefined, - ); - const responseBody: APIResponse = { success: true, data: updatedDump }; - ctx.response.body = responseBody; -}); - router.put("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; @@ -252,54 +199,16 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { throw new APIException(403, "Not authorized to update dump"); } - const updatedDump = await updateDump(dump.id, body); + const updatedDump = await updateDump( + dump.id, + body, + can(ctx.state.user, "dump:moderate"), + ); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); -router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { - const dumpId = ctx.params.dumpId; - const userId = ctx.state.user.userId; - - const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException(403, "Not authorized to update dump"); - } - - const formData = await ctx.request.body.formData(); - const file = formData.get("file"); - - if (!(file instanceof File)) { - throw new APIException(400, "Missing file field"); - } - - const data = new Uint8Array(await file.arrayBuffer()); - const mime = validateImageUpload(data); - - // Written before setDumpThumbnail announces it, or clients would fetch it - // too early. - await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data); - const updatedDump = setDumpThumbnail(dump.id, mime); - - const responseBody: APIResponse = { success: true, data: updatedDump }; - ctx.response.body = responseBody; -}); - -router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => { - const dumpId = ctx.params.dumpId; - const userId = ctx.state.user.userId; - - const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException(403, "Not authorized to update dump"); - } - - const updatedDump = await removeDumpThumbnail(dump.id); - const responseBody: APIResponse = { success: true, data: updatedDump }; - ctx.response.body = responseBody; -}); - -router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { +router.post("/:dumpId/metadata", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); diff --git a/api/routes/files.ts b/api/routes/files.ts index 08cd2c20..a24d6c73 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,26 +1,39 @@ /** - * Serves file dumps' uploads to the players, viewers and download links of the - * frontend, which builds the URL with `dumpFileUrl`. + * Serves and replaces file dumps' uploads, a sub-resource of their dump. The + * players, viewers and download links of the frontend build the URL with + * `dumpFileUrl`, and its edit page replaces the file. * - * - `GET /api/files/:dumpId?v=&token=` — public; a private dump's file needs - * its poster's media `token`. Serves the file, inline when that is safe and - * as a download otherwise, with byte ranges. + * - `GET /api/dumps/:dumpId/file?v=&token=` — public; a private dump's file + * needs its poster's media `token`. Serves the file, inline when that is + * safe and as a download otherwise, with byte ranges. + * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with + * `dump:moderate`. Replaces a file dump's upload and comment, and any of its + * title, slug, categories and visibility (multipart). * * @module */ import { Router } from "@oak/oak"; import { APIException } from "../lib/api-exception.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import type { Dump } from "../../shared/dump.ts"; import { DUMPS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; +import { can } from "../lib/permissions.ts"; import { serveFile } from "../lib/serve-file.ts"; -import { dumpFileVersion, getDump } from "../services/dump-service.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { parseCategoryIds } from "../validators/dump.ts"; +import { + dumpFileVersion, + getDump, + replaceFileDump, +} from "../services/dump-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ -const router: Router = new Router({ prefix: "/api/files" }); +const router: Router = new Router({ prefix: "/api/dumps" }); /** * Builds a `Content-Disposition` value for a file called `name`. `Headers.set` @@ -54,7 +67,7 @@ function isInlineSafe(mime: string): boolean { return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase()); } -router.get("/:dumpId", async (ctx) => { +router.get("/:dumpId/file", async (ctx) => { const { dumpId } = ctx.params; // The id names a file on disk: nothing but an id's characters gets through. @@ -110,4 +123,40 @@ router.get("/:dumpId", async (ctx) => { } }); +router.put("/:dumpId/file", authMiddleware, async (ctx) => { + const userId = ctx.state.user.userId; + const dump = getDump(ctx.params.dumpId, userId); + const canModerate = can(ctx.state.user, "dump:moderate"); + if (userId !== dump.userId && !canModerate) { + throw new APIException(403, "Not authorized to update dump"); + } + + const formData = await ctx.request.body.formData(); + const file = formData.get("file"); + const comment = formData.get("comment"); + const title = formData.get("title"); + const slug = formData.get("slug"); + + if (!(file instanceof File)) { + throw new APIException(400, "A file is required"); + } + + const updatedDump = await replaceFileDump( + dump.id, + file, + typeof comment === "string" && comment ? comment : undefined, + typeof title === "string" && title ? title : undefined, + formData.has("categoryIds") + ? parseCategoryIds(formData.get("categoryIds")) + : undefined, + formData.has("isPrivate") + ? formData.get("isPrivate") === "true" + : undefined, + typeof slug === "string" && slug ? slug : undefined, + canModerate, + ); + const responseBody: APIResponse = { success: true, data: updatedDump }; + ctx.response.body = responseBody; +}); + export default router; diff --git a/api/routes/follows.ts b/api/routes/follows.ts index fd304511..b55b62a6 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -1,27 +1,28 @@ /** - * Serves the endpoints for what the caller follows, and the index's "followed" - * tab built from it. What another user follows is served by `routes/users.ts`. + * Serves what the caller follows, and the index's "follow" tab built from it. + * What another user follows is served by `routes/users.ts`. * - * - `GET /api/follows/status` — signed in. Answers the ids of the users and + * - `GET /api/me/follow` — signed in. Answers the ids of the users and * playlists they follow. - * - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a - * page of the public dumps of the users they follow, newest first, optionally - * in one category. - * - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in. + * - `PUT /api/me/follow/users/:userId` — signed in. Follows a user. + * - `DELETE /api/me/follow/users/:userId` — signed in. Stops following a user. + * - `PUT /api/me/follow/playlists/:playlistId` — signed in. Follows a public + * playlist. + * - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following + * a playlist. + * - `GET /api/me/feed?from=users&page=&limit=&category=` — signed in. Answers + * a page of the public dumps of the users they follow, newest first, + * optionally in one category. + * - `GET /api/me/feed?from=playlists&page=&limit=&category=` — signed in. * Answers a page of the public dumps in the public playlists they follow, * most recently added first, optionally in one category. - * - `POST /api/follows/users/:userId` — signed in. Follows a user. - * - `DELETE /api/follows/users/:userId` — signed in. Stops following a user. - * - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public - * playlist. - * - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a - * playlist. * * @module */ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; +import { APIException } from "../lib/api-exception.ts"; import type { APIResponse, PaginatedData } from "../../shared/api.ts"; import type { Dump } from "../../shared/dump.ts"; import type { FollowStatus } from "../../shared/follow.ts"; @@ -56,65 +57,53 @@ const EMPTY_FEED: Page = { items: [], hasMore: false }; * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ -const router: Router = new Router({ prefix: "/api/follows" }); +const router: Router = new Router({ prefix: "/api/me" }); -router.get("/status", authMiddleware, (ctx) => { +router.get("/follow", authMiddleware, (ctx) => { const status = getFollowStatus(ctx.state.user.userId); const body: APIResponse = { success: true, data: status }; ctx.response.body = body; }); -router.get("/feed/users", authMiddleware, (ctx) => { - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); - const { items, hasMore } = categoryId === "missing" - ? EMPTY_FEED - : getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId); - const data: PaginatedData = { - items, - hasMore, - }; - const body: APIResponse> = { success: true, data }; - ctx.response.body = body; -}); - -router.get("/feed/playlists", authMiddleware, (ctx) => { - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); - const { items, hasMore } = categoryId === "missing" - ? EMPTY_FEED - : getFollowedPlaylistsDumpFeed( - ctx.state.user.userId, - page, - limit, - categoryId, - ); - const data: PaginatedData = { - items, - hasMore, - }; - const body: APIResponse> = { success: true, data }; - ctx.response.body = body; -}); - -router.post("/users/:userId", authMiddleware, (ctx) => { +router.put("/follow/users/:userId", authMiddleware, (ctx) => { followUser(ctx.state.user.userId, ctx.params.userId); ctx.response.status = 204; }); -router.delete("/users/:userId", authMiddleware, (ctx) => { +router.delete("/follow/users/:userId", authMiddleware, (ctx) => { unfollowUser(ctx.state.user.userId, ctx.params.userId); ctx.response.status = 204; }); -router.post("/playlists/:playlistId", authMiddleware, (ctx) => { +router.put("/follow/playlists/:playlistId", authMiddleware, (ctx) => { followPlaylist(ctx.state.user.userId, ctx.params.playlistId); ctx.response.status = 204; }); -router.delete("/playlists/:playlistId", authMiddleware, (ctx) => { +router.delete("/follow/playlists/:playlistId", authMiddleware, (ctx) => { unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId); ctx.response.status = 204; }); +router.get("/feed", authMiddleware, (ctx) => { + const params = ctx.request.url.searchParams; + const from = params.get("from"); + if (from !== "users" && from !== "playlists") { + throw new APIException(400, "from must be users or playlists"); + } + const { page, limit } = parsePagination(params); + const categoryId = resolveCategoryFilter(params); + const userId = ctx.state.user.userId; + const { items, hasMore } = categoryId === "missing" + ? EMPTY_FEED + : from === "users" + ? getFollowedUsersDumpFeed(userId, page, limit, categoryId) + : getFollowedPlaylistsDumpFeed(userId, page, limit, categoryId); + const body: APIResponse> = { + success: true, + data: { items, hasMore }, + }; + ctx.response.body = body; +}); + export default router; diff --git a/api/routes/me.ts b/api/routes/me.ts new file mode 100644 index 00000000..d3123804 --- /dev/null +++ b/api/routes/me.ts @@ -0,0 +1,174 @@ +/** + * Serves the caller's own account: their user, settings, password and media + * token, and which of their playlists hold a dump. What they follow, their + * feed and their notifications are under `/api/me` too, served by + * `routes/follows.ts` and `routes/notifications.ts`, and their avatar by + * `routes/avatars.ts`. + * + * - `GET /api/me` — signed in. Answers the caller's own user. + * - `PATCH /api/me` — signed in. Changes the caller's description, email and + * username, and answers their user. A new username keeps the old one as an + * alias, at most once in 30 days but for those with `user:manage`. + * - `GET /api/me/media-token` — signed in. Answers a media token, for a + * session that predates them. + * - `GET /api/me/settings` — signed in. Answers the caller's settings. + * - `PATCH /api/me/settings` — signed in. Changes some of the caller's + * settings, and answers them all. + * - `PUT /api/me/password` — signed in, with the current password. Sets a new + * one, ending every other session, and answers fresh tokens. + * - `GET /api/me/playlists?contains=:dumpId` — signed in. Answers each of the + * caller's playlists and whether it holds the dump. + * + * @module + */ + +import { Router } from "@oak/oak"; + +import { type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + AuthTokens, + MediaTokenResponse, + SelfUser, +} from "../../shared/user.ts"; +import type { PlaylistMembership } from "../../shared/playlist.ts"; +import type { UserSettings } from "../../shared/settings.ts"; +import { fieldOf, isUpdateUserRequest } from "../validators/user.ts"; +import { isUpdateSettingsRequest } from "../validators/settings.ts"; +import { toPublicUser, toSelfUser } from "../model/user.ts"; +import { createMediaToken, issueTokens } from "../lib/jwt.ts"; +import { verifyPassword } from "../lib/password.ts"; +import { can } from "../lib/permissions.ts"; +import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; +import { + getUserById, + renameUser, + updateUser, +} from "../services/user-service.ts"; +import { getSettings, updateSettings } from "../services/settings-service.ts"; +import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts"; +import { + broadcastUserUpdated, + updateClientUsername, +} from "../services/ws-service.ts"; +import { VALIDATION } from "../../shared/validation.ts"; + +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ +const router: Router = new Router({ prefix: "/api/me" }); + +router.get("/", authMiddleware, (ctx: AuthContext) => { + const responseBody: APIResponse = { + success: true, + data: toSelfUser(getUserById(ctx.state.user.userId)), + }; + ctx.response.body = responseBody; +}); + +router.patch("/", authMiddleware, async (ctx: AuthContext) => { + const body: unknown = await ctx.request.body.json(); + if (!isUpdateUserRequest(body)) { + throw new APIException(400, "Invalid request"); + } + const userId = ctx.state.user.userId; + // Only these fields: a stolen token alone must not change the role or the + // password, which needs the current one. The rename first: it is the change + // most likely refused, and a refusal should leave the rest as it was. + if (body.username !== undefined) { + const renamed = renameUser( + userId, + body.username, + can(ctx.state.user, "user:manage"), + ); + updateClientUsername(userId, renamed.username); + } + const updated = await updateUser(userId, { + description: body.description, + email: body.email, + }); + broadcastUserUpdated(toPublicUser(updated)); + const responseBody: APIResponse = { + success: true, + data: toSelfUser(updated), + }; + ctx.response.body = responseBody; +}); + +router.get("/media-token", authMiddleware, async (ctx: AuthContext) => { + const responseBody: APIResponse = { + success: true, + data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, + }; + ctx.response.body = responseBody; +}); + +router.get("/settings", authMiddleware, (ctx: AuthContext) => { + const responseBody: APIResponse = { + success: true, + data: getSettings(ctx.state.user.userId), + }; + ctx.response.body = responseBody; +}); + +router.patch("/settings", authMiddleware, async (ctx: AuthContext) => { + const body: unknown = await ctx.request.body.json(); + if (!isUpdateSettingsRequest(body)) { + throw new APIException(400, "Invalid settings"); + } + const responseBody: APIResponse = { + success: true, + data: updateSettings(ctx.state.user.userId, body), + }; + ctx.response.body = responseBody; +}); + +router.put("/password", authMiddleware, async (ctx: AuthContext) => { + const body: unknown = await ctx.request.body.json(); + const currentPassword = fieldOf(body, "currentPassword"); + const newPassword = fieldOf(body, "newPassword"); + + if (typeof currentPassword !== "string" || typeof newPassword !== "string") { + throw new APIException(400, "Invalid request"); + } + + if ( + newPassword.length < VALIDATION.PASSWORD_MIN || + newPassword.length > VALIDATION.PASSWORD_MAX + ) { + throw new APIException( + 400, + `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, + ); + } + + const user = getUserById(ctx.state.user.userId); + const valid = await verifyPassword(currentPassword, user.passwordHash); + if (!valid) { + throw new APIException(403, "Current password is incorrect"); + } + + // Changing the password revokes every token issued before it, this + // session's included, so hand back fresh ones for the client to keep. + const updated = await updateUser(ctx.state.user.userId, { + password: newPassword, + }); + const responseBody: APIResponse = { + success: true, + data: await issueTokens(updated), + }; + ctx.response.body = responseBody; +}); + +router.get("/playlists", authMiddleware, (ctx: AuthContext) => { + const dumpId = ctx.request.url.searchParams.get("contains"); + if (!dumpId) throw new APIException(400, "contains is required"); + const responseBody: APIResponse = { + success: true, + data: getPlaylistMembershipsForDump(dumpId, ctx.state.user.userId), + }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 0d1bcb87..3e796f41 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -3,9 +3,9 @@ * page, which marks them all read when it opens. New ones arrive over the * WebSocket instead, as `notification_created`. * - * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, - * newest first. - * - `POST /api/notifications/read-all` — signed in. Marks them all read. + * - `GET /api/me/notifications?page=&limit=` — signed in. Answers a page of + * them, newest first. + * - `POST /api/me/notifications/read` — signed in. Marks them all read. * * @module */ @@ -24,7 +24,7 @@ import { * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ -const router: Router = new Router({ prefix: "/api/notifications" }); +const router: Router = new Router({ prefix: "/api/me/notifications" }); router.get("/", authMiddleware, (ctx: AuthContext) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); @@ -40,7 +40,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { ctx.response.body = responseBody; }); -router.post("/read-all", authMiddleware, (ctx: AuthContext) => { +router.post("/read", authMiddleware, (ctx: AuthContext) => { markAllRead(ctx.state.user.userId); ctx.response.status = 204; }); diff --git a/api/routes/password-resets.ts b/api/routes/password-resets.ts new file mode 100644 index 00000000..05a0cdb0 --- /dev/null +++ b/api/routes/password-resets.ts @@ -0,0 +1,56 @@ +/** + * Resets forgotten passwords. The client's `/login` page asks for a reset + * link, which the email takes to the `/reset-password` page, whose token + * names the reset. + * + * - `POST /api/password-resets` — public. Emails a reset link to the account + * with that `email`, if any; always answers 200. + * - `PUT /api/password-resets/:token` — public, with a reset token. Sets the + * `newPassword`. + * + * @module + */ + +import { Router } from "@oak/oak"; + +import { type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { fieldOf } from "../validators/user.ts"; +import { + requestPasswordReset, + resetPassword, +} from "../services/password-reset-service.ts"; + +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ +const router: Router = new Router({ prefix: "/api/password-resets" }); + +// Answers the same whatever happens, so it can't tell which addresses have an +// account. +router.post("/", async (ctx) => { + const body: unknown = await ctx.request.body.json(); + const email = fieldOf(body, "email"); + const trimmed = typeof email === "string" ? email.trim() : ""; + if (trimmed) { + await requestPasswordReset(trimmed).catch((err) => + console.error("[password-resets]", err) + ); + } + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; +}); + +router.put("/:token", async (ctx) => { + const body: unknown = await ctx.request.body.json(); + const newPassword = fieldOf(body, "newPassword"); + if (typeof newPassword !== "string") { + throw new APIException(400, "Invalid request"); + } + await resetPassword(ctx.params.token, newPassword); + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index fb6f8454..1d989ad0 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -1,29 +1,32 @@ /** - * Serves playlists, their dumps and their covers. `:playlistId` takes an id or - * a slug. + * Serves playlists, their dumps and their covers. `:playlistId` is a + * playlist's id; its page reads it by its path, through + * `GET /api/users/:key/playlists/:slug`. * - * - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each - * of the caller's playlists and whether it holds the dump. * - `POST /api/playlists` — signed in. Creates a playlist. * - `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, description and visibility. + * `playlist:moderate`. Updates its title, slug, description and visibility. + * 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 * `playlist:moderate`. Deletes it. * - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * or with `playlist:moderate`. Adds a dump at the top. * - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * or with `playlist:moderate`. Removes a dump. - * - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with + * - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). - * - `GET /api/playlists/:playlistId/image?token=` — public, but a private + * - `GET /api/playlists/:playlistId/cover?token=` — public, but a private * playlist's cover needs its owner's media `token`. Serves its cover, or 404s * without one. * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * `playlist:moderate`. Reorders the dumps the caller can see in it. * + * Which of the caller's playlists hold a dump is `GET /api/me/playlists`'s. + * * @module */ @@ -36,11 +39,7 @@ import { isReorderPlaylistRequest, isUpdatePlaylistRequest, } from "../validators/playlist.ts"; -import type { - Playlist, - PlaylistMembership, - PlaylistWithDumps, -} from "../../shared/playlist.ts"; +import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { @@ -50,7 +49,6 @@ import { getEditablePlaylist, getPlaylist, getPlaylistImageInfo, - getPlaylistMembershipsForDump, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, @@ -71,17 +69,6 @@ const router: Router = new Router({ prefix: "/api/playlists", }); -// Before /:playlistId, which would otherwise match "by-dump". -router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { - const { dumpId } = ctx.params; - const userId = ctx.state.user.userId; - const responseBody: APIResponse = { - success: true, - data: getPlaylistMembershipsForDump(dumpId, userId), - }; - ctx.response.body = responseBody; -}); - router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { @@ -148,7 +135,7 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { ctx.response.status = 204; }); -router.post("/:playlistId/image", authMiddleware, async (ctx) => { +router.put("/:playlistId/cover", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { throw new APIException(400, "Expected multipart/form-data"); @@ -182,7 +169,7 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -router.get("/:playlistId/image", async (ctx) => { +router.get("/:playlistId/cover", async (ctx) => { // An can't send an Authorization header, so a private playlist's owner // is known by the media token in the query string, as for dump files. const token = ctx.request.url.searchParams.get("token"); diff --git a/api/routes/preview.ts b/api/routes/remote.ts similarity index 51% rename from api/routes/preview.ts rename to api/routes/remote.ts index 71eadacc..c0564f68 100644 --- a/api/routes/preview.ts +++ b/api/routes/remote.ts @@ -1,11 +1,19 @@ /** - * Fetches link previews for the create form, and proxies the remote images - * that the frontend's thumbnails can't load directly. + * Reads other sites for the client, as `api/remote/` does for the server: the + * previews of links on the create form, the images its thumbnails can't load + * directly, and the tracks the players list. On any failure of the last two, + * the player falls back to the embed. * - * - `GET /api/preview?url=` — signed in. Answers the preview of a link, and - * whether its page could be reached. - * - `GET /api/proxy-image?url=` — public. Serves a remote raster image through - * this site; 400 when it isn't one, 502 when its host can't be reached. + * - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link, + * and whether its page could be reached. + * - `GET /api/remote/image?url=` — public. Serves a remote raster image + * through this site; 400 when it isn't one, 502 when its host can't be + * reached. + * - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp + * page's tracks and their stream URLs, for the native player; `force=1` + * skips the server's cache. + * - `GET /api/remote/youtube?list=` — public. Answers a YouTube playlist's + * tracklist, which the global player lists beside the embed. * * @module */ @@ -13,6 +21,8 @@ import { Router } from "@oak/oak"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { fetchProxiedImage } from "../remote/image-proxy.ts"; +import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; +import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import { tryFetchRichContent } from "../services/rich-content-service.ts"; import { APIErrorCode, @@ -20,16 +30,17 @@ import { type APIResponse, } from "../../shared/api.ts"; import type { LinkPreview } from "../../shared/dump.ts"; +import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts"; import { authMiddleware } from "../middleware/auth.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ -const previewRouter: Router = new Router(); +const router: Router = new Router({ prefix: "/api/remote" }); // Signed-in only: an open preview would let anyone make the server fetch pages. -previewRouter.get("/api/preview", authMiddleware, async (ctx) => { +router.get("/preview", authMiddleware, async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { const responseBody: APIFailure = { @@ -69,7 +80,7 @@ function sendImage( // The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS // page won't load, or when its host's hotlink protection refused the browser. -previewRouter.get("/api/proxy-image", async (ctx) => { +router.get("/image", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { ctx.response.status = 400; @@ -88,4 +99,33 @@ previewRouter.get("/api/proxy-image", async (ctx) => { } }); -export default previewRouter; +// Public: it reads a public page and answers the URLs that page gives any +// visitor. +router.get("/bandcamp", async (ctx) => { + const url = ctx.request.url.searchParams.get("url") ?? ""; + const force = ctx.request.url.searchParams.get("force") === "1"; + + const responseBody: APIResponse = { + success: true, + data: await resolveBandcamp(url, { force }), + }; + + // Not in a shared cache: the signed URLs belong to one page fetch. An hour + // is well inside their 24 hours. + ctx.response.headers.set("Cache-Control", "private, max-age=3600"); + ctx.response.body = responseBody; +}); + +// Public, like /bandcamp: it only reads a public page. +router.get("/youtube", async (ctx) => { + const listId = ctx.request.url.searchParams.get("list") ?? ""; + const responseBody: APIResponse = { + success: true, + data: await resolveYouTubePlaylist(listId), + }; + + ctx.response.headers.set("Cache-Control", "public, max-age=3600"); + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/sessions.ts b/api/routes/sessions.ts new file mode 100644 index 00000000..f2682019 --- /dev/null +++ b/api/routes/sessions.ts @@ -0,0 +1,74 @@ +/** + * Signs users in, from the client's `/login` page. A session is a token the + * client keeps; signing out forgets it, with no request. + * + * - `POST /api/sessions` — public. Answers the user and their tokens; + * rate-limited per account. + * + * @module + */ + +import { Router } from "@oak/oak"; + +import { type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { AuthResponse } from "../../shared/user.ts"; +import { isLoginUserRequest } from "../validators/user.ts"; +import { toSelfUser } from "../model/user.ts"; +import { issueTokens } from "../lib/jwt.ts"; +import { hashPassword, verifyPassword } from "../lib/password.ts"; +import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; +import { getUserByUsername } from "../services/user-service.ts"; + +// Hashed once, at startup, for the sign-ins of usernames that don't exist. +const UNKNOWN_USER_HASH: Promise = hashPassword(crypto.randomUUID()); + +/** Allows a burst of 10 login attempts per account, then one every 30s. */ +const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; + +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ +const router: Router = new Router({ prefix: "/api/sessions" }); + +router.post("/", async (ctx) => { + const body: unknown = await ctx.request.body.json(); + + if (!isLoginUserRequest(body)) { + throw new APIException(400, "Invalid request"); + } + + // Per account, so guessing one user's password is slow however many + // connections it comes from. + if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) { + throw new APIException( + 429, + "Too many login attempts — try again in a minute", + ); + } + + let user; + try { + user = getUserByUsername(body.username); + } catch { + user = undefined; + } + // An unknown username gets the same answer as a wrong password, after the + // same work, so neither the message nor the timing tells which exist. + const valid = await verifyPassword( + body.password, + user?.passwordHash ?? await UNKNOWN_USER_HASH, + ); + if (!user || !valid) { + throw new APIException(401, "Invalid username or password"); + } + + const responseBody: APIResponse = { + success: true, + data: { ...await issueTokens(user), user: toSelfUser(user) }, + }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 24fa25ee..e0b38e91 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -1,34 +1,51 @@ /** - * Serves dumps' thumbnails to the cards and pages that show a file dump, which - * build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are - * generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`. + * Serves and changes dumps' thumbnails, a sub-resource of their dump. The + * cards and pages that show a file dump build the URL with + * `dumpThumbnailUrl`, and the edit page uploads a custom one. Thumbnails made + * from an upload are generated with ffmpeg on first request and kept in + * `THUMBNAILS_DIR`. * - * - `GET /api/thumbnails/:dumpId?v=&token=` — public; a private dump's + * - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's * thumbnail needs its poster's media `token`. Serves the dump's custom * thumbnail, else one made from its upload: a still of a video, or a * scaled-down copy of an image (the image itself when none can be made); 404 * when there is neither. + * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with + * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 + * MB). + * - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with + * `dump:moderate`. Removes its custom thumbnail. * * @module */ import { Router } from "@oak/oak"; import { APIException } from "../lib/api-exception.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import type { Dump } from "../../shared/dump.ts"; import { dumpFileVersion, getDump, getDumpThumbnailInfo, + removeDumpThumbnail, + setDumpThumbnail, } from "../services/dump-service.ts"; import { serveFile } from "../lib/serve-file.ts"; -import { serveUploadedFile } from "../lib/upload.ts"; +import { + replaceUploadedFile, + serveUploadedFile, + validateImageUpload, +} from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; +import { can } from "../lib/permissions.ts"; +import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ -const router: Router = new Router({ prefix: "/api/thumbnails" }); +const router: Router = new Router({ prefix: "/api/dumps" }); /** * Spares a process per uncached thumbnail request: whether ffmpeg runs is @@ -169,7 +186,7 @@ async function ensureGenerated( return await pending.then(() => true, () => false); } -router.get("/:dumpId", async (ctx) => { +router.get("/:dumpId/thumbnail", async (ctx) => { const { dumpId } = ctx.params; // The id names files on disk: nothing but an id's characters gets through. @@ -253,4 +270,59 @@ router.get("/:dumpId", async (ctx) => { throw new APIException(404, "No thumbnail for this dump"); }); +/** + * Returns the dump `ctx` names if the caller may change it, as its poster or + * with `dump:moderate`. + * + * @throws {APIException} 404 when the caller can't see it, 403 when they may + * not change it. + */ +function editableDump(ctx: AuthContext<"/:dumpId/thumbnail">): Dump { + const userId = ctx.state.user.userId; + const dump = getDump(ctx.params.dumpId, userId); + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { + throw new APIException(403, "Not authorized to update dump"); + } + return dump; +} + +router.put( + "/:dumpId/thumbnail", + authMiddleware, + async (ctx: AuthContext<"/:dumpId/thumbnail">) => { + const dump = editableDump(ctx); + const formData = await ctx.request.body.formData(); + const file = formData.get("file"); + + if (!(file instanceof File)) { + throw new APIException(400, "Missing file field"); + } + + const data = new Uint8Array(await file.arrayBuffer()); + const mime = validateImageUpload(data); + + // Written before setDumpThumbnail announces it, or clients would fetch it + // too early. + await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data); + const responseBody: APIResponse = { + success: true, + data: setDumpThumbnail(dump.id, mime), + }; + ctx.response.body = responseBody; + }, +); + +router.delete( + "/:dumpId/thumbnail", + authMiddleware, + async (ctx: AuthContext<"/:dumpId/thumbnail">) => { + const dump = editableDump(ctx); + const responseBody: APIResponse = { + success: true, + data: await removeDumpThumbnail(dump.id), + }; + ctx.response.body = responseBody; + }, +); + export default router; diff --git a/api/routes/unsubscribe.ts b/api/routes/unsubscribe.ts new file mode 100644 index 00000000..dbc8b3ae --- /dev/null +++ b/api/routes/unsubscribe.ts @@ -0,0 +1,49 @@ +/** + * Turns a digest off from the link in its email, without signing in. Mail + * clients post here for a one-click unsubscribe (RFC 8058), and so does the + * client's `/unsubscribe` page. + * + * - `POST /api/unsubscribe?token=` — public, with a digest's unsubscribe + * token. Turns that digest off and answers its cadence. + * - `POST /api/users/unsubscribe?token=` — the same, at its v3 path, which the + * `List-Unsubscribe` header of every digest sent before v4 names. Those + * tokens never expire, so this path stays for good. + * + * @module + */ + +import { Router, type RouterMiddleware } from "@oak/oak"; + +import { type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { DIGEST_SETTING, type DigestCadence } from "../../shared/settings.ts"; +import { verifyUnsubscribeToken } from "../lib/jwt.ts"; +import { getUserById } from "../services/user-service.ts"; +import { updateSettings } from "../services/settings-service.ts"; + +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ +const router: Router = new Router(); + +// The body is ignored: a mail client's one-click post sends a form saying +// "List-Unsubscribe=One-Click", the client's page sends nothing. +const unsubscribe: RouterMiddleware = async (ctx) => { + const token = ctx.request.url.searchParams.get("token") ?? ""; + const payload = await verifyUnsubscribeToken(token); + if (!payload) throw new APIException(400, "Invalid unsubscribe link"); + // Throws a 404 once the account is gone. + getUserById(payload.userId); + updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false }); + const responseBody: APIResponse<{ cadence: DigestCadence }> = { + success: true, + data: { cadence: payload.cadence }, + }; + ctx.response.body = responseBody; +}; + +router.post("/api/unsubscribe", unsubscribe); +router.post("/api/users/unsubscribe", unsubscribe); + +export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index 75b4c060..121187f2 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -1,49 +1,37 @@ /** - * Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles, - * with what each user posted, upvoted, follows and invited. A profile sent to - * anyone but its user goes through `toPublicUser`, which drops the email. + * Serves sign-up and user profiles, with what each user posted, upvoted, made, + * follows and invited, and the dumps and playlists under them. A user's `:key` + * is their id or `@name`, a current or former name in any case + * (`resolveUserKey`). A profile sent to anyone goes through `toPublicUser`, + * which drops the email. The caller's own account is `routes/me.ts`'s. * - * - `POST /api/users/register` — public, with a valid invite. Creates an - * account, sends the welcome email, and answers the user and their tokens. - * - `POST /api/users/login` — public. Answers the user and their tokens; - * rate-limited per account. - * - `GET /api/users/me` — signed in. Answers the caller's own user. - * - `GET /api/users/me/media-token` — signed in. Answers a media token, for a - * session that predates them. - * - `POST /api/users/request-password-reset` — public. Emails a reset link to - * the account with that address, if any; always answers 200. - * - `POST /api/users/reset-password` — public, with a reset token. Sets a new - * password. - * - `POST /api/users/me/change-password` — signed in, with the current - * password. Sets a new one, ending every other session, and answers fresh - * tokens. - * - `PATCH /api/users/me` — signed in. Updates the caller's profile fields: - * description and email. - * - `GET /api/users/me/settings` — signed in. Answers the caller's settings. - * - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's - * settings, and answers them all. - * - `POST /api/users/unsubscribe?token=` — public, with a digest's unsubscribe - * token. Turns that digest off and answers its cadence. Mail clients post - * here for a one-click unsubscribe (RFC 8058), and so does the client's - * `/unsubscribe` page. - * - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another - * user's role. - * - `GET /api/users/search?q=` — public. Answers up to 8 users whose name - * starts with `q`, for @mention suggestions. - * - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id. - * - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a - * page of the users they follow. - * - `GET /api/users/:username/followed-playlists?page=&limit=` — public. - * Answers a page of the public playlists they follow. - * - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in. - * Answers a page of their playlists, the private ones only to themselves. - * - `GET /api/users/:username/invitees` — public. Answers the tree of users - * they invited. - * - `GET /api/users/:username` — public. Answers a user's profile. - * - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a + * - `POST /api/users` — public, with a valid invite. Creates an account, sends + * the welcome email, and answers the user and their tokens. + * - `GET /api/users?q=` — public. Answers up to 8 users whose name starts + * with `q`, for @mention suggestions. + * - `GET /api/users/:key` — public. Answers a user's profile. + * - `GET /api/users/:key/dumps?page=&limit=` — optional sign-in. Answers a * page of their dumps, the private ones only to themselves. - * - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a + * - `GET /api/users/:key/dumps/:slug` — optional sign-in. Answers their dump + * whose slug is or was `slug`, with its current slug; a private one 404s + * but for its poster. + * - `GET /api/users/:key/upvoted?page=&limit=` — optional sign-in. Answers a * page of the dumps they upvoted, private ones only to themselves. + * - `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/: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. + * - `GET /api/users/:key/follow/users?page=&limit=` — public. Answers a page of + * the users they follow. + * - `GET /api/users/:key/follow/playlists?page=&limit=` — public. Answers a + * page of the public playlists they follow. + * - `GET /api/users/:key/invitees` — public. Answers the tree of users they + * invited. + * - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another + * user's role. + * - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a + * user, with no rate limit. * * @module */ @@ -54,32 +42,20 @@ import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { type AuthResponse, - type AuthTokens, type Invitee, isRole, - type MediaTokenResponse, type PublicUser, - type SelfUser, type UserSuggestion, } from "../../shared/user.ts"; import type { Dump } from "../../shared/dump.ts"; +import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts"; import { fieldOf, - isLoginUserRequest, isRegisterUserRequest, - isUpdateUserRequest, validateRegisterUserRequest, } from "../validators/user.ts"; -import type { Playlist } from "../../shared/playlist.ts"; -import { toPublicUser, toSelfUser, type User } from "../model/user.ts"; - -import { - createJWT, - createMediaToken, - verifyUnsubscribeToken, -} from "../lib/jwt.ts"; -import { hashPassword, verifyPassword } from "../lib/password.ts"; -import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; +import { toPublicUser, toSelfUser } from "../model/user.ts"; +import { issueTokens } from "../lib/jwt.ts"; import { type AuthContext, authMiddleware, @@ -90,64 +66,39 @@ import { parsePagination } from "../lib/pagination.ts"; import { createUser, getInviteTree, - getUserById, - getUserByUsername, + renameUser, + resolveUserKey, searchUsers, sendWelcomeEmail, updateUser, } from "../services/user-service.ts"; import { claimInvite, releaseInvite } from "../services/invite-service.ts"; import { - requestPasswordReset, - resetPassword, -} from "../services/password-reset-service.ts"; -import { VALIDATION } from "../../shared/validation.ts"; -import { - DIGEST_SETTING, - type DigestCadence, - type UserSettings, -} from "../../shared/settings.ts"; -import { isUpdateSettingsRequest } from "../validators/settings.ts"; -import { getSettings, updateSettings } from "../services/settings-service.ts"; -import { broadcastUserUpdated } from "../services/ws-service.ts"; + broadcastUserUpdated, + updateClientUsername, +} from "../services/ws-service.ts"; import { + getDumpByPath, getDumpsByUser, getVotedDumpsByUser, } from "../services/dump-service.ts"; -import { listPlaylistsByUser } from "../services/playlist-service.ts"; +import { + getPlaylist, + getPlaylistByPath, + listPlaylistsByUser, +} from "../services/playlist-service.ts"; import { getFollowedPlaylistsByUser, getFollowedUsersByUser, } from "../services/follow-service.ts"; -// Hashed once, at startup, for the sign-ins of usernames that don't exist. -const UNKNOWN_USER_HASH: Promise = hashPassword(crypto.randomUUID()); - /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ const router: Router = new Router({ prefix: "/api/users" }); -/** Allows a burst of 10 login attempts per account, then one every 30s. */ -const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; - -/** - * Returns a new session token and media token for `user`, as sign-up, sign-in - * and a password change answer them. - */ -async function issueTokens(user: User): Promise { - return { - token: await createJWT({ - userId: user.id, - username: user.username, - role: user.role, - }), - mediaToken: await createMediaToken(user.id), - }; -} - -router.post("/register", async (ctx) => { +router.post("/", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isRegisterUserRequest(body)) { @@ -179,208 +130,7 @@ router.post("/register", async (ctx) => { ctx.response.body = responseBody; }); -router.post("/login", async (ctx) => { - const body: unknown = await ctx.request.body.json(); - - if (!isLoginUserRequest(body)) { - throw new APIException(400, "Invalid request"); - } - - // Per account, so guessing one user's password is slow however many - // connections it comes from. - if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) { - throw new APIException( - 429, - "Too many login attempts — try again in a minute", - ); - } - - let user; - try { - user = getUserByUsername(body.username); - } catch { - user = undefined; - } - // An unknown username gets the same answer as a wrong password, after the - // same work, so neither the message nor the timing tells which exist. - const valid = await verifyPassword( - body.password, - user?.passwordHash ?? await UNKNOWN_USER_HASH, - ); - if (!user || !valid) { - throw new APIException(401, "Invalid username or password"); - } - - const responseBody: APIResponse = { - success: true, - data: { ...await issueTokens(user), user: toSelfUser(user) }, - }; - ctx.response.body = responseBody; -}); - -router.get("/me", authMiddleware, (ctx: AuthContext) => { - const responseBody: APIResponse = { - success: true, - data: toSelfUser(getUserById(ctx.state.user.userId)), - }; - ctx.response.body = responseBody; -}); - -router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { - const responseBody: APIResponse = { - success: true, - data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, - }; - ctx.response.body = responseBody; -}); - -// Answers the same whatever happens, so it can't tell which addresses have an -// account. -router.post("/request-password-reset", async (ctx) => { - const body: unknown = await ctx.request.body.json(); - const email = fieldOf(body, "email"); - const trimmed = typeof email === "string" ? email.trim() : ""; - if (trimmed) { - await requestPasswordReset(trimmed).catch((err) => - console.error("[request-password-reset]", err) - ); - } - const responseBody: APIResponse = { success: true, data: null }; - ctx.response.body = responseBody; -}); - -router.post("/reset-password", async (ctx) => { - const body: unknown = await ctx.request.body.json(); - const token = fieldOf(body, "token"); - const newPassword = fieldOf(body, "newPassword"); - if (typeof token !== "string" || typeof newPassword !== "string") { - throw new APIException(400, "Invalid request"); - } - await resetPassword(token, newPassword); - const responseBody: APIResponse = { success: true, data: null }; - ctx.response.body = responseBody; -}); - -router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { - const body: unknown = await ctx.request.body.json(); - const currentPassword = fieldOf(body, "currentPassword"); - const newPassword = fieldOf(body, "newPassword"); - - if (typeof currentPassword !== "string" || typeof newPassword !== "string") { - throw new APIException(400, "Invalid request"); - } - - if ( - newPassword.length < VALIDATION.PASSWORD_MIN || - newPassword.length > VALIDATION.PASSWORD_MAX - ) { - throw new APIException( - 400, - `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, - ); - } - - const user = getUserById(ctx.state.user.userId); - const valid = await verifyPassword(currentPassword, user.passwordHash); - if (!valid) { - throw new APIException(403, "Current password is incorrect"); - } - - // Changing the password revokes every token issued before it, this - // session's included, so hand back fresh ones for the client to keep. - const updated = await updateUser(ctx.state.user.userId, { - password: newPassword, - }); - const responseBody: APIResponse = { - success: true, - data: await issueTokens(updated), - }; - ctx.response.body = responseBody; -}); - -router.patch("/me", authMiddleware, async (ctx: AuthContext) => { - const body: unknown = await ctx.request.body.json(); - if (!isUpdateUserRequest(body)) { - throw new APIException(400, "Invalid request"); - } - // Only the profile fields: a stolen token alone must not change the role, - // the password (which needs the current one) or the username (which is - // fixed). - const updated = await updateUser(ctx.state.user.userId, { - description: body.description, - email: body.email, - }); - const publicUser = toPublicUser(updated); - broadcastUserUpdated(publicUser); - const responseBody: APIResponse = { - success: true, - data: publicUser, - }; - ctx.response.body = responseBody; -}); - -router.get("/me/settings", authMiddleware, (ctx: AuthContext) => { - const responseBody: APIResponse = { - success: true, - data: getSettings(ctx.state.user.userId), - }; - ctx.response.body = responseBody; -}); - -router.patch("/me/settings", authMiddleware, async (ctx: AuthContext) => { - const body: unknown = await ctx.request.body.json(); - if (!isUpdateSettingsRequest(body)) { - throw new APIException(400, "Invalid settings"); - } - const responseBody: APIResponse = { - success: true, - data: updateSettings(ctx.state.user.userId, body), - }; - ctx.response.body = responseBody; -}); - -// The body is ignored: a mail client's one-click post sends a form saying -// "List-Unsubscribe=One-Click", the client's page sends nothing. -router.post("/unsubscribe", async (ctx) => { - const token = ctx.request.url.searchParams.get("token") ?? ""; - const payload = await verifyUnsubscribeToken(token); - if (!payload) throw new APIException(400, "Invalid unsubscribe link"); - // Throws a 404 once the account is gone. - getUserById(payload.userId); - updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false }); - const responseBody: APIResponse<{ cadence: DigestCadence }> = { - success: true, - data: { cadence: payload.cadence }, - }; - ctx.response.body = responseBody; -}); - -router.patch( - "/:userId/role", - authMiddleware, - requirePermission("user:manage"), - async (ctx: AuthContext<"/:userId/role">) => { - const body: unknown = await ctx.request.body.json(); - const role = fieldOf(body, "role"); - if (!isRole(role)) { - throw new APIException(400, "Invalid role"); - } - // So that an admin can't demote themselves by mistake and leave no admin. - if (ctx.params.userId === ctx.state.user.userId) { - throw new APIException(400, "Cannot change your own role"); - } - const updated = await updateUser(ctx.params.userId, { role }); - const publicUser = toPublicUser(updated); - broadcastUserUpdated(publicUser); - const responseBody: APIResponse = { - success: true, - data: publicUser, - }; - ctx.response.body = responseBody; - }, -); - -router.get("/search", (ctx) => { +router.get("/", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const responseBody: APIResponse = { success: true, @@ -389,75 +139,16 @@ router.get("/search", (ctx) => { ctx.response.body = responseBody; }); -router.get("/by-id/:userId", (ctx) => { +router.get("/:key", (ctx) => { const responseBody: APIResponse = { success: true, - data: toPublicUser(getUserById(ctx.params.userId)), + data: toPublicUser(resolveUserKey(ctx.params.key)), }; ctx.response.body = responseBody; }); -router.get("/:username/followed-users", (ctx) => { - const user = getUserByUsername(ctx.params.username); - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = getFollowedUsersByUser(user.id, page, limit); - const responseBody: APIResponse> = { - success: true, - data: { - items: items.map(toPublicUser), - hasMore, - }, - }; - ctx.response.body = responseBody; -}); - -router.get("/:username/followed-playlists", (ctx) => { - const user = getUserByUsername(ctx.params.username); - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit); - const responseBody: APIResponse> = { - success: true, - data: { items, hasMore }, - }; - ctx.response.body = responseBody; -}); - -router.get("/:username/playlists", async (ctx) => { - const user = getUserByUsername(ctx.params.username); - const requestingUserId = await parseOptionalAuth(ctx); - const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, hasMore } = listPlaylistsByUser( - user.id, - requestingUserId, - page, - limit, - ); - const responseBody: APIResponse> = { - success: true, - data: { items, hasMore }, - }; - ctx.response.body = responseBody; -}); - -router.get("/:username/invitees", (ctx) => { - const user = getUserByUsername(ctx.params.username); - const responseBody: APIResponse = { - success: true, - data: getInviteTree(user.id), - }; - ctx.response.body = responseBody; -}); - -router.get("/:username", (ctx) => { - const responseBody: APIResponse = { - success: true, - data: toPublicUser(getUserByUsername(ctx.params.username)), - }; - ctx.response.body = responseBody; -}); - -router.get("/:username/dumps", async (ctx) => { - const user = getUserByUsername(ctx.params.username); +router.get("/:key/dumps", async (ctx) => { + const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const includePrivate = requestingUserId === user.id; @@ -474,8 +165,18 @@ router.get("/:username/dumps", async (ctx) => { ctx.response.body = responseBody; }); -router.get("/:username/votes", async (ctx) => { - const user = getUserByUsername(ctx.params.username); +router.get("/:key/dumps/:slug", async (ctx) => { + const user = resolveUserKey(ctx.params.key); + const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; + const responseBody: APIResponse = { + success: true, + data: getDumpByPath(user.id, ctx.params.slug, requestingUserId), + }; + ctx.response.body = responseBody; +}); + +router.get("/:key/upvoted", async (ctx) => { + const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, hasMore } = getVotedDumpsByUser( @@ -491,4 +192,116 @@ router.get("/:username/votes", async (ctx) => { ctx.response.body = responseBody; }); +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 responseBody: APIResponse> = { + success: true, + data: { items, hasMore }, + }; + ctx.response.body = responseBody; +}); + +router.get("/:key/playlists/:slug", async (ctx) => { + const user = resolveUserKey(ctx.params.key); + const requestingUserId = await parseOptionalAuth(ctx); + const responseBody: APIResponse = { + success: true, + data: getPlaylist( + getPlaylistByPath(user.id, ctx.params.slug), + requestingUserId, + ), + }; + ctx.response.body = responseBody; +}); + +router.get("/:key/follow/users", (ctx) => { + const user = resolveUserKey(ctx.params.key); + const { page, limit } = parsePagination(ctx.request.url.searchParams); + const { items, hasMore } = getFollowedUsersByUser(user.id, page, limit); + const responseBody: APIResponse> = { + success: true, + data: { + items: items.map(toPublicUser), + hasMore, + }, + }; + ctx.response.body = responseBody; +}); + +router.get("/:key/follow/playlists", (ctx) => { + const user = resolveUserKey(ctx.params.key); + const { page, limit } = parsePagination(ctx.request.url.searchParams); + const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit); + const responseBody: APIResponse> = { + success: true, + data: { items, hasMore }, + }; + ctx.response.body = responseBody; +}); + +router.get("/:key/invitees", (ctx) => { + const user = resolveUserKey(ctx.params.key); + const responseBody: APIResponse = { + success: true, + data: getInviteTree(user.id), + }; + ctx.response.body = responseBody; +}); + +router.patch( + "/:key/role", + authMiddleware, + requirePermission("user:manage"), + async (ctx: AuthContext<"/:key/role">) => { + const body: unknown = await ctx.request.body.json(); + const role = fieldOf(body, "role"); + if (!isRole(role)) { + throw new APIException(400, "Invalid role"); + } + const user = resolveUserKey(ctx.params.key); + // So that an admin can't demote themselves by mistake and leave no admin. + if (user.id === ctx.state.user.userId) { + throw new APIException(400, "Cannot change your own role"); + } + const updated = await updateUser(user.id, { role }); + const publicUser = toPublicUser(updated); + broadcastUserUpdated(publicUser); + const responseBody: APIResponse = { + success: true, + data: publicUser, + }; + ctx.response.body = responseBody; + }, +); + +router.patch( + "/:key/username", + authMiddleware, + requirePermission("user:manage"), + async (ctx: AuthContext<"/:key/username">) => { + const body: unknown = await ctx.request.body.json(); + const username = fieldOf(body, "username"); + if (typeof username !== "string") { + throw new APIException(400, "Invalid request"); + } + const user = resolveUserKey(ctx.params.key); + const publicUser = toPublicUser(renameUser(user.id, username, true)); + updateClientUsername(publicUser.id, publicUser.username); + broadcastUserUpdated(publicUser); + const responseBody: APIResponse = { + success: true, + data: publicUser, + }; + ctx.response.body = responseBody; + }, +); + export default router; diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts deleted file mode 100644 index b9c234a8..00000000 --- a/api/routes/youtube.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Serves the titles and lengths of a YouTube playlist's entries, which the - * global player lists beside the embed. On any failure it shows the embed - * without a tracklist. - * - * - `GET /api/youtube/playlist?list=` — public. Answers the playlist's - * tracklist. - * - * @module - */ - -import { Router } from "@oak/oak"; -import type { APIResponse } from "../../shared/api.ts"; -import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; -import type { YouTubePlaylist } from "../../shared/player.ts"; - -/** - * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. - * An `APIException` thrown by a handler becomes the JSON error response there. - */ -const youtubeRouter: Router = new Router(); - -// Public, like /api/bandcamp/tracks: it only reads a public page. -youtubeRouter.get("/api/youtube/playlist", async (ctx) => { - const listId = ctx.request.url.searchParams.get("list") ?? ""; - const responseBody: APIResponse = { - success: true, - data: await resolveYouTubePlaylist(listId), - }; - - ctx.response.headers.set("Cache-Control", "public, max-age=3600"); - ctx.response.body = responseBody; -}); - -export default youtubeRouter; diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index e3c40337..ebd41f69 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -13,7 +13,7 @@ import { dumpRowToApi, isDumpRow, } from "../model/dump.ts"; -import { UUID_RE } from "../lib/slugify.ts"; +import { parsePagePath } from "../../shared/paths.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { parseRows } from "../model/rows.ts"; @@ -24,25 +24,34 @@ import { parseRows } from "../model/rows.ts"; const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; /** - * Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what - * follows it, so an `/edit` or `#comment-x` link counts too. The host isn't - * checked, since the site's public URL differs between setups. + * Returns the id of the dump whose page, or its editor, `pathname` is, by its + * owner's name and its slug, current or former; null when it is no dump's. + * The host isn't checked, since the site's public URL differs between setups; + * a query or a fragment (`#comment-x`) isn't in a pathname anyway. */ -const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; - -/** Returns the id of the dump with id or slug `idOrSlug`, or null. */ -function resolveDumpPathId(idOrSlug: string): string | null { - const row = UUID_RE.test(idOrSlug) - ? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) - : db.prepare(`SELECT id FROM dumps WHERE slug = ?;`).get(idOrSlug); - return (row as { id: string } | undefined)?.id ?? null; +function resolveDumpPathId(pathname: string): string | null { + const page = parsePagePath(pathname); + if (page?.kind !== "dump") return null; + const row = db.prepare( + `SELECT d.id FROM dumps d + JOIN users u ON u.id = d.user_id + LEFT JOIN user_aliases ua ON ua.user_id = u.id + WHERE (lower(u.username) = lower(:name) OR lower(ua.username) = lower(:name)) + AND (d.slug = :slug OR d.id IN (SELECT dump_id FROM dump_slug_aliases + WHERE user_id = u.id AND slug = :slug)) + LIMIT 1;`, + ).get({ name: page.username, slug: page.slug }) as + | { id: string } + | undefined; + return row?.id ?? null; } /** * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * of its comments (`comment`), with the links its current `text` holds. A link - * counts when it is a dump's page on this site, or when it is the URL a dump - * was posted with, compared by `canonicalizeUrl` as reposts are. The dump and + * counts when it is a dump's page on this site (`/@owner/dumps/`), or + * when it is the URL a dump was posted with, compared by `canonicalizeUrl` as + * reposts are. The dump and * comment services call it on every save, and with an empty `text` when a * comment is deleted. */ @@ -62,11 +71,10 @@ export function relinkBacklinks( } catch { continue; } - const pathMatch = DUMP_PATH_RE.exec(parsed.pathname); - if (pathMatch) { - const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); - if (resolved) targetIds.add(resolved); - } else { + const resolved = resolveDumpPathId(parsed.pathname); + if (resolved) { + targetIds.add(resolved); + } else if (parsePagePath(parsed.pathname)?.kind !== "dump") { const canonical = canonicalizeUrl(match[0]); if (canonical) externalUrls.add(canonical); } diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 80c1f99d..e6608de3 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -21,7 +21,8 @@ import { isDumpRow, } from "../model/dump.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; -import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; +import { isReservedSlug } from "../lib/reserved-slugs.ts"; +import { SLUG_RE } from "../../shared/slug.ts"; import { VALIDATION } from "../../shared/validation.ts"; import { parseRows } from "../model/rows.ts"; @@ -30,7 +31,7 @@ import { parseRows } from "../model/rows.ts"; * with a message for each case. */ function validateSlug(slug: string): void { - if (!isValidSlugFormat(slug)) { + if (!SLUG_RE.test(slug)) { throw new APIException( 400, "Slug must be lowercase letters, numbers and hyphens", @@ -91,7 +92,7 @@ export function getCategoryById(id: string): Category { } /** - * Returns the category at `slug`, for `GET /api/categories/:slug`. + * Returns the category at `slug`, for {@link listDumpsByCategory}. * * @throws {APIException} 404 when there is no such category. */ @@ -291,7 +292,7 @@ export function attachCategoryIds(dumps: Dump[]): void { /** * Returns a page of the dumps in the category at `slug`, newest first, for - * `GET /api/categories/:slug/dumps`. Private dumps are left out, except + * `GET /api/dumps?category=`. Private dumps are left out, except * `requestingUserId`'s own. * * @throws {APIException} 404 when there is no such category. diff --git a/api/services/digest-service.ts b/api/services/digest-service.ts index 2b2aefad..f1c01ac9 100644 --- a/api/services/digest-service.ts +++ b/api/services/digest-service.ts @@ -22,7 +22,12 @@ import { type DigestCadence, } from "../../shared/settings.ts"; import { SETTINGS_COLUMNS } from "../model/settings.ts"; -import type { NotificationType } from "../../shared/notification.ts"; +import type { + DumpRef, + NotificationType, + PlaylistRef, +} from "../../shared/notification.ts"; +import { dumpPath, feedPath, playlistPath } from "../../shared/paths.ts"; import { type ActivityKind, type DigestContent, @@ -35,6 +40,7 @@ import { createUnsubscribeToken } from "../lib/jwt.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { getSettings } from "./settings-service.ts"; import { dumpFileVersion } from "./dump-service.ts"; +import { withCurrentRefs } from "./notification-service.ts"; const DAY_MS = 86_400_000; /** How long each cadence's period lasts. */ @@ -67,11 +73,6 @@ interface Window { to: string; } -const dumpUrl = (row: { id: string; slug: string | null }) => - `${FRONTEND_URL}/dumps/${encodeURIComponent(row.slug ?? row.id)}`; -const playlistUrl = (row: { id: string; slug: string | null }) => - `${FRONTEND_URL}/playlists/${encodeURIComponent(row.slug ?? row.id)}`; - /** Flattens Markdown into a short line of text for a quote. */ function quoteOf(markdown: string | undefined | null): string | undefined { if (!markdown) return undefined; @@ -95,7 +96,7 @@ const DUMP_COLS = `d.id, d.slug, d.title, d.kind, d.url, d.user_id, interface DumpRow { id: string; - slug: string | null; + slug: string; title: string; kind: string; url: string | null; @@ -126,7 +127,7 @@ function thumbnailOf(row: DumpRow): string | null { createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, }); - return `${PUBLIC_URL}/api/thumbnails/${row.id}?v=${version}`; + return `${PUBLIC_URL}/api/dumps/${row.id}/thumbnail?v=${version}`; } let thumbnailUrl: unknown; try { @@ -139,7 +140,7 @@ function thumbnailOf(row: DumpRow): string | null { if (typeof thumbnailUrl !== "string") return null; if (thumbnailUrl.startsWith("https://")) return thumbnailUrl; if (thumbnailUrl.startsWith("http://")) { - return `${PUBLIC_URL}/api/proxy-image?url=${ + return `${PUBLIC_URL}/api/remote/image?url=${ encodeURIComponent(thumbnailUrl) }`; } @@ -157,7 +158,9 @@ function toDigestDump(row: DumpRow, readerId: string): DigestDump { } return { title: row.title, - url: dumpUrl(row), + url: `${FRONTEND_URL}${ + dumpPath({ ownerUsername: row.poster, slug: row.slug }) + }`, poster: row.poster, domain, thumbnail: thumbnailOf(row), @@ -205,7 +208,7 @@ function activityOf( }[]; const highlights = rows.flatMap((row) => { try { - const h = highlightOf(row.type, JSON.parse(row.data)); + const h = highlightOf(row.type, withCurrentRefs(JSON.parse(row.data))); return h ? [{ ...h, at: new Date(row.created_at) }] : []; } catch { return []; @@ -219,24 +222,35 @@ function activityOf( const commentBody = (id: string) => (db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`) .get(id) as { body: string } | undefined)?.body; -const dumpTarget = (id: string, fallbackTitle: string) => { - const row = db.prepare(`SELECT id, slug, title FROM dumps WHERE id = ?;`) - .get(id) as { id: string; slug: string | null; title: string } | undefined; - return row ? { title: row.title, url: dumpUrl(row) } : { - title: fallbackTitle, - url: `${FRONTEND_URL}/dumps/${encodeURIComponent(id)}`, - }; -}; -const playlistTarget = (id: string, fallbackTitle: string) => { - const row = db.prepare(`SELECT id, slug, title FROM playlists WHERE id = ?;`) - .get(id) as { id: string; slug: string | null; title: string } | undefined; - return row ? { title: row.title, url: playlistUrl(row) } : { - title: fallbackTitle, - url: `${FRONTEND_URL}/playlists/${encodeURIComponent(id)}`, - }; -}; +/** + * Returns a highlight's dump, titled `title`, linked where the notification's + * joined ref says it is now, or unlinked once it is deleted. + */ +const dumpTarget = (ref: DumpRef, title: string) => ({ + title, + url: ref.dumpSlug && ref.dumpOwnerUsername + ? `${FRONTEND_URL}${ + dumpPath({ ownerUsername: ref.dumpOwnerUsername, slug: ref.dumpSlug }) + }` + : null, +}); +/** Returns a highlight's playlist as {@link dumpTarget} does its dump. */ +const playlistTarget = (ref: PlaylistRef, title: string) => ({ + title, + url: ref.playlistSlug && ref.playlistOwnerUsername + ? `${FRONTEND_URL}${ + playlistPath({ + ownerUsername: ref.playlistOwnerUsername, + slug: ref.playlistSlug, + }) + }` + : null, +}); -/** Returns the highlight a notification makes, or null for another kind. */ +/** + * Returns the highlight a notification makes, from its data with the refs + * joined, or null for another kind. + */ function highlightOf( type: NotificationType, // deno-lint-ignore no-explicit-any @@ -249,20 +263,20 @@ function highlightOf( return { kind: "mentionComment", who: data.mentionerUsername, - target: dumpTarget(data.dumpId, data.contextTitle), + target: dumpTarget(data, data.contextTitle), quote: quoteOf(commentBody(data.contextId)), }; case "dump": return { kind: "mentionDump", who: data.mentionerUsername, - target: dumpTarget(data.contextId, data.contextTitle), + target: dumpTarget(data, data.contextTitle), }; case "playlist": return { kind: "mentionPlaylist", who: data.mentionerUsername, - target: playlistTarget(data.contextId, data.contextTitle), + target: playlistTarget(data, data.contextTitle), }; case "chat": return { @@ -279,14 +293,14 @@ function highlightOf( return { kind: "comment", who: data.commenterUsername, - target: dumpTarget(data.dumpId, data.dumpTitle), + target: dumpTarget(data, data.dumpTitle), quote: quoteOf(commentBody(data.commentId)), }; case "playlist_followed": return { kind: "playlistFollow", who: data.followerUsername, - target: playlistTarget(data.playlistId, data.playlistTitle), + target: playlistTarget(data, data.playlistTitle), }; case "user_followed": return { kind: "userFollow", who: data.followerUsername }; @@ -294,7 +308,7 @@ function highlightOf( return { kind: "commentLike", who: data.likerUsername, - target: dumpTarget(data.dumpId, data.dumpTitle), + target: dumpTarget(data, data.dumpTitle), quote: quoteOf(commentBody(data.commentId)), }; } @@ -393,7 +407,7 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] { ORDER BY dumps DESC, p.created_at DESC;`, ).all(w.from, w.to) as { id: string; - slug: string | null; + slug: string; title: string; owner: string; dumps: number; @@ -432,7 +446,9 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] { membersMore: Math.max(0, members.length - MEMBERS_MAX), playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({ title: p.title, - url: playlistUrl(p), + url: `${FRONTEND_URL}${ + playlistPath({ ownerUsername: p.owner, slug: p.slug }) + }`, owner: p.owner, dumps: p.dumps, })), @@ -473,11 +489,9 @@ export function buildDigest( community, links: { notifications: `${FRONTEND_URL}/notifications`, - followed: `${FRONTEND_URL}/~/followed`, + followed: `${FRONTEND_URL}${feedPath(undefined, "follow")}`, chat: `${FRONTEND_URL}/?chat=open`, - settings: `${FRONTEND_URL}/users/${ - encodeURIComponent(reader) - }/~/settings`, + settings: `${FRONTEND_URL}/settings`, unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${ encodeURIComponent(token) }`, @@ -567,7 +581,7 @@ export async function sendDueDigests(now: Date = new Date()): Promise { to: row.email, ...email, headers: { - "List-Unsubscribe": `<${PUBLIC_URL}/api/users/unsubscribe?token=${ + "List-Unsubscribe": `<${PUBLIC_URL}/api/unsubscribe?token=${ encodeURIComponent(token) }>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index f4383753..d29799cc 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -1,7 +1,8 @@ /** * Posts, edits and deletes dumps, and reads them as each viewer may see them. * `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail - * routes to read dumps. It broadcasts the changes to public dumps over the + * routes to read dumps. A dump's slug is made from its title when it is + * posted, and changes only when an edit sends a new one (`slug-service.ts`). It broadcasts the changes to public dumps over the * WebSocket. * * The functions that change a dump don't check who asks: the routes do first. @@ -35,7 +36,7 @@ import { notifyMentions, notifyUserFollowersNewDump, } from "./notification-service.ts"; -import { makeSlug, UUID_RE } from "../lib/slugify.ts"; +import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { DUMP_ALLOWED_MIME_PREFIXES, @@ -116,10 +117,10 @@ export async function createUrlDump( const title = request.title?.trim() || richContent?.title || titleFromUrl(request.url); const isPrivate = request.isPrivate ?? false; - const slug = makeSlug(title, dumpId); const categoryIds = [...new Set(request.categoryIds ?? [])]; transaction(() => { + const slug = newSlug("dump", userId, title); db.prepare( `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, @@ -139,21 +140,7 @@ export async function createUrlDump( setDumpCategories(dumpId, categoryIds); }); - const dump: Dump = { - id: dumpId, - kind: "url", - title, - slug, - comment: request.comment, - userId, - createdAt, - url: request.url, - richContent, - voteCount: 0, - commentCount: 0, - isPrivate, - categoryIds, - }; + const dump: Dump = { ...fetchDump(dumpId), categoryIds }; broadcastDumpChange(null, dump); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title); if (request.comment) { @@ -195,7 +182,6 @@ export async function createFileDump( const dumpId = crypto.randomUUID(); const createdAt = new Date(); const finalTitle = title?.trim() || file.name; - const slug = makeSlug(finalTitle, dumpId); const dedupedCategoryIds = [...new Set(categoryIds)]; await Deno.mkdir(DUMPS_DIR, { recursive: true }); @@ -205,6 +191,7 @@ export async function createFileDump( await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream()); transaction(() => { + const slug = newSlug("dump", userId, finalTitle); db.prepare( `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, @@ -229,22 +216,7 @@ export async function createFileDump( throw err; } - const dump: Dump = { - id: dumpId, - kind: "file", - title: finalTitle, - slug, - comment, - userId, - createdAt, - fileName: file.name, - fileMime: file.type, - fileSize: file.size, - voteCount: 0, - commentCount: 0, - isPrivate, - categoryIds: dedupedCategoryIds, - }; + const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds }; broadcastDumpChange(null, dump); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle); if (comment) { @@ -286,15 +258,12 @@ async function removeGeneratedThumbnails(dumpId: string): Promise { } /** - * Returns the dump with id or slug `idOrSlug`, without its categories, or - * throws a 404. It doesn't check privacy: the routes do, with {@link getDump}. + * Returns dump `dumpId`, without its categories, or throws a 404. It doesn't + * check privacy: the routes do, with {@link getDump}. */ -function fetchDump(idOrSlug: string): Dump { - const row = UUID_RE.test(idOrSlug) - ? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug) - : db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get( - idOrSlug, - ); +function fetchDump(dumpId: string): Dump { + const row = db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`) + .get(dumpId); if (!row || !isDumpRow(row)) { throw new APIException(404, "Dump not found"); } @@ -302,8 +271,8 @@ function fetchDump(idOrSlug: string): Dump { } /** - * Returns a dump, by id or slug, as `requestingUserId` may see it. The routes - * resolve every `:dumpId` with it before acting on the dump. + * Returns dump `dumpId` as `requestingUserId` may see it. The routes resolve + * every `:dumpId` with it before acting on the dump. * * @throws {APIException} 404 when there is no such dump, or it is private and * not theirs. @@ -317,6 +286,25 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { return dump; } +/** + * Returns the dump of `userId`'s whose slug is or was `slug`, as + * `requestingUserId` may see it, for `GET /api/users/:key/dumps/:slug`, which + * reads a dump by its page's path. Reached by a former slug, it comes with + * its current one, which the client puts in its URL. + * + * @throws {APIException} 404 when there is no such dump, or it is private and + * not theirs. + */ +export function getDumpByPath( + userId: string, + slug: string, + requestingUserId?: string, +): Dump { + const dumpId = resolveSlug("dump", userId, slug); + if (!dumpId) throw new APIException(404, "Dump not found"); + return getDump(dumpId, requestingUserId); +} + /** * Returns a page of the dumps `requestingUserId` may see whose title, note, or * previewed title or description contains `query`, newest first, with how many @@ -391,7 +379,7 @@ export function listDumps( /** * Returns the dumps `requestingUserId` may see that were posted with the same * link as `url`, newest first, for the create form's duplicate notice - * (`GET /api/dumps/by-url`). Links are compared in their canonical form, so + * (`GET /api/dumps?url=`). Links are compared in their canonical form, so * tracking parameters, the scheme, `www.` or a YouTube share format don't hide * a repost. */ @@ -413,7 +401,7 @@ export function findDumpsByUrl( LIMIT ?;`, ).all(canonical, requestingUserId ?? null, limit) as { id: string; - slug: string | null; + slug: string; title: string; created_at: string; vote_count: number; @@ -423,9 +411,9 @@ export function findDumpsByUrl( return rows.map((row) => ({ id: row.id, - slug: row.slug ?? undefined, + slug: row.slug, title: row.title, - username: row.username, + ownerUsername: row.username, createdAt: new Date(row.created_at), voteCount: row.vote_count, commentCount: row.comment_count, @@ -435,27 +423,38 @@ export function findDumpsByUrl( /** * Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its * preview fetched and gives the dump its page's title, unless `request` sets - * one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a + * one. A file dump ignores `url`. A new `slug` goes through `changeSlug`, + * which `exempt` (a moderator) frees from its rate limit; a new title leaves + * the slug as it is. Clients get the dump as `dump_updated`, or a * `dump_deleted` when it turns private and a `dump_created` when it turns * public. Anyone @mentioned in the note is notified. * * @throws {APIException} 404 when there is no such dump, 400 when a new URL - * isn't a public http(s) URL or a category doesn't exist. + * isn't a public http(s) URL, a category doesn't exist or the slug isn't + * valid, 409 when the slug is taken, 429 when it changed too often lately. */ export async function updateDump( dumpId: string, request: UpdateDumpRequest, + exempt = false, ): Promise { const dump = fetchDump(dumpId); if (request.categoryIds !== undefined) { assertCategoriesExist(request.categoryIds); } + if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) { + throw new APIException(400, "Invalid URL"); + } + // First, so that a refused slug leaves the rest of the dump unchanged. + if (request.slug !== undefined) { + changeSlug("dump", dump, request.slug, exempt); + } + const slug = request.slug ?? dump.slug; const now = new Date(); if (dump.kind === "file") { const title = request.title?.trim() || dump.title; - const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; const updatedDump: Dump = { ...dump, title, @@ -469,10 +468,9 @@ export async function updateDump( updatedAt: now, }; db.prepare( - `UPDATE dumps SET title = ?, slug = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + `UPDATE dumps SET title = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, ).run( updatedDump.title, - updatedDump.slug ?? null, updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, now.toISOString(), @@ -504,10 +502,6 @@ export async function updateDump( const newUrl = request.url ?? dump.url!; - if (!isValidHttpUrl(newUrl)) { - throw new APIException(400, "Invalid URL"); - } - let { richContent, title } = dump; if (newUrl !== dump.url) { @@ -519,11 +513,10 @@ export async function updateDump( title = request.title.trim(); } - const newSlug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; const updatedDump: Dump = { ...dump, title, - slug: newSlug, + slug, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, @@ -537,10 +530,9 @@ export async function updateDump( const row = dumpApiToRow(updatedDump); const result = db.prepare( - `UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + `UPDATE dumps SET title = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`, ).run( row.title, - row.slug, row.comment, row.url, row.url ? canonicalizeUrl(row.url) : null, @@ -581,14 +573,17 @@ export async function updateDump( /** * Replaces a file dump's upload and its comment, for - * `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left - * out stays as it is. Visibility is taken here so that a new file and a change - * of visibility land together. The old file is put back if the database update - * fails, and clients are told as {@link updateDump} tells them. + * `PUT /api/dumps/:dumpId/file`. A `title`, `slug`, `categoryIds` or + * `isPrivate` left out stays as it is. Visibility is taken here so that a new + * file and a change of visibility land together. The slug changes as in + * {@link updateDump}, before the file is written. The old file is put back if + * the database update fails, and clients are told as {@link updateDump} tells + * them. * * @throws {APIException} 404 when there is no such dump, 400 when it isn't a * file dump, the title or comment is too long, the file's type isn't allowed, - * it exceeds 50 MB, or a category doesn't exist. + * it exceeds 50 MB, a category doesn't exist or the slug isn't valid, 409 when + * the slug is taken, 429 when it changed too often lately. */ export async function replaceFileDump( dumpId: string, @@ -597,6 +592,8 @@ export async function replaceFileDump( title?: string, categoryIds?: string[], isPrivate?: boolean, + slug?: string, + exempt = false, ): Promise { assertFileDumpText(title, comment); if (!isAllowedMime(file.type)) { @@ -611,6 +608,7 @@ export async function replaceFileDump( throw new APIException(400, "Not a file dump"); } if (categoryIds !== undefined) assertCategoriesExist(categoryIds); + if (slug !== undefined) changeSlug("dump", dump, slug, exempt); // The new file is written beside the old one and renamed over it (atomic: a // reader sees one or the other). A hard link keeps the old one reachable so @@ -633,17 +631,13 @@ export async function replaceFileDump( const now = new Date(); const finalTitle = title?.trim() || dump.title; - const newSlug = finalTitle !== dump.title - ? makeSlug(finalTitle, dumpId) - : dump.slug; const finalPrivate = isPrivate ?? dump.isPrivate; try { transaction(() => { db.prepare( - `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + `UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, ).run( finalTitle, - newSlug ?? null, file.name, file.type, file.size, @@ -680,7 +674,7 @@ export async function replaceFileDump( const updatedDump: Dump = { ...dump, title: finalTitle, - slug: newSlug, + slug: slug ?? dump.slug, fileName: file.name, fileMime: file.type, fileSize: file.size, @@ -694,7 +688,7 @@ export async function replaceFileDump( /** * Returns a page of `userId`'s dumps, newest first, for - * `GET /api/users/:username/dumps`. The route sets `includePrivate` when users + * `GET /api/users/:key/dumps`. The route sets `includePrivate` when users * ask for their own. * * @throws {APIException} 500 when a row doesn't parse as a dump. @@ -720,7 +714,7 @@ export function getDumpsByUser( /** * Returns a page of the dumps `userId` upvoted, latest vote first, for - * `GET /api/users/:username/votes`. Their own private dumps are included only + * `GET /api/users/:key/upvoted`. Their own private dumps are included only * when they are the one asking. * * @throws {APIException} 500 when a row doesn't parse as a dump. @@ -763,7 +757,7 @@ export function getVotedDumpsByUser( /** * Fetches a URL dump's preview again, bypassing the cache, and replaces its - * title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`. + * title and preview, for `POST /api/dumps/:dumpId/metadata`. The slug stays. * * @throws {APIException} 404 when there is no such dump, 400 when it isn't a * URL dump, 502 when the page can't be reached. @@ -808,9 +802,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise { * @throws {APIException} 404 when there is no such dump. */ export function getDumpThumbnailInfo( - idOrSlug: string, + dumpId: string, ): { id: string; mime: string } | undefined { - const dump = fetchDump(idOrSlug); + const dump = fetchDump(dumpId); return dump.thumbnailMime ? { id: dump.id, mime: dump.thumbnailMime } : undefined; diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 272b2988..ed8a2e07 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -127,7 +127,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void { /** * Returns the ids of every user and playlist `followerId` follows, private - * playlists included, for `GET /api/follows/status`. + * playlists included, for `GET /api/me/follow`. * * @throws {APIException} 500 when a row doesn't parse. */ diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts index 595ef9f4..1f7c332f 100644 --- a/api/services/invite-service.ts +++ b/api/services/invite-service.ts @@ -1,7 +1,7 @@ /** * Makes invites, and checks and claims them at sign-up. An invite works once, * for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and - * checks them, and `POST /api/users/register` claims one. + * checks them, and `POST /api/users` claims one. * * @module */ diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index c9dc8d58..943fdfec 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -3,7 +3,9 @@ * `notification_created`, and reads them back for `routes/notifications.ts`. * The services of follows, votes, likes, comments, dumps, playlists and the * chat call its `notify…` triggers. Every trigger passes a source key, so the - * same event never notifies a user twice. + * same event never notifies a user twice. What a notification names by id is + * joined each time it is sent ({@link withCurrentRefs}), so its names and + * links follow renames. * * @module */ @@ -24,6 +26,58 @@ import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; import { mentionedUsernames } from "../../shared/mention.ts"; +/** Each id of a user a notification names, beside the field of their name. */ +const USER_FIELDS = [ + ["followerId", "followerUsername"], + ["dumperId", "dumperUsername"], + ["voterId", "voterUsername"], + ["mentionerId", "mentionerUsername"], + ["commenterId", "commenterUsername"], + ["likerId", "likerUsername"], +] as const; + +/** + * Returns `data` with what may have changed since it was stored joined from + * the database: each user's current name, and the slug and owner of its dump + * and playlist, which the client builds their paths from. A user since deleted + * keeps their stored name; a deleted dump or playlist gets no ref, and so no + * link. Every notification the server sends goes through it, and so do the + * digests. + */ +export function withCurrentRefs(data: NotificationData): NotificationData { + const fields = data as unknown as Record; + const out: Record = { ...fields }; + for (const [idField, nameField] of USER_FIELDS) { + const id = fields[idField]; + if (!id) continue; + const row = db.prepare(`SELECT username FROM users WHERE id = ?;`).get( + id, + ) as { username: string } | undefined; + if (row) out[nameField] = row.username; + } + const context = fields.contextType; + const dumpId = fields.dumpId ?? + (context === "dump" ? fields.contextId : undefined); + const playlistId = fields.playlistId ?? + (context === "playlist" ? fields.contextId : undefined); + const ref = (table: string, id: string) => + db.prepare( + `SELECT t.slug, u.username FROM ${table} t JOIN users u ON u.id = t.user_id + WHERE t.id = ?;`, + ).get(id) as { slug: string; username: string } | undefined; + if (dumpId) { + const row = ref("dumps", dumpId); + out.dumpSlug = row?.slug; + out.dumpOwnerUsername = row?.username; + } + if (playlistId) { + const row = ref("playlists", playlistId); + out.playlistSlug = row?.slug; + out.playlistOwnerUsername = row?.username; + } + return out as unknown as NotificationData; +} + /** * Stores a notification for `userId` and pushes it live. With a `sourceKey`, * a second one for the same `(userId, sourceKey)` is neither stored nor sent. @@ -62,7 +116,7 @@ function createNotification( id, userId, type, - data, + data: withCurrentRefs(data), read: false, createdAt: now, }, @@ -71,8 +125,8 @@ function createNotification( } /** - * Returns a page of `userId`'s notifications, newest first, for - * `GET /api/notifications`. + * Returns a page of `userId`'s notifications, newest first, with their refs + * joined, for `GET /api/me/notifications`. * * @throws {APIException} 500 when a row doesn't parse. */ @@ -89,7 +143,13 @@ export function getNotificationsForUser( if (!rawRows.every(isNotificationRow)) { throw new APIException(500, "Malformed notification data"); } - return pageOf(rawRows.map(notificationRowToApi), limit); + return pageOf( + rawRows.map(notificationRowToApi).map((n) => ({ + ...n, + data: withCurrentRefs(n.data), + })), + limit, + ); } /** @@ -195,6 +255,7 @@ export function notifyUserFollowersNewDump( dumpTitle, }; const dataJson = JSON.stringify(data); + const sent = withCurrentRefs(data); const now = new Date(); const createdAt = now.toISOString(); const sourceKey = `dump:${dumpId}`; @@ -227,7 +288,7 @@ export function notifyUserFollowersNewDump( id, userId: row.follower_id, type: "user_dump_posted", - data, + data: sent, read: false, createdAt: now, }, diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index 63a99be0..1efd0797 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -1,7 +1,7 @@ /** - * Emails password reset links and sets the new password when one is used. The - * users routes call it for `POST /api/users/request-password-reset` and - * `POST /api/users/reset-password`; the link opens the client's + * Emails password reset links and sets the new password when one is used. + * `routes/password-resets.ts` calls it for `POST /api/password-resets` and + * `PUT /api/password-resets/:token`; the link opens the client's * `/reset-password` page. * * @module diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index a4303d98..f7133559 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -34,7 +34,7 @@ import { notifyMentions, notifyPlaylistFollowersNewDump, } from "./notification-service.ts"; -import { makeSlug, UUID_RE } from "../lib/slugify.ts"; +import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { linkAttachments } from "./attachment-service.ts"; import { isMemberVisibleTo, @@ -50,25 +50,34 @@ import { parseRows } from "../model/rows.ts"; */ 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 LEFT JOIN users u ON u.id = p.user_id`; + FROM playlists p JOIN users u ON u.id = p.user_id`; /** - * Returns the playlist with id or slug `idOrSlug`, private or not. A caller - * that shows it to someone must check `isPublic` first, as the OG middleware - * does. + * Returns playlist `playlistId`, private or not. A caller that shows it to + * someone must check `isPublic` first, as the OG middleware does. * * @throws {APIException} 404 when there is no such playlist. */ -export function getPlaylistById(idOrSlug: string): Playlist { - const row = UUID_RE.test(idOrSlug) - ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) - : db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug); +export function getPlaylistById(playlistId: string): Playlist { + const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`) + .get(playlistId); if (!row || !isPlaylistRow(row)) { throw new APIException(404, "Playlist not found"); } return playlistRowToApi(row); } +/** + * Returns the playlist of `userId`'s whose slug is or was `slug`, private or + * not, or throws a 404, as {@link getPlaylistById} does. Reached by a former + * slug, it comes with its current one. + */ +export function getPlaylistByPath(userId: string, slug: string): Playlist { + const playlistId = resolveSlug("playlist", userId, slug); + if (!playlistId) throw new APIException(404, "Playlist not found"); + return getPlaylistById(playlistId); +} + /** * Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is * public, it notifies the users its description mentions. It links the @@ -81,28 +90,21 @@ export function createPlaylist( const id = crypto.randomUUID(); const createdAt = new Date(); const title = req.title.trim(); - const slug = makeSlug(title, id); - db.prepare( - `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?);`, - ).run( - id, - userId, - title, - slug, - req.description ?? null, - req.isPublic ? 1 : 0, - createdAt.toISOString(), - ); - const playlist: Playlist = { - id, - userId, - title, - slug, - description: req.description, - isPublic: req.isPublic, - createdAt, - }; + transaction(() => { + db.prepare( + `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?);`, + ).run( + id, + userId, + title, + newSlug("playlist", userId, title), + req.description ?? null, + req.isPublic ? 1 : 0, + createdAt.toISOString(), + ); + }); + const playlist = getPlaylistById(id); if (req.description) { // A private playlist's mentions are notified if it goes public. if (req.isPublic) { @@ -116,17 +118,20 @@ export function createPlaylist( /** * Returns a playlist with the dumps `requestingUserId` may see in it, in - * order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a - * signed-out viewer. + * order, for `GET /api/playlists/:playlistId`, and for + * `GET /api/users/:key/playlists/:slug` once it has found the playlist by its + * path. A null `requestingUserId` is a signed-out viewer. * * @throws {APIException} 404 when there is no such playlist, or it is private * and not theirs. */ export function getPlaylist( - playlistId: string, + playlistId: string | Playlist, requestingUserId: string | null, ): PlaylistWithDumps { - const playlist = getPlaylistById(playlistId); + const playlist = typeof playlistId === "string" + ? getPlaylistById(playlistId) + : playlistId; if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(404, "Playlist not found"); @@ -176,7 +181,7 @@ export function searchPlaylists( /** * Returns a page of `userId`'s playlists, newest first, for - * `GET /api/users/:username/playlists`. The private ones are listed only when + * `GET /api/users/:key/playlists`. The private ones are listed only when * `requestingUserId` is `userId`. */ export function listPlaylistsByUser( @@ -198,13 +203,16 @@ export function listPlaylistsByUser( /** * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns - * it as it now is. The slug follows the title. `canModerate` lets someone - * other than the owner edit it. If the playlist is public, it notifies, in the - * owner's name, the users the description mentions. It links the description's - * attachments and broadcasts `playlist_updated`. + * it as it now is. A new `slug` goes through `changeSlug`, first, and a + * moderator is exempt from its rate limit; a new title leaves the slug as it + * is. `canModerate` lets someone other than the owner edit it. If the + * playlist is public, it notifies, in the owner's name, the users the + * description mentions. It links the description's 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. + * 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. */ export function updatePlaylist( playlistId: string, @@ -226,13 +234,15 @@ export function updatePlaylist( ? req.isPublic : playlist.isPublic; + if (req.slug !== undefined) { + changeSlug("playlist", playlist, req.slug, canModerate); + } + const now = new Date(); - const newSlug = makeSlug(newTitle, playlist.id); db.prepare( - `UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, + `UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, ).run( newTitle, - newSlug, newDescription, newIsPublic ? 1 : 0, now.toISOString(), @@ -242,7 +252,7 @@ export function updatePlaylist( const updated: Playlist = { ...playlist, title: newTitle, - slug: newSlug, + slug: req.slug ?? playlist.slug, description: newDescription ?? undefined, isPublic: newIsPublic, updatedAt: now, @@ -487,8 +497,8 @@ export function reorderPlaylist( /** * Returns each of `userId`'s playlists, newest first, and whether it holds - * dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The - * client's add-to-playlist modal shows it. The playlists have no dump count. + * dump `dumpId`, for `GET /api/me/playlists?contains=`. The client's + * add-to-playlist modal shows it. * * @throws {APIException} 500 when a row doesn't parse as a playlist. */ @@ -497,9 +507,11 @@ export function getPlaylistMembershipsForDump( userId: string, ): PlaylistMembership[] { const rows = db.prepare( - `SELECT p.*, pd.dump_id IS NOT NULL as has_dump - FROM playlists p - LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ? + `SELECT p.*, u.username as owner_username, + (SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count, + EXISTS (SELECT 1 FROM playlist_dumps pd + WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump + FROM playlists p JOIN users u ON u.id = p.user_id WHERE p.user_id = ? ORDER BY p.created_at DESC;`, ).all(dumpId, userId); @@ -536,7 +548,7 @@ export function getPlaylistsForDump( } /** - * Returns what `GET /api/playlists/:playlistId/image` needs to serve a + * Returns what `GET /api/playlists/:playlistId/cover` needs to serve a * playlist's cover, or undefined when it has none. The route serves a private * playlist's cover as private, so no shared cache keeps it. * @@ -544,10 +556,10 @@ export function getPlaylistsForDump( * and `requestingUserId` isn't its owner. */ export function getPlaylistImageInfo( - idOrSlug: string, + playlistId: string, requestingUserId: string | null, ): { id: string; imageMime: string; isPublic: boolean } | undefined { - const playlist = getPlaylistById(idOrSlug); + const playlist = getPlaylistById(playlistId); if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(404, "Playlist not found"); } diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index d800834d..64db6f8a 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -1,7 +1,7 @@ /** * Fetches a link's preview through the provider that knows its site, and * reuses it for a few minutes. `dump-service.ts` calls it when a link is - * posted, changed or refreshed, and `GET /api/preview` when the create form + * posted, changed or refreshed, and `GET /api/remote/preview` when the create form * previews one. * * @module @@ -29,7 +29,7 @@ const providers: RichContentProvider[] = [ ]; /** - * Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports + * Returned by {@link tryFetchRichContent} to `GET /api/remote/preview`, which reports * `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is * false. */ diff --git a/api/services/settings-service.ts b/api/services/settings-service.ts index 730c622c..cb28b2c3 100644 --- a/api/services/settings-service.ts +++ b/api/services/settings-service.ts @@ -1,6 +1,6 @@ /** - * Reads and writes each user's settings in `user_settings`. The users routes - * answer `GET` and `PATCH /api/users/me/settings` with it, and the digests read + * Reads and writes each user's settings in `user_settings`. `routes/me.ts` + * answers `GET` and `PATCH /api/me/settings` with it, and the digests read * their readers' choices from the same table. * * @module diff --git a/api/services/slug-service.ts b/api/services/slug-service.ts new file mode 100644 index 00000000..bf7026aa --- /dev/null +++ b/api/services/slug-service.ts @@ -0,0 +1,91 @@ +/** + * Changes and resolves the slugs of dumps and playlists. A slug changes only + * when someone edits it (the dump and playlist services call + * {@link changeSlug} with the slug their edit form sent), and the slug it + * leaves becomes an alias of the item, so that old links still reach it; the + * routes that read an item by its page path resolve the slug with + * {@link resolveSlug}, aliases included, and answer the item, whose current + * slug the client then puts in its URL. + * + * @module + */ + +import { APIException } from "../lib/api-exception.ts"; +import { db, transaction } from "../db/connection.ts"; +import { + SLUG_TABLES, + slugHolder, + type SlugKind, + uniqueSlug, + validateSlug, +} from "../lib/slugify.ts"; +import { slugify } from "../../shared/slug.ts"; +import { RENAME_LIMITS } from "../../shared/validation.ts"; + +/** + * Returns a free slug for a new item of `userId`'s titled `title`, for the + * dump and playlist services to insert it with, inside their transaction. + */ +export function newSlug(kind: SlugKind, userId: string, title: string): string { + return uniqueSlug(db, kind, userId, slugify(title)); +} + +/** + * Returns the id of `userId`'s item of `kind` whose slug is or was `slug`, or + * undefined when none is. + */ +export function resolveSlug( + kind: SlugKind, + userId: string, + slug: string, +): string | undefined { + return slugHolder(db, kind, userId, slug); +} + +/** + * Gives `item` the slug `slug`, keeping its current one as an alias. An alias + * always names the item itself, so redirects never chain; taking back one of + * the item's own aliases swaps it with the current slug. Unless `exempt` (a + * moderator), an item's slug changes at most `RENAME_LIMITS.SLUG.count` times + * in its window, counted from its aliases. A slug equal to the current one + * changes nothing. + * + * @throws {APIException} 400 when `slug` isn't valid, 409 when another item + * of the owner's holds it now or held it before, 429 when the item's slug + * changed too often lately. + */ +export function changeSlug( + kind: SlugKind, + item: { id: string; userId: string; slug: string }, + slug: string, + exempt: boolean, +): void { + if (slug === item.slug) return; + validateSlug(slug); + const { items, aliases, itemId } = SLUG_TABLES[kind]; + const now = new Date(); + transaction(() => { + const holder = slugHolder(db, kind, item.userId, slug); + if (holder && holder !== item.id) { + throw new APIException(409, `The slug "${slug}" is already taken`); + } + const { count, windowMs } = RENAME_LIMITS.SLUG; + const recent = (db.prepare( + `SELECT COUNT(*) AS n FROM ${aliases} WHERE ${itemId} = ? AND created_at > ?;`, + ).get(item.id, new Date(now.getTime() - windowMs).toISOString()) as { + n: number; + }).n; + if (!exempt && recent >= count) { + throw new APIException( + 429, + `A slug can only be changed ${count} times a day`, + ); + } + db.prepare(`DELETE FROM ${aliases} WHERE user_id = ? AND slug = ?;`) + .run(item.userId, slug); + db.prepare( + `INSERT INTO ${aliases} (user_id, slug, ${itemId}, created_at) VALUES (?, ?, ?, ?);`, + ).run(item.userId, item.slug, item.id, now.toISOString()); + db.prepare(`UPDATE ${items} SET slug = ? WHERE id = ?;`).run(slug, item.id); + }); +} diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 712deaf1..75095011 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -23,7 +23,7 @@ import { userApiToRow, userRowToApi, } from "../model/user.ts"; -import { db } from "../db/connection.ts"; +import { db, transaction } from "../db/connection.ts"; import { closeUserSockets } from "./ws-service.ts"; import { hashPassword } from "../lib/password.ts"; @@ -32,6 +32,11 @@ import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { renderWelcomeEmail } from "../mail/welcome.ts"; +import { + RENAME_LIMITS, + USERNAME_RE, + VALIDATION, +} from "../../shared/validation.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ const USER_SELECT = @@ -40,13 +45,34 @@ const USER_SELECT = FROM users u LEFT JOIN users i ON i.id = u.invited_by`; +/** + * Throws a 409 when `username` is, in any case, the name or a former name of + * an account other than `exceptUserId`. A former name stays its user's for + * good, so that old links and old @mentions can't be taken over. + */ +function assertUsernameAvailable( + username: string, + exceptUserId?: string, +): void { + const row = db.prepare( + `SELECT id FROM users WHERE lower(username) = lower(?) AND id != ? + UNION ALL + SELECT user_id FROM user_aliases + WHERE lower(username) = lower(?) AND user_id != ? + LIMIT 1;`, + ).get(username, exceptUserId ?? "", username, exceptUserId ?? ""); + if (row) { + throw new APIException(409, "Username already exists"); + } +} + /** * Creates a user with the `user` role, invited by `inviterId`, for - * `POST /api/users/register`. The caller has validated `request` and claimed - * the invite. + * `POST /api/users`. The caller has validated `request` and claimed the + * invite. * * @throws {APIException} 409 when the username or the email is taken, in any - * case. + * case, or the username is someone's former name. */ export async function createUser( request: RegisterUserRequest, @@ -55,14 +81,7 @@ export async function createUser( const userId = crypto.randomUUID(); const createdAt = new Date(); - const existingUser = db.prepare( - "SELECT id FROM users WHERE lower(username) = lower(?);", - ).get(request.username); - - if (existingUser) { - throw new APIException(409, "Username already exists"); - } - + assertUsernameAvailable(request.username); assertEmailAvailable(request.email); const passwordHash = await hashPassword(request.password); @@ -144,14 +163,14 @@ export function getSessionState( } /** - * Returns the user named `username`, with their hash and email. The name - * matches case-sensitively. + * Returns the user named `username` now, in any case, with their hash and + * email. Signing in takes it: a former name doesn't sign in. * * @throws {APIException} 404 when there is no such user. */ export function getUserByUsername(username: string): User { const userRow = db.prepare( - `${USER_SELECT} WHERE u.username = ?`, + `${USER_SELECT} WHERE lower(u.username) = lower(?)`, ).get(username); if (!userRow || !isUserRow(userRow)) { @@ -161,6 +180,90 @@ export function getUserByUsername(username: string): User { return userRowToApi(userRow); } +/** + * Returns the user who is or was named `username`, in any case: a page or an + * API path naming a user by a former name reaches them, and the caller sends + * the client on to the current one. + * + * @throws {APIException} 404 when nobody is or was. + */ +export function resolveUsername(username: string): User { + const alias = db.prepare( + `SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`, + ).get(username) as { user_id: string } | undefined; + return alias ? getUserById(alias.user_id) : getUserByUsername(username); +} + +/** + * Returns the user an API path's key names: `@name` by their name, current or + * former ({@link resolveUsername}), anything else by their id. Every route + * under `/api/users/:key` resolves its key with it. + * + * @throws {APIException} 404 when there is no such user. + */ +export function resolveUserKey(key: string): User { + return key.startsWith("@") ? resolveUsername(key.slice(1)) : getUserById(key); +} + +/** + * Renames user `userId` to `username`, for `PATCH /api/me` and, by a + * moderator, `PATCH /api/users/:key/username`, and returns them renamed. The + * old name becomes an alias of theirs for good, so `/@old/…` still reaches + * them and nobody else may take it; a change of case alone leaves none, since + * names are looked up in any case. Taking back a former name of one's own is + * allowed. Their dumps' and playlists' slugs belong to their id, so their + * paths follow the new name. Unless `exempt` (a moderator), a user may change + * names once in `RENAME_LIMITS.USERNAME`'s window. + * + * @throws {APIException} 400 when `username` isn't a valid name, 404 when + * there is no such user, 409 when the name is or was someone else's, 429 when + * the user changed names too recently. + */ +export function renameUser( + userId: string, + username: string, + exempt: boolean, +): User { + if (!USERNAME_RE.test(username)) { + throw new APIException( + 400, + `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`, + ); + } + const user = getUserById(userId); + if (username === user.username) return user; + + const now = new Date(); + transaction(() => { + assertUsernameAvailable(username, userId); + if (username.toLowerCase() !== user.username.toLowerCase()) { + const { count, windowMs } = RENAME_LIMITS.USERNAME; + const recent = (db.prepare( + `SELECT COUNT(*) AS n FROM user_aliases WHERE user_id = ? AND created_at > ?;`, + ).get(userId, new Date(now.getTime() - windowMs).toISOString()) as { + n: number; + }).n; + if (!exempt && recent >= count) { + throw new APIException( + 429, + `A username can only be changed once every ${ + windowMs / 86_400_000 + } days`, + ); + } + db.prepare( + `DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`, + ).run(userId, username); + db.prepare( + `INSERT INTO user_aliases (username, user_id, created_at) VALUES (?, ?, ?);`, + ).run(user.username, userId, now.toISOString()); + } + db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`) + .run(username, now.toISOString(), userId); + }); + return getUserById(userId); +} + /** * Throws a 409 when an account other than `exceptUserId` has `email`, in any * case. Password resets are looked up by email, so an address must belong to @@ -201,7 +304,7 @@ export function getUserByEmail(email: string): User | null { /** * Returns up to `limit` users whose name starts with `query`, for the editor's - * @mention suggestions (`GET /api/users/search`). An empty query returns none. + * @mention suggestions (`GET /api/users?q=`). An empty query returns none. */ export function searchUsers( query: string, @@ -237,7 +340,9 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { /** * Applies `request` to user `userId` and returns the updated user. It trusts - * `request`: the routes pick the fields a caller may change. A new password + * `request`: the routes pick the fields a caller may change. The username + * changes through {@link renameUser} alone, which keeps the old one as an + * alias, so a `username` here is ignored. A new password * also ends every session opened before it and closes the user's sockets, so * the caller must issue fresh tokens if the user is to stay signed in. A * `description` of null keeps the current one. @@ -251,7 +356,8 @@ export async function updateUser( ): Promise { const user = getUserById(userId); - const { password, description, email, ...requestFields } = request; + const { password, description, email, username: _, ...requestFields } = + request; if (email !== undefined && email !== user.email) { assertEmailAvailable(email, userId); } @@ -271,9 +377,8 @@ export async function updateUser( let userResult; try { userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, + `UPDATE users SET password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, ).run( - updatedUserRow.username, updatedUserRow.password_hash, updatedUserRow.role, updatedUserRow.description, @@ -306,7 +411,7 @@ export async function updateUser( /** * Records that user `userId` now has an avatar of type `mime`, once - * `POST /api/avatars/me` has written the file. It bumps `updated_at`, which + * `PUT /api/me/avatar` has written the file. It bumps `updated_at`, which * the client uses to reload the image. * * @throws {APIException} 404 when there is no such user. @@ -324,7 +429,7 @@ export function updateUserAvatar(userId: string, mime: string): void { /** * Returns everyone `userId` invited, and everyone they invited in turn, eleven * levels deep at most, in sign-up order, for the profile page's invite tree - * (`GET /api/users/:username/invitees`). + * (`GET /api/users/:key/invitees`). */ export function getInviteTree(userId: string): Invitee[] { const rows = db.prepare(` diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 06931a08..f6508032 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -83,6 +83,18 @@ export function updateClientAvatar(userId: string, avatarMime: string): void { broadcastPresence(); } +/** + * Records `userId`'s new name on their clients, so presence and the typing + * list show it, and rebroadcasts presence. The routes that rename a user call + * it, along with `broadcastUserUpdated`. + */ +export function updateClientUsername(userId: string, username: string): void { + for (const client of clients) { + if (client.userId === userId) client.username = username; + } + broadcastPresence(); +} + /** * Returns each signed-in user with a socket open, once. The route sends it in * `welcome`, and {@link broadcastPresence} in `presence_update`. diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index 280049b8..ed98d583 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, 20); + assertEquals(applied.n, 21); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 20, + 21, ); db.close(); }); diff --git a/api/test/db/migrations/0021_page_urls.test.ts b/api/test/db/migrations/0021_page_urls.test.ts new file mode 100644 index 00000000..56f2ff40 --- /dev/null +++ b/api/test/db/migrations/0021_page_urls.test.ts @@ -0,0 +1,168 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0021 } from "../../../db/migrations/0021_page_urls.ts"; + +/** A database as v3 left it: schema.sql without 0021's tables and indexes. */ +function v3Db(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec( + Deno.readTextFileSync(new URL("../../../db/schema.sql", import.meta.url)), + ); + db.exec(` + DROP TABLE dump_slug_aliases; + DROP TABLE playlist_slug_aliases; + DROP TABLE user_aliases; + DROP INDEX idx_dumps_user_slug; + DROP INDEX idx_playlists_user_slug; + CREATE INDEX idx_dumps_slug ON dumps(slug); + CREATE INDEX idx_playlists_slug ON playlists(slug); + `); + return db; +} + +const NOW = "2024-01-01T00:00:00.000Z"; +const later = (n: number) => new Date(Date.parse(NOW) + n * 1000).toISOString(); + +function user(db: DatabaseSync, username: string): string { + const id = crypto.randomUUID(); + db.prepare( + `INSERT INTO users (id, username, password_hash, created_at, email) VALUES (?, ?, 'x', ?, ?);`, + ).run(id, username, NOW, `${username}@example.test`); + return id; +} + +/** Inserts a dump or playlist with its v3 slug, and returns its id and slug. */ +function item( + db: DatabaseSync, + table: "dumps" | "playlists", + userId: string, + title: string, + at: number, +): { id: string; v3: string } { + const id = crypto.randomUUID(); + const v3 = `${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${ + id.slice(0, 8) + }`; + db.prepare( + table === "dumps" + ? `INSERT INTO dumps (id, kind, title, slug, user_id, created_at, url) VALUES (?, 'url', ?, ?, ?, ?, 'https://x.test');` + : `INSERT INTO playlists (id, title, slug, user_id, created_at) VALUES (?, ?, ?, ?, ?);`, + ).run(id, title, v3, userId, later(at)); + return { id, v3 }; +} + +const slugOf = (db: DatabaseSync, table: string, id: string) => + (db.prepare(`SELECT slug FROM ${table} WHERE id = ?;`).get(id) as { + slug: string; + }).slug; + +Deno.test("migration 0021: bare slugs unique per owner, reserved categories renamed", () => { + const db = v3Db(); + const bob = user(db, "Bob"); + const ann = user(db, "ann"); + const first = item(db, "dumps", bob, "Never Gonna", 0); + const second = item(db, "dumps", bob, "Never gonna!", 1); + const anns = item(db, "dumps", ann, "Never Gonna", 2); + const mix = item(db, "playlists", bob, "Never Gonna", 3); + for ( + const [slug, name] of [ + ["hot", "Hot"], + ["hot-2", "Hot 2"], + ["follow", "F"], + ["music", "M"], + ] + ) { + db.prepare( + `INSERT INTO categories (id, slug, name, created_at) VALUES (?, ?, ?, ?);`, + ).run(crypto.randomUUID(), slug, name, NOW); + } + + up0021(db); + + // The oldest keeps the bare slug; each kind and owner is its own space. + assertEquals(slugOf(db, "dumps", first.id), "never-gonna"); + assertEquals(slugOf(db, "dumps", second.id), "never-gonna-2"); + assertEquals(slugOf(db, "dumps", anns.id), "never-gonna"); + assertEquals(slugOf(db, "playlists", mix.id), "never-gonna"); + assertEquals( + (db.prepare(`SELECT slug FROM categories ORDER BY slug;`).all() as { + slug: string; + }[]).map((r) => r.slug), + ["follow-2", "hot-2", "hot-3", "music"], + ); + // The new index holds. + let clash = false; + try { + db.prepare(`UPDATE dumps SET slug = 'never-gonna' WHERE id = ?;`).run( + second.id, + ); + } catch { + clash = true; + } + assertEquals(clash, true); + db.close(); +}); + +Deno.test("migration 0021: v3 links in notes, comments and chat become v4 paths", () => { + const db = v3Db(); + const bob = user(db, "Bob"); + const dump = item(db, "dumps", bob, "Never Gonna", 0); + const mix = item(db, "playlists", bob, "Sunday Mix", 1); + const text = [ + `[by slug](http://gerbeur.test/dumps/${dump.v3}#comment-1)`, + `[relative by id](/dumps/${dump.id}/edit)`, + `http://gerbeur.test/playlists/${mix.id}?x=1`, + ``, + `[list](/users/BOB/upvoted) [settings](/users/bob/~/settings)`, + `[feed](/~/followed) [search](/search/~/users?q=a)`, + // Another site's, a bare path in prose and a deleted dump stay as they are. + `https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`, + `http://gerbeur.test/dumps/gone-12345678`, + ].join("\n"); + const expected = [ + `[by slug](http://gerbeur.test/@Bob/dumps/never-gonna#comment-1)`, + `[relative by id](/@Bob/dumps/never-gonna/edit)`, + `http://gerbeur.test/@Bob/playlists/sunday-mix?x=1`, + ``, + `[list](/@Bob/upvoted) [settings](/settings)`, + `[feed](/follow) [search](/search/users?q=a)`, + `https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`, + `http://gerbeur.test/dumps/gone-12345678`, + ].join("\n"); + const commentId = crypto.randomUUID(); + const messageId = crypto.randomUUID(); + db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`).run(text, dump.id); + db.prepare(`UPDATE playlists SET description = ? WHERE id = ?;`).run( + text, + mix.id, + ); + db.prepare( + `INSERT INTO comments (id, dump_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?);`, + ).run(commentId, dump.id, bob, text, NOW); + db.prepare( + `INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`, + ).run(messageId, bob, text, NOW); + + up0021(db); + + const read = (sql: string, id: string) => + (db.prepare(sql).get(id) as { text: string }).text; + assertEquals( + read(`SELECT comment AS text FROM dumps WHERE id = ?;`, dump.id), + expected, + ); + assertEquals( + read(`SELECT body AS text FROM comments WHERE id = ?;`, commentId), + expected, + ); + assertEquals( + read(`SELECT body AS text FROM chat_messages WHERE id = ?;`, messageId), + expected, + ); + assertEquals( + read(`SELECT description AS text FROM playlists WHERE id = ?;`, mix.id), + expected, + ); + db.close(); +}); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 0fb82085..ddab51dc 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -6,7 +6,9 @@ import { app } from "../main.ts"; import { db } from "../db/connection.ts"; import { createJWT, createMediaToken } from "../lib/jwt.ts"; import { hashPassword } from "../lib/password.ts"; -import { makeSlug } from "../lib/slugify.ts"; +import { uniqueSlug } from "../lib/slugify.ts"; +import { slugify } from "../../shared/slug.ts"; +import { dumpPath, playlistPath } from "../../shared/paths.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearImageCache } from "../remote/image-proxy.ts"; @@ -36,6 +38,9 @@ const TABLES = [ "chat_messages", "user_settings", "digest_schedule", + "dump_slug_aliases", + "playlist_slug_aliases", + "user_aliases", "categories", "playlists", "dumps", @@ -147,16 +152,24 @@ export interface InsertDumpOptions { richContent?: Record; } +/** Returns the name of user `userId`, for the page paths of their things. */ +function usernameOf(userId: string): string { + return (db.prepare(`SELECT username FROM users WHERE id = ?;`).get( + userId, + ) as { username: string }).username; +} + /** * Inserts a dump row directly, skipping the metadata fetch the create route - * does. Returns its id and slug. + * does, with a slug made from its title as the service would. Returns its id, + * slug and page path. */ export function insertDump( opts: InsertDumpOptions, -): { id: string; slug: string } { +): { id: string; slug: string; path: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A dump"; - const slug = makeSlug(title, id); + const slug = uniqueSlug(db, "dump", opts.userId, slugify(title)); const kind = opts.kind ?? (opts.fileName ? "file" : "url"); const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null; const data = opts.fileData ?? new TextEncoder().encode("hello"); @@ -184,15 +197,20 @@ export function insertDump( Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true }); Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data); } - return { id, slug }; + return { + id, + slug, + path: dumpPath({ ownerUsername: usernameOf(opts.userId), slug }), + }; } +/** Inserts a playlist row as {@link insertDump} does a dump. */ export function insertPlaylist( opts: { userId: string; title?: string; isPublic?: boolean }, -): { id: string; slug: string } { +): { id: string; slug: string; path: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A playlist"; - const slug = makeSlug(title, id); + const slug = uniqueSlug(db, "playlist", opts.userId, slugify(title)); db.prepare( `INSERT INTO playlists (id, user_id, title, slug, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?);`, @@ -204,7 +222,11 @@ export function insertPlaylist( opts.isPublic === false ? 0 : 1, new Date().toISOString(), ); - return { id, slug }; + return { + id, + slug, + path: playlistPath({ ownerUsername: usernameOf(opts.userId), slug }), + }; } export function insertComment( diff --git a/api/test/lib/auth.test.ts b/api/test/lib/auth.test.ts index 5b33af99..0aa5e283 100644 --- a/api/test/lib/auth.test.ts +++ b/api/test/lib/auth.test.ts @@ -12,7 +12,7 @@ test("authenticate: a deleted user's token is refused", async () => { const user = await createUser(); db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id); assertEquals(await authenticate(user.token), null); - assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); + assertEquals((await get("/api/me", { auth: user.auth })).status, 401); }); test("authenticate: tokens issued before tokens_valid_after are revoked", async () => { @@ -43,7 +43,7 @@ test("authenticateMedia: takes a media token or a legacy session token", async ( test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => { const owner = await createUser(); // A public listing that reads optional auth. - const path = `/api/users/${owner.username}/dumps`; + const path = `/api/users/@${owner.username}/dumps`; assertEquals((await get(path)).status, 200); for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) { assertEquals( diff --git a/api/test/lib/reserved-slugs.test.ts b/api/test/lib/reserved-slugs.test.ts index ee4f6c36..a282f76c 100644 --- a/api/test/lib/reserved-slugs.test.ts +++ b/api/test/lib/reserved-slugs.test.ts @@ -1,16 +1,13 @@ import "../setup.ts"; import { assert, assertFalse } from "@std/assert"; -import { isReservedSlug, isValidSlugFormat } from "../../lib/reserved-slugs.ts"; +import { isReservedSlug } from "../../lib/reserved-slugs.ts"; -Deno.test("category slugs: format and reserved words", () => { - assert(isValidSlugFormat("music")); - assert(isValidSlugFormat("lo-fi-2")); - for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { - assertFalse(isValidSlugFormat(bad), bad); +Deno.test("category slugs: the feed tabs, app pages and server paths are reserved", () => { + for (const word of ["hot", "follow", "settings", "manage", "api", "ws"]) { + 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", "~"]) { + assertFalse(isReservedSlug(word), word); } - assert(isReservedSlug("dumps")); - assert(isReservedSlug("api")); - assert(isReservedSlug("ws")); - assert(isReservedSlug("emojibase")); - assertFalse(isReservedSlug("music")); }); diff --git a/api/test/lib/self-url.test.ts b/api/test/lib/self-url.test.ts index b8676102..cfc74804 100644 --- a/api/test/lib/self-url.test.ts +++ b/api/test/lib/self-url.test.ts @@ -4,10 +4,12 @@ import { isSelfUrl } from "../../lib/self-url.ts"; Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => { // GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite. - assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); - assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob"))); - assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1"))); - assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit"))); + assert(isSelfUrl(new URL("http://gerbeur.test/@bob/dumps/abc"))); + assert(isSelfUrl(new URL("https://GERBEUR.test/@bob"))); + assert(isSelfUrl(new URL("http://gerbeur.test/@bob/playlists"))); + assert(isSelfUrl(new URL("http://gerbeur.test/@bob/playlists/p-1"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/@bob/dumps/abc/edit"))); assertFalse(isSelfUrl(new URL("http://gerbeur.test/search"))); - assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); + assertFalse(isSelfUrl(new URL("http://elsewhere.test/@bob"))); }); diff --git a/api/test/lib/slugify.test.ts b/api/test/lib/slugify.test.ts index 8a6ff144..5b22e8d1 100644 --- a/api/test/lib/slugify.test.ts +++ b/api/test/lib/slugify.test.ts @@ -1,19 +1,31 @@ import "../setup.ts"; -import { assert, assertEquals, assertFalse } from "@std/assert"; -import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts"; +import { assertEquals, assertThrows } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { uniqueSlug, validateSlug } from "../../lib/slugify.ts"; +import { APIException } from "../../lib/api-exception.ts"; -Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { - assertEquals(slugify("Hello, World!"), "hello-world"); - assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); - assertEquals(slugify(" --already--dashed-- "), "already-dashed"); - assertEquals(slugify("日本語"), "untitled"); - assertEquals(slugify(""), "untitled"); - assertEquals(slugify("x".repeat(100)).length, 60); +Deno.test("uniqueSlug: numbers a clash within the owner's space, aliases included", () => { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE dumps (id TEXT, user_id TEXT, slug TEXT); + CREATE TABLE dump_slug_aliases (user_id TEXT, slug TEXT, dump_id TEXT); + INSERT INTO dumps VALUES ('1', 'bob', 'mix'), ('2', 'bob', 'mix-2'); + INSERT INTO dump_slug_aliases VALUES ('bob', 'mix-3', '1'); + `); + assertEquals(uniqueSlug(db, "dump", "bob", "mix"), "mix-4"); + assertEquals(uniqueSlug(db, "dump", "ann", "mix"), "mix"); + // The suffix still fits in 60 characters, cut at no hyphen. + db.prepare(`INSERT INTO dumps VALUES ('3', 'bob', ?);`).run("a".repeat(60)); + assertEquals( + uniqueSlug(db, "dump", "bob", "a".repeat(60)), + "a".repeat(58) + "-2", + ); + db.close(); }); -Deno.test("makeSlug: suffixes the first 8 characters of the id", () => { - const id = "0123abcd-0000-4000-8000-000000000000"; - assertEquals(makeSlug("My Song", id), "my-song-0123abcd"); - assert(UUID_RE.test(id)); - assertFalse(UUID_RE.test("my-song-0123abcd")); +Deno.test("validateSlug: lowercase words joined by hyphens, 60 at most", () => { + validateSlug("never-gonna-2"); + for (const bad of ["Never", "-a", "a-", "a--b", "a b", "", "a".repeat(61)]) { + assertThrows(() => validateSlug(bad), APIException, undefined, bad); + } }); diff --git a/api/test/mail/digest.test.ts b/api/test/mail/digest.test.ts index 3068e0f7..a0dce975 100644 --- a/api/test/mail/digest.test.ts +++ b/api/test/mail/digest.test.ts @@ -14,7 +14,7 @@ const dump = (title: string, over = {}) => ({ url: "https://g.test/dumps/x", poster: "bob", domain: "example.com", - thumbnail: "https://g.test/api/thumbnails/x?v=1", + thumbnail: "https://g.test/api/dumps/x/thumbnail?v=1", votes: 1, comments: 0, mine: false, diff --git a/api/test/middleware/auth.test.ts b/api/test/middleware/auth.test.ts index 82b738be..b0ee57e4 100644 --- a/api/test/middleware/auth.test.ts +++ b/api/test/middleware/auth.test.ts @@ -2,15 +2,15 @@ import { createUser, get, test } from "../helpers.ts"; import { assertEquals } from "@std/assert"; test("required auth: missing, malformed and forged tokens are 401", async () => { - assertEquals((await get("/api/users/me")).status, 401); + assertEquals((await get("/api/me")).status, 401); assertEquals( - (await get("/api/users/me", { headers: { Authorization: "Token x" } })) + (await get("/api/me", { headers: { Authorization: "Token x" } })) .status, 401, ); const media = (await createUser()).mediaToken; assertEquals( - (await get("/api/users/me", { + (await get("/api/me", { headers: { Authorization: `Bearer ${media}` }, })).status, 401, diff --git a/api/test/middleware/og.test.ts b/api/test/middleware/og.test.ts index 843a9a89..afffb623 100644 --- a/api/test/middleware/og.test.ts +++ b/api/test/middleware/og.test.ts @@ -1,6 +1,7 @@ import { BASE_URL, createUser, + db, insertDump, insertPlaylist, test, @@ -40,7 +41,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => { thumbnailUrl: "https://img.test/a.jpg", }, }); - const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`); + const { res, text } = await handle(ogApp(), dump.path); assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8"); assertStringIncludes( text, @@ -61,7 +62,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => { assertStringIncludes(text, `content="summary_large_image"`); assertStringIncludes( text, - ``, + ``, ); // The index's own title is replaced, not duplicated. assertEquals(text.match(//g)?.length, 1); @@ -71,7 +72,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => { test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => { const user = await createUser(); const dump = insertDump({ userId: user.id, title: "Plain" }); - const { text } = await handle(ogApp(), `/dumps/${dump.id}`); + const { text } = await handle(ogApp(), dump.path); assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`); assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`); const file = insertDump({ @@ -80,8 +81,8 @@ test("ogMiddleware: no artwork falls back to the site icon as a small card", asy fileMime: "image/png", }); assertStringIncludes( - (await handle(ogApp(), `/dumps/${file.id}`)).text, - `content="${BASE_URL}/api/files/${file.id}"`, + (await handle(ogApp(), file.path)).text, + `content="${BASE_URL}/api/dumps/${file.id}/file"`, ); }); @@ -89,12 +90,16 @@ test("ogMiddleware: users and public playlists; private things fall through", as const user = await createUser(); const app = ogApp(); assertStringIncludes( - (await handle(app, `/users/${user.username}/~/votes`)).text, + (await handle(app, `/@${user.username}/upvoted`)).text, `<meta property="og:title" content="${user.username}" />`, ); + assertEquals( + (await handle(app, `/@${user.username}/nope`)).text, + "fallthrough", + ); const pub = insertPlaylist({ userId: user.id, title: "Open list" }); assertStringIncludes( - (await handle(app, `/playlists/${pub.slug}`)).text, + (await handle(app, pub.path)).text, "Open list", ); const priv = insertPlaylist({ @@ -103,7 +108,7 @@ test("ogMiddleware: users and public playlists; private things fall through", as isPublic: false, }); assertEquals( - (await handle(app, `/playlists/${priv.slug}`)).text, + (await handle(app, priv.path)).text, "fallthrough", ); const secret = insertDump({ @@ -111,11 +116,48 @@ test("ogMiddleware: users and public playlists; private things fall through", as title: "Secret", isPrivate: true, }); + assertEquals((await handle(app, secret.path)).text, "fallthrough"); + assertEquals( + (await handle(app, `/@${user.username}/dumps/nope`)).text, + "fallthrough", + ); + // v3 paths aren't redirected. assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough"); - assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough"); assertEquals( (await handle(app, `/api/dumps/${secret.id}`)).text, "fallthrough", ); assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough"); }); + +test("ogMiddleware: a former slug, a former name or another case gets a 301", async () => { + const user = await createUser(); + const dump = insertDump({ userId: user.id, title: "Now" }); + db.prepare( + `INSERT INTO dump_slug_aliases (user_id, slug, dump_id, created_at) VALUES (?, 'before', ?, ?);`, + ).run(user.id, dump.id, new Date().toISOString()); + db.prepare( + `INSERT INTO user_aliases (username, user_id, created_at) VALUES ('old_name', ?, ?);`, + ).run(user.id, new Date().toISOString()); + for ( + const [from, to] of [ + [`/@${user.username}/dumps/before?x=1`, `${dump.path}?x=1`], + [`/@OLD_NAME/dumps/now`, dump.path], + [`/@old_name/follow/playlists`, `/@${user.username}/follow/playlists`], + [`/@${user.username.toUpperCase()}`, `/@${user.username}`], + ] + ) { + const { res } = await handle(ogApp(), from); + assertEquals(res.status, 301, from); + assertEquals(res.headers.get("location"), `${BASE_URL}${to}`, from); + } + // A private dump's former slug tells nothing. + const secret = insertDump({ userId: user.id, title: "S", isPrivate: true }); + db.prepare( + `INSERT INTO dump_slug_aliases (user_id, slug, dump_id, created_at) VALUES (?, 'hush', ?, ?);`, + ).run(user.id, secret.id, new Date().toISOString()); + assertEquals( + (await handle(ogApp(), `/@${user.username}/dumps/hush`)).text, + "fallthrough", + ); +}); diff --git a/api/test/model/dump.test.ts b/api/test/model/dump.test.ts index 2c14ba84..0fe933ef 100644 --- a/api/test/model/dump.test.ts +++ b/api/test/model/dump.test.ts @@ -19,6 +19,7 @@ Deno.test("dump rows round-trip through the API shape", () => { file_size: null, vote_count: 3, comment_count: 2, + owner_username: "bob", is_private: 1, custom_thumbnail_mime: null, }; diff --git a/api/test/remote/providers/self.test.ts b/api/test/remote/providers/self.test.ts index 8b584638..f58d99e1 100644 --- a/api/test/remote/providers/self.test.ts +++ b/api/test/remote/providers/self.test.ts @@ -20,14 +20,14 @@ test("self provider: our own dump, user and playlist pages come from the DB", as throw new Error("no network for self links"); }); const content = await fetchRichContent( - `http://gerbeur.test/dumps/${dump.slug}`, + `http://gerbeur.test${dump.path}`, ); assertEquals(content?.title, "Local dump"); assertEquals(content?.description, "About it"); assertEquals(content?.thumbnailUrl, "https://t/x.jpg"); assertEquals(content?.siteName, "gerbeur"); const profile = await fetchRichContent( - `http://gerbeur.test/users/${user.username}`, + `http://gerbeur.test/@${user.username}/playlists`, ); assertEquals(profile?.title, user.username); assertEquals(net.calls.length, 0); @@ -44,7 +44,7 @@ test("self provider: a private playlist gets no preview", async () => { throw new Error("no network for self links"); }); const content = await fetchRichContent( - `http://gerbeur.test/playlists/${playlist.id}`, + `http://gerbeur.test${playlist.path}`, ); assertEquals(content?.title, undefined); }); diff --git a/api/test/routes/avatars.test.ts b/api/test/routes/avatars.test.ts index 0d90ce65..5cbee195 100644 --- a/api/test/routes/avatars.test.ts +++ b/api/test/routes/avatars.test.ts @@ -1,23 +1,26 @@ -import { createUser, formWithFile, get, PNG, post, test } from "../helpers.ts"; +import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts"; import type { SelfUser } from "../../../shared/user.ts"; import { assertEquals, assertFalse } from "@std/assert"; test("avatars: upload, serve, and 404 when there is none", async () => { const user = await createUser(); - assertEquals((await get(`/api/avatars/${user.id}`)).status, 404); - assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404); - const res = await post<SelfUser>("/api/avatars/me", { + assertEquals((await get(`/api/users/${user.id}/avatar`)).status, 404); + assertEquals( + (await get(`/api/users/${crypto.randomUUID()}/avatar`)).status, + 404, + ); + const res = await put<SelfUser>("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG), }); assertEquals(res.status, 200); assertEquals(res.data.avatarMime, "image/png"); assertFalse("passwordHash" in res.data); - const served = await get(`/api/avatars/${user.id}`); + const served = await get(`/api/users/${user.id}/avatar`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); assertEquals( - (await post("/api/avatars/me", { + (await put("/api/me/avatar", { auth: user.auth, form: formWithFile(new Uint8Array([1, 2, 3])), })).status, diff --git a/api/test/routes/bandcamp.test.ts b/api/test/routes/bandcamp.test.ts deleted file mode 100644 index 6ddfed13..00000000 --- a/api/test/routes/bandcamp.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { get, html, stubNetwork } from "../helpers.ts"; -import { assertEquals } from "@std/assert"; -import type { Tralbum } from "../../../shared/player.ts"; - -function escapeAttr(s: string): string { - return s.replaceAll("&", "&").replaceAll('"', """); -} - -function tralbumPage(data: unknown): string { - return `<div id="player" data-tralbum="${ - escapeAttr(JSON.stringify(data)) - }"></div>`; -} - -const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x"; - -const ALBUM = { - item_type: "album", - artist: "Artist", - art_id: 12345, - current: { title: 'The "Album" & more' }, - trackinfo: [ - { - track_num: 1, - title: "One", - duration: 180.5, - streaming: 1, - file: { "mp3-128": STREAM }, - }, - { - track_num: 2, - title: "Preorder", - streaming: 1, - unreleased_track: true, - file: null, - }, - { - track_num: 3, - title: "", - streaming: 1, - file: { "mp3-128": "https://evil.test/x.mp3" }, - }, - { - track_num: 4, - title: "Capped", - streaming: 1, - is_capped: true, - file: { "mp3-128": STREAM }, - }, - ], -}; - -Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => { - using net = stubNetwork(() => html(tralbumPage(ALBUM))); - const url = "https://cached.bandcamp.com/album/x"; - const res = await get<Tralbum>( - `/api/bandcamp/tracks?url=${encodeURIComponent(url)}`, - ); - assertEquals(res.status, 200); - assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); - assertEquals(res.data.tracks.length, 4); - // Same page (different query/case/trailing slash) → cache hit. - await get( - `/api/bandcamp/tracks?url=${ - encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y") - }`, - ); - assertEquals(net.calls.length, 1); - await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}&force=1`); - assertEquals(net.calls.length, 2); -}); - -Deno.test("GET /api/bandcamp/tracks: refusals and upstream failures", async () => { - using _ = stubNetwork((url) => { - if (url.hostname.startsWith("down")) { - return new Response("", { status: 503 }); - } - if (url.hostname.startsWith("json")) return Response.json({}); - return html("<html>no tralbum</html>"); - }); - const status = async (url: string) => - (await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`)).status; - assertEquals(await status("https://example.com/album/x"), 400); - assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400); - assertEquals(await status("http://localhost/album/x"), 400); - assertEquals(await status("https://down.bandcamp.com/album/x"), 502); - assertEquals(await status("https://json.bandcamp.com/album/x"), 502); - assertEquals(await status("https://empty.bandcamp.com/album/x"), 404); -}); diff --git a/api/test/routes/categories.test.ts b/api/test/routes/categories.test.ts index 05b7318b..64325928 100644 --- a/api/test/routes/categories.test.ts +++ b/api/test/routes/categories.test.ts @@ -14,7 +14,7 @@ import type { Dump } from "../../../shared/dump.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { assertEquals, assertStringIncludes } from "@std/assert"; -test("categories: listed by position, then slug; found by slug", async () => { +test("categories: listed by position, then slug", async () => { insertCategory({ slug: "zeta", position: 0 }); insertCategory({ slug: "alpha", position: 1 }); insertCategory({ slug: "beta", position: 0 }); @@ -24,11 +24,6 @@ test("categories: listed by position, then slug; found by slug", async () => { "zeta", "alpha", ]); - assertEquals( - (await get<Category>("/api/categories/alpha")).data.slug, - "alpha", - ); - assertEquals((await get("/api/categories/nope")).status, 404); }); test("categories: only category:manage may create, update or delete", async () => { @@ -74,7 +69,8 @@ test("categories: slug and name rules, and friendly conflicts", async () => { for ( const [json, message] of [ [{ slug: "Bad Slug", name: "x" }, "lowercase"], - [{ slug: "users", name: "x" }, "reserved"], + [{ slug: "hot", name: "x" }, "reserved"], + [{ slug: "settings", 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"], @@ -110,7 +106,7 @@ test("categories: slug and name rules, and friendly conflicts", async () => { assertEquals(ok.data.position, 3); }); -test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => { +test("GET /api/dumps?category=: members only, private for the owner", async () => { const owner = await createUser(); const cat = insertCategory({ slug: "music" }); const inCat = insertDump({ userId: owner.id, title: "in" }); @@ -126,19 +122,19 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy ) .run(d.id, cat); } - const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps"); + const anon = await get<PaginatedData<Dump>>("/api/dumps?category=music"); assertEquals(anon.data.items.map((d) => d.title), [ "in", ]); assertEquals(anon.data.items[0].categoryIds, [cat]); assertEquals( - (await get<PaginatedData<Dump>>("/api/categories/music/dumps", { + (await get<PaginatedData<Dump>>("/api/dumps?category=music", { auth: owner.auth, })).data .items.length, 2, ); - assertEquals((await get("/api/categories/nope/dumps")).status, 404); + assertEquals((await get("/api/dumps?category=nope")).status, 404); // Deleting the category unlinks its dumps without deleting them. const admin = await createUser({ role: "admin" }); diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index f20e3971..6de41436 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -21,13 +21,7 @@ import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import type { Playlist } from "../../../shared/playlist.ts"; import type { PublicUser } from "../../../shared/user.ts"; -import { - assertEquals, - assertExists, - assertFalse, - assertMatch, - assertNotEquals, -} from "@std/assert"; +import { assertEquals, assertExists, assertFalse } from "@std/assert"; const PAGE = html(`<title>Scraped title `); @@ -53,8 +47,9 @@ test("POST /api/dumps: a URL dump takes its title from the page", async () => { const dump = res.data; assertEquals(dump.kind, "url"); assertEquals(dump.title, "Scraped title"); - assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/); + assertEquals(dump.slug, "scraped-title"); assertEquals(dump.userId, user.id); + assertEquals(dump.ownerUsername, user.username); assertEquals(dump.isPrivate, false); assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg"); const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) @@ -149,7 +144,7 @@ test("POST /api/dumps: a file dump is stored and served back", async () => { assertEquals(dump.fileSize, PNG.byteLength); assertEquals(dump.comment, "tiny"); assertEquals(readFile(`dumps/${dump.id}`), PNG); - const file = await get(`/api/files/${dump.id}`); + const file = await get(`/api/dumps/${dump.id}/file`); assertEquals(file.status, 200); assertEquals(file.bytes, PNG); }); @@ -221,11 +216,12 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones" // ── Read ───────────────────────────────────────────────────────────────────── -test("GET /api/dumps/:id works by id and by slug", async () => { +test("GET /api/dumps/:id works by id only", async () => { const user = await createUser(); const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); assertEquals((await get(`/api/dumps/${id}`)).data.id, id); - assertEquals((await get(`/api/dumps/${slug}`)).data.id, id); + // A slug is only unique under its owner: it is read by page path. + assertEquals((await get(`/api/dumps/${slug}`)).status, 404); assertEquals((await get(`/api/dumps/nope`)).status, 404); assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); }); @@ -293,7 +289,7 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only", ); }); -test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => { +test("GET /api/dumps?url=: matches on the canonical key, respects privacy", async () => { const owner = await createUser(); const other = await createUser(); insertDump({ @@ -308,24 +304,24 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as isPrivate: true, }); const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); - const anon = await get(`/api/dumps/by-url?url=${q}`); + const anon = await get(`/api/dumps?url=${q}`); assertEquals(anon.data.map((d) => d.title), [ "public", ]); - assertEquals(anon.data[0].username, owner.username); - const mine = await get(`/api/dumps/by-url?url=${q}`, { + assertEquals(anon.data[0].ownerUsername, owner.username); + const mine = await get(`/api/dumps?url=${q}`, { auth: owner.auth, }); assertEquals(mine.data.length, 2); assertEquals( - (await get(`/api/dumps/by-url?url=${q}`, { + (await get(`/api/dumps?url=${q}`, { auth: other.auth, })).data .length, 1, ); assertEquals( - (await get(`/api/dumps/by-url?url=garbage`)).data, + (await get(`/api/dumps?url=garbage`)).data, [], ); }); @@ -365,7 +361,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones" json: { url: "https://example.com/src", comment: - `see https://www.x.test/t/?utm_source=feed and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, + `see https://www.x.test/t/?utm_source=feed and http://anywhere${secret.path}#comment-x and http://gerbeur.test/@${other.username}/dumps/scraped-title`, }, }); const srcId = src.data.id; @@ -416,6 +412,48 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private // ── Update ─────────────────────────────────────────────────────────────────── +test("PUT /api/dumps/:id: a slug changes when sent, the old one stays an alias, 3 a day", async () => { + const owner = await createUser(); + const mod = await createUser({ role: "moderator" }); + const { id } = insertDump({ userId: owner.id, title: "First" }); + const other = insertDump({ userId: owner.id, title: "Taken" }); + const byPath = (slug: string) => + get(`/api/users/@${owner.username}/dumps/${slug}`, { + auth: owner.auth, + }); + const setSlug = (slug: string, auth = owner.auth) => + put(`/api/dumps/${id}`, { auth, json: { slug } }); + + assertEquals((await setSlug("Not A Slug")).status, 400); + // Another dump's slug, current or former, is refused. + assertEquals((await setSlug(other.slug)).status, 409); + assertEquals((await setSlug("second")).data.slug, "second"); + // The old slug answers the dump, which carries its current one. + assertEquals((await byPath("first")).data.slug, "second"); + assertEquals((await byPath("second")).data.id, id); + // Taking back its own former slug swaps them; the limit counts the aliases + // left, so a swap doesn't add to it. + assertEquals((await setSlug("first")).data.slug, "first"); + assertEquals((await byPath("second")).data.slug, "first"); + assertEquals((await setSlug("third")).data.slug, "third"); + assertEquals((await setSlug("fourth")).data.slug, "fourth"); + // A fourth alias in a day is refused, but not to a moderator. + assertEquals((await setSlug("fifth")).status, 429); + assertEquals((await setSlug("fifth", mod.auth)).data.slug, "fifth"); + // Every former slug still leads to it, without chaining. + for (const slug of ["first", "second", "third", "fourth", "fifth"]) { + assertEquals((await byPath(slug)).data.slug, "fifth", slug); + } + // Deleting the dump frees its aliases. + await del(`/api/dumps/${id}`, { auth: owner.auth }); + assertEquals( + count(`SELECT COUNT(*) AS n FROM dump_slug_aliases WHERE dump_id = ?;`, id), + 0, + ); + const reborn = insertDump({ userId: owner.id, title: "Second" }); + assertEquals(reborn.slug, "second"); +}); + test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => { const owner = await createUser(); const other = await createUser(); @@ -430,14 +468,14 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async ( (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, 401, ); - const res = await put(`/api/dumps/${slug}`, { + const res = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "New title", comment: "hello" }, }); assertEquals(res.status, 200); assertEquals(res.data.title, "New title"); - assertMatch(res.data.slug ?? "", /^new-title-/); - assertNotEquals(res.data.slug, slug); + // A new title never changes the slug by itself. + assertEquals(res.data.slug, slug); assertExists(res.data.updatedAt); const byMod = await put(`/api/dumps/${id}`, { auth: mod.auth, @@ -635,7 +673,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn let res = await replace({ isPrivate: "true" }); assertEquals(res.data.isPrivate, true); assertEquals(await visible(), false); - assertEquals((await get(`/api/files/${id}`)).status, 404); + assertEquals((await get(`/api/dumps/${id}/file`)).status, 404); // Left out, it stays private. res = await replace({}); assertEquals(res.data.isPrivate, true); @@ -677,7 +715,7 @@ test("custom thumbnails: set, served, removed", async () => { }); assertEquals(set.status, 200); assertEquals(set.data.thumbnailMime, "image/png"); - const thumb = await get(`/api/thumbnails/${id}`); + const thumb = await get(`/api/dumps/${id}/thumbnail`); assertEquals(thumb.status, 200); assertEquals(thumb.headers.get("content-type"), "image/png"); assertEquals(thumb.bytes, PNG); @@ -688,10 +726,10 @@ test("custom thumbnails: set, served, removed", async () => { assertEquals(removed.data.thumbnailMime, undefined); assertEquals(readFile(`thumbnails/${id}-custom`), null); // A URL dump without its own thumbnail has none to serve. - assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); + assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404); }); -test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => { +test("POST /api/dumps/:id/metadata: updates, or keeps what it had on failure", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id, @@ -707,7 +745,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai using _ = stubNetwork(() => { throw new TypeError("down"); }); - const res = await post(`/api/dumps/${id}/refresh-metadata`, { + const res = await post(`/api/dumps/${id}/metadata`, { auth: owner.auth, }); assertEquals(res.status, 502); @@ -715,7 +753,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai } { using _ = stubNetwork(() => PAGE.clone()); - const res = await post(`/api/dumps/${id}/refresh-metadata`, { + const res = await post(`/api/dumps/${id}/metadata`, { auth: owner.auth, }); assertEquals(res.status, 200); @@ -723,7 +761,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai } const file = insertDump({ userId: owner.id, fileName: "f.txt" }); assertEquals( - (await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth })) + (await post(`/api/dumps/${file.id}/metadata`, { auth: owner.auth })) .status, 400, ); diff --git a/api/test/routes/files.test.ts b/api/test/routes/files.test.ts index f4b32dfe..cb869039 100644 --- a/api/test/routes/files.test.ts +++ b/api/test/routes/files.test.ts @@ -9,7 +9,7 @@ function createdAtOf(id: string): string { }).created_at; } -test("GET /api/files/:id: headers for a safe inline type", async () => { +test("GET /api/dumps/:id/file: headers for a safe inline type", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, @@ -17,7 +17,7 @@ test("GET /api/files/:id: headers for a safe inline type", async () => { fileMime: "text/plain", fileData: BYTES, }); - const res = await get(`/api/files/${id}`); + const res = await get(`/api/dumps/${id}/file`); assertEquals(res.status, 200); assertEquals(res.bytes, BYTES); assertEquals(res.headers.get("content-type"), "text/plain"); @@ -33,7 +33,7 @@ test("GET /api/files/:id: headers for a safe inline type", async () => { assertStringIncludes(etag, 'W/"'); // The client already has it: a 304, no bytes. - const again = await get(`/api/files/${id}`, { + const again = await get(`/api/dumps/${id}/file`, { headers: { "If-None-Match": etag }, }); assertEquals(again.status, 304); @@ -42,17 +42,17 @@ test("GET /api/files/:id: headers for a safe inline type", async () => { // The app's URL names the file's current version, which never changes. const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`; assertEquals( - (await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"), + (await get(`/api/dumps/${id}/file?v=${v}`)).headers.get("cache-control"), "public, max-age=31536000, immutable", ); // A stale version is revalidated like no version at all. assertEquals( - (await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"), + (await get(`/api/dumps/${id}/file?v=10-0`)).headers.get("cache-control"), "public, no-cache", ); }); -test("GET /api/files/:id: active content downloads instead of rendering", async () => { +test("GET /api/dumps/:id/file: active content downloads instead of rendering", async () => { const user = await createUser(); for (const mime of ["text/html", "image/svg+xml", "application/xml"]) { const { id } = insertDump({ @@ -60,7 +60,7 @@ test("GET /api/files/:id: active content downloads instead of rendering", async fileName: "x", fileMime: mime, }); - const res = await get(`/api/files/${id}`); + const res = await get(`/api/dumps/${id}/file`); assertStringIncludes( res.headers.get("content-disposition")!, "attachment;", @@ -72,19 +72,19 @@ test("GET /api/files/:id: active content downloads instead of rendering", async fileName: "a.pdf", fileMime: "application/pdf", }); - const res = await get(`/api/files/${pdf.id}`); + const res = await get(`/api/dumps/${pdf.id}/file`); assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); assertEquals(res.headers.get("content-security-policy"), null); }); -test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => { +test("GET /api/dumps/:id/file: a non-Latin-1 filename doesn't break the header", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, fileName: 'mix "été" 🎵.mp3', fileMime: "audio/mpeg", }); - const res = await get(`/api/files/${id}`); + const res = await get(`/api/dumps/${id}/file`); assertEquals(res.status, 200); assertEquals( res.headers.get("content-disposition"), @@ -94,7 +94,7 @@ test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", asyn ); }); -test("GET /api/files/:id: byte ranges", async () => { +test("GET /api/dumps/:id/file: byte ranges", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, @@ -102,7 +102,7 @@ test("GET /api/files/:id: byte ranges", async () => { fileData: BYTES, }); const range = (r: string) => - get(`/api/files/${id}`, { headers: { Range: r } }); + get(`/api/dumps/${id}/file`, { headers: { Range: r } }); let res = await range("bytes=2-5"); assertEquals(res.status, 206); @@ -156,7 +156,7 @@ test("GET /api/files/:id: byte ranges", async () => { } }); -test("GET /api/files/:id: private files need the owner's media token", async () => { +test("GET /api/dumps/:id/file: private files need the owner's media token", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ @@ -164,28 +164,31 @@ test("GET /api/files/:id: private files need the owner's media token", async () fileName: "p", isPrivate: true, }); - assertEquals((await get(`/api/files/${id}`)).status, 404); + assertEquals((await get(`/api/dumps/${id}/file`)).status, 404); assertEquals( - (await get(`/api/files/${id}?token=${other.mediaToken}`)).status, + (await get(`/api/dumps/${id}/file?token=${other.mediaToken}`)).status, 404, ); - assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404); - const res = await get(`/api/files/${id}?token=${owner.mediaToken}`); + assertEquals((await get(`/api/dumps/${id}/file?token=garbage`)).status, 404); + const res = await get(`/api/dumps/${id}/file?token=${owner.mediaToken}`); assertEquals(res.status, 200); assertEquals(res.headers.get("cache-control"), "private, no-cache"); // A legacy session token still works in the query string. assertEquals( - (await get(`/api/files/${id}?token=${owner.token}`)).status, + (await get(`/api/dumps/${id}/file?token=${owner.token}`)).status, 200, ); }); -test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => { +test("GET /api/dumps/:id/file: bad ids, URL dumps and missing bytes", async () => { const user = await createUser(); - assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400); + assertEquals( + (await get(`/api/dumps/..%2F..%2Fetc%2Fpasswd/file`)).status, + 400, + ); const url = insertDump({ userId: user.id }); - assertEquals((await get(`/api/files/${url.id}`)).status, 404); + assertEquals((await get(`/api/dumps/${url.id}/file`)).status, 404); const gone = insertDump({ userId: user.id, fileName: "g" }); Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`); - assertEquals((await get(`/api/files/${gone.id}`)).status, 404); + assertEquals((await get(`/api/dumps/${gone.id}/file`)).status, 404); }); diff --git a/api/test/routes/follows.test.ts b/api/test/routes/follows.test.ts index f19a8587..0e1f4683 100644 --- a/api/test/routes/follows.test.ts +++ b/api/test/routes/follows.test.ts @@ -9,6 +9,7 @@ import { insertDump, insertPlaylist, post, + put, test, } from "../helpers.ts"; import type { Dump } from "../../../shared/dump.ts"; @@ -26,35 +27,35 @@ test("following users: status, idempotence, no self-follow, one notification", a const me = await createUser(); const them = await createUser(); assertEquals( - (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, + (await put(`/api/me/follow/users/${them.id}`, { auth: me.auth })).status, 204, ); assertEquals( - (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, + (await put(`/api/me/follow/users/${them.id}`, { auth: me.auth })).status, 204, ); assertEquals( - (await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status, + (await put(`/api/me/follow/users/${me.id}`, { auth: me.auth })).status, 400, ); assertEquals( - (await post(`/api/follows/users/${crypto.randomUUID()}`, { + (await put(`/api/me/follow/users/${crypto.randomUUID()}`, { auth: me.auth, })).status, 404, ); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).data, + (await get("/api/me/follow", { auth: me.auth })).data, { followedUserIds: [them.id], followedPlaylistIds: [] }, ); // Unfollow and follow again: still just the one notification. - await del(`/api/follows/users/${them.id}`, { auth: me.auth }); + await del(`/api/me/follow/users/${them.id}`, { auth: me.auth }); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).data + (await get("/api/me/follow", { auth: me.auth })).data .followedUserIds, [], ); - await post(`/api/follows/users/${them.id}`, { auth: me.auth }); + await put(`/api/me/follow/users/${them.id}`, { auth: me.auth }); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`, @@ -63,7 +64,7 @@ test("following users: status, idempotence, no self-follow, one notification", a 1, ); const list = await get>( - `/api/users/${me.username}/followed-users`, + `/api/users/@${me.username}/follow/users`, ); assertEquals(list.data.items.map((u) => u.id), [ them.id, @@ -77,20 +78,21 @@ test("following playlists: public ones only, and not your own notification", asy const pub = insertPlaylist({ userId: owner.id }); const priv = insertPlaylist({ userId: owner.id, isPublic: false }); assertEquals( - (await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status, + (await put(`/api/me/follow/playlists/${pub.id}`, { auth: me.auth })).status, 204, ); assertEquals( - (await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, + (await put(`/api/me/follow/playlists/${priv.id}`, { auth: me.auth })) + .status, 404, ); assertEquals( - (await post(`/api/follows/playlists/${crypto.randomUUID()}`, { + (await put(`/api/me/follow/playlists/${crypto.randomUUID()}`, { auth: me.auth, })).status, 404, ); - await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth }); + await put(`/api/me/follow/playlists/${pub.id}`, { auth: owner.auth }); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`, @@ -99,14 +101,14 @@ test("following playlists: public ones only, and not your own notification", asy ); assertEquals( (await get>( - `/api/users/${me.username}/followed-playlists`, + `/api/users/@${me.username}/follow/playlists`, )).data .items[0].id, pub.id, ); - await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); + await del(`/api/me/follow/playlists/${pub.id}`, { auth: me.auth }); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).data + (await get("/api/me/follow", { auth: me.auth })).data .followedPlaylistIds, [], ); @@ -133,27 +135,27 @@ test("followed-users feed: public dumps of the people I follow, newest first", a .run(tagged.id, music); insertDump({ userId: followed.id, title: "private", isPrivate: true }); insertDump({ userId: stranger.id, title: "stranger" }); - await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); + await put(`/api/me/follow/users/${followed.id}`, { auth: me.auth }); - const feed = await get>("/api/follows/feed/users", { + const feed = await get>("/api/me/feed?from=users", { auth: me.auth, }); assertEquals(titles(feed), ["new", "old"]); assertEquals(feed.data.items[0].categoryIds, [music]); assertEquals( titles( - await get("/api/follows/feed/users?category=music", { auth: me.auth }), + await get("/api/me/feed?from=users&category=music", { auth: me.auth }), ), ["new"], ); // An unknown category filters everything out rather than nothing. assertEquals( - (await get>("/api/follows/feed/users?category=nope", { + (await get>("/api/me/feed?from=users&category=nope", { auth: me.auth, })).data, { items: [], hasMore: false }, ); - assertEquals((await get("/api/follows/feed/users")).status, 401); + assertEquals((await get("/api/me/feed?from=users")).status, 401); }); test("followed-playlists feed: each dump once, public playlists only", async () => { @@ -168,11 +170,11 @@ test("followed-playlists feed: each dump once, public playlists only", async () isPrivate: true, }); for (const p of [p1, p2]) { - await post(`/api/follows/playlists/${p.id}`, { auth: me.auth }); + await put(`/api/me/follow/playlists/${p.id}`, { auth: me.auth }); await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); } await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); - const feed = await get>("/api/follows/feed/playlists", { + const feed = await get>("/api/me/feed?from=playlists", { auth: me.auth, }); assertEquals(titles(feed), ["shared"]); @@ -181,7 +183,7 @@ test("followed-playlists feed: each dump once, public playlists only", async () // A playlist gone private drops out of the feed. db.prepare(`UPDATE playlists SET is_public = 0;`).run(); assertEquals( - (await get>("/api/follows/feed/playlists", { + (await get>("/api/me/feed?from=playlists", { auth: me.auth, })).data .items.length, diff --git a/api/test/routes/notifications.test.ts b/api/test/routes/notifications.test.ts index 44dba8ff..772e59dc 100644 --- a/api/test/routes/notifications.test.ts +++ b/api/test/routes/notifications.test.ts @@ -1,5 +1,18 @@ -import { count, createUser, get, insertDump, post, test } from "../helpers.ts"; -import type { Notification } from "../../../shared/notification.ts"; +import { + count, + createUser, + db, + get, + insertDump, + post, + put, + test, +} from "../helpers.ts"; +import type { + DumpCommentedData, + DumpUpvotedData, + Notification, +} from "../../../shared/notification.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { assertEquals } from "@std/assert"; @@ -8,14 +21,14 @@ test("notifications: listed newest first, marked read one or all, never someone const other = await createUser(); const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" }); // Two notifications for me, one for other. - await post(`/api/follows/users/${me.id}`, { auth: other.auth }); + await put(`/api/me/follow/users/${me.id}`, { auth: other.auth }); await post(`/api/dumps/${dumpId}/comments`, { auth: other.auth, json: { body: "hey" }, }); - await post(`/api/follows/users/${other.id}`, { auth: me.auth }); + await put(`/api/me/follow/users/${other.id}`, { auth: me.auth }); - const list = await get>("/api/notifications", { + const list = await get>("/api/me/notifications", { auth: me.auth, }); assertEquals(list.data.items.length, 2); @@ -29,7 +42,7 @@ test("notifications: listed newest first, marked read one or all, never someone ); assertEquals( - (await post("/api/notifications/read-all", { auth: me.auth })).status, + (await post("/api/me/notifications/read", { auth: me.auth })).status, 204, ); assertEquals( @@ -39,5 +52,49 @@ test("notifications: listed newest first, marked read one or all, never someone ), 0, ); - assertEquals((await get("/api/notifications")).status, 401); + assertEquals((await get("/api/me/notifications")).status, 401); +}); + +test("notifications: names and item paths are joined when read, so they follow renames", async () => { + const me = await createUser(); + const fan = await createUser(); + const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" }); + await post(`/api/dumps/${dumpId}/comments`, { + auth: fan.auth, + json: { body: "nice" }, + }); + db.prepare(`UPDATE users SET username = 'fan_renamed' WHERE id = ?;`) + .run(fan.id); + db.prepare(`UPDATE dumps SET slug = 'mine-now' WHERE id = ?;`).run(dumpId); + const [note] = (await get>( + "/api/me/notifications", + { auth: me.auth }, + )).data.items; + assertEquals(note.type, "dump_commented"); + const data = note.data as DumpCommentedData; + assertEquals(data.commenterUsername, "fan_renamed"); + assertEquals([data.dumpOwnerUsername, data.dumpSlug], [ + me.username, + "mine-now", + ]); + // Once the dump is gone, the notification has no path to it. + db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId); + db.prepare( + `INSERT INTO notifications (id, user_id, type, data, created_at) VALUES (?, ?, 'dump_upvoted', ?, ?);`, + ).run( + crypto.randomUUID(), + me.id, + JSON.stringify({ + voterId: fan.id, + voterUsername: "x", + dumpId, + dumpTitle: "Mine", + }), + new Date().toISOString(), + ); + const [upvote] = (await get>( + "/api/me/notifications", + { auth: me.auth }, + )).data.items; + assertEquals((upvote.data as DumpUpvotedData).dumpSlug, undefined); }); diff --git a/api/test/routes/playlists.test.ts b/api/test/routes/playlists.test.ts index 52c27b0b..05b9cc42 100644 --- a/api/test/routes/playlists.test.ts +++ b/api/test/routes/playlists.test.ts @@ -26,7 +26,7 @@ function ids(res: ApiResponse): string[] { return res.data.dumps.map((d) => d.id); } -test("POST /api/playlists creates; GET finds it by id or slug", async () => { +test("POST /api/playlists creates; GET finds it by id, or by path under its owner", async () => { const user = await createUser(); const res = await post("/api/playlists", { auth: user.auth, @@ -34,12 +34,16 @@ test("POST /api/playlists creates; GET finds it by id or slug", async () => { }); assertEquals(res.status, 201); const { id, slug } = res.data; - assertMatch(slug ?? "", /^road-trip-/); + assertEquals(slug, "road-trip"); + assertEquals(res.data.ownerUsername, user.username); assertEquals( (await get(`/api/playlists/${id}`)).data.title, "Road Trip", ); - const bySlug = await get(`/api/playlists/${slug}`); + assertEquals((await get(`/api/playlists/${slug}`)).status, 404); + const bySlug = await get( + `/api/users/@${user.username}/playlists/${slug}`, + ); assertEquals(bySlug.data.id, id); assertEquals(bySlug.data.ownerUsername, user.username); assertEquals(bySlug.data.dumps, []); @@ -69,7 +73,7 @@ test("private playlists are the owner's alone", async () => { 200, ); insertPlaylist({ userId: owner.id }); - const path = `/api/users/${owner.username}/playlists`; + const path = `/api/users/@${owner.username}/playlists`; assertEquals((await get>(path)).data.items.length, 1); assertEquals( (await get>(path, { auth: owner.auth })).data.items @@ -220,8 +224,28 @@ test("removing a dump; updating; deleting", async () => { json: { title: "New", description: "d", isPublic: false }, }); assertEquals(updated.data.title, "New"); - assertMatch(updated.data.slug ?? "", /^new-/); + // A new title never changes the slug by itself; a slug sent does. + assertEquals(updated.data.slug, "old"); assertEquals(updated.data.isPublic, false); + const reslugged = await put(`/api/playlists/${id}`, { + auth: owner.auth, + json: { slug: "new" }, + }); + assertEquals(reslugged.data.slug, "new"); + assertEquals( + (await get( + `/api/users/@${owner.username}/playlists/old`, + { auth: owner.auth }, + )).data.slug, + "new", + ); + assertEquals( + (await put(`/api/playlists/${id}`, { + auth: owner.auth, + json: { slug: "Bad slug" }, + })).status, + 400, + ); const cleared = await put(`/api/playlists/${id}`, { auth: owner.auth, json: { description: null }, @@ -253,7 +277,7 @@ test("memberships: which of my playlists hold this dump", async () => { const d = insertDump({ userId: owner.id }); await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth }); const res = await get( - `/api/playlists/by-dump/${d.id}/memberships`, + `/api/me/playlists?contains=${d.id}`, { auth: owner.auth, }, @@ -273,7 +297,7 @@ test("adding to a followed public playlist notifies followers, private dumps exc const owner = await createUser(); const follower = await createUser(); const { id } = insertPlaylist({ userId: owner.id, title: "Mix" }); - await post(`/api/follows/playlists/${id}`, { auth: follower.auth }); + await put(`/api/me/follow/playlists/${id}`, { auth: follower.auth }); db.exec(`DELETE FROM notifications;`); // the follow notified the owner const priv = insertDump({ userId: owner.id, isPrivate: true }); await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth }); @@ -296,20 +320,20 @@ test("adding to a followed public playlist notifies followers, private dumps exc }); }); -test("playlist images: upload and serve", async () => { +test("playlist covers: upload and serve", async () => { const owner = await createUser(); const other = await createUser(); - const { id, slug } = insertPlaylist({ userId: owner.id }); - assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); + const { id } = insertPlaylist({ userId: owner.id }); + assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404); assertEquals( - (await post(`/api/playlists/${id}/image`, { + (await put(`/api/playlists/${id}/cover`, { auth: other.auth, form: formWithFile(PNG), })) .status, 403, ); - const res = await post(`/api/playlists/${slug}/image`, { + const res = await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); @@ -317,37 +341,37 @@ test("playlist images: upload and serve", async () => { assertEquals(res.data.imageMime, "image/png"); // A new cover counts as an edit, so image URLs versioned on it change. assert(res.data.updatedAt); - const served = await get(`/api/playlists/${slug}/image`); + const served = await get(`/api/playlists/${id}/cover`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); }); -test("playlist images: a private playlist's cover only for its owner's media token", async () => { +test("playlist covers: a private playlist's cover only for its owner's media token", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertPlaylist({ userId: owner.id, isPublic: false }); - await post(`/api/playlists/${id}/image`, { + await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); - assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); + assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404); assertEquals( - (await get(`/api/playlists/${id}/image?token=${other.mediaToken}`)).status, + (await get(`/api/playlists/${id}/cover?token=${other.mediaToken}`)).status, 404, ); assertEquals( - (await get(`/api/playlists/${id}/image?token=garbage`)).status, + (await get(`/api/playlists/${id}/cover?token=garbage`)).status, 404, ); const served = await get( - `/api/playlists/${id}/image?token=${owner.mediaToken}`, + `/api/playlists/${id}/cover?token=${owner.mediaToken}`, ); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); assertMatch(served.headers.get("cache-control") ?? "", /^private/); }); -test("playlist images: a failed write leaves the row and the old cover alone", async () => { +test("playlist covers: a failed write leaves the row and the old cover alone", async () => { const owner = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); const dir = `${TEST_DIR}/uploads/playlist-images`; @@ -359,27 +383,27 @@ test("playlist images: a failed write leaves the row and the old cover alone", a // the playlist mustn't claim a cover it hasn't got. await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true }); try { - const failed = await post(`/api/playlists/${id}/image`, { + const failed = await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals(failed.status, 500); assertEquals(row(), { image_mime: null, updated_at: null }); - assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); + assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404); } finally { await Deno.remove(`${dir}/${id}`, { recursive: true }); } // With a cover in place, a failed replacement keeps serving the old one // under the old version, and leaves no stray temporary file behind. - await post(`/api/playlists/${id}/image`, { + await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); const before = row(); await Deno.chmod(dir, 0o555); try { - const failed = await post(`/api/playlists/${id}/image`, { + const failed = await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(new Uint8Array([...PNG, 0])), }); @@ -388,28 +412,28 @@ test("playlist images: a failed write leaves the row and the old cover alone", a await Deno.chmod(dir, 0o755); } assertEquals(row(), before); - assertEquals((await get(`/api/playlists/${id}/image`)).bytes, PNG); + assertEquals((await get(`/api/playlists/${id}/cover`)).bytes, PNG); const left = [...Deno.readDirSync(dir)] .map((e) => e.name) .filter((name) => name.startsWith(id)); assertEquals(left, [id]); }); -test("playlist images: a replaced cover is served under the new version", async () => { +test("playlist covers: a replaced cover is served under the new version", async () => { const owner = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); - const first = await post(`/api/playlists/${id}/image`, { + const first = await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at const other = new Uint8Array([...PNG, 1, 2, 3]); - const second = await post(`/api/playlists/${id}/image`, { + const second = await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(other), }); assert(second.data.updatedAt !== first.data.updatedAt); - assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other); + assertEquals((await get(`/api/playlists/${id}/cover`)).bytes, other); }); test("reorder: someone else's dump that went private keeps its slot", async () => { diff --git a/api/test/routes/preview.test.ts b/api/test/routes/preview.test.ts deleted file mode 100644 index f5f45579..00000000 --- a/api/test/routes/preview.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { get, PNG, stubNetwork } from "../helpers.ts"; -import { assertEquals } from "@std/assert"; - -Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async () => { - using net = stubNetwork((url) => - url.pathname.endsWith(".svg") - ? new Response("", { headers: { "content-type": "image/svg+xml" } }) - : new Response(PNG, { headers: { "content-type": "image/png" } }) - ); - const path = `/api/proxy-image?url=${ - encodeURIComponent("https://cdn.example.com/a.png") - }`; - const first = await get(path); - assertEquals(first.status, 200); - assertEquals(first.headers.get("content-type"), "image/png"); - assertEquals(first.bytes, PNG); - const second = await get(path); - assertEquals(second.bytes, PNG); - assertEquals(net.calls.length, 1); - - const svg = await get( - `/api/proxy-image?url=${ - encodeURIComponent("https://cdn.example.com/a.svg") - }`, - ); - assertEquals(svg.status, 400); -}); - -Deno.test("GET /api/proxy-image: an error answer is never served nor cached", async () => { - let failing = true; - using net = stubNetwork(() => - failing - ? new Response(PNG, { - status: 503, - headers: { "content-type": "image/png" }, - }) - : new Response(PNG, { headers: { "content-type": "image/png" } }) - ); - const path = `/api/proxy-image?url=${ - encodeURIComponent("https://cdn.example.com/flaky.png") - }`; - assertEquals((await get(path)).status, 400); - failing = false; - assertEquals((await get(path)).status, 200); - assertEquals(net.calls.length, 2); -}); diff --git a/api/test/routes/remote.test.ts b/api/test/routes/remote.test.ts new file mode 100644 index 00000000..a161dd70 --- /dev/null +++ b/api/test/routes/remote.test.ts @@ -0,0 +1,199 @@ +import { get, html, PNG, stubNetwork } from "../helpers.ts"; +import { assertEquals, assertFalse } from "@std/assert"; +import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts"; + +// ── Images ─────────────────────────────────────────────────────────────────── + +Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", async () => { + using net = stubNetwork((url) => + url.pathname.endsWith(".svg") + ? new Response("", { headers: { "content-type": "image/svg+xml" } }) + : new Response(PNG, { headers: { "content-type": "image/png" } }) + ); + const path = `/api/remote/image?url=${ + encodeURIComponent("https://cdn.example.com/a.png") + }`; + const first = await get(path); + assertEquals(first.status, 200); + assertEquals(first.headers.get("content-type"), "image/png"); + assertEquals(first.bytes, PNG); + const second = await get(path); + assertEquals(second.bytes, PNG); + assertEquals(net.calls.length, 1); + + const svg = await get( + `/api/remote/image?url=${ + encodeURIComponent("https://cdn.example.com/a.svg") + }`, + ); + assertEquals(svg.status, 400); +}); + +Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => { + let failing = true; + using net = stubNetwork(() => + failing + ? new Response(PNG, { + status: 503, + headers: { "content-type": "image/png" }, + }) + : new Response(PNG, { headers: { "content-type": "image/png" } }) + ); + const path = `/api/remote/image?url=${ + encodeURIComponent("https://cdn.example.com/flaky.png") + }`; + assertEquals((await get(path)).status, 400); + failing = false; + assertEquals((await get(path)).status, 200); + assertEquals(net.calls.length, 2); +}); + +// ── Bandcamp ───────────────────────────────────────────────────────────────── + +function escapeAttr(s: string): string { + return s.replaceAll("&", "&").replaceAll('"', """); +} + +function tralbumPage(data: unknown): string { + return `
`; +} + +const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x"; + +const ALBUM = { + item_type: "album", + artist: "Artist", + art_id: 12345, + current: { title: 'The "Album" & more' }, + trackinfo: [ + { + track_num: 1, + title: "One", + duration: 180.5, + streaming: 1, + file: { "mp3-128": STREAM }, + }, + { + track_num: 2, + title: "Preorder", + streaming: 1, + unreleased_track: true, + file: null, + }, + { + track_num: 3, + title: "", + streaming: 1, + file: { "mp3-128": "https://evil.test/x.mp3" }, + }, + { + track_num: 4, + title: "Capped", + streaming: 1, + is_capped: true, + file: { "mp3-128": STREAM }, + }, + ], +}; + +Deno.test("GET /api/remote/bandcamp: resolves, caches, and forces a refetch", async () => { + using net = stubNetwork(() => html(tralbumPage(ALBUM))); + const url = "https://cached.bandcamp.com/album/x"; + const res = await get( + `/api/remote/bandcamp?url=${encodeURIComponent(url)}`, + ); + assertEquals(res.status, 200); + assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); + assertEquals(res.data.tracks.length, 4); + // Same page (different query/case/trailing slash) → cache hit. + await get( + `/api/remote/bandcamp?url=${ + encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y") + }`, + ); + assertEquals(net.calls.length, 1); + await get(`/api/remote/bandcamp?url=${encodeURIComponent(url)}&force=1`); + assertEquals(net.calls.length, 2); +}); + +Deno.test("GET /api/remote/bandcamp: refusals and upstream failures", async () => { + using _ = stubNetwork((url) => { + if (url.hostname.startsWith("down")) { + return new Response("", { status: 503 }); + } + if (url.hostname.startsWith("json")) return Response.json({}); + return html("no tralbum"); + }); + const status = async (url: string) => + (await get(`/api/remote/bandcamp?url=${encodeURIComponent(url)}`)).status; + assertEquals(await status("https://example.com/album/x"), 400); + assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400); + assertEquals(await status("http://localhost/album/x"), 400); + assertEquals(await status("https://down.bandcamp.com/album/x"), 502); + assertEquals(await status("https://json.bandcamp.com/album/x"), 502); + assertEquals(await status("https://empty.bandcamp.com/album/x"), 404); +}); + +// ── YouTube ────────────────────────────────────────────────────────────────── + +function ytPage(data: unknown): string { + return ``; +} + +const YT_DATA = { + metadata: { playlistMetadataRenderer: { title: "My mix" } }, + contents: { + list: [ + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_VIDEO", + contentId: "vid1", + metadata: { + lockupMetadataViewModel: { title: { content: "First" } }, + }, + contentImage: { + overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], + }, + }, + }, + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", + contentId: "ignored", + }, + }, + { + playlistVideoRenderer: { + videoId: "vid2", + title: { runs: [{ text: "Second" }] }, + lengthSeconds: "235", + }, + }, + ], + }, +}; + +Deno.test("GET /api/remote/youtube: resolves and validates the id", async () => { + let cookie: string | null = null; + using net = stubNetwork((url, init) => { + cookie = new Headers(init?.headers).get("cookie"); + return url.searchParams.get("list") === "PLgone" + ? new Response("", { status: 404 }) + : html(ytPage(YT_DATA)); + }); + const ok = await get("/api/remote/youtube?list=PLgood"); + assertEquals(ok.status, 200); + assertEquals(ok.data.tracks.length, 2); + assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); + // The pre-answered consent cookie keeps EU requests off the consent page. + assertEquals(cookie, "SOCS=CAI"); + await get("/api/remote/youtube?list=PLgood"); // cached + assertEquals(net.calls.length, 1); + + assertEquals((await get("/api/remote/youtube?list=bad%20id")).status, 400); + assertEquals((await get("/api/remote/youtube")).status, 400); + assertEquals((await get("/api/remote/youtube?list=PLgone")).status, 502); + assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id")); +}); diff --git a/api/test/routes/thumbnails.test.ts b/api/test/routes/thumbnails.test.ts index aa9b25bf..edbb2e0b 100644 --- a/api/test/routes/thumbnails.test.ts +++ b/api/test/routes/thumbnails.test.ts @@ -37,7 +37,7 @@ function readDir(path: string): string[] { } } -test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => { +test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy", async () => { const owner = await createUser(); const video = insertDump({ userId: owner.id, @@ -54,20 +54,21 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async for (const { id } of [video, priv]) { Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG); } - const res = await get(`/api/thumbnails/${video.id}`); + const res = await get(`/api/dumps/${video.id}/thumbnail`); assertEquals(res.status, 200); assertEquals(res.headers.get("content-type"), "image/jpeg"); assertEquals(res.headers.get("cache-control"), "public, no-cache"); const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`; assertEquals( - (await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).headers.get( - "cache-control", - ), + (await get(`/api/dumps/${video.id}/thumbnail?v=${v(video.id)}`)).headers + .get( + "cache-control", + ), "public, max-age=31536000, immutable", ); - assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); + assertEquals((await get(`/api/dumps/${priv.id}/thumbnail`)).status, 404); const own = await get( - `/api/thumbnails/${priv.id}?v=${v(priv.id)}&token=${owner.mediaToken}`, + `/api/dumps/${priv.id}/thumbnail?v=${v(priv.id)}&token=${owner.mediaToken}`, ); assertEquals(own.status, 200); assertEquals( @@ -76,11 +77,11 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async ); const text = insertDump({ userId: owner.id, fileName: "t.txt" }); - assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404); - assertEquals((await get(`/api/thumbnails/nope`)).status, 400); + assertEquals((await get(`/api/dumps/${text.id}/thumbnail`)).status, 404); + assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400); }); -test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => { +test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => { const owner = await createUser(); const gif = new TextEncoder().encode("GIF89a-not-really"); const animated = insertDump({ @@ -89,7 +90,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn fileMime: "image/gif", fileData: gif, }); - const res = await get(`/api/thumbnails/${animated.id}`); + const res = await get(`/api/dumps/${animated.id}/thumbnail`); assertEquals(res.status, 200); assertEquals(res.headers.get("content-type"), "image/gif"); assertEquals(res.bytes, gif); @@ -101,7 +102,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn fileMime: "image/png", fileData: PNG, }); - const preview = await get(`/api/thumbnails/${photo.id}`); + const preview = await get(`/api/dumps/${photo.id}/thumbnail`); assertEquals(preview.status, 200); assertEquals(preview.headers.get("content-type"), "image/png"); if (await hasFfmpeg()) { @@ -120,7 +121,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn fileMime: "image/png", fileData: BYTES, }); - const fallback = await get(`/api/thumbnails/${broken.id}`); + const fallback = await get(`/api/dumps/${broken.id}/thumbnail`); assertEquals(fallback.status, 200); assertEquals(fallback.headers.get("content-type"), "image/png"); assertEquals(fallback.bytes, BYTES); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index 2cd280e6..25df357c 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -6,10 +6,13 @@ import { del, get, insertDump, + insertPlaylist, PASSWORD, patch, post, + put, test, + uniqueName, } from "../helpers.ts"; import type { AuthResponse, @@ -28,6 +31,7 @@ import { } from "../../lib/jwt.ts"; import { authenticateMedia } from "../../lib/auth.ts"; import type { Dump } from "../../../shared/dump.ts"; +import type { Playlist } from "../../../shared/playlist.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { DEFAULT_SETTINGS, @@ -42,10 +46,14 @@ function invite(inviterId: string, createdAt = new Date()): string { return token; } -test("public profile: by username or id, without private fields", async () => { +test("public profile: by @name in any case or id, without private fields", async () => { const user = await createUser(); for ( - const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`] + const path of [ + `/api/users/@${user.username}`, + `/api/users/@${user.username.toUpperCase()}`, + `/api/users/${user.id}`, + ] ) { const res = await get(path); assertEquals(res.status, 200, path); @@ -53,53 +61,55 @@ test("public profile: by username or id, without private fields", async () => { assertFalse("email" in res.data); assertFalse("passwordHash" in res.data); } - assertEquals((await get(`/api/users/no_one_here`)).status, 404); - const me = await get("/api/users/me", { auth: user.auth }); + assertEquals((await get(`/api/users/@no_one_here`)).status, 404); + // Without the `@`, the key is an id. + assertEquals((await get(`/api/users/${user.username}`)).status, 404); + const me = await get("/api/me", { auth: user.auth }); assertEquals(me.data.email, user.email); assertFalse("passwordHash" in me.data); }); -test("PATCH /api/users/me: description and email only", async () => { +test("PATCH /api/me: description, email and username only", async () => { const user = await createUser(); const taken = await createUser(); - const res = await patch("/api/users/me", { + const res = await patch("/api/me", { auth: user.auth, json: { description: "about me", email: "new@example.test" }, }); assertEquals(res.status, 200); assertEquals(res.data.description, "about me"); - assertFalse("email" in res.data); - assertEquals( - (await get("/api/users/me", { auth: user.auth })).data.email, - "new@example.test", - ); - // A role, username or password in the body is ignored, not applied. - await patch("/api/users/me", { + assertEquals(res.data.email, "new@example.test"); + assertFalse("passwordHash" in res.data); + // A role or password in the body is ignored, not applied. + const hash = () => + (db.prepare(`SELECT password_hash FROM users WHERE id = ?;`).get( + user.id, + ) as { password_hash: string }).password_hash; + const before = hash(); + await patch("/api/me", { auth: user.auth, - json: { role: "admin", username: "hijack", password: "whatever-long" }, + json: { role: "admin", password: "whatever-long" }, }); - const row = db.prepare(`SELECT role, username FROM users WHERE id = ?;`).get( + const row = db.prepare(`SELECT role FROM users WHERE id = ?;`).get( user.id, - ) as { - role: string; - username: string; - }; - assertEquals(row, { role: "user", username: user.username }); + ) as { role: string }; + assertEquals(row.role, "user"); + assertEquals(hash(), before); assertEquals( - (await patch("/api/users/me", { + (await patch("/api/me", { auth: user.auth, json: { email: taken.email }, })).status, 409, ); assertEquals( - (await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } })) + (await patch("/api/me", { auth: user.auth, json: { email: "bad" } })) .status, 400, ); assertEquals( - (await patch("/api/users/me", { + (await patch("/api/me", { auth: user.auth, json: { description: "x".repeat(2001) }, })).status, @@ -107,7 +117,7 @@ test("PATCH /api/users/me: description and email only", async () => { ); }); -test("PATCH /api/users/:id/role: admins only, never their own", async () => { +test("PATCH /api/users/:key/role: admins only, never their own", async () => { const admin = await createUser({ role: "admin" }); const mod = await createUser({ role: "moderator" }); const user = await createUser(); @@ -157,28 +167,28 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => { ); }); -test("GET /api/users/search: prefix match for mentions, capped at 8", async () => { +test("GET /api/users?q=: prefix match for mentions, capped at 8", async () => { for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` }); await createUser({ username: "other_zed" }); - const res = await get("/api/users/search?q=zed"); + const res = await get("/api/users?q=zed"); assertEquals(res.data.length, 8); assertEquals(res.data[0], { id: res.data[0].id, username: "zed_0", avatarMime: null, }); - assertEquals((await get("/api/users/search?q=")).data, []); + assertEquals((await get("/api/users?q=")).data, []); // An underscore is a letter of the name, not a wildcard. await createUser({ username: "zedx9" }); assertEquals( - (await get("/api/users/search?q=zed_9")).data.map((u) => + (await get("/api/users?q=zed_9")).data.map((u) => u.username ), ["zed_9"], ); }); -test("GET /api/users/:username/invitees: the invite tree below a user", async () => { +test("GET /api/users/:key/invitees: the invite tree below a user", async () => { const root = await createUser(); const child = await createUser({ invitedBy: root.id, @@ -189,18 +199,18 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async () createdAt: "2024-01-02T00:00:00Z", }); const tree = - (await get(`/api/users/${root.username}/invitees`)).data; + (await get(`/api/users/@${root.username}/invitees`)).data; assertEquals( tree.map(( n: { username: string; invitedById: string }, ) => [n.username, n.invitedById]), [[child.username, root.id], [grandchild.username, child.id]], ); - const profile = await get(`/api/users/${child.username}`); + const profile = await get(`/api/users/@${child.username}`); assertEquals(profile.data.invitedByUsername, root.username); }); -test("GET /api/users/:username/dumps and /votes respect privacy", async () => { +test("GET /api/users/:key/dumps and /upvoted respect privacy", async () => { const owner = await createUser(); const other = await createUser(); const pub = insertDump({ userId: owner.id, title: "pub" }); @@ -211,8 +221,8 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => { ) .run(d.id, owner.id, new Date().toISOString()); } - for (const sub of ["dumps", "votes"]) { - const path = `/api/users/${owner.username}/${sub}`; + for (const sub of ["dumps", "upvoted"]) { + const path = `/api/users/@${owner.username}/${sub}`; const total = async (opts?: { auth: { Authorization: string } }) => (await get>(path, opts)).data.items.length; assertEquals(await total(), 1, sub); @@ -223,14 +233,14 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => { sub, ); } - assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404); + assert((await get(`/api/users/@nobody_at_all/dumps`)).status === 404); }); // ── Login and register ─────────────────────────────────────────────────────── -test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { +test("POST /api/sessions: right password logs in, wrong one doesn't say which", async () => { const user = await createUser(); - const ok = await post("/api/users/login", { + const ok = await post("/api/sessions", { json: { username: user.username, password: PASSWORD }, }); assertEquals(ok.status, 200); @@ -242,10 +252,10 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which user.id, ); - const wrong = await post("/api/users/login", { + const wrong = await post("/api/sessions", { json: { username: user.username, password: "nope" }, }); - const unknown = await post("/api/users/login", { + const unknown = await post("/api/sessions", { json: { username: "no_such_user_here", password: "nope" }, }); assertEquals(wrong.status, 401); @@ -253,19 +263,19 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which assertEquals(wrong.error.message, unknown.error.message); assertEquals( - (await post("/api/users/login", { json: { username: user.username } })) + (await post("/api/sessions", { json: { username: user.username } })) .status, 400, ); }); -test("POST /api/users/login: rate limited per account, case-insensitively", async () => { +test("POST /api/sessions: rate limited per account, case-insensitively", async () => { const user = await createUser(); const statuses: number[] = []; for (let i = 0; i < 11; i++) { const username = i % 2 ? user.username.toUpperCase() : user.username; statuses.push( - (await post("/api/users/login", { + (await post("/api/sessions", { json: { username, password: "wrong" }, })).status, ); @@ -274,18 +284,18 @@ test("POST /api/users/login: rate limited per account, case-insensitively", asyn assertEquals(statuses[10], 429); // Even the right password is held back now. assertEquals( - (await post("/api/users/login", { + (await post("/api/sessions", { json: { username: user.username, password: PASSWORD }, })).status, 429, ); }); -test("POST /api/users/register: an invite opens one account", async () => { +test("POST /api/users: an invite opens one account", async () => { using mail = captureEmails(); const inviter = await createUser(); const inviteToken = invite(inviter.id); - const res = await post("/api/users/register", { + const res = await post("/api/users", { json: { username: "newbie_reg", password: "longenough", @@ -306,7 +316,7 @@ test("POST /api/users/register: an invite opens one account", async () => { assertEquals(mail.sent.map((m) => m.to), ["newbie@example.test"]); assert(mail.sent[0].html.includes("newbie_reg")); - const again = await post("/api/users/register", { + const again = await post("/api/users", { json: { username: "second_reg", password: "longenough", @@ -317,7 +327,7 @@ test("POST /api/users/register: an invite opens one account", async () => { assertEquals(again.status, 409); }); -test("POST /api/users/register: validation and unknown invites", async () => { +test("POST /api/users: validation and unknown invites", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const base = { @@ -335,11 +345,11 @@ test("POST /api/users/register: validation and unknown invites", async () => { [{ ...base, inviteToken: 42 }, /Invalid request/], ]; for (const [json, message] of cases) { - const res = await post("/api/users/register", { json }); + const res = await post("/api/users", { json }); assertEquals(res.status, 400, JSON.stringify(json)); assert(message.test(res.error.message), res.error.message); } - const unknown = await post("/api/users/register", { + const unknown = await post("/api/users", { json: { ...base, inviteToken: "made-up" }, }); assertEquals(unknown.status, 404); @@ -350,11 +360,11 @@ test("POST /api/users/register: validation and unknown invites", async () => { ); }); -test("POST /api/users/register: a taken name or email hands the invite back", async () => { +test("POST /api/users: a taken name or email hands the invite back", async () => { const inviter = await createUser(); const taken = await createUser(); const inviteToken = invite(inviter.id); - const byName = await post("/api/users/register", { + const byName = await post("/api/users", { json: { username: taken.username.toUpperCase(), password: "longenough", @@ -363,7 +373,7 @@ test("POST /api/users/register: a taken name or email hands the invite back", as }, }); assertEquals(byName.status, 409); - const byEmail = await post("/api/users/register", { + const byEmail = await post("/api/users", { json: { username: "fresh_name", password: "longenough", @@ -381,13 +391,13 @@ test("POST /api/users/register: a taken name or email hands the invite back", as ); }); -test("POST /api/users/register: an invite older than 7 days is refused", async () => { +test("POST /api/users: an invite older than 7 days is refused", async () => { const inviter = await createUser(); const inviteToken = invite( inviter.id, new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), ); - const res = await post("/api/users/register", { + const res = await post("/api/users", { json: { username: "late_comer", password: "longenough", @@ -398,12 +408,12 @@ test("POST /api/users/register: an invite older than 7 days is refused", async ( assertEquals(res.status, 404); }); -test("POST /api/users/register: parallel registrations can't share an invite", async () => { +test("POST /api/users: parallel registrations can't share an invite", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const results = await Promise.all( [1, 2, 3].map((i) => - post("/api/users/register", { + post("/api/users", { json: { username: `racer_${i}`, password: "longenough", @@ -424,12 +434,12 @@ test("POST /api/users/register: parallel registrations can't share an invite", a test("change-password: needs the current one, revokes old tokens, returns new ones", async () => { const user = await createUser(); - const wrong = await post("/api/users/me/change-password", { + const wrong = await put("/api/me/password", { auth: user.auth, json: { currentPassword: "nope", newPassword: "brand-new-pass" }, }); assertEquals(wrong.status, 403); - const short = await post("/api/users/me/change-password", { + const short = await put("/api/me/password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "short" }, }); @@ -438,18 +448,18 @@ test("change-password: needs the current one, revokes old tokens, returns new on // iat has one-second resolution: make sure the old token is from an earlier // second than the change. await new Promise((r) => setTimeout(r, 1100)); - const ok = await post("/api/users/me/change-password", { + const ok = await put("/api/me/password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, }); assertEquals(ok.status, 200); - assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); + assertEquals((await get("/api/me", { auth: user.auth })).status, 401); assertEquals(await authenticateMedia(user.mediaToken), null); const fresh = { Authorization: `Bearer ${ok.data.token}` }; - assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); + assertEquals((await get("/api/me", { auth: fresh })).status, 200); assertEquals(await authenticateMedia(ok.data.mediaToken), user.id); - const login = await post("/api/users/login", { + const login = await post("/api/sessions", { json: { username: user.username, password: "brand-new-pass" }, }); assertEquals(login.status, 200); @@ -458,7 +468,7 @@ test("change-password: needs the current one, revokes old tokens, returns new on test("request-password-reset: always 200, stores nothing without SMTP", async () => { const user = await createUser(); for (const email of [user.email, "nobody@example.test", "", undefined]) { - const res = await post("/api/users/request-password-reset", { + const res = await post("/api/password-resets", { json: { email }, }); assertEquals(res.status, 200); @@ -469,10 +479,10 @@ test("request-password-reset: always 200, stores nothing without SMTP", async () test("request-password-reset: emails a link that resets the password", async () => { using mail = captureEmails(); const user = await createUser(); - await post("/api/users/request-password-reset", { + await post("/api/password-resets", { json: { email: user.email }, }); - await post("/api/users/request-password-reset", { + await post("/api/password-resets", { json: { email: "nobody@example.test" }, }); await new Promise((r) => setTimeout(r, 0)); @@ -481,8 +491,8 @@ test("request-password-reset: emails a link that resets the password", async () const token = decodeURIComponent( mail.sent[0].text.match(/token=([^\s)]+)/)![1], ); - const res = await post("/api/users/reset-password", { - json: { token, newPassword: "from-the-email" }, + const res = await put(`/api/password-resets/${token}`, { + json: { newPassword: "from-the-email" }, }); assertEquals(res.status, 200); }); @@ -494,22 +504,22 @@ test("reset-password: a stored token works once", async () => { `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); - const short = await post("/api/users/reset-password", { - json: { token, newPassword: "short" }, + const short = await put(`/api/password-resets/${token}`, { + json: { newPassword: "short" }, }); assertEquals(short.status, 400); - const ok = await post("/api/users/reset-password", { - json: { token, newPassword: "reset-password-1" }, + const ok = await put(`/api/password-resets/${token}`, { + json: { newPassword: "reset-password-1" }, }); assertEquals(ok.status, 200); - const login = await post("/api/users/login", { + const login = await post("/api/sessions", { json: { username: user.username, password: "reset-password-1" }, }); assertEquals(login.status, 200); - const reuse = await post("/api/users/reset-password", { - json: { token, newPassword: "reset-password-2" }, + const reuse = await put(`/api/password-resets/${token}`, { + json: { newPassword: "reset-password-2" }, }); assertEquals(reuse.status, 409); }); @@ -518,28 +528,28 @@ test("reset-password: an unstored, forged or wrong-purpose token fails", async ( const user = await createUser(); const unstored = await createPasswordResetToken(user.id); assertEquals( - (await post("/api/users/reset-password", { - json: { token: unstored, newPassword: "whatever-123" }, + (await put(`/api/password-resets/${unstored}`, { + json: { newPassword: "whatever-123" }, })).status, 404, ); for (const token of ["garbage", user.token, user.mediaToken]) { assertEquals( - (await post("/api/users/reset-password", { - json: { token, newPassword: "whatever-123" }, + (await put(`/api/password-resets/${token}`, { + json: { newPassword: "whatever-123" }, })).status, 400, ); } assertEquals( - (await post("/api/users/reset-password", { json: { token: 1 } })).status, + (await put(`/api/password-resets/${unstored}`, { json: {} })).status, 400, ); }); -test("GET /api/users/me/media-token issues a media token for the caller", async () => { +test("GET /api/me/media-token issues a media token for the caller", async () => { const user = await createUser(); - const res = await get("/api/users/me/media-token", { + const res = await get("/api/me/media-token", { auth: user.auth, }); assertEquals(res.status, 200); @@ -551,12 +561,12 @@ test("GET /api/users/me/media-token issues a media token for the caller", async test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => { const ann = await createUser(); const bob = await createUser(); - const initial = await get("/api/users/me/settings", { + const initial = await get("/api/me/settings", { auth: ann.auth, }); assertEquals(initial.data, DEFAULT_SETTINGS); - const changed = await patch("/api/users/me/settings", { + const changed = await patch("/api/me/settings", { auth: ann.auth, json: { style: "kanagawa", digestWeekly: false }, }); @@ -565,7 +575,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their style: "kanagawa", digestWeekly: false, }); - const scheme = await patch("/api/users/me/settings", { + const scheme = await patch("/api/me/settings", { auth: ann.auth, json: { colorScheme: "dark" }, }); @@ -573,7 +583,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their assertEquals(scheme.data.colorScheme, "dark"); assertEquals(scheme.data.digestWeekly, false); - const bobs = await get("/api/users/me/settings", { + const bobs = await get("/api/me/settings", { auth: bob.auth, }); assertEquals(bobs.data, DEFAULT_SETTINGS); @@ -584,7 +594,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their test("settings: signed in only, and a bad value or unknown key is refused", async () => { const ann = await createUser(); - assertEquals((await get("/api/users/me/settings")).status, 401); + assertEquals((await get("/api/me/settings")).status, 401); for ( const json of [ { style: "comic" }, @@ -594,13 +604,182 @@ test("settings: signed in only, and a bad value or unknown key is refused", asyn [], ] ) { - const res = await patch("/api/users/me/settings", { auth: ann.auth, json }); + const res = await patch("/api/me/settings", { auth: ann.auth, json }); assertEquals(res.status, 400, JSON.stringify(json)); } // Null hands the style back to the instance's. - const reset = await patch("/api/users/me/settings", { + const reset = await patch("/api/me/settings", { auth: ann.auth, json: { style: null }, }); assertEquals(reset.data.style, null); }); + +// ── Usernames and paths ────────────────────────────────────────────────────── + +test("renaming: the old name redirects for good, and nobody else can take it", async () => { + const bob = await createUser(); + const old = bob.username; + const renamed = uniqueName("robert"); + const dump = insertDump({ userId: bob.id, title: "Never gonna" }); + const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" }); + + const res = await patch("/api/me", { + auth: bob.auth, + json: { username: renamed }, + }); + assertEquals(res.data.username, renamed); + + // The old name, in any case, still answers the user, under the new one. + for (const name of [old, old.toUpperCase(), renamed]) { + assertEquals( + (await get(`/api/users/@${name}`)).data.username, + renamed, + name, + ); + } + // Their dumps and playlists follow them to the new name. + for (const name of [old, renamed]) { + const d = await get(`/api/users/@${name}/dumps/${dump.slug}`); + assertEquals([d.data.id, d.data.ownerUsername], [dump.id, renamed]); + const p = await get( + `/api/users/@${name}/playlists/${playlist.slug}`, + ); + assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]); + } + + // Nobody can register or rename to the old name, in any case. + const other = await createUser(); + const register = await post("/api/users", { + json: { + username: old.toUpperCase(), + password: PASSWORD, + email: `${uniqueName()}@example.test`, + inviteToken: invite(other.id), + }, + }); + assertEquals(register.status, 409); + assertEquals( + (await patch("/api/me", { auth: other.auth, json: { username: old } })) + .status, + 409, + ); + // Nor to a current one. + assertEquals( + (await patch("/api/me", { + auth: other.auth, + json: { username: renamed }, + })).status, + 409, + ); + // Signing in takes the current name only. + assertEquals( + (await post("/api/sessions", { + json: { username: old, password: PASSWORD }, + })).status, + 401, + ); +}); + +test("renaming: once in 30 days, a case change aside, and a moderator is exempt", async () => { + const user = await createUser(); + const first = uniqueName("first"); + assertEquals( + (await patch("/api/me", { auth: user.auth, json: { username: first } })) + .status, + 200, + ); + // A change of case alone leaves no alias, so it isn't counted. + assertEquals( + (await patch("/api/me", { + auth: user.auth, + json: { username: first.toUpperCase() }, + })).data.username, + first.toUpperCase(), + ); + const again = await patch("/api/me", { + auth: user.auth, + json: { username: uniqueName("second") }, + }); + assertEquals(again.status, 429); + assertEquals( + (await patch("/api/me", { auth: user.auth, json: { username: "a b" } })) + .status, + 400, + ); + + // An admin renames anyone at any time, and themselves too. + const admin = await createUser({ role: "admin" }); + const third = uniqueName("third"); + assertEquals( + (await patch(`/api/users/${user.id}/username`, { + auth: admin.auth, + json: { username: third }, + })).data.username, + third, + ); + assertEquals( + (await patch(`/api/users/@${third}/username`, { + auth: admin.auth, + json: { username: first }, + })).data.username, + first, + "a former name of one's own can be taken back", + ); + for (let i = 0; i < 2; i++) { + assertEquals( + (await patch("/api/me", { + auth: admin.auth, + json: { username: uniqueName("admin") }, + })).status, + 200, + ); + } + // Others may not. + const moderator = await createUser({ role: "moderator" }); + assertEquals( + (await patch(`/api/users/${user.id}/username`, { + auth: moderator.auth, + json: { username: uniqueName() }, + })).status, + 403, + ); +}); + +test("GET /api/users/:key/dumps/:slug and /playlists/:slug: by path, privacy kept", async () => { + const owner = await createUser(); + const other = await createUser(); + const dump = insertDump({ userId: owner.id, title: "Hello there" }); + const secret = insertDump({ + userId: owner.id, + title: "Secret", + isPrivate: true, + }); + const hidden = insertPlaylist({ + userId: owner.id, + title: "Hidden", + isPublic: false, + }); + assertEquals(dump.slug, "hello-there"); + assertEquals( + (await get(`/api/users/@${owner.username}/dumps/hello-there`)).data + .id, + dump.id, + ); + assertEquals( + (await get(`/api/users/${owner.id}/dumps/hello-there`)).status, + 200, + ); + // Another user's space holds none of it. + assertEquals( + (await get(`/api/users/@${other.username}/dumps/hello-there`)).status, + 404, + ); + const secretPath = `/api/users/@${owner.username}/dumps/${secret.slug}`; + assertEquals((await get(secretPath)).status, 404); + assertEquals((await get(secretPath, { auth: other.auth })).status, 404); + assertEquals((await get(secretPath, { auth: owner.auth })).status, 200); + const hiddenPath = `/api/users/@${owner.username}/playlists/${hidden.slug}`; + assertEquals((await get(hiddenPath)).status, 404); + assertEquals((await get(hiddenPath, { auth: owner.auth })).status, 200); +}); diff --git a/api/test/routes/ws.test.ts b/api/test/routes/ws.test.ts index eb09254f..4b65920a 100644 --- a/api/test/routes/ws.test.ts +++ b/api/test/routes/ws.test.ts @@ -578,7 +578,7 @@ wsTest("a password change closes the user's sockets", async (connect) => { const user = await createUser(); const c = await connect(user); await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts - const res = await post("/api/users/me/change-password", { + const res = await put("/api/me/password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "a-new-password" }, }); @@ -642,14 +642,14 @@ wsTest( const pl = insertPlaylist({ userId: owner.id }); const c = await connect(); const cover = new Uint8Array([...PNG, 9, 9, 9]); - const upload = post(`/api/playlists/${pl.id}/image`, { + const upload = put(`/api/playlists/${pl.id}/cover`, { auth: owner.auth, form: formWithFile(cover), }); // Fetch the image the moment the socket says it changed, as a client would. const msg = await c.next("playlist_updated"); const served = await get( - `/api/playlists/${pl.id}/image?v=${msg.playlist.updatedAt}`, + `/api/playlists/${pl.id}/cover?v=${msg.playlist.updatedAt}`, ); assertEquals(served.status, 200); assertEquals(served.bytes, cover); diff --git a/api/test/routes/youtube.test.ts b/api/test/routes/youtube.test.ts deleted file mode 100644 index e6b98342..00000000 --- a/api/test/routes/youtube.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { get, html, stubNetwork } from "../helpers.ts"; -import { assertEquals, assertFalse } from "@std/assert"; -import type { YouTubePlaylist } from "../../../shared/player.ts"; - -function ytPage(data: unknown): string { - return ``; -} - -const YT_DATA = { - metadata: { playlistMetadataRenderer: { title: "My mix" } }, - contents: { - list: [ - { - lockupViewModel: { - contentType: "LOCKUP_CONTENT_TYPE_VIDEO", - contentId: "vid1", - metadata: { - lockupMetadataViewModel: { title: { content: "First" } }, - }, - contentImage: { - overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], - }, - }, - }, - { - lockupViewModel: { - contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", - contentId: "ignored", - }, - }, - { - playlistVideoRenderer: { - videoId: "vid2", - title: { runs: [{ text: "Second" }] }, - lengthSeconds: "235", - }, - }, - ], - }, -}; - -Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => { - let cookie: string | null = null; - using net = stubNetwork((url, init) => { - cookie = new Headers(init?.headers).get("cookie"); - return url.searchParams.get("list") === "PLgone" - ? new Response("", { status: 404 }) - : html(ytPage(YT_DATA)); - }); - const ok = await get("/api/youtube/playlist?list=PLgood"); - assertEquals(ok.status, 200); - assertEquals(ok.data.tracks.length, 2); - assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); - // The pre-answered consent cookie keeps EU requests off the consent page. - assertEquals(cookie, "SOCS=CAI"); - await get("/api/youtube/playlist?list=PLgood"); // cached - assertEquals(net.calls.length, 1); - - assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400); - assertEquals((await get("/api/youtube/playlist")).status, 400); - assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502); - assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id")); -}); diff --git a/api/test/services/digest-service.test.ts b/api/test/services/digest-service.test.ts index 1b7850e2..d3ecd410 100644 --- a/api/test/services/digest-service.test.ts +++ b/api/test/services/digest-service.test.ts @@ -19,6 +19,7 @@ import { getSettings, updateSettings, } from "../../services/settings-service.ts"; +import { createUnsubscribeToken } from "../../lib/jwt.ts"; const DAY = 86_400_000; const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); @@ -148,6 +149,77 @@ test("buildDigest: the weekly digest gathers activity, network, top and communit ); assertEquals(digest.community?.totals.dumps, 3); assert(digest.links.unsubscribe.includes("/unsubscribe?token=")); + + // Every page link has a v4 shape, and none names anything by its id. + const pageLinks = [ + ...digest.activity!.highlights.flatMap((h) => + h.target?.url ? [h.target.url] : [] + ), + ...digest.network!.dumps.map((d) => d.url), + ...digest.top.map((d) => d.url), + ...digest.community!.playlists.map((p) => p.url), + digest.links.followed, + digest.links.settings, + ]; + assertEquals(pageLinks.length, 8); + for (const link of pageLinks) { + assert( + /^\/@\w+\/(dumps|playlists)\/[a-z0-9-]+$|^\/follow$|^\/settings$/.test( + new URL(link).pathname, + ), + link, + ); + for (const id of [reader.id, mine.id, posted.id, added.id, mix.id]) { + assertFalse(link.includes(id), link); + } + } + assertEquals( + new URL(digest.top[1].url).pathname, + `/@${reader.username}/dumps/my-dump`, + ); +}); + +test("buildDigest: names and links follow renames, and a deleted dump is unlinked", async () => { + const reader = await createUser(); + const friend = await createUser(); + const kept = insertDump({ userId: reader.id, title: "Kept" }); + const gone = insertDump({ userId: reader.id, title: "Gone" }); + for (const d of [kept, gone]) { + notify(reader.id, "dump_upvoted", { + voterId: friend.id, + voterUsername: friend.username, + dumpId: d.id, + dumpTitle: d.id === kept.id ? "Kept" : "Gone", + }); + notify(reader.id, "dump_commented", { + commenterId: friend.id, + commenterUsername: friend.username, + commentId: crypto.randomUUID(), + dumpId: d.id, + dumpTitle: d.id === kept.id ? "Kept" : "Gone", + }); + } + db.prepare(`UPDATE users SET username = 'renamed_friend' WHERE id = ?;`) + .run(friend.id); + db.prepare(`UPDATE dumps SET slug = 'still-kept' WHERE id = ?;`) + .run(kept.id); + db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(gone.id); + + const [from, to] = week(); + const digest = + (buildDigest(reader.id, reader.username, "weekly", from, to, "tok"))!; + const targets = digest.activity!.highlights.map((h) => [ + h.who, + h.target?.title, + h.target?.url && new URL(h.target.url).pathname, + ]); + assertEquals( + targets.sort((a, b) => String(a[1]).localeCompare(String(b[1]))), + [ + ["renamed_friend", "Gone", null], + ["renamed_friend", "Kept", `/@${reader.username}/dumps/still-kept`], + ], + ); }); test("buildDigest: the monthly recap has no personal sections, and nothing makes no digest", async () => { @@ -261,10 +333,28 @@ test("syncDigestSchedules: new users start now, a turned-off cadence loses its r test("unsubscribe: a bad or other kind of token is refused", async () => { const user = await createUser(); - assertEquals((await post("/api/users/unsubscribe?token=nope")).status, 400); - assertEquals( - (await post(`/api/users/unsubscribe?token=${user.token}`)).status, - 400, - ); - assertEquals((await post("/api/users/unsubscribe")).status, 400); + for (const path of ["/api/unsubscribe", "/api/users/unsubscribe"]) { + assertEquals((await post(`${path}?token=nope`)).status, 400, path); + assertEquals( + (await post(`${path}?token=${user.token}`)).status, + 400, + path, + ); + assertEquals((await post(path)).status, 400, path); + } +}); + +test("unsubscribe: the v4 path and the v3 one sent emails carry both work", async () => { + const user = await createUser(); + for ( + const [path, cadence, setting] of [ + ["/api/unsubscribe", "weekly", "digestWeekly"], + ["/api/users/unsubscribe", "monthly", "digestMonthly"], + ] as const + ) { + const token = await createUnsubscribeToken(user.id, cadence); + const res = await post<{ cadence: string }>(`${path}?token=${token}`); + assertEquals(res.data.cadence, cadence); + assertEquals(getSettings(user.id)[setting], false, path); + } }); diff --git a/api/validators/dump.ts b/api/validators/dump.ts index 62079ca4..96c9fc83 100644 --- a/api/validators/dump.ts +++ b/api/validators/dump.ts @@ -53,6 +53,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("url" in o && typeof o.url !== "string") return false; + // Its format is changeSlug's check, which says what is wrong. + if ("slug" in o && typeof o.slug !== "string") return false; if ("title" in o) { if (typeof o.title !== "string") return false; const trimmed = (o.title as string).trim(); @@ -69,3 +71,20 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; return true; } + +/** + * Parses a multipart form's `categoryIds`, a JSON array sent as a string. A + * missing or malformed value counts as no category. The dumps and files + * routes read the forms that post and replace a file with it. + */ +export function parseCategoryIds(value: FormDataEntryValue | null): string[] { + if (typeof value !== "string" || !value) return []; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((v): v is string => typeof v === "string") + : []; + } catch { + return []; + } +} diff --git a/api/validators/playlist.ts b/api/validators/playlist.ts index 5a4650a7..cf4be684 100644 --- a/api/validators/playlist.ts +++ b/api/validators/playlist.ts @@ -53,6 +53,8 @@ export function isUpdatePlaylistRequest( ): obj is UpdatePlaylistRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; + // Its format is changeSlug's check, which says what is wrong. + if ("slug" in o && typeof o.slug !== "string") return false; if ("title" in o) { if (typeof o.title !== "string") return false; if (!isValidTitle(o.title as string)) return false; diff --git a/api/validators/settings.ts b/api/validators/settings.ts index 27ed279f..5432dfaa 100644 --- a/api/validators/settings.ts +++ b/api/validators/settings.ts @@ -1,6 +1,6 @@ /** - * Checks the body of `PATCH /api/users/me/settings` before the users route - * trusts it. + * Checks the body of `PATCH /api/me/settings` before `routes/me.ts` trusts + * it. * * @module */ diff --git a/api/validators/user.ts b/api/validators/user.ts index 6d5d349c..35a8a00b 100644 --- a/api/validators/user.ts +++ b/api/validators/user.ts @@ -1,5 +1,6 @@ /** - * Checks the JSON bodies that `routes/users.ts` receives before it reads them. + * Checks the JSON bodies that the account routes (`routes/users.ts`, + * `routes/me.ts`, `routes/sessions.ts`) receive before they read them. * * @module */ @@ -74,7 +75,7 @@ export function validateRegisterUserRequest(obj: unknown): string | null { /** * Checks whether `obj` is a valid {@link UpdateUserRequest}, for - * `PATCH /api/users/me`. Every field is optional, but each one present must be + * `PATCH /api/me`. Every field is optional, but each one present must be * well-formed and within the `VALIDATION` limits. */ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { diff --git a/locales/en.mjs b/locales/en.mjs index 08d9c8a4..26c18022 100644 --- a/locales/en.mjs +++ b/locales/en.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2ygf_L\":[\"← Back\"],\"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\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"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\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"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\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"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\"],\"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…\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"P5MuiB\":[\"Add image\"],\"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.\"],\"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…\"],\"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\"],\"\"],\"TAtbho\":[\"Loading messages…\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"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\"]],\"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\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"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\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_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…\"],\"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.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"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.community\":[\"Community\"],\"email.digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"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.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"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.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.invitedBy\":[\"invited by @\",[\"username\"]],\"email.digest.invitedByYou\":[\"invited by you\"],\"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.newMembers\":[\"New members\"],\"email.digest.newPlaylists\":[\"New public playlists\"],\"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.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"email.digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.topMonth\":[\"Top this month\"],\"email.digest.topWeek\":[\"Top this week\"],\"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\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"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\"],\"\"],\"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.\"],\"jbernk\":[\"Loading profile…\"],\"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\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"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\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"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\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"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\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"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\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"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("{\"-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\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"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\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"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\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"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\"],\"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\"],\"JzARG2\":[\"Links to the old address keep working.\"],\"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!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"P5MuiB\":[\"Add image\"],\"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.\"],\"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…\"],\"SMaFdc\":[\"Unsubscribe\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T62TQc\":[\"You can change it once every \",[\"renameDays\"],\" days. Links to your old name keep working, and nobody else can take it.\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"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\"]],\"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\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"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\"],\"_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\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"azhsgG\":[\"Their old name keeps leading to them, and nobody else can take it.\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"b_1P1D\":[\"Renaming…\"],\"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.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"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.community\":[\"Community\"],\"email.digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"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.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"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.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.invitedBy\":[\"invited by @\",[\"username\"]],\"email.digest.invitedByYou\":[\"invited by you\"],\"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.newMembers\":[\"New members\"],\"email.digest.newPlaylists\":[\"New public playlists\"],\"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.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"email.digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.topMonth\":[\"Top this month\"],\"email.digest.topWeek\":[\"Top this week\"],\"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\"],\"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\"],\"\"],\"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.\"],\"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\"],\"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…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"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\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"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\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"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 b8143aa4..dfdaa91b 100644 --- a/locales/en.po +++ b/locales/en.po @@ -14,271 +14,271 @@ msgstr "" "Plural-Forms: \n" #. js-lingui-explicit-id -#: api/mail/digest.ts:124 +#: api/mail/digest.ts:127 msgid "email.digest.count.upvotes" msgstr "{n, plural, one {upvote} other {upvotes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:126 +#: api/mail/digest.ts:129 msgid "email.digest.count.followers" msgstr "{n, plural, one {new follower} other {new followers}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:128 +#: api/mail/digest.ts:131 msgid "email.digest.count.comments" msgstr "{n, plural, one {comment} other {comments}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:130 +#: api/mail/digest.ts:133 msgid "email.digest.count.mentions" msgstr "{n, plural, one {mention} other {mentions}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:132 +#: api/mail/digest.ts:135 msgid "email.digest.count.likes" msgstr "{n, plural, one {comment like} other {comment likes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:134 +#: api/mail/digest.ts:137 msgid "email.digest.count.playlistFollows" msgstr "{n, plural, one {playlist follow} other {playlist follows}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:150 +#: api/mail/digest.ts:153 msgid "email.digest.hl.mentionComment" msgstr "{who} mentioned you in a comment on {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:153 +#: api/mail/digest.ts:156 msgid "email.digest.hl.mentionIn" msgstr "{who} mentioned you in {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:155 +#: api/mail/digest.ts:158 msgid "email.digest.hl.mentionPlaylist" msgstr "{who} mentioned you in the playlist {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:157 +#: api/mail/digest.ts:160 msgid "email.digest.hl.comment" msgstr "{who} commented on your dump {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:159 +#: api/mail/digest.ts:162 msgid "email.digest.hl.playlistFollow" msgstr "{who} followed your playlist {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:161 +#: api/mail/digest.ts:164 msgid "email.digest.hl.userFollow" msgstr "{who} started following you" #. js-lingui-explicit-id -#: api/mail/digest.ts:163 +#: api/mail/digest.ts:166 msgid "email.digest.hl.commentLike" msgstr "{who} liked your comment on {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:167 +#: api/mail/digest.ts:170 msgid "email.digest.by" msgstr "by @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:169 +#: api/mail/digest.ts:172 msgid "email.digest.addedTo" msgstr "added to {playlist}" #. js-lingui-explicit-id -#: api/mail/digest.ts:170 +#: api/mail/digest.ts:173 msgid "email.digest.upload" msgstr "upload" #. js-lingui-explicit-id -#: api/mail/digest.ts:173 +#: api/mail/digest.ts:176 msgid "email.digest.invitedByYou" msgstr "invited by you" #. js-lingui-explicit-id -#: api/mail/digest.ts:174 +#: api/mail/digest.ts:177 msgid "email.digest.invitedBy" msgstr "invited by @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:175 +#: api/mail/digest.ts:178 msgid "email.digest.andMore" msgstr "and {n} more" #. js-lingui-explicit-id -#: api/mail/digest.ts:177 +#: api/mail/digest.ts:180 msgid "email.digest.playlistMeta" msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:200 +#: api/mail/digest.ts:203 msgid "email.digest.yours" msgstr "yours" #. js-lingui-explicit-id -#: api/mail/digest.ts:218 -#: src/components/profile/ProfileSettingsTab.tsx:140 +#: api/mail/digest.ts:221 +#: src/pages/Settings.tsx:190 msgid "digest.monthly" msgstr "Monthly recap" #. js-lingui-explicit-id -#: api/mail/digest.ts:218 -#: src/components/profile/ProfileSettingsTab.tsx:131 +#: api/mail/digest.ts:221 +#: src/pages/Settings.tsx:181 msgid "digest.weekly" msgstr "Weekly digest" #. js-lingui-explicit-id -#: api/mail/digest.ts:226 +#: api/mail/digest.ts:229 msgid "email.digest.monthly.title" msgstr "The month on {site}" #. js-lingui-explicit-id -#: api/mail/digest.ts:227 +#: api/mail/digest.ts:230 msgid "email.digest.weekly.title" msgstr "Your week, {username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:235 +#: api/mail/digest.ts:238 msgid "email.digest.forYou" msgstr "For you" #. js-lingui-explicit-id -#: api/mail/digest.ts:244 +#: api/mail/digest.ts:247 msgid "email.digest.theChat" msgstr "the chat" #. js-lingui-explicit-id -#: api/mail/digest.ts:257 +#: api/mail/digest.ts:262 msgid "email.digest.seeAll" msgstr "{n, plural, one {See the notification} other {See all # notifications}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:266 +#: api/mail/digest.ts:271 msgid "email.digest.network" msgstr "From people you follow" #. js-lingui-explicit-id -#: api/mail/digest.ts:272 +#: api/mail/digest.ts:277 msgid "email.digest.more" msgstr "{n} more on your Followed feed" #. js-lingui-explicit-id -#: api/mail/digest.ts:285 +#: api/mail/digest.ts:290 msgid "email.digest.topMonth" msgstr "Top this month" #. js-lingui-explicit-id -#: api/mail/digest.ts:286 +#: api/mail/digest.ts:291 msgid "email.digest.topWeek" msgstr "Top this week" #. js-lingui-explicit-id -#: api/mail/digest.ts:302 +#: api/mail/digest.ts:307 msgid "email.digest.community" msgstr "Community" #. js-lingui-explicit-id -#: api/mail/digest.ts:309 +#: api/mail/digest.ts:314 msgid "email.digest.stat.dumps" msgstr "{n, plural, one {dump} other {dumps}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:315 +#: api/mail/digest.ts:320 msgid "email.digest.stat.chat" msgstr "{n, plural, one {chat message} other {chat messages}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:324 +#: api/mail/digest.ts:329 msgid "email.digest.newMembers" msgstr "New members" #. js-lingui-explicit-id -#: api/mail/digest.ts:343 +#: api/mail/digest.ts:348 msgid "email.digest.newPlaylists" msgstr "New public playlists" #. js-lingui-explicit-id -#: api/mail/digest.ts:358 +#: api/mail/digest.ts:363 msgid "email.digest.inChat" msgstr "In the chat" #. js-lingui-explicit-id -#: api/mail/digest.ts:360 +#: api/mail/digest.ts:365 msgid "email.digest.chatWeek" msgstr "{n, plural, one {# message this week} other {# messages this week}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:363 +#: api/mail/digest.ts:368 msgid "email.digest.openChat" msgstr "Open the chat" #. js-lingui-explicit-id -#: api/mail/digest.ts:372 +#: api/mail/digest.ts:377 msgid "email.digest.totals" msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}." #. js-lingui-explicit-id -#: api/mail/digest.ts:382 +#: api/mail/digest.ts:387 msgid "email.digest.monthly.reason" msgstr "You're getting the monthly recap because it's on in your settings." #. js-lingui-explicit-id -#: api/mail/digest.ts:383 +#: api/mail/digest.ts:388 msgid "email.digest.weekly.reason" msgstr "You're getting the weekly digest because it's on in your settings." #. js-lingui-explicit-id -#: api/mail/digest.ts:387 +#: api/mail/digest.ts:392 #: src/pages/Unsubscribe.tsx:118 msgid "digest.monthly.off" msgstr "Turn off monthly recaps" #. js-lingui-explicit-id -#: api/mail/digest.ts:387 +#: api/mail/digest.ts:392 #: src/pages/Unsubscribe.tsx:117 msgid "digest.weekly.off" msgstr "Turn off weekly digests" #. js-lingui-explicit-id -#: api/mail/digest.ts:392 +#: api/mail/digest.ts:397 msgid "email.settings" msgstr "Settings" #. js-lingui-explicit-id -#: api/mail/digest.ts:400 +#: api/mail/digest.ts:405 msgid "email.digest.monthly.subject" msgstr "The month on {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:404 +#: api/mail/digest.ts:409 msgid "email.digest.weekly.subject" msgstr "Your week on {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:427 +#: api/mail/digest.ts:432 msgid "email.digest.pre.network" msgstr "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:436 +#: api/mail/digest.ts:441 msgid "email.digest.pre.topMonth" msgstr "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:440 +#: api/mail/digest.ts:445 msgid "email.digest.pre.members" msgstr "{n, plural, one {# new member} other {# new members}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:443 +#: api/mail/digest.ts:448 msgid "email.digest.pre.playlists" msgstr "{n, plural, one {# new playlist} other {# new playlists}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:447 +#: api/mail/digest.ts:452 msgid "email.digest.pre.topWeek" msgstr "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}" @@ -361,7 +361,7 @@ msgstr "Welcome to {site}" msgid "email.welcome.preheader" msgstr "Your account is ready." -#: src/components/comment/CommentNode.tsx:139 +#: src/components/comment/CommentNode.tsx:140 msgid "[deleted]" msgstr "[deleted]" @@ -372,12 +372,12 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:117 +#: src/components/playlist/PlaylistCard.tsx:118 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" #. placeholder {0}: rest.length -#: src/components/dump/create/DuplicateNotice.tsx:52 +#: src/components/dump/create/DuplicateNotice.tsx:53 msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" @@ -393,7 +393,7 @@ msgid "{0} is typing…" msgstr "{0} is typing…" #. placeholder {0}: rest.length -#: src/components/feed/PresenceRow.tsx:81 +#: src/components/feed/PresenceRow.tsx:82 msgid "{0} more online: {restNames}" msgstr "{0} more online: {restNames}" @@ -403,7 +403,7 @@ msgstr "{0} more online: {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" -#: src/pages/Search.tsx:120 +#: src/pages/Search.tsx:124 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -415,24 +415,18 @@ msgstr "{unseenCount} new messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" -#: src/components/dump/create/DumpCreateModal.tsx:741 +#: src/components/dump/create/DumpCreateModal.tsx:742 #: src/pages/PlaylistDetail.tsx:80 -#: src/pages/UserPublicProfile.tsx:159 +#: src/pages/UserPublicProfile.tsx:123 msgid "← Back" msgstr "← Back" -#: src/pages/Dump.tsx:314 -#: src/pages/Dump.tsx:554 -#: src/pages/DumpEdit.tsx:217 +#: src/pages/Dump.tsx:326 +#: src/pages/Dump.tsx:566 +#: src/pages/DumpEdit.tsx:231 msgid "← Back to all dumps" msgstr "← Back to all dumps" -#: src/pages/UserDumps.tsx:74 -#: src/pages/UserPlaylists.tsx:240 -#: src/pages/UserUpvoted.tsx:60 -msgid "← Back to profile" -msgstr "← Back to profile" - #: src/components/profile/ProfileOwnActions.tsx:80 msgid "+ Invite someone" msgstr "+ Invite someone" @@ -441,53 +435,53 @@ msgstr "+ Invite someone" msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:391 +#: src/pages/Dump.tsx:403 msgid "+ Playlist" msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:199 +#: src/pages/Notifications.tsx:225 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} commented on <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:159 +#: src/pages/Notifications.tsx:185 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} followed your playlist <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:209 +#: src/pages/Notifications.tsx:235 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} liked your comment on <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:225 +#: src/pages/Notifications.tsx:251 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} mentioned you in <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:169 +#: src/pages/Notifications.tsx:195 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:150 +#: src/pages/Notifications.tsx:176 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:189 +#: src/pages/Notifications.tsx:215 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:179 +#: src/pages/Notifications.tsx:205 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" @@ -495,15 +489,19 @@ msgstr "<0>{0} was added to <1>{1}" msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:220 +#: src/pages/Notifications.tsx:246 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:223 +#: src/components/form/SlugField.tsx:83 +msgid "A link is required" +msgstr "A link is required" + +#: src/pages/Notifications.tsx:249 msgid "a post" msgstr "a post" -#: src/components/profile/ProfileSettingsTab.tsx:72 +#: src/pages/Settings.tsx:108 msgid "Account" msgstr "Account" @@ -516,7 +514,7 @@ msgstr "Add a comment…" msgid "Add category" msgstr "Add category" -#: src/components/profile/ProfileHeader.tsx:152 +#: src/components/profile/ProfileHeader.tsx:155 msgid "Add email…" msgstr "Add email…" @@ -524,7 +522,7 @@ msgstr "Add email…" msgid "Add image" msgstr "Add image" -#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/create/DumpCreateModal.tsx:465 #: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Add to playlist" @@ -533,12 +531,12 @@ msgstr "Add to playlist" msgid "Admin" msgstr "Admin" -#: src/components/category/CategorySwitcher.tsx:84 -#: src/components/category/CategorySwitcher.tsx:87 +#: src/components/category/CategorySwitcher.tsx:96 +#: src/components/category/CategorySwitcher.tsx:99 msgid "All" msgstr "All" -#. placeholder {0}: first.username +#. placeholder {0}: first.ownerUsername #. placeholder {1}: relativeTime(first.createdAt) #: src/components/dump/create/DuplicateNotice.tsx:39 msgid "Already dumped by {0} {1}" @@ -552,13 +550,13 @@ msgstr "Already have an account? <0>Log in" msgid "An unexpected error occurred." msgstr "An unexpected error occurred." -#: src/components/profile/ProfileSettingsTab.tsx:85 +#: src/pages/Settings.tsx:135 msgid "Appearance" msgstr "Appearance" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/profile/ChangePasswordModal.tsx:102 -#: src/pages/ResetPassword.tsx:121 +#: src/components/profile/ChangePasswordModal.tsx:101 +#: src/pages/ResetPassword.tsx:124 #: src/pages/UserRegister.tsx:164 msgid "At least {0} characters" msgstr "At least {0} characters" @@ -567,12 +565,12 @@ msgstr "At least {0} characters" msgid "At most {maxLength} characters." msgstr "At most {maxLength} characters." -#: src/components/profile/ProfileSettingsTab.tsx:37 +#: src/pages/Settings.tsx:51 msgid "Auto" msgstr "Auto" -#: src/pages/ResetPassword.tsx:71 -#: src/pages/ResetPassword.tsx:144 +#: src/pages/ResetPassword.tsx:74 +#: src/pages/ResetPassword.tsx:147 msgid "Back to login" msgstr "Back to login" @@ -585,14 +583,14 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may #: src/components/category/CategoryManager.tsx:226 #: src/components/chat/ChatEditBox.tsx:88 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:751 +#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/form/FormActions.tsx:42 -#: src/components/playlist/PlaylistEditForm.tsx:205 +#: src/components/playlist/PlaylistEditForm.tsx:218 #: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 -#: src/pages/Dump.tsx:432 -#: src/pages/DumpEdit.tsx:542 +#: src/pages/Dump.tsx:444 +#: src/pages/DumpEdit.tsx:570 msgid "Cancel" msgstr "Cancel" @@ -605,20 +603,20 @@ msgstr "Cancel removal" msgid "Cancel reply" msgstr "Cancel reply" -#: src/components/layout/AppHeader.tsx:162 +#: src/components/layout/AppHeader.tsx:163 msgid "Cancel search" msgstr "Cancel search" #: src/components/category/CategorySelect.tsx:33 -#: src/components/profile/ProfileManageTab.tsx:49 +#: src/pages/Manage.tsx:33 msgid "Categories" msgstr "Categories" -#: src/components/category/CategorySwitcher.tsx:107 +#: src/components/category/CategorySwitcher.tsx:119 msgid "Category" msgstr "Category" -#: src/components/profile/ProfileHeader.tsx:93 +#: src/components/profile/ProfileHeader.tsx:96 msgid "Change avatar" msgstr "Change avatar" @@ -626,12 +624,12 @@ msgstr "Change avatar" msgid "Change image" msgstr "Change image" -#: src/components/profile/ChangePasswordModal.tsx:67 -#: src/components/profile/ChangePasswordModal.tsx:121 +#: src/components/profile/ChangePasswordModal.tsx:66 +#: src/components/profile/ChangePasswordModal.tsx:120 msgid "Change password" msgstr "Change password" -#: src/components/profile/ProfileSettingsTab.tsx:80 +#: src/pages/Settings.tsx:130 msgid "Change password…" msgstr "Change password…" @@ -655,12 +653,12 @@ msgstr "Chat ({unreadChatCount} unread)" msgid "Checking invite…" msgstr "Checking invite…" -#: src/components/profile/ChangePasswordModal.tsx:76 +#: src/components/profile/ChangePasswordModal.tsx:75 #: src/components/shared/Modal.tsx:76 msgid "Close" msgstr "Close" -#: src/components/dump/create/DumpCreateModal.tsx:495 +#: src/components/dump/create/DumpCreateModal.tsx:496 msgid "Close anyway" msgstr "Close anyway" @@ -672,7 +670,7 @@ msgstr "Close emoji picker" msgid "Close player" msgstr "Close player" -#: src/components/dump/create/DumpCreateModal.tsx:485 +#: src/components/dump/create/DumpCreateModal.tsx:486 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." @@ -680,12 +678,12 @@ msgstr "Close without posting? The attached file won't be kept — everything yo msgid "Collapse player" msgstr "Collapse player" -#: src/components/profile/ProfileSettingsTab.tsx:94 +#: src/pages/Settings.tsx:144 msgid "Color scheme" msgstr "Color scheme" -#: src/components/profile/ChangePasswordModal.tsx:109 -#: src/pages/ResetPassword.tsx:128 +#: src/components/profile/ChangePasswordModal.tsx:108 +#: src/pages/ResetPassword.tsx:131 msgid "Confirm new password" msgstr "Confirm new password" @@ -701,7 +699,7 @@ msgstr "Copied!" msgid "Copy" msgstr "Copy" -#: src/components/profile/ChangePasswordModal.tsx:118 +#: src/components/profile/ChangePasswordModal.tsx:117 msgid "Could not change password" msgstr "Could not change password" @@ -709,16 +707,20 @@ 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/pages/DumpEdit.tsx:530 -#: src/pages/UserPlaylists.tsx:264 +#: src/components/profile/ProfilePlaylistsTab.tsx:126 +#: src/pages/DumpEdit.tsx:558 msgid "Could not delete" msgstr "Could not delete" -#: src/pages/DumpEdit.tsx:490 +#: src/components/profile/UsernameField.tsx:81 +msgid "Could not rename" +msgstr "Could not rename" + +#: src/pages/DumpEdit.tsx:511 msgid "Could not save" msgstr "Could not save" -#: src/components/dump/create/DumpCreateModal.tsx:606 +#: src/components/dump/create/DumpCreateModal.tsx:607 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." @@ -742,36 +744,30 @@ msgstr "Create" msgid "Create & Add" msgstr "Create & Add" -#. placeholder {0}: created.items.length -#. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:269 -msgid "Created ({0}{1})" -msgstr "Created ({0}{1})" - #: src/components/playlist/PlaylistCreateForm.tsx:111 msgid "Creating…" msgstr "Creating…" -#: src/components/profile/ChangePasswordModal.tsx:87 +#: src/components/profile/ChangePasswordModal.tsx:86 msgid "Current password" msgstr "Current password" -#: src/components/profile/ProfileSettingsTab.tsx:39 +#: src/pages/Settings.tsx:53 msgid "Dark" msgstr "Dark" -#: src/components/profile/ProfileSettingsTab.tsx:105 +#: src/pages/Settings.tsx:155 msgid "Default tab" msgstr "Default tab" #: src/components/category/CategoryManager.tsx:236 #: src/components/category/CategoryManager.tsx:237 -#: src/components/chat/ChatMessageItem.tsx:114 #: src/components/chat/ChatMessageItem.tsx:115 -#: src/components/chat/ChatMessageItem.tsx:213 -#: src/components/comment/CommentNode.tsx:263 -#: src/components/comment/CommentNode.tsx:269 -#: src/components/playlist/PlaylistEditForm.tsx:201 +#: src/components/chat/ChatMessageItem.tsx:116 +#: src/components/chat/ChatMessageItem.tsx:214 +#: src/components/comment/CommentNode.tsx:265 +#: src/components/comment/CommentNode.tsx:271 +#: src/components/playlist/PlaylistEditForm.tsx:214 #: src/components/shared/ConfirmModal.tsx:34 msgid "Delete" msgstr "Delete" @@ -785,36 +781,36 @@ msgstr "Delete category" msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone." -#: src/pages/DumpEdit.tsx:299 -#: src/pages/DumpEdit.tsx:538 +#: src/pages/DumpEdit.tsx:313 +#: src/pages/DumpEdit.tsx:566 msgid "Delete dump" msgstr "Delete dump" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:178 +#: src/pages/DumpEdit.tsx:192 msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/playlist/PlaylistCard.tsx:141 +#: src/components/playlist/PlaylistCard.tsx:142 +#: src/components/profile/ProfilePlaylistsTab.tsx:156 #: src/pages/PlaylistDetail.tsx:136 -#: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" msgstr "Delete playlist" -#: src/components/comment/CommentNode.tsx:268 +#: src/components/comment/CommentNode.tsx:270 msgid "Delete this comment?" msgstr "Delete this comment?" -#: src/pages/DumpEdit.tsx:298 +#: src/pages/DumpEdit.tsx:312 msgid "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone." -#: src/components/chat/ChatMessageItem.tsx:212 +#: src/components/chat/ChatMessageItem.tsx:213 msgid "Delete this message?" msgstr "Delete this message?" +#: src/components/profile/ProfilePlaylistsTab.tsx:155 #: src/pages/PlaylistDetail.tsx:135 -#: src/pages/UserPlaylists.tsx:326 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -823,7 +819,7 @@ msgid "deleted message" msgstr "deleted message" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/components/playlist/PlaylistEditForm.tsx:173 +#: src/components/playlist/PlaylistEditForm.tsx:186 msgid "Description (optional)" msgstr "Description (optional)" @@ -840,7 +836,7 @@ msgstr "Disconnected — reconnecting…" msgid "Dismiss" msgstr "Dismiss" -#: src/components/dump/create/DumpCreateModal.tsx:545 +#: src/components/dump/create/DumpCreateModal.tsx:546 msgid "Done" msgstr "Done" @@ -853,48 +849,47 @@ msgstr "Download {0}" msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/dump/create/DumpCreateModal.tsx:652 +#: src/components/dump/create/DumpCreateModal.tsx:653 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" -#: src/pages/DumpEdit.tsx:481 +#: src/pages/DumpEdit.tsx:502 msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/layout/AppHeader.tsx:201 +#: src/components/layout/AppHeader.tsx:202 msgid "Dump" msgstr "Dump" -#: src/components/dump/create/DumpCreateModal.tsx:764 +#: src/components/dump/create/DumpCreateModal.tsx:765 msgid "Dump it" msgstr "Dump it" -#: src/components/dump/create/DumpCreateModal.tsx:523 +#: src/components/dump/create/DumpCreateModal.tsx:524 msgid "Dumped!" msgstr "Dumped!" -#: src/pages/Search.tsx:116 -#: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:189 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/pages/Search.tsx:120 +#: src/pages/UserPublicProfile.tsx:154 msgid "Dumps" msgstr "Dumps" -#. placeholder {0}: dumps.items.length -#. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:37 -msgid "Dumps ({0}{1})" -msgstr "Dumps ({0}{1})" +#. placeholder {0}: countOf(dumps.state) +#: src/components/profile/ProfileOverviewTab.tsx:56 +msgid "Dumps ({0})" +msgstr "Dumps ({0})" -#: src/pages/Notifications.tsx:375 +#: src/pages/Notifications.tsx:405 msgid "Earlier" msgstr "Earlier" -#: src/components/chat/ChatMessageItem.tsx:103 #: src/components/chat/ChatMessageItem.tsx:104 +#: src/components/chat/ChatMessageItem.tsx:105 #: src/components/chat/PendingItem.tsx:93 -#: src/components/comment/CommentNode.tsx:254 -#: src/components/playlist/PlaylistHeader.tsx:58 -#: src/pages/Dump.tsx:550 +#: src/components/comment/CommentNode.tsx:256 +#: src/components/playlist/PlaylistHeader.tsx:59 +#: src/pages/Dump.tsx:562 msgid "Edit" msgstr "Edit" @@ -907,49 +902,49 @@ msgstr "Edit {0}" msgid "Edit description" msgstr "Edit description" -#: src/components/profile/ProfileHeader.tsx:150 +#: src/components/profile/ProfileHeader.tsx:153 msgid "Edit email" msgstr "Edit email" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:448 +#: src/pages/Dump.tsx:460 msgid "Edit title" msgstr "Edit title" -#: src/components/chat/ChatMessageItem.tsx:127 +#: src/components/chat/ChatMessageItem.tsx:128 msgid "edited" msgstr "edited" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/chat/ChatMessageItem.tsx:128 -#: src/components/comment/CommentNode.tsx:203 -#: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:491 +#: src/components/chat/ChatMessageItem.tsx:129 +#: src/components/comment/CommentNode.tsx:205 +#: src/components/playlist/PlaylistHeader.tsx:94 +#: src/pages/Dump.tsx:503 msgid "edited {0}" msgstr "edited {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/chat/ChatMessageItem.tsx:124 -#: src/components/comment/CommentNode.tsx:201 -#: src/components/playlist/PlaylistHeader.tsx:91 -#: src/pages/Dump.tsx:489 +#: src/components/chat/ChatMessageItem.tsx:125 +#: src/components/comment/CommentNode.tsx:203 +#: src/components/playlist/PlaylistHeader.tsx:92 +#: src/pages/Dump.tsx:501 msgid "Edited {0}" msgstr "Edited {0}" -#: src/pages/DumpEdit.tsx:242 +#: src/pages/DumpEdit.tsx:256 msgid "Editing" msgstr "Editing" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:72 +#: src/pages/DumpEdit.tsx:79 msgid "Editing {0}" msgstr "Editing {0}" -#: src/components/profile/ProfileSettingsTab.tsx:129 +#: src/pages/Settings.tsx:179 msgid "Email" msgstr "Email" @@ -957,7 +952,7 @@ msgstr "Email" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:149 +#: src/pages/Search.tsx:155 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -984,11 +979,11 @@ msgid "Failed to generate invite" msgstr "Failed to generate invite" #: src/components/shared/ListState.tsx:43 -#: src/pages/Notifications.tsx:349 +#: src/pages/Notifications.tsx:379 msgid "Failed to load" msgstr "Failed to load" -#: src/components/dump/create/DumpCreateModal.tsx:554 +#: src/components/dump/create/DumpCreateModal.tsx:555 msgid "Failed to post" msgstr "Failed to post" @@ -996,11 +991,11 @@ msgstr "Failed to post" msgid "Failed to post comment" msgstr "Failed to post comment" -#: src/components/comment/CommentNode.tsx:289 +#: src/components/comment/CommentNode.tsx:291 msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/components/playlist/PlaylistEditForm.tsx:193 +#: src/components/playlist/PlaylistEditForm.tsx:206 #: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 msgid "Failed to save" @@ -1010,11 +1005,11 @@ msgstr "Failed to save" msgid "Failed to save category" msgstr "Failed to save category" -#: src/components/comment/CommentNode.tsx:216 +#: src/components/comment/CommentNode.tsx:218 msgid "Failed to save edit" msgstr "Failed to save edit" -#: src/components/profile/ProfileHeader.tsx:161 +#: src/components/profile/ProfileHeader.tsx:164 msgid "Failed to update avatar" msgstr "Failed to update avatar" @@ -1022,15 +1017,15 @@ msgstr "Failed to update avatar" msgid "Failed to update role" msgstr "Failed to update role" -#: src/components/profile/ProfileSettingsTab.tsx:103 +#: src/pages/Settings.tsx:153 msgid "Feeds" msgstr "Feeds" -#: src/components/dump/create/DumpCreateModal.tsx:593 +#: src/components/dump/create/DumpCreateModal.tsx:594 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/dump/create/DumpCreateModal.tsx:416 +#: src/components/dump/create/DumpCreateModal.tsx:417 msgid "File too large (max {max})." msgstr "File too large (max {max})." @@ -1055,23 +1050,13 @@ msgstr "Follow some public playlists to see their dumps here." msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/components/feed/FeedTabBar.tsx:49 -#: src/components/profile/ProfileSettingsTab.tsx:46 -#: src/pages/UserPublicProfile.tsx:191 +#: src/components/feed/FeedTabBar.tsx:43 +#: src/components/profile/ProfileFollowTab.tsx:163 +#: src/pages/Settings.tsx:60 +#: src/pages/UserPublicProfile.tsx:157 msgid "Followed" msgstr "Followed" -#. placeholder {0}: followed.items.length -#. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:300 -msgid "Followed ({0}{1})" -msgstr "Followed ({0}{1})" - -#: src/components/profile/ProfileFollowedTab.tsx:100 -msgid "Followed playlists" -msgstr "Followed playlists" - -#: src/components/profile/ProfileFollowedTab.tsx:68 #: src/components/social/FollowButton.tsx:62 #: src/components/social/FollowButton.tsx:102 msgid "Following" @@ -1097,12 +1082,12 @@ msgstr "Fullscreen" msgid "Go home" msgstr "Go home" -#: src/pages/ResetPassword.tsx:93 +#: src/pages/ResetPassword.tsx:96 msgid "Go to login" msgstr "Go to login" -#: src/components/feed/FeedTabBar.tsx:45 -#: src/components/profile/ProfileSettingsTab.tsx:43 +#: src/components/feed/FeedTabBar.tsx:39 +#: src/pages/Settings.tsx:57 msgid "Hot" msgstr "Hot" @@ -1114,7 +1099,7 @@ msgstr "If that address is registered you'll receive a reset link shortly." msgid "image" msgstr "image" -#: src/pages/Dump.tsx:570 +#: src/pages/Dump.tsx:582 msgid "In collections" msgstr "In collections" @@ -1127,7 +1112,7 @@ msgstr "Insert emoji" msgid "Invalid invite" msgstr "Invalid invite" -#: src/pages/ResetPassword.tsx:61 +#: src/pages/ResetPassword.tsx:64 #: src/pages/Unsubscribe.tsx:64 msgid "Invalid link" msgstr "Invalid link" @@ -1137,25 +1122,29 @@ msgstr "Invalid link" msgid "Invite link" msgstr "Invite link" -#: src/components/profile/ProfileHeader.tsx:115 +#: src/components/profile/ProfileHeader.tsx:118 msgid "invited by" msgstr "invited by" -#: src/components/profile/ProfileInviteesTab.tsx:76 -#: src/pages/UserPublicProfile.tsx:192 +#: src/components/profile/ProfileInviteesTab.tsx:77 +#: src/pages/UserPublicProfile.tsx:158 msgid "Invitees" msgstr "Invitees" -#: src/components/feed/FeedTabBar.tsx:47 -#: src/components/profile/ProfileSettingsTab.tsx:45 +#: src/components/feed/FeedTabBar.tsx:41 +#: src/pages/Settings.tsx:59 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:492 +#: src/components/dump/create/DumpCreateModal.tsx:493 msgid "Keep editing" msgstr "Keep editing" -#: src/components/profile/ProfileSettingsTab.tsx:38 +#: src/components/profile/UsernameField.tsx:63 +msgid "Letters, numbers and underscores only" +msgstr "Letters, numbers and underscores only" + +#: src/pages/Settings.tsx:52 msgid "Light" msgstr "Light" @@ -1163,7 +1152,8 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/components/dump/create/DumpCreateModal.tsx:568 +#: src/components/dump/create/DumpCreateModal.tsx:569 +#: src/components/form/SlugField.tsx:70 msgid "Link" msgstr "Link" @@ -1171,11 +1161,15 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" +#: src/components/form/SlugField.tsx:100 +msgid "Links to the old address keep working." +msgstr "Links to the old address keep working." + #: src/contexts/ws/WSProvider.tsx:760 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/layout/AppHeader.tsx:236 +#: src/components/layout/AppHeader.tsx:237 msgid "Live updates unavailable." msgstr "Live updates unavailable." @@ -1187,8 +1181,8 @@ msgstr "Load more" msgid "Load older messages" msgstr "Load older messages" -#: src/pages/Dump.tsx:290 -#: src/pages/DumpEdit.tsx:193 +#: src/pages/Dump.tsx:302 +#: src/pages/DumpEdit.tsx:207 msgid "Loading dump…" msgstr "Loading dump…" @@ -1204,7 +1198,7 @@ msgstr "Loading more…" msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:142 +#: src/pages/UserPublicProfile.tsx:106 msgid "Loading profile…" msgstr "Loading profile…" @@ -1214,15 +1208,12 @@ msgstr "Loading profile…" #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 #: src/components/shared/LoadMoreButton.tsx:32 -#: src/components/social/UserListPopover.tsx:150 -#: src/pages/Notifications.tsx:345 -#: src/pages/UserDumps.tsx:62 -#: src/pages/UserPlaylists.tsx:228 -#: src/pages/UserUpvoted.tsx:48 +#: src/components/social/UserListPopover.tsx:151 +#: src/pages/Notifications.tsx:375 msgid "Loading…" msgstr "Loading…" -#: src/components/layout/AppHeader.tsx:209 +#: src/components/layout/AppHeader.tsx:210 #: src/pages/UserLogin.tsx:41 #: src/pages/UserLogin.tsx:67 #: src/pages/UserLogin.tsx:86 @@ -1237,8 +1228,8 @@ msgstr "Log in to like" msgid "Log in to vote" msgstr "Log in to vote" -#: src/components/profile/ProfileHeader.tsx:174 -#: src/pages/UserPublicProfile.tsx:163 +#: src/components/profile/ProfileHeader.tsx:185 +#: src/pages/UserPublicProfile.tsx:127 msgid "Log out" msgstr "Log out" @@ -1250,7 +1241,15 @@ msgstr "Logging in…" msgid "Login failed" msgstr "Login failed" -#: src/pages/UserPublicProfile.tsx:197 +#: src/components/form/SlugField.tsx:86 +msgid "Lowercase letters, numbers and single hyphens only" +msgstr "Lowercase letters, numbers and single hyphens only" + +#: src/components/layout/UserMenu.tsx:98 +#: src/components/profile/ProfileHeader.tsx:181 +#: src/pages/Manage.tsx:23 +#: src/pages/Manage.tsx:30 +#: src/pages/UserPublicProfile.tsx:160 msgid "Manage" msgstr "Manage" @@ -1271,25 +1270,25 @@ msgstr "Mute" msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:338 +#: src/pages/Notifications.tsx:368 msgid "new" msgstr "new" #: src/components/chat/ChatModal.tsx:669 -#: src/components/feed/FeedTabBar.tsx:46 -#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/components/feed/FeedTabBar.tsx:40 +#: src/pages/Settings.tsx:58 msgid "New" msgstr "New" -#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/create/DumpCreateModal.tsx:465 #: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 msgid "New dump" msgstr "New dump" -#: src/components/profile/ChangePasswordModal.tsx:95 -#: src/pages/ResetPassword.tsx:113 +#: src/components/profile/ChangePasswordModal.tsx:94 +#: src/pages/ResetPassword.tsx:116 msgid "New password" msgstr "New password" @@ -1298,7 +1297,7 @@ msgstr "New password" msgid "New playlist" msgstr "New playlist" -#: src/components/dump/create/DumpCreateModal.tsx:763 +#: src/components/dump/create/DumpCreateModal.tsx:764 msgid "Next →" msgstr "Next →" @@ -1310,7 +1309,7 @@ msgstr "Next track" msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:165 +#: src/pages/Search.tsx:171 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." @@ -1323,12 +1322,11 @@ msgstr "No dumps yet. Be the first!" msgid "No emoji found." msgstr "No emoji found." -#: src/components/profile/ProfileFollowedTab.tsx:110 -#: src/pages/UserPlaylists.tsx:309 +#: src/components/profile/ProfileFollowTab.tsx:136 msgid "No followed playlists yet." msgstr "No followed playlists yet." -#: src/components/profile/ProfileInviteesTab.tsx:82 +#: src/components/profile/ProfileInviteesTab.tsx:83 msgid "No invitees yet." msgstr "No invitees yet." @@ -1336,17 +1334,16 @@ msgstr "No invitees yet." msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" -#: src/components/social/UserListPopover.tsx:158 +#: src/components/social/UserListPopover.tsx:159 msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:206 +#: src/pages/Search.tsx:212 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:52 -#: src/pages/UserPlaylists.tsx:278 +#: src/components/profile/ProfilePlaylistsTab.tsx:133 msgid "No playlists yet." msgstr "No playlists yet." @@ -1354,26 +1351,24 @@ 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:180 +#: src/pages/Search.tsx:186 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." -#: src/components/profile/ProfileFollowedTab.tsx:74 +#: src/components/profile/ProfileFollowTab.tsx:99 msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/components/profile/ProfileDumpsTab.tsx:60 -#: src/pages/Notifications.tsx:356 -#: src/pages/UserDumps.tsx:94 -#: src/pages/UserUpvoted.tsx:79 +#: src/components/profile/ProfileDumpsTab.tsx:53 +#: src/components/profile/ProfileOverviewTab.tsx:47 +#: src/pages/Notifications.tsx:386 msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:54 -#: src/components/profile/ProfileSettingsTab.tsx:114 -#: src/pages/Notifications.tsx:282 -#: src/pages/Notifications.tsx:334 +#: src/pages/Notifications.tsx:308 +#: src/pages/Notifications.tsx:364 +#: src/pages/Settings.tsx:164 msgid "Notifications" msgstr "Notifications" @@ -1381,11 +1376,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" -#: src/components/profile/ProfileSettingsTab.tsx:51 +#: src/pages/Settings.tsx:65 msgid "Off" msgstr "Off" -#: src/components/profile/ProfileSettingsTab.tsx:50 +#: src/pages/Settings.tsx:64 msgid "On" msgstr "On" @@ -1393,7 +1388,7 @@ msgstr "On" msgid "Only images can be dropped here." msgstr "Only images can be dropped here." -#: src/pages/DumpEdit.tsx:103 +#: src/pages/DumpEdit.tsx:117 msgid "Only its poster or a moderator can edit this dump." msgstr "Only its poster or a moderator can edit this dump." @@ -1410,11 +1405,15 @@ msgstr "Open source" msgid "or <0>browse files" msgstr "or <0>browse files" +#: src/pages/UserPublicProfile.tsx:153 +msgid "Overview" +msgstr "Overview" + #: src/pages/NotFound.tsx:16 msgid "Page not found" msgstr "Page not found" -#: src/components/profile/ProfileSettingsTab.tsx:74 +#: src/pages/Settings.tsx:124 #: src/pages/UserLogin.tsx:82 msgid "Password" msgstr "Password" @@ -1424,20 +1423,20 @@ msgstr "Password" msgid "Password (min. {0} characters)" msgstr "Password (min. {0} characters)" -#: src/components/profile/ChangePasswordModal.tsx:72 +#: src/components/profile/ChangePasswordModal.tsx:71 msgid "Password changed successfully." msgstr "Password changed successfully." -#: src/pages/ResetPassword.tsx:83 +#: src/pages/ResetPassword.tsx:86 msgid "Password updated" msgstr "Password updated" -#: src/components/profile/ChangePasswordModal.tsx:115 -#: src/pages/ResetPassword.tsx:133 +#: src/components/profile/ChangePasswordModal.tsx:114 +#: src/pages/ResetPassword.tsx:136 msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/dump/create/DumpCreateModal.tsx:586 +#: src/components/dump/create/DumpCreateModal.tsx:587 msgid "Paste a link…" msgstr "Paste a link…" @@ -1448,7 +1447,7 @@ msgstr "Paste a link…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:504 +#: src/components/dump/create/DumpCreateModal.tsx:505 msgid "Picked up where you left off." msgstr "Picked up where you left off." @@ -1462,36 +1461,31 @@ msgstr "Picked up where you left off." msgid "Play" msgstr "Play" -#: src/components/playlist/PlaylistEditForm.tsx:157 +#: src/components/playlist/PlaylistEditForm.tsx:162 msgid "Playlist title" msgstr "Playlist title" #: src/components/dump/create/StepIndicator.tsx:21 -#: src/components/layout/AppHeader.tsx:182 -#: src/components/layout/UserMenu.tsx:78 -#: src/pages/Search.tsx:119 -#: src/pages/UserPlaylists.tsx:254 -#: src/pages/UserPublicProfile.tsx:190 +#: src/components/layout/AppHeader.tsx:183 +#: src/components/layout/UserMenu.tsx:81 +#: src/components/profile/ProfileFollowTab.tsx:167 +#: src/components/profile/ProfilePlaylistsTab.tsx:118 +#: src/pages/Search.tsx:123 +#: src/pages/UserPublicProfile.tsx:156 msgid "Playlists" msgstr "Playlists" -#. placeholder {0}: playlists.items.length -#. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/components/profile/ProfilePlaylistsTab.tsx:35 -msgid "Playlists ({0}{1})" -msgstr "Playlists ({0}{1})" - #: src/components/comment/CommentThread.tsx:99 msgid "Post comment" msgstr "Post comment" -#: src/components/comment/CommentNode.tsx:287 +#: src/components/comment/CommentNode.tsx:289 msgid "Post reply" msgstr "Post reply" -#: src/components/comment/CommentNode.tsx:288 +#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:757 +#: src/components/dump/create/DumpCreateModal.tsx:758 msgid "Posting…" msgstr "Posting…" @@ -1501,10 +1495,10 @@ msgstr "Previous track" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 -#: src/components/playlist/PlaylistCard.tsx:103 -#: src/components/playlist/PlaylistHeader.tsx:75 +#: src/components/playlist/PlaylistCard.tsx:104 +#: src/components/playlist/PlaylistHeader.tsx:76 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:497 +#: src/pages/Dump.tsx:509 msgid "private" msgstr "private" @@ -1512,8 +1506,8 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/playlist/PlaylistCard.tsx:102 -#: src/components/playlist/PlaylistHeader.tsx:75 +#: src/components/playlist/PlaylistCard.tsx:103 +#: src/components/playlist/PlaylistHeader.tsx:76 msgid "public" msgstr "public" @@ -1521,7 +1515,7 @@ msgstr "public" msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:275 +#: src/pages/DumpEdit.tsx:289 msgid "Refresh metadata" msgstr "Refresh metadata" @@ -1529,7 +1523,7 @@ msgstr "Refresh metadata" msgid "Refreshing stream…" msgstr "Refreshing stream…" -#: src/pages/DumpEdit.tsx:274 +#: src/pages/DumpEdit.tsx:288 msgid "Refreshing…" msgstr "Refreshing…" @@ -1547,7 +1541,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:561 +#: src/pages/Dump.tsx:573 msgid "Related" msgstr "Related" @@ -1567,13 +1561,21 @@ msgstr "Remove like" msgid "Remove vote" msgstr "Remove vote" -#: src/pages/DumpEdit.tsx:473 +#: src/components/profile/UsernameField.tsx:72 +msgid "Rename" +msgstr "Rename" + +#: src/components/profile/UsernameField.tsx:69 +msgid "Renaming…" +msgstr "Renaming…" + +#: src/pages/DumpEdit.tsx:494 msgid "Replace file" msgstr "Replace file" -#: src/components/chat/ChatMessageItem.tsx:93 #: src/components/chat/ChatMessageItem.tsx:94 -#: src/components/comment/CommentNode.tsx:242 +#: src/components/chat/ChatMessageItem.tsx:95 +#: src/components/comment/CommentNode.tsx:244 msgid "Reply" msgstr "Reply" @@ -1585,7 +1587,7 @@ msgstr "Replying to" msgid "Request failed" msgstr "Request failed" -#: src/pages/ResetPassword.tsx:109 +#: src/pages/ResetPassword.tsx:112 msgid "Reset failed" msgstr "Reset failed" @@ -1593,8 +1595,8 @@ msgstr "Reset failed" msgid "Reset password" msgstr "Reset password" -#: src/pages/DumpEdit.tsx:449 -#: src/pages/DumpEdit.tsx:504 +#: src/pages/DumpEdit.tsx:470 +#: src/pages/DumpEdit.tsx:525 msgid "Reset to default" msgstr "Reset to default" @@ -1604,8 +1606,8 @@ msgid "Reset to the suggested title" msgstr "Reset to the suggested title" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:307 -#: src/pages/DumpEdit.tsx:210 +#: src/pages/Dump.tsx:319 +#: src/pages/DumpEdit.tsx:224 msgid "Retry" msgstr "Retry" @@ -1615,28 +1617,28 @@ msgstr "Role" #: src/components/category/CategoryManager.tsx:216 #: src/components/chat/ChatEditBox.tsx:85 -#: src/components/comment/CommentNode.tsx:214 -#: src/components/playlist/PlaylistEditForm.tsx:212 +#: src/components/comment/CommentNode.tsx:216 +#: src/components/playlist/PlaylistEditForm.tsx:225 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:424 -#: src/pages/DumpEdit.tsx:545 +#: src/pages/Dump.tsx:436 +#: src/pages/DumpEdit.tsx:573 msgid "Save" msgstr "Save" -#: src/components/comment/CommentNode.tsx:215 -#: src/components/playlist/PlaylistEditForm.tsx:208 -#: src/components/profile/ChangePasswordModal.tsx:120 +#: src/components/comment/CommentNode.tsx:217 +#: src/components/playlist/PlaylistEditForm.tsx:221 +#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:423 -#: src/pages/ResetPassword.tsx:136 +#: src/pages/Dump.tsx:435 +#: src/pages/ResetPassword.tsx:139 msgid "Saving…" msgstr "Saving…" -#: src/components/layout/AppHeader.tsx:162 +#: src/components/layout/AppHeader.tsx:163 #: src/components/layout/SearchBar.tsx:82 -#: src/pages/Search.tsx:65 +#: src/pages/Search.tsx:69 msgid "Search" msgstr "Search" @@ -1652,11 +1654,11 @@ msgstr "Search emoji" msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:160 +#: src/pages/Search.tsx:166 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:155 +#: src/pages/Search.tsx:161 msgid "Searching…" msgstr "Searching…" @@ -1677,16 +1679,19 @@ msgstr "Send reset link" msgid "Sending…" msgstr "Sending…" -#: src/components/layout/AppHeader.tsx:198 +#: src/components/layout/AppHeader.tsx:199 msgid "Server unreachable" msgstr "Server unreachable" -#: src/pages/ResetPassword.tsx:104 -#: src/pages/ResetPassword.tsx:137 +#: src/pages/ResetPassword.tsx:107 +#: src/pages/ResetPassword.tsx:140 msgid "Set new password" msgstr "Set new password" -#: src/pages/UserPublicProfile.tsx:194 +#: src/components/layout/UserMenu.tsx:89 +#: src/components/profile/ProfileHeader.tsx:177 +#: src/pages/Settings.tsx:77 +#: src/pages/Settings.tsx:105 msgid "Settings" msgstr "Settings" @@ -1713,15 +1718,15 @@ msgstr "Some images couldn't be uploaded." msgid "Something went wrong" msgstr "Something went wrong" -#: src/components/profile/ProfileSettingsTab.tsx:116 +#: src/pages/Settings.tsx:166 msgid "Sounds" msgstr "Sounds" -#: src/components/dump/create/DumpCreateModal.tsx:511 +#: src/components/dump/create/DumpCreateModal.tsx:512 msgid "Start fresh" msgstr "Start fresh" -#: src/components/profile/ProfileSettingsTab.tsx:87 +#: src/pages/Settings.tsx:137 msgid "Style" msgstr "Style" @@ -1733,11 +1738,11 @@ msgstr "Submit search" msgid "That file is over {limit}." msgstr "That file is over {limit}." -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:248 msgid "the chat" msgstr "the chat" -#: src/hooks/playlist/usePlaylist.ts:161 +#: src/hooks/playlist/usePlaylist.ts:179 msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." @@ -1745,11 +1750,15 @@ msgstr "The dump couldn't be removed. Try again." msgid "The image couldn't be uploaded." msgstr "The image couldn't be uploaded." -#: src/hooks/playlist/usePlaylist.ts:307 +#: src/hooks/playlist/usePlaylist.ts:322 msgid "The new order couldn't be saved. Try again." msgstr "The new order couldn't be saved. Try again." -#: src/components/comment/CommentNode.tsx:280 +#: src/components/profile/ProfileManageTab.tsx:67 +msgid "Their old name keeps leading to them, and nobody else can take it." +msgstr "Their old name keeps leading to them, and nobody else can take it." + +#: src/components/comment/CommentNode.tsx:282 msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." @@ -1765,11 +1774,11 @@ msgstr "This is a mirage." msgid "This page does not exist." msgstr "This page does not exist." -#: src/pages/ResetPassword.tsx:64 +#: src/pages/ResetPassword.tsx:67 msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." -#: src/components/profile/ProfileSettingsTab.tsx:152 +#: src/pages/Settings.tsx:202 msgid "This site doesn't send email, so no digest goes out." msgstr "This site doesn't send email, so no digest goes out." @@ -1777,21 +1786,21 @@ msgstr "This site doesn't send email, so no digest goes out." msgid "This unsubscribe link is missing or malformed." msgstr "This unsubscribe link is missing or malformed." -#: src/pages/DumpEdit.tsx:433 +#: src/pages/DumpEdit.tsx:454 msgid "Thumbnail" msgstr "Thumbnail" #: src/components/dump/create/TitleField.tsx:45 #: src/components/playlist/PlaylistCreateForm.tsx:96 -#: src/pages/DumpEdit.tsx:494 +#: src/pages/DumpEdit.tsx:515 msgid "Title" msgstr "Title" -#: src/components/dump/create/DumpCreateModal.tsx:413 +#: src/components/dump/create/DumpCreateModal.tsx:414 msgid "Title is required." msgstr "Title is required." -#: src/pages/Notifications.tsx:372 +#: src/pages/Notifications.tsx:402 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Today" @@ -1849,12 +1858,12 @@ msgstr "Unsubscribe" msgid "Unsubscribed" msgstr "Unsubscribed" -#: src/components/profile/ProfileHeader.tsx:70 +#: src/components/profile/ProfileHeader.tsx:73 msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:448 +#: src/components/dump/create/DumpCreateModal.tsx:449 msgid "Uploading {0}%" msgstr "Uploading {0}%" @@ -1862,7 +1871,7 @@ msgstr "Uploading {0}%" msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/dump/create/DumpCreateModal.tsx:760 +#: src/components/dump/create/DumpCreateModal.tsx:761 msgid "Uploading…" msgstr "Uploading…" @@ -1870,21 +1879,21 @@ msgstr "Uploading…" msgid "Upvote" msgstr "Upvote" -#: src/pages/UserUpvoted.tsx:74 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/pages/UserPublicProfile.tsx:155 msgid "Upvoted" msgstr "Upvoted" -#. placeholder {0}: votes.items.length -#. placeholder {1}: votes.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:52 -msgid "Upvoted ({0}{1})" -msgstr "Upvoted ({0}{1})" +#. placeholder {0}: countOf(votes.state) +#: src/components/profile/ProfileOverviewTab.tsx:71 +msgid "Upvoted ({0})" +msgstr "Upvoted ({0})" -#: src/pages/DumpEdit.tsx:465 +#: src/pages/DumpEdit.tsx:486 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:397 +#: src/components/dump/create/DumpCreateModal.tsx:398 msgid "URL is required." msgstr "URL is required." @@ -1896,20 +1905,24 @@ msgstr "URL slug" msgid "User" msgstr "User" -#: src/components/profile/ProfileManageTab.tsx:38 +#: src/components/profile/ProfileManageTab.tsx:54 msgid "User management" msgstr "User management" -#: src/components/layout/UserMenu.tsx:53 +#: src/components/layout/UserMenu.tsx:56 msgid "User menu" msgstr "User menu" +#: src/components/profile/ProfileManageTab.tsx:61 +#: src/components/profile/UsernameField.tsx:55 +#: src/pages/Settings.tsx:110 #: src/pages/UserLogin.tsx:75 #: src/pages/UserRegister.tsx:137 msgid "Username" msgstr "Username" -#: src/pages/Search.tsx:118 +#: src/components/profile/ProfileFollowTab.tsx:166 +#: src/pages/Search.tsx:122 msgid "Users" msgstr "Users" @@ -1917,7 +1930,7 @@ msgstr "Users" msgid "View all →" msgstr "View all →" -#: src/components/dump/create/DumpCreateModal.tsx:525 +#: src/components/dump/create/DumpCreateModal.tsx:526 msgid "View dump →" msgstr "View dump →" @@ -1925,8 +1938,8 @@ msgstr "View dump →" msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:711 -#: src/pages/DumpEdit.tsx:515 +#: src/components/dump/create/DumpCreateModal.tsx:712 +#: src/pages/DumpEdit.tsx:543 msgid "What makes it worth it?" msgstr "What makes it worth it?" @@ -1935,7 +1948,7 @@ msgstr "What makes it worth it?" msgid "Who am I?" msgstr "Who am I?" -#: src/components/dump/create/DumpCreateModal.tsx:726 +#: src/components/dump/create/DumpCreateModal.tsx:727 msgid "Who can see it" msgstr "Who can see it" @@ -1943,21 +1956,25 @@ msgstr "Who can see it" msgid "Why & where" msgstr "Why & where" -#: src/components/dump/create/DumpCreateModal.tsx:710 -#: src/pages/DumpEdit.tsx:514 +#: src/components/dump/create/DumpCreateModal.tsx:711 +#: src/pages/DumpEdit.tsx:542 msgid "Why?" msgstr "Why?" -#: src/components/comment/CommentNode.tsx:291 +#: src/components/comment/CommentNode.tsx:293 msgid "Write a reply…" msgstr "Write a reply…" -#: src/pages/Notifications.tsx:374 +#: src/pages/Notifications.tsx:404 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Yesterday" -#: src/components/dump/create/DuplicateNotice.tsx:60 +#: src/pages/Settings.tsx:116 +msgid "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it." +msgstr "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it." + +#: src/components/dump/create/DuplicateNotice.tsx:61 msgid "You can post it anyway." msgstr "You can post it anyway." @@ -1969,7 +1986,7 @@ msgstr "You won't get the monthly recap any more. You can turn it back on in you msgid "You won't get the weekly digest any more. You can turn it back on in your settings." msgstr "You won't get the weekly digest any more. You can turn it back on in your settings." -#: src/pages/Notifications.tsx:359 +#: src/pages/Notifications.tsx:389 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." @@ -1985,7 +2002,7 @@ msgstr "You've reached the end." msgid "Your email address" msgstr "Your email address" -#: src/pages/ResetPassword.tsx:86 +#: src/pages/ResetPassword.tsx:89 msgid "Your password has been changed. You can now log in." msgstr "Your password has been changed. You can now log in." diff --git a/locales/fr.mjs b/locales/fr.mjs index b2efb26e..8df58b1f 100644 --- a/locales/fr.mjs +++ b/locales/fr.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2ygf_L\":[\"← Retour\"],\"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\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"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\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"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.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <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\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"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\"],\"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…\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"P5MuiB\":[\"Ajouter une image\"],\"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.\"],\"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…\"],\"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\"],\"\"],\"TAtbho\":[\"Chargement des messages…\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"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\"],\"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…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"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\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_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…\"],\"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.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"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.community\":[\"La communauté\"],\"email.digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"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.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"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.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.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"email.digest.invitedByYou\":[\"sur votre invitation\"],\"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.newMembers\":[\"Nouveaux membres\"],\"email.digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"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 votée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus votées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus votée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus votées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"email.digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.topMonth\":[\"Les plus votées du mois\"],\"email.digest.topWeek\":[\"Les plus votées de la semaine\"],\"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 voté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\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"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\"],\"\"],\"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.\"],\"jbernk\":[\"Chargement du profil…\"],\"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\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour 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\"],\"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…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"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\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"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\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"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\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"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\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"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("{\"-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é\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"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\"],\"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\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"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\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"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.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <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\"],\"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\"],\"JzARG2\":[\"Les liens vers l’ancienne adresse marchent toujours.\"],\"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 !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"P5MuiB\":[\"Ajouter une image\"],\"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.\"],\"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…\"],\"SMaFdc\":[\"Se désabonner\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T62TQc\":[\"Vous pouvez le changer une fois tous les \",[\"renameDays\"],\" jours. Les liens vers votre ancien nom marchent toujours, et personne d’autre ne peut le prendre.\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"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\"],\"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…\"],\"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\"],\"_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\"],\"_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\"],\"azhsgG\":[\"Son ancien nom mène toujours à son profil, et personne d’autre ne peut le prendre.\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"b_1P1D\":[\"Renommage…\"],\"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.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"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.community\":[\"La communauté\"],\"email.digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"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.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"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.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.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"email.digest.invitedByYou\":[\"sur votre invitation\"],\"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.newMembers\":[\"Nouveaux membres\"],\"email.digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"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 votée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus votées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus votée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus votées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"email.digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.topMonth\":[\"Les plus votées du mois\"],\"email.digest.topWeek\":[\"Les plus votées de la semaine\"],\"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 voté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\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"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\"],\"\"],\"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.\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Voté (\",[\"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, vote pour 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\"],\"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…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"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\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"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\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"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\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"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 bd47be26..5f4647cc 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -14,271 +14,271 @@ msgstr "" "Language-Team: \n" #. js-lingui-explicit-id -#: api/mail/digest.ts:124 +#: api/mail/digest.ts:127 msgid "email.digest.count.upvotes" msgstr "{n, plural, one {vote} other {votes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:126 +#: api/mail/digest.ts:129 msgid "email.digest.count.followers" msgstr "{n, plural, one {nouvel abonné} other {nouveaux abonnés}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:128 +#: api/mail/digest.ts:131 msgid "email.digest.count.comments" msgstr "{n, plural, one {commentaire} other {commentaires}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:130 +#: api/mail/digest.ts:133 msgid "email.digest.count.mentions" msgstr "{n, plural, one {mention} other {mentions}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:132 +#: api/mail/digest.ts:135 msgid "email.digest.count.likes" msgstr "{n, plural, one {j'aime sur vos commentaires} other {j'aime sur vos commentaires}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:134 +#: api/mail/digest.ts:137 msgid "email.digest.count.playlistFollows" msgstr "{n, plural, one {abonnement à vos collections} other {abonnements à vos collections}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:150 +#: api/mail/digest.ts:153 msgid "email.digest.hl.mentionComment" msgstr "{who} vous a mentionné dans un commentaire sur {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:153 +#: api/mail/digest.ts:156 msgid "email.digest.hl.mentionIn" msgstr "{who} vous a mentionné dans {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:155 +#: api/mail/digest.ts:158 msgid "email.digest.hl.mentionPlaylist" msgstr "{who} vous a mentionné dans la collection {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:157 +#: api/mail/digest.ts:160 msgid "email.digest.hl.comment" msgstr "{who} a commenté votre reco {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:159 +#: api/mail/digest.ts:162 msgid "email.digest.hl.playlistFollow" msgstr "{who} a suivi votre collection {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:161 +#: api/mail/digest.ts:164 msgid "email.digest.hl.userFollow" msgstr "{who} vous suit désormais" #. js-lingui-explicit-id -#: api/mail/digest.ts:163 +#: api/mail/digest.ts:166 msgid "email.digest.hl.commentLike" msgstr "{who} a aimé votre commentaire sur {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:167 +#: api/mail/digest.ts:170 msgid "email.digest.by" msgstr "par @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:169 +#: api/mail/digest.ts:172 msgid "email.digest.addedTo" msgstr "ajoutée à {playlist}" #. js-lingui-explicit-id -#: api/mail/digest.ts:170 +#: api/mail/digest.ts:173 msgid "email.digest.upload" msgstr "fichier" #. js-lingui-explicit-id -#: api/mail/digest.ts:173 +#: api/mail/digest.ts:176 msgid "email.digest.invitedByYou" msgstr "sur votre invitation" #. js-lingui-explicit-id -#: api/mail/digest.ts:174 +#: api/mail/digest.ts:177 msgid "email.digest.invitedBy" msgstr "sur invitation de @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:175 +#: api/mail/digest.ts:178 msgid "email.digest.andMore" msgstr "et {n} de plus" #. js-lingui-explicit-id -#: api/mail/digest.ts:177 +#: api/mail/digest.ts:180 msgid "email.digest.playlistMeta" msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:200 +#: api/mail/digest.ts:203 msgid "email.digest.yours" msgstr "à vous" #. js-lingui-explicit-id -#: api/mail/digest.ts:218 -#: src/components/profile/ProfileSettingsTab.tsx:140 +#: api/mail/digest.ts:221 +#: src/pages/Settings.tsx:190 msgid "digest.monthly" msgstr "Récap mensuel" #. js-lingui-explicit-id -#: api/mail/digest.ts:218 -#: src/components/profile/ProfileSettingsTab.tsx:131 +#: api/mail/digest.ts:221 +#: src/pages/Settings.tsx:181 msgid "digest.weekly" msgstr "Résumé hebdomadaire" #. js-lingui-explicit-id -#: api/mail/digest.ts:226 +#: api/mail/digest.ts:229 msgid "email.digest.monthly.title" msgstr "Le mois sur {site}" #. js-lingui-explicit-id -#: api/mail/digest.ts:227 +#: api/mail/digest.ts:230 msgid "email.digest.weekly.title" msgstr "Votre semaine, {username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:235 +#: api/mail/digest.ts:238 msgid "email.digest.forYou" msgstr "Pour vous" #. js-lingui-explicit-id -#: api/mail/digest.ts:244 +#: api/mail/digest.ts:247 msgid "email.digest.theChat" msgstr "la tribune" #. js-lingui-explicit-id -#: api/mail/digest.ts:257 +#: api/mail/digest.ts:262 msgid "email.digest.seeAll" msgstr "{n, plural, one {Voir la notification} other {Voir les # notifications}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:266 +#: api/mail/digest.ts:271 msgid "email.digest.network" msgstr "De ceux que vous suivez" #. js-lingui-explicit-id -#: api/mail/digest.ts:272 +#: api/mail/digest.ts:277 msgid "email.digest.more" msgstr "{n} de plus dans votre fil Suivi" #. js-lingui-explicit-id -#: api/mail/digest.ts:285 +#: api/mail/digest.ts:290 msgid "email.digest.topMonth" msgstr "Les plus votées du mois" #. js-lingui-explicit-id -#: api/mail/digest.ts:286 +#: api/mail/digest.ts:291 msgid "email.digest.topWeek" msgstr "Les plus votées de la semaine" #. js-lingui-explicit-id -#: api/mail/digest.ts:302 +#: api/mail/digest.ts:307 msgid "email.digest.community" msgstr "La communauté" #. js-lingui-explicit-id -#: api/mail/digest.ts:309 +#: api/mail/digest.ts:314 msgid "email.digest.stat.dumps" msgstr "{n, plural, one {reco} other {recos}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:315 +#: api/mail/digest.ts:320 msgid "email.digest.stat.chat" msgstr "{n, plural, one {message dans la tribune} other {messages dans la tribune}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:324 +#: api/mail/digest.ts:329 msgid "email.digest.newMembers" msgstr "Nouveaux membres" #. js-lingui-explicit-id -#: api/mail/digest.ts:343 +#: api/mail/digest.ts:348 msgid "email.digest.newPlaylists" msgstr "Nouvelles collections publiques" #. js-lingui-explicit-id -#: api/mail/digest.ts:358 +#: api/mail/digest.ts:363 msgid "email.digest.inChat" msgstr "Dans la tribune" #. js-lingui-explicit-id -#: api/mail/digest.ts:360 +#: api/mail/digest.ts:365 msgid "email.digest.chatWeek" msgstr "{n, plural, one {# message cette semaine} other {# messages cette semaine}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:363 +#: api/mail/digest.ts:368 msgid "email.digest.openChat" msgstr "Ouvrir la tribune" #. js-lingui-explicit-id -#: api/mail/digest.ts:372 +#: api/mail/digest.ts:377 msgid "email.digest.totals" msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}." #. js-lingui-explicit-id -#: api/mail/digest.ts:382 +#: api/mail/digest.ts:387 msgid "email.digest.monthly.reason" msgstr "Vous recevez ce récap mensuel car il est activé dans vos paramètres." #. js-lingui-explicit-id -#: api/mail/digest.ts:383 +#: api/mail/digest.ts:388 msgid "email.digest.weekly.reason" msgstr "Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres." #. js-lingui-explicit-id -#: api/mail/digest.ts:387 +#: api/mail/digest.ts:392 #: src/pages/Unsubscribe.tsx:118 msgid "digest.monthly.off" msgstr "Ne plus recevoir le récap mensuel" #. js-lingui-explicit-id -#: api/mail/digest.ts:387 +#: api/mail/digest.ts:392 #: src/pages/Unsubscribe.tsx:117 msgid "digest.weekly.off" msgstr "Ne plus recevoir le résumé hebdomadaire" #. js-lingui-explicit-id -#: api/mail/digest.ts:392 +#: api/mail/digest.ts:397 msgid "email.settings" msgstr "Paramètres" #. js-lingui-explicit-id -#: api/mail/digest.ts:400 +#: api/mail/digest.ts:405 msgid "email.digest.monthly.subject" msgstr "Le mois sur {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:404 +#: api/mail/digest.ts:409 msgid "email.digest.weekly.subject" msgstr "Votre semaine sur {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:427 +#: api/mail/digest.ts:432 msgid "email.digest.pre.network" msgstr "{n, plural, one {# nouvelle reco de ceux que vous suivez} other {# nouvelles recos de ceux que vous suivez}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:436 +#: api/mail/digest.ts:441 msgid "email.digest.pre.topMonth" msgstr "{n, plural, one {la reco la plus votée du mois} other {les # recos les plus votées du mois}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:440 +#: api/mail/digest.ts:445 msgid "email.digest.pre.members" msgstr "{n, plural, one {# nouveau membre} other {# nouveaux membres}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:443 +#: api/mail/digest.ts:448 msgid "email.digest.pre.playlists" msgstr "{n, plural, one {# nouvelle collection} other {# nouvelles collections}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:447 +#: api/mail/digest.ts:452 msgid "email.digest.pre.topWeek" msgstr "{n, plural, one {la reco la plus votée de la semaine} other {les # recos les plus votées de la semaine}}" @@ -361,7 +361,7 @@ msgstr "Bienvenue sur {site}" msgid "email.welcome.preheader" msgstr "Votre compte est prêt." -#: src/components/comment/CommentNode.tsx:139 +#: src/components/comment/CommentNode.tsx:140 msgid "[deleted]" msgstr "[supprimé]" @@ -372,12 +372,12 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:117 +#: src/components/playlist/PlaylistCard.tsx:118 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" #. placeholder {0}: rest.length -#: src/components/dump/create/DuplicateNotice.tsx:52 +#: src/components/dump/create/DuplicateNotice.tsx:53 msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}" @@ -393,7 +393,7 @@ msgid "{0} is typing…" msgstr "{0} est en train d'écrire…" #. placeholder {0}: rest.length -#: src/components/feed/PresenceRow.tsx:81 +#: src/components/feed/PresenceRow.tsx:82 msgid "{0} more online: {restNames}" msgstr "{0} de plus en ligne : {restNames}" @@ -403,7 +403,7 @@ msgstr "{0} de plus en ligne : {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" -#: src/pages/Search.tsx:120 +#: src/pages/Search.tsx:124 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -415,24 +415,18 @@ msgstr "{unseenCount} nouveaux messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" -#: src/components/dump/create/DumpCreateModal.tsx:741 +#: src/components/dump/create/DumpCreateModal.tsx:742 #: src/pages/PlaylistDetail.tsx:80 -#: src/pages/UserPublicProfile.tsx:159 +#: src/pages/UserPublicProfile.tsx:123 msgid "← Back" msgstr "← Retour" -#: src/pages/Dump.tsx:314 -#: src/pages/Dump.tsx:554 -#: src/pages/DumpEdit.tsx:217 +#: src/pages/Dump.tsx:326 +#: src/pages/Dump.tsx:566 +#: src/pages/DumpEdit.tsx:231 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" -#: src/pages/UserDumps.tsx:74 -#: src/pages/UserPlaylists.tsx:240 -#: src/pages/UserUpvoted.tsx:60 -msgid "← Back to profile" -msgstr "← Retour au profil" - #: src/components/profile/ProfileOwnActions.tsx:80 msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" @@ -441,53 +435,53 @@ msgstr "+ Inviter quelqu'un" msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:391 +#: src/pages/Dump.tsx:403 msgid "+ Playlist" msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:199 +#: src/pages/Notifications.tsx:225 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} a commenté sur <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:159 +#: src/pages/Notifications.tsx:185 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} a suivi votre collection <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:209 +#: src/pages/Notifications.tsx:235 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} a aimé votre commentaire sur <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:225 +#: src/pages/Notifications.tsx:251 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} vous a mentionné dans <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:169 +#: src/pages/Notifications.tsx:195 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:150 +#: src/pages/Notifications.tsx:176 msgid "<0>{0} started following you" msgstr "<0>{0} a commencé à vous suivre" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:189 +#: src/pages/Notifications.tsx:215 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} a voté pour <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:179 +#: src/pages/Notifications.tsx:205 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" @@ -495,15 +489,19 @@ msgstr "<0>{0} a été ajouté à <1>{1}" msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:220 +#: src/pages/Notifications.tsx:246 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:223 +#: src/components/form/SlugField.tsx:83 +msgid "A link is required" +msgstr "Il faut un lien" + +#: src/pages/Notifications.tsx:249 msgid "a post" msgstr "une publication" -#: src/components/profile/ProfileSettingsTab.tsx:72 +#: src/pages/Settings.tsx:108 msgid "Account" msgstr "Compte" @@ -516,7 +514,7 @@ msgstr "Ajouter un commentaire…" msgid "Add category" msgstr "Ajouter une catégorie" -#: src/components/profile/ProfileHeader.tsx:152 +#: src/components/profile/ProfileHeader.tsx:155 msgid "Add email…" msgstr "Ajouter un e-mail…" @@ -524,7 +522,7 @@ msgstr "Ajouter un e-mail…" msgid "Add image" msgstr "Ajouter une image" -#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/create/DumpCreateModal.tsx:465 #: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Ajouter à la collection" @@ -533,12 +531,12 @@ msgstr "Ajouter à la collection" msgid "Admin" msgstr "Administrateur" -#: src/components/category/CategorySwitcher.tsx:84 -#: src/components/category/CategorySwitcher.tsx:87 +#: src/components/category/CategorySwitcher.tsx:96 +#: src/components/category/CategorySwitcher.tsx:99 msgid "All" msgstr "Tout" -#. placeholder {0}: first.username +#. placeholder {0}: first.ownerUsername #. placeholder {1}: relativeTime(first.createdAt) #: src/components/dump/create/DuplicateNotice.tsx:39 msgid "Already dumped by {0} {1}" @@ -552,13 +550,13 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter" msgid "An unexpected error occurred." msgstr "Une erreur inattendue s'est produite." -#: src/components/profile/ProfileSettingsTab.tsx:85 +#: src/pages/Settings.tsx:135 msgid "Appearance" msgstr "Apparence" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/profile/ChangePasswordModal.tsx:102 -#: src/pages/ResetPassword.tsx:121 +#: src/components/profile/ChangePasswordModal.tsx:101 +#: src/pages/ResetPassword.tsx:124 #: src/pages/UserRegister.tsx:164 msgid "At least {0} characters" msgstr "Au moins {0} caractères" @@ -567,12 +565,12 @@ msgstr "Au moins {0} caractères" msgid "At most {maxLength} characters." msgstr "{maxLength} caractères au maximum." -#: src/components/profile/ProfileSettingsTab.tsx:37 +#: src/pages/Settings.tsx:51 msgid "Auto" msgstr "Auto" -#: src/pages/ResetPassword.tsx:71 -#: src/pages/ResetPassword.tsx:144 +#: src/pages/ResetPassword.tsx:74 +#: src/pages/ResetPassword.tsx:147 msgid "Back to login" msgstr "Retour à la connexion" @@ -585,14 +583,14 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo #: src/components/category/CategoryManager.tsx:226 #: src/components/chat/ChatEditBox.tsx:88 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:751 +#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/form/FormActions.tsx:42 -#: src/components/playlist/PlaylistEditForm.tsx:205 +#: src/components/playlist/PlaylistEditForm.tsx:218 #: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 -#: src/pages/Dump.tsx:432 -#: src/pages/DumpEdit.tsx:542 +#: src/pages/Dump.tsx:444 +#: src/pages/DumpEdit.tsx:570 msgid "Cancel" msgstr "Annuler" @@ -605,20 +603,20 @@ msgstr "Annuler la suppression" msgid "Cancel reply" msgstr "Annuler la réponse" -#: src/components/layout/AppHeader.tsx:162 +#: src/components/layout/AppHeader.tsx:163 msgid "Cancel search" msgstr "Annuler la recherche" #: src/components/category/CategorySelect.tsx:33 -#: src/components/profile/ProfileManageTab.tsx:49 +#: src/pages/Manage.tsx:33 msgid "Categories" msgstr "Catégories" -#: src/components/category/CategorySwitcher.tsx:107 +#: src/components/category/CategorySwitcher.tsx:119 msgid "Category" msgstr "Catégorie" -#: src/components/profile/ProfileHeader.tsx:93 +#: src/components/profile/ProfileHeader.tsx:96 msgid "Change avatar" msgstr "Changer l'avatar" @@ -626,12 +624,12 @@ msgstr "Changer l'avatar" msgid "Change image" msgstr "Changer l'image" -#: src/components/profile/ChangePasswordModal.tsx:67 -#: src/components/profile/ChangePasswordModal.tsx:121 +#: src/components/profile/ChangePasswordModal.tsx:66 +#: src/components/profile/ChangePasswordModal.tsx:120 msgid "Change password" msgstr "Changer le mot de passe" -#: src/components/profile/ProfileSettingsTab.tsx:80 +#: src/pages/Settings.tsx:130 msgid "Change password…" msgstr "Changer le mot de passe…" @@ -655,12 +653,12 @@ msgstr "{unreadChatCount} messages non lus" msgid "Checking invite…" msgstr "Vérification de l'invitation…" -#: src/components/profile/ChangePasswordModal.tsx:76 +#: src/components/profile/ChangePasswordModal.tsx:75 #: src/components/shared/Modal.tsx:76 msgid "Close" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:495 +#: src/components/dump/create/DumpCreateModal.tsx:496 msgid "Close anyway" msgstr "Fermer quand même" @@ -672,7 +670,7 @@ msgstr "Fermer le sélecteur d'emoji" msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/dump/create/DumpCreateModal.tsx:485 +#: src/components/dump/create/DumpCreateModal.tsx:486 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é." @@ -680,12 +678,12 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez msgid "Collapse player" msgstr "Réduire le lecteur" -#: src/components/profile/ProfileSettingsTab.tsx:94 +#: src/pages/Settings.tsx:144 msgid "Color scheme" msgstr "Thème de couleur" -#: src/components/profile/ChangePasswordModal.tsx:109 -#: src/pages/ResetPassword.tsx:128 +#: src/components/profile/ChangePasswordModal.tsx:108 +#: src/pages/ResetPassword.tsx:131 msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" @@ -701,7 +699,7 @@ msgstr "Copié !" msgid "Copy" msgstr "Copier" -#: src/components/profile/ChangePasswordModal.tsx:118 +#: src/components/profile/ChangePasswordModal.tsx:117 msgid "Could not change password" msgstr "Impossible de changer le mot de passe" @@ -709,16 +707,20 @@ 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/pages/DumpEdit.tsx:530 -#: src/pages/UserPlaylists.tsx:264 +#: src/components/profile/ProfilePlaylistsTab.tsx:126 +#: src/pages/DumpEdit.tsx:558 msgid "Could not delete" msgstr "Suppression impossible" -#: src/pages/DumpEdit.tsx:490 +#: src/components/profile/UsernameField.tsx:81 +msgid "Could not rename" +msgstr "Renommage impossible" + +#: src/pages/DumpEdit.tsx:511 msgid "Could not save" msgstr "Sauvegarde impossible" -#: src/components/dump/create/DumpCreateModal.tsx:606 +#: src/components/dump/create/DumpCreateModal.tsx:607 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." @@ -742,36 +744,30 @@ msgstr "Créer" msgid "Create & Add" msgstr "Créer et ajouter" -#. placeholder {0}: created.items.length -#. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:269 -msgid "Created ({0}{1})" -msgstr "Créées ({0}{1})" - #: src/components/playlist/PlaylistCreateForm.tsx:111 msgid "Creating…" msgstr "Création…" -#: src/components/profile/ChangePasswordModal.tsx:87 +#: src/components/profile/ChangePasswordModal.tsx:86 msgid "Current password" msgstr "Mot de passe actuel" -#: src/components/profile/ProfileSettingsTab.tsx:39 +#: src/pages/Settings.tsx:53 msgid "Dark" msgstr "Sombre" -#: src/components/profile/ProfileSettingsTab.tsx:105 +#: src/pages/Settings.tsx:155 msgid "Default tab" msgstr "Onglet par défaut" #: src/components/category/CategoryManager.tsx:236 #: src/components/category/CategoryManager.tsx:237 -#: src/components/chat/ChatMessageItem.tsx:114 #: src/components/chat/ChatMessageItem.tsx:115 -#: src/components/chat/ChatMessageItem.tsx:213 -#: src/components/comment/CommentNode.tsx:263 -#: src/components/comment/CommentNode.tsx:269 -#: src/components/playlist/PlaylistEditForm.tsx:201 +#: src/components/chat/ChatMessageItem.tsx:116 +#: src/components/chat/ChatMessageItem.tsx:214 +#: src/components/comment/CommentNode.tsx:265 +#: src/components/comment/CommentNode.tsx:271 +#: src/components/playlist/PlaylistEditForm.tsx:214 #: src/components/shared/ConfirmModal.tsx:34 msgid "Delete" msgstr "Supprimer" @@ -785,36 +781,36 @@ msgstr "Supprimer la catégorie" msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." -#: src/pages/DumpEdit.tsx:299 -#: src/pages/DumpEdit.tsx:538 +#: src/pages/DumpEdit.tsx:313 +#: src/pages/DumpEdit.tsx:566 msgid "Delete dump" msgstr "Supprimer la reco" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:178 +#: src/pages/DumpEdit.tsx:192 msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/playlist/PlaylistCard.tsx:141 +#: src/components/playlist/PlaylistCard.tsx:142 +#: src/components/profile/ProfilePlaylistsTab.tsx:156 #: src/pages/PlaylistDetail.tsx:136 -#: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" msgstr "Supprimer la collection" -#: src/components/comment/CommentNode.tsx:268 +#: src/components/comment/CommentNode.tsx:270 msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" -#: src/pages/DumpEdit.tsx:298 +#: src/pages/DumpEdit.tsx:312 msgid "Delete this dump? This cannot be undone." msgstr "Supprimer cette reco ? Cette action est irréversible." -#: src/components/chat/ChatMessageItem.tsx:212 +#: src/components/chat/ChatMessageItem.tsx:213 msgid "Delete this message?" msgstr "Supprimer ce message ?" +#: src/components/profile/ProfilePlaylistsTab.tsx:155 #: src/pages/PlaylistDetail.tsx:135 -#: src/pages/UserPlaylists.tsx:326 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -823,7 +819,7 @@ msgid "deleted message" msgstr "message supprimé" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/components/playlist/PlaylistEditForm.tsx:173 +#: src/components/playlist/PlaylistEditForm.tsx:186 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -840,7 +836,7 @@ msgstr "Déconnecté — reconnexion…" msgid "Dismiss" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:545 +#: src/components/dump/create/DumpCreateModal.tsx:546 msgid "Done" msgstr "Terminé" @@ -853,48 +849,47 @@ msgstr "Télécharger {0}" msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/dump/create/DumpCreateModal.tsx:652 +#: src/components/dump/create/DumpCreateModal.tsx:653 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" -#: src/pages/DumpEdit.tsx:481 +#: src/pages/DumpEdit.tsx:502 msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/layout/AppHeader.tsx:201 +#: src/components/layout/AppHeader.tsx:202 msgid "Dump" msgstr "Reco" -#: src/components/dump/create/DumpCreateModal.tsx:764 +#: src/components/dump/create/DumpCreateModal.tsx:765 msgid "Dump it" msgstr "Recommander" -#: src/components/dump/create/DumpCreateModal.tsx:523 +#: src/components/dump/create/DumpCreateModal.tsx:524 msgid "Dumped!" msgstr "Recommandé !" -#: src/pages/Search.tsx:116 -#: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:189 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/pages/Search.tsx:120 +#: src/pages/UserPublicProfile.tsx:154 msgid "Dumps" msgstr "Recos" -#. placeholder {0}: dumps.items.length -#. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:37 -msgid "Dumps ({0}{1})" -msgstr "Recos ({0}{1})" +#. placeholder {0}: countOf(dumps.state) +#: src/components/profile/ProfileOverviewTab.tsx:56 +msgid "Dumps ({0})" +msgstr "Recos ({0})" -#: src/pages/Notifications.tsx:375 +#: src/pages/Notifications.tsx:405 msgid "Earlier" msgstr "Plus tôt" -#: src/components/chat/ChatMessageItem.tsx:103 #: src/components/chat/ChatMessageItem.tsx:104 +#: src/components/chat/ChatMessageItem.tsx:105 #: src/components/chat/PendingItem.tsx:93 -#: src/components/comment/CommentNode.tsx:254 -#: src/components/playlist/PlaylistHeader.tsx:58 -#: src/pages/Dump.tsx:550 +#: src/components/comment/CommentNode.tsx:256 +#: src/components/playlist/PlaylistHeader.tsx:59 +#: src/pages/Dump.tsx:562 msgid "Edit" msgstr "Modifier" @@ -907,49 +902,49 @@ msgstr "Modifier {0}" msgid "Edit description" msgstr "Modifier la description" -#: src/components/profile/ProfileHeader.tsx:150 +#: src/components/profile/ProfileHeader.tsx:153 msgid "Edit email" msgstr "Modifier l'e-mail" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:448 +#: src/pages/Dump.tsx:460 msgid "Edit title" msgstr "Modifier le titre" -#: src/components/chat/ChatMessageItem.tsx:127 +#: src/components/chat/ChatMessageItem.tsx:128 msgid "edited" msgstr "modifié" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/chat/ChatMessageItem.tsx:128 -#: src/components/comment/CommentNode.tsx:203 -#: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:491 +#: src/components/chat/ChatMessageItem.tsx:129 +#: src/components/comment/CommentNode.tsx:205 +#: src/components/playlist/PlaylistHeader.tsx:94 +#: src/pages/Dump.tsx:503 msgid "edited {0}" msgstr "modifié {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/chat/ChatMessageItem.tsx:124 -#: src/components/comment/CommentNode.tsx:201 -#: src/components/playlist/PlaylistHeader.tsx:91 -#: src/pages/Dump.tsx:489 +#: src/components/chat/ChatMessageItem.tsx:125 +#: src/components/comment/CommentNode.tsx:203 +#: src/components/playlist/PlaylistHeader.tsx:92 +#: src/pages/Dump.tsx:501 msgid "Edited {0}" msgstr "Modifié le {0}" -#: src/pages/DumpEdit.tsx:242 +#: src/pages/DumpEdit.tsx:256 msgid "Editing" msgstr "Modification" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:72 +#: src/pages/DumpEdit.tsx:79 msgid "Editing {0}" msgstr "Édition de {0}" -#: src/components/profile/ProfileSettingsTab.tsx:129 +#: src/pages/Settings.tsx:179 msgid "Email" msgstr "E-mail" @@ -957,7 +952,7 @@ msgstr "E-mail" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:149 +#: src/pages/Search.tsx:155 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -984,11 +979,11 @@ msgid "Failed to generate invite" msgstr "Impossible de générer une invitation" #: src/components/shared/ListState.tsx:43 -#: src/pages/Notifications.tsx:349 +#: src/pages/Notifications.tsx:379 msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/dump/create/DumpCreateModal.tsx:554 +#: src/components/dump/create/DumpCreateModal.tsx:555 msgid "Failed to post" msgstr "Publication échouée" @@ -996,11 +991,11 @@ msgstr "Publication échouée" msgid "Failed to post comment" msgstr "Impossible de publier le commentaire" -#: src/components/comment/CommentNode.tsx:289 +#: src/components/comment/CommentNode.tsx:291 msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/components/playlist/PlaylistEditForm.tsx:193 +#: src/components/playlist/PlaylistEditForm.tsx:206 #: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 msgid "Failed to save" @@ -1010,11 +1005,11 @@ msgstr "Enregistrement échoué" msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" -#: src/components/comment/CommentNode.tsx:216 +#: src/components/comment/CommentNode.tsx:218 msgid "Failed to save edit" msgstr "Impossible d'enregistrer la modification" -#: src/components/profile/ProfileHeader.tsx:161 +#: src/components/profile/ProfileHeader.tsx:164 msgid "Failed to update avatar" msgstr "Impossible de mettre à jour l'avatar" @@ -1022,15 +1017,15 @@ msgstr "Impossible de mettre à jour l'avatar" msgid "Failed to update role" msgstr "Erreur lors de la mise à jour du rôle" -#: src/components/profile/ProfileSettingsTab.tsx:103 +#: src/pages/Settings.tsx:153 msgid "Feeds" msgstr "Flux" -#: src/components/dump/create/DumpCreateModal.tsx:593 +#: src/components/dump/create/DumpCreateModal.tsx:594 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/dump/create/DumpCreateModal.tsx:416 +#: src/components/dump/create/DumpCreateModal.tsx:417 msgid "File too large (max {max})." msgstr "Fichier trop volumineux (max {max})." @@ -1055,23 +1050,13 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici." msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/components/feed/FeedTabBar.tsx:49 -#: src/components/profile/ProfileSettingsTab.tsx:46 -#: src/pages/UserPublicProfile.tsx:191 +#: src/components/feed/FeedTabBar.tsx:43 +#: src/components/profile/ProfileFollowTab.tsx:163 +#: src/pages/Settings.tsx:60 +#: src/pages/UserPublicProfile.tsx:157 msgid "Followed" msgstr "Suivi" -#. placeholder {0}: followed.items.length -#. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:300 -msgid "Followed ({0}{1})" -msgstr "Suivies ({0}{1})" - -#: src/components/profile/ProfileFollowedTab.tsx:100 -msgid "Followed playlists" -msgstr "Collections suivies" - -#: src/components/profile/ProfileFollowedTab.tsx:68 #: src/components/social/FollowButton.tsx:62 #: src/components/social/FollowButton.tsx:102 msgid "Following" @@ -1097,12 +1082,12 @@ msgstr "Plein écran" msgid "Go home" msgstr "Accueil" -#: src/pages/ResetPassword.tsx:93 +#: src/pages/ResetPassword.tsx:96 msgid "Go to login" msgstr "Aller à la connexion" -#: src/components/feed/FeedTabBar.tsx:45 -#: src/components/profile/ProfileSettingsTab.tsx:43 +#: src/components/feed/FeedTabBar.tsx:39 +#: src/pages/Settings.tsx:57 msgid "Hot" msgstr "Tendances" @@ -1114,7 +1099,7 @@ msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialis msgid "image" msgstr "image" -#: src/pages/Dump.tsx:570 +#: src/pages/Dump.tsx:582 msgid "In collections" msgstr "Dans les collections" @@ -1127,7 +1112,7 @@ msgstr "Insérer un emoji" msgid "Invalid invite" msgstr "Invitation invalide" -#: src/pages/ResetPassword.tsx:61 +#: src/pages/ResetPassword.tsx:64 #: src/pages/Unsubscribe.tsx:64 msgid "Invalid link" msgstr "Lien invalide" @@ -1137,25 +1122,29 @@ msgstr "Lien invalide" msgid "Invite link" msgstr "Lien d'invitation" -#: src/components/profile/ProfileHeader.tsx:115 +#: src/components/profile/ProfileHeader.tsx:118 msgid "invited by" msgstr "invité par" -#: src/components/profile/ProfileInviteesTab.tsx:76 -#: src/pages/UserPublicProfile.tsx:192 +#: src/components/profile/ProfileInviteesTab.tsx:77 +#: src/pages/UserPublicProfile.tsx:158 msgid "Invitees" msgstr "Invités" -#: src/components/feed/FeedTabBar.tsx:47 -#: src/components/profile/ProfileSettingsTab.tsx:45 +#: src/components/feed/FeedTabBar.tsx:41 +#: src/pages/Settings.tsx:59 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:492 +#: src/components/dump/create/DumpCreateModal.tsx:493 msgid "Keep editing" msgstr "Continuer l'édition" -#: src/components/profile/ProfileSettingsTab.tsx:38 +#: src/components/profile/UsernameField.tsx:63 +msgid "Letters, numbers and underscores only" +msgstr "Lettres, chiffres et tirets bas seulement" + +#: src/pages/Settings.tsx:52 msgid "Light" msgstr "Clair" @@ -1163,7 +1152,8 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/components/dump/create/DumpCreateModal.tsx:568 +#: src/components/dump/create/DumpCreateModal.tsx:569 +#: src/components/form/SlugField.tsx:70 msgid "Link" msgstr "Lien" @@ -1171,11 +1161,15 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" +#: src/components/form/SlugField.tsx:100 +msgid "Links to the old address keep working." +msgstr "Les liens vers l’ancienne adresse marchent toujours." + #: src/contexts/ws/WSProvider.tsx:760 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/layout/AppHeader.tsx:236 +#: src/components/layout/AppHeader.tsx:237 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." @@ -1187,8 +1181,8 @@ msgstr "Charger plus" msgid "Load older messages" msgstr "Charger les messages plus anciens" -#: src/pages/Dump.tsx:290 -#: src/pages/DumpEdit.tsx:193 +#: src/pages/Dump.tsx:302 +#: src/pages/DumpEdit.tsx:207 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -1204,7 +1198,7 @@ msgstr "Chargement…" msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:142 +#: src/pages/UserPublicProfile.tsx:106 msgid "Loading profile…" msgstr "Chargement du profil…" @@ -1214,15 +1208,12 @@ msgstr "Chargement du profil…" #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 #: src/components/shared/LoadMoreButton.tsx:32 -#: src/components/social/UserListPopover.tsx:150 -#: src/pages/Notifications.tsx:345 -#: src/pages/UserDumps.tsx:62 -#: src/pages/UserPlaylists.tsx:228 -#: src/pages/UserUpvoted.tsx:48 +#: src/components/social/UserListPopover.tsx:151 +#: src/pages/Notifications.tsx:375 msgid "Loading…" msgstr "Chargement…" -#: src/components/layout/AppHeader.tsx:209 +#: src/components/layout/AppHeader.tsx:210 #: src/pages/UserLogin.tsx:41 #: src/pages/UserLogin.tsx:67 #: src/pages/UserLogin.tsx:86 @@ -1237,8 +1228,8 @@ msgstr "Se connecter pour aimer" msgid "Log in to vote" msgstr "Se connecter pour voter" -#: src/components/profile/ProfileHeader.tsx:174 -#: src/pages/UserPublicProfile.tsx:163 +#: src/components/profile/ProfileHeader.tsx:185 +#: src/pages/UserPublicProfile.tsx:127 msgid "Log out" msgstr "Se déconnecter" @@ -1250,7 +1241,15 @@ msgstr "Connexion…" msgid "Login failed" msgstr "Connexion échouée" -#: src/pages/UserPublicProfile.tsx:197 +#: src/components/form/SlugField.tsx:86 +msgid "Lowercase letters, numbers and single hyphens only" +msgstr "Minuscules, chiffres et tirets simples seulement" + +#: src/components/layout/UserMenu.tsx:98 +#: src/components/profile/ProfileHeader.tsx:181 +#: src/pages/Manage.tsx:23 +#: src/pages/Manage.tsx:30 +#: src/pages/UserPublicProfile.tsx:160 msgid "Manage" msgstr "Administration" @@ -1271,25 +1270,25 @@ msgstr "Couper le son" msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:338 +#: src/pages/Notifications.tsx:368 msgid "new" msgstr "nouveau" #: src/components/chat/ChatModal.tsx:669 -#: src/components/feed/FeedTabBar.tsx:46 -#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/components/feed/FeedTabBar.tsx:40 +#: src/pages/Settings.tsx:58 msgid "New" msgstr "Nouveau" -#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/create/DumpCreateModal.tsx:465 #: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 msgid "New dump" msgstr "Nouvelle reco" -#: src/components/profile/ChangePasswordModal.tsx:95 -#: src/pages/ResetPassword.tsx:113 +#: src/components/profile/ChangePasswordModal.tsx:94 +#: src/pages/ResetPassword.tsx:116 msgid "New password" msgstr "Nouveau mot de passe" @@ -1298,7 +1297,7 @@ msgstr "Nouveau mot de passe" msgid "New playlist" msgstr "Nouvelle collection" -#: src/components/dump/create/DumpCreateModal.tsx:763 +#: src/components/dump/create/DumpCreateModal.tsx:764 msgid "Next →" msgstr "Suivant →" @@ -1310,7 +1309,7 @@ msgstr "Piste suivante" msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:165 +#: src/pages/Search.tsx:171 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." @@ -1323,12 +1322,11 @@ msgstr "Pas encore de recos. Soyez le premier !" msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/components/profile/ProfileFollowedTab.tsx:110 -#: src/pages/UserPlaylists.tsx:309 +#: src/components/profile/ProfileFollowTab.tsx:136 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." -#: src/components/profile/ProfileInviteesTab.tsx:82 +#: src/components/profile/ProfileInviteesTab.tsx:83 msgid "No invitees yet." msgstr "Aucun invité pour le moment." @@ -1336,17 +1334,16 @@ msgstr "Aucun invité pour le moment." msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" -#: src/components/social/UserListPopover.tsx:158 +#: src/components/social/UserListPopover.tsx:159 msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:206 +#: src/pages/Search.tsx:212 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:52 -#: src/pages/UserPlaylists.tsx:278 +#: src/components/profile/ProfilePlaylistsTab.tsx:133 msgid "No playlists yet." msgstr "Pas encore de collections." @@ -1354,26 +1351,24 @@ 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:180 +#: src/pages/Search.tsx:186 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." -#: src/components/profile/ProfileFollowedTab.tsx:74 +#: src/components/profile/ProfileFollowTab.tsx:99 msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/components/profile/ProfileDumpsTab.tsx:60 -#: src/pages/Notifications.tsx:356 -#: src/pages/UserDumps.tsx:94 -#: src/pages/UserUpvoted.tsx:79 +#: src/components/profile/ProfileDumpsTab.tsx:53 +#: src/components/profile/ProfileOverviewTab.tsx:47 +#: src/pages/Notifications.tsx:386 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:54 -#: src/components/profile/ProfileSettingsTab.tsx:114 -#: src/pages/Notifications.tsx:282 -#: src/pages/Notifications.tsx:334 +#: src/pages/Notifications.tsx:308 +#: src/pages/Notifications.tsx:364 +#: src/pages/Settings.tsx:164 msgid "Notifications" msgstr "Notifications" @@ -1381,11 +1376,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" -#: src/components/profile/ProfileSettingsTab.tsx:51 +#: src/pages/Settings.tsx:65 msgid "Off" msgstr "Désactivés" -#: src/components/profile/ProfileSettingsTab.tsx:50 +#: src/pages/Settings.tsx:64 msgid "On" msgstr "Activés" @@ -1393,7 +1388,7 @@ msgstr "Activés" msgid "Only images can be dropped here." msgstr "Seules les images peuvent être déposées ici." -#: src/pages/DumpEdit.tsx:103 +#: src/pages/DumpEdit.tsx:117 msgid "Only its poster or a moderator can edit this dump." msgstr "Seuls son auteur et les modérateurs peuvent modifier ce dump." @@ -1410,11 +1405,15 @@ msgstr "Ouvrir la source" msgid "or <0>browse files" msgstr "ou <0>parcourir les fichiers" +#: src/pages/UserPublicProfile.tsx:153 +msgid "Overview" +msgstr "Aperçu" + #: src/pages/NotFound.tsx:16 msgid "Page not found" msgstr "Page introuvable" -#: src/components/profile/ProfileSettingsTab.tsx:74 +#: src/pages/Settings.tsx:124 #: src/pages/UserLogin.tsx:82 msgid "Password" msgstr "Mot de passe" @@ -1424,20 +1423,20 @@ msgstr "Mot de passe" msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" -#: src/components/profile/ChangePasswordModal.tsx:72 +#: src/components/profile/ChangePasswordModal.tsx:71 msgid "Password changed successfully." msgstr "Mot de passe modifié avec succès." -#: src/pages/ResetPassword.tsx:83 +#: src/pages/ResetPassword.tsx:86 msgid "Password updated" msgstr "Mot de passe mis à jour" -#: src/components/profile/ChangePasswordModal.tsx:115 -#: src/pages/ResetPassword.tsx:133 +#: src/components/profile/ChangePasswordModal.tsx:114 +#: src/pages/ResetPassword.tsx:136 msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/dump/create/DumpCreateModal.tsx:586 +#: src/components/dump/create/DumpCreateModal.tsx:587 msgid "Paste a link…" msgstr "Collez un lien…" @@ -1448,7 +1447,7 @@ msgstr "Collez un lien…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:504 +#: src/components/dump/create/DumpCreateModal.tsx:505 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." @@ -1462,36 +1461,31 @@ msgstr "Reprise de votre brouillon." msgid "Play" msgstr "Lecture" -#: src/components/playlist/PlaylistEditForm.tsx:157 +#: src/components/playlist/PlaylistEditForm.tsx:162 msgid "Playlist title" msgstr "Titre de la collection" #: src/components/dump/create/StepIndicator.tsx:21 -#: src/components/layout/AppHeader.tsx:182 -#: src/components/layout/UserMenu.tsx:78 -#: src/pages/Search.tsx:119 -#: src/pages/UserPlaylists.tsx:254 -#: src/pages/UserPublicProfile.tsx:190 +#: src/components/layout/AppHeader.tsx:183 +#: src/components/layout/UserMenu.tsx:81 +#: src/components/profile/ProfileFollowTab.tsx:167 +#: src/components/profile/ProfilePlaylistsTab.tsx:118 +#: src/pages/Search.tsx:123 +#: src/pages/UserPublicProfile.tsx:156 msgid "Playlists" msgstr "Collections" -#. placeholder {0}: playlists.items.length -#. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/components/profile/ProfilePlaylistsTab.tsx:35 -msgid "Playlists ({0}{1})" -msgstr "Collections ({0}{1})" - #: src/components/comment/CommentThread.tsx:99 msgid "Post comment" msgstr "Publier le commentaire" -#: src/components/comment/CommentNode.tsx:287 +#: src/components/comment/CommentNode.tsx:289 msgid "Post reply" msgstr "Publier la réponse" -#: src/components/comment/CommentNode.tsx:288 +#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:757 +#: src/components/dump/create/DumpCreateModal.tsx:758 msgid "Posting…" msgstr "Publication…" @@ -1501,10 +1495,10 @@ msgstr "Piste précédente" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 -#: src/components/playlist/PlaylistCard.tsx:103 -#: src/components/playlist/PlaylistHeader.tsx:75 +#: src/components/playlist/PlaylistCard.tsx:104 +#: src/components/playlist/PlaylistHeader.tsx:76 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:497 +#: src/pages/Dump.tsx:509 msgid "private" msgstr "privé" @@ -1512,8 +1506,8 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/playlist/PlaylistCard.tsx:102 -#: src/components/playlist/PlaylistHeader.tsx:75 +#: src/components/playlist/PlaylistCard.tsx:103 +#: src/components/playlist/PlaylistHeader.tsx:76 msgid "public" msgstr "public" @@ -1521,7 +1515,7 @@ msgstr "public" msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:275 +#: src/pages/DumpEdit.tsx:289 msgid "Refresh metadata" msgstr "Actualiser les métadonnées" @@ -1529,7 +1523,7 @@ msgstr "Actualiser les métadonnées" msgid "Refreshing stream…" msgstr "Actualisation du flux…" -#: src/pages/DumpEdit.tsx:274 +#: src/pages/DumpEdit.tsx:288 msgid "Refreshing…" msgstr "Actualisation…" @@ -1547,7 +1541,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:561 +#: src/pages/Dump.tsx:573 msgid "Related" msgstr "Connexe" @@ -1567,13 +1561,21 @@ msgstr "Retirer le j'aime" msgid "Remove vote" msgstr "Retirer le vote" -#: src/pages/DumpEdit.tsx:473 +#: src/components/profile/UsernameField.tsx:72 +msgid "Rename" +msgstr "Renommer" + +#: src/components/profile/UsernameField.tsx:69 +msgid "Renaming…" +msgstr "Renommage…" + +#: src/pages/DumpEdit.tsx:494 msgid "Replace file" msgstr "Remplacer le fichier" -#: src/components/chat/ChatMessageItem.tsx:93 #: src/components/chat/ChatMessageItem.tsx:94 -#: src/components/comment/CommentNode.tsx:242 +#: src/components/chat/ChatMessageItem.tsx:95 +#: src/components/comment/CommentNode.tsx:244 msgid "Reply" msgstr "Répondre" @@ -1585,7 +1587,7 @@ msgstr "En réponse à" msgid "Request failed" msgstr "Échec de la demande" -#: src/pages/ResetPassword.tsx:109 +#: src/pages/ResetPassword.tsx:112 msgid "Reset failed" msgstr "Échec de la réinitialisation" @@ -1593,8 +1595,8 @@ msgstr "Échec de la réinitialisation" msgid "Reset password" msgstr "Réinitialiser le mot de passe" -#: src/pages/DumpEdit.tsx:449 -#: src/pages/DumpEdit.tsx:504 +#: src/pages/DumpEdit.tsx:470 +#: src/pages/DumpEdit.tsx:525 msgid "Reset to default" msgstr "Réinitialiser par défaut" @@ -1604,8 +1606,8 @@ msgid "Reset to the suggested title" msgstr "Rétablir le titre suggéré" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:307 -#: src/pages/DumpEdit.tsx:210 +#: src/pages/Dump.tsx:319 +#: src/pages/DumpEdit.tsx:224 msgid "Retry" msgstr "Réessayer" @@ -1615,28 +1617,28 @@ msgstr "Rôle" #: src/components/category/CategoryManager.tsx:216 #: src/components/chat/ChatEditBox.tsx:85 -#: src/components/comment/CommentNode.tsx:214 -#: src/components/playlist/PlaylistEditForm.tsx:212 +#: src/components/comment/CommentNode.tsx:216 +#: src/components/playlist/PlaylistEditForm.tsx:225 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:424 -#: src/pages/DumpEdit.tsx:545 +#: src/pages/Dump.tsx:436 +#: src/pages/DumpEdit.tsx:573 msgid "Save" msgstr "Enregistrer" -#: src/components/comment/CommentNode.tsx:215 -#: src/components/playlist/PlaylistEditForm.tsx:208 -#: src/components/profile/ChangePasswordModal.tsx:120 +#: src/components/comment/CommentNode.tsx:217 +#: src/components/playlist/PlaylistEditForm.tsx:221 +#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:423 -#: src/pages/ResetPassword.tsx:136 +#: src/pages/Dump.tsx:435 +#: src/pages/ResetPassword.tsx:139 msgid "Saving…" msgstr "Enregistrement…" -#: src/components/layout/AppHeader.tsx:162 +#: src/components/layout/AppHeader.tsx:163 #: src/components/layout/SearchBar.tsx:82 -#: src/pages/Search.tsx:65 +#: src/pages/Search.tsx:69 msgid "Search" msgstr "Rechercher" @@ -1652,11 +1654,11 @@ msgstr "Rechercher un emoji" msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:160 +#: src/pages/Search.tsx:166 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:155 +#: src/pages/Search.tsx:161 msgid "Searching…" msgstr "Recherche…" @@ -1677,16 +1679,19 @@ msgstr "Envoyer le lien de réinitialisation" msgid "Sending…" msgstr "Envoi…" -#: src/components/layout/AppHeader.tsx:198 +#: src/components/layout/AppHeader.tsx:199 msgid "Server unreachable" msgstr "Serveur inaccessible" -#: src/pages/ResetPassword.tsx:104 -#: src/pages/ResetPassword.tsx:137 +#: src/pages/ResetPassword.tsx:107 +#: src/pages/ResetPassword.tsx:140 msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/pages/UserPublicProfile.tsx:194 +#: src/components/layout/UserMenu.tsx:89 +#: src/components/profile/ProfileHeader.tsx:177 +#: src/pages/Settings.tsx:77 +#: src/pages/Settings.tsx:105 msgid "Settings" msgstr "Paramètres" @@ -1713,15 +1718,15 @@ msgstr "Certaines images n'ont pas pu être envoyées." msgid "Something went wrong" msgstr "Une erreur est survenue" -#: src/components/profile/ProfileSettingsTab.tsx:116 +#: src/pages/Settings.tsx:166 msgid "Sounds" msgstr "Sons" -#: src/components/dump/create/DumpCreateModal.tsx:511 +#: src/components/dump/create/DumpCreateModal.tsx:512 msgid "Start fresh" msgstr "Repartir de zéro" -#: src/components/profile/ProfileSettingsTab.tsx:87 +#: src/pages/Settings.tsx:137 msgid "Style" msgstr "Style" @@ -1733,11 +1738,11 @@ msgstr "Lancer la recherche" msgid "That file is over {limit}." msgstr "Ce fichier dépasse {limit}." -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:248 msgid "the chat" msgstr "la tribune" -#: src/hooks/playlist/usePlaylist.ts:161 +#: src/hooks/playlist/usePlaylist.ts:179 msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." @@ -1745,11 +1750,15 @@ msgstr "Le dump n'a pas pu être retiré. Réessayez." msgid "The image couldn't be uploaded." msgstr "L'image n'a pas pu être envoyée." -#: src/hooks/playlist/usePlaylist.ts:307 +#: src/hooks/playlist/usePlaylist.ts:322 msgid "The new order couldn't be saved. Try again." msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." -#: src/components/comment/CommentNode.tsx:280 +#: src/components/profile/ProfileManageTab.tsx:67 +msgid "Their old name keeps leading to them, and nobody else can take it." +msgstr "Son ancien nom mène toujours à son profil, et personne d’autre ne peut le prendre." + +#: src/components/comment/CommentNode.tsx:282 msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." @@ -1765,11 +1774,11 @@ msgstr "C'est un mirage." msgid "This page does not exist." msgstr "Rien à voir, circulez." -#: src/pages/ResetPassword.tsx:64 +#: src/pages/ResetPassword.tsx:67 msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." -#: src/components/profile/ProfileSettingsTab.tsx:152 +#: src/pages/Settings.tsx:202 msgid "This site doesn't send email, so no digest goes out." msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc." @@ -1777,21 +1786,21 @@ msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc." msgid "This unsubscribe link is missing or malformed." msgstr "Ce lien de désabonnement est absent ou mal formé." -#: src/pages/DumpEdit.tsx:433 +#: src/pages/DumpEdit.tsx:454 msgid "Thumbnail" msgstr "Miniature" #: src/components/dump/create/TitleField.tsx:45 #: src/components/playlist/PlaylistCreateForm.tsx:96 -#: src/pages/DumpEdit.tsx:494 +#: src/pages/DumpEdit.tsx:515 msgid "Title" msgstr "Titre" -#: src/components/dump/create/DumpCreateModal.tsx:413 +#: src/components/dump/create/DumpCreateModal.tsx:414 msgid "Title is required." msgstr "Un titre est requis." -#: src/pages/Notifications.tsx:372 +#: src/pages/Notifications.tsx:402 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Aujourd'hui" @@ -1849,12 +1858,12 @@ msgstr "Se désabonner" msgid "Unsubscribed" msgstr "Désabonné" -#: src/components/profile/ProfileHeader.tsx:70 +#: src/components/profile/ProfileHeader.tsx:73 msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:448 +#: src/components/dump/create/DumpCreateModal.tsx:449 msgid "Uploading {0}%" msgstr "Envoi {0} %" @@ -1862,7 +1871,7 @@ msgstr "Envoi {0} %" msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/dump/create/DumpCreateModal.tsx:760 +#: src/components/dump/create/DumpCreateModal.tsx:761 msgid "Uploading…" msgstr "Envoi…" @@ -1870,21 +1879,21 @@ msgstr "Envoi…" msgid "Upvote" msgstr "Voter" -#: src/pages/UserUpvoted.tsx:74 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/pages/UserPublicProfile.tsx:155 msgid "Upvoted" msgstr "Voté" -#. placeholder {0}: votes.items.length -#. placeholder {1}: votes.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:52 -msgid "Upvoted ({0}{1})" -msgstr "Votés ({0}{1})" +#. placeholder {0}: countOf(votes.state) +#: src/components/profile/ProfileOverviewTab.tsx:71 +msgid "Upvoted ({0})" +msgstr "Voté ({0})" -#: src/pages/DumpEdit.tsx:465 +#: src/pages/DumpEdit.tsx:486 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:397 +#: src/components/dump/create/DumpCreateModal.tsx:398 msgid "URL is required." msgstr "L'URL est obligatoire." @@ -1896,20 +1905,24 @@ msgstr "Identifiant d’URL" msgid "User" msgstr "Utilisateur" -#: src/components/profile/ProfileManageTab.tsx:38 +#: src/components/profile/ProfileManageTab.tsx:54 msgid "User management" msgstr "Gestion utilisateur" -#: src/components/layout/UserMenu.tsx:53 +#: src/components/layout/UserMenu.tsx:56 msgid "User menu" msgstr "Menu utilisateur" +#: src/components/profile/ProfileManageTab.tsx:61 +#: src/components/profile/UsernameField.tsx:55 +#: src/pages/Settings.tsx:110 #: src/pages/UserLogin.tsx:75 #: src/pages/UserRegister.tsx:137 msgid "Username" msgstr "Nom d'utilisateur" -#: src/pages/Search.tsx:118 +#: src/components/profile/ProfileFollowTab.tsx:166 +#: src/pages/Search.tsx:122 msgid "Users" msgstr "Utilisateurs" @@ -1917,7 +1930,7 @@ msgstr "Utilisateurs" msgid "View all →" msgstr "Tout voir →" -#: src/components/dump/create/DumpCreateModal.tsx:525 +#: src/components/dump/create/DumpCreateModal.tsx:526 msgid "View dump →" msgstr "Voir la reco →" @@ -1925,8 +1938,8 @@ msgstr "Voir la reco →" msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:711 -#: src/pages/DumpEdit.tsx:515 +#: src/components/dump/create/DumpCreateModal.tsx:712 +#: src/pages/DumpEdit.tsx:543 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" @@ -1935,7 +1948,7 @@ msgstr "Pourquoi on en voudrait ?" msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/dump/create/DumpCreateModal.tsx:726 +#: src/components/dump/create/DumpCreateModal.tsx:727 msgid "Who can see it" msgstr "Qui peut la voir" @@ -1943,21 +1956,25 @@ msgstr "Qui peut la voir" msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/dump/create/DumpCreateModal.tsx:710 -#: src/pages/DumpEdit.tsx:514 +#: src/components/dump/create/DumpCreateModal.tsx:711 +#: src/pages/DumpEdit.tsx:542 msgid "Why?" msgstr "Pourquoi ?" -#: src/components/comment/CommentNode.tsx:291 +#: src/components/comment/CommentNode.tsx:293 msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/pages/Notifications.tsx:374 +#: src/pages/Notifications.tsx:404 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Hier" -#: src/components/dump/create/DuplicateNotice.tsx:60 +#: src/pages/Settings.tsx:116 +msgid "You can change it once every {renameDays} days. Links to your old name keep working, and nobody else can take it." +msgstr "Vous pouvez le changer une fois tous les {renameDays} jours. Les liens vers votre ancien nom marchent toujours, et personne d’autre ne peut le prendre." + +#: src/components/dump/create/DuplicateNotice.tsx:61 msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." @@ -1969,7 +1986,7 @@ msgstr "Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans msgid "You won't get the weekly digest any more. You can turn it back on in your settings." msgstr "Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres." -#: src/pages/Notifications.tsx:359 +#: src/pages/Notifications.tsx:389 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." @@ -1985,7 +2002,7 @@ msgstr "Vous avez tout lu, tout vu, tout bu." msgid "Your email address" msgstr "Votre adresse e-mail" -#: src/pages/ResetPassword.tsx:86 +#: src/pages/ResetPassword.tsx:89 msgid "Your password has been changed. You can now log in." msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter." diff --git a/mod.ts b/mod.ts index fb892c12..b70af073 100644 --- a/mod.ts +++ b/mod.ts @@ -14,8 +14,10 @@ * other sites only through `remote/`. Each folder holds one kind of module, * and each file is named after its domain. * - * - `routes/` holds the HTTP API. Each router's page lists its endpoints, with - * their method and path, who may call them, and what they do. + * - `routes/` holds the HTTP API, one router per resource. Each router's page + * lists its endpoints, with their method and path, who may call them, and + * what they do; `API.md` at the root sums them up, with the rule they all + * follow. * - `middleware/` holds what runs around the routes, which is auth, errors and * the Open Graph tags. * - `services/` holds the logic and every SQL query, one module per domain. @@ -36,9 +38,11 @@ * * The types and constants both halves use sit here, one module per domain. * They are the API envelope, each domain's types and request bodies, the - * socket protocol, the validation limits and the role permissions. `shared/` - * imports nothing from either half. The server sends `Date`s, and the client - * reads the same types through `Wire`, where every `Date` is an ISO string. + * socket protocol, the validation limits, the role permissions, and the page + * paths and slugs (`paths.ts`, `slug.ts`) the client links with and the + * server matches. `shared/` imports nothing from either half. The server + * sends `Date`s, and the client reads the same types through `Wire`, where + * every `Date` is an ISO string. * * Beside it, `locales/` holds one Lingui catalogue per language for the whole * app: the interface's messages, and the emails' that `api/mail/i18n.ts` diff --git a/shared/dump.ts b/shared/dump.ts index ac1493f5..413188a1 100644 --- a/shared/dump.ts +++ b/shared/dump.ts @@ -47,11 +47,17 @@ export interface Dump { id: string; kind: "url" | "file"; title: string; - /** Made from the title. The dump's page URL takes it in place of the id. */ - slug?: string; + /** + * Names the dump in its page's path, `dumpPath` (`shared/paths.ts`) building + * it with {@link ownerUsername}. Made from the title on creation, it changes + * only when edited, and is unique among its poster's dumps. + */ + slug: string; /** The poster's note on the dump, in Markdown. */ comment?: string; userId: string; + /** The poster's current name, which the dump's path starts with. */ + ownerUsername: string; createdAt: Date; updatedAt?: Date; url?: string; @@ -69,23 +75,23 @@ export interface Dump { } /** - * Returned by `GET /api/dumps/by-url` for each dump already posted with the + * Returned by `GET /api/dumps?url=` for each dump already posted with the * link being typed, and shown by the create form's `DuplicateNotice`. It holds - * only what the notice displays. + * only what the notice displays and links to. */ export interface DumpUrlMatch { id: string; - slug?: string; + slug: string; title: string; /** The poster's username. */ - username: string; + ownerUsername: string; createdAt: Date; voteCount: number; commentCount: number; } /** - * Returned by `GET /api/preview` to the create form, which shows it before the + * Returned by `GET /api/remote/preview` to the create form, which shows it before the * link is posted. */ export interface LinkPreview { @@ -123,6 +129,11 @@ export interface UpdateDumpRequest { /** Ignored for a file dump. A changed link has its preview fetched again. */ url?: string; title?: string; + /** + * A new slug, which the edit page's slug field sends. A title change never + * changes the slug by itself. + */ + slug?: string; /** `null` removes the note. */ comment?: string | null; isPrivate?: boolean; diff --git a/shared/follow.ts b/shared/follow.ts index af6b0e0c..6d5ef5fa 100644 --- a/shared/follow.ts +++ b/shared/follow.ts @@ -7,7 +7,7 @@ */ /** - * Returned by `GET /api/follows/status` to `FollowProvider`, which then keeps + * Returned by `GET /api/me/follow` to `FollowProvider`, which then keeps * it up to date itself as the user follows and unfollows. */ export interface FollowStatus { diff --git a/shared/notification.ts b/shared/notification.ts index 1ba38dd0..c93a2f17 100644 --- a/shared/notification.ts +++ b/shared/notification.ts @@ -22,10 +22,26 @@ export type NotificationType = | "dump_commented" | "comment_liked"; +/** + * Where a notification's dump is now, for its link (`dumpPath`). The server + * joins it each time it sends the notification, so a renamed dump or poster + * still links right; it is absent once the dump is deleted. + */ +export interface DumpRef { + dumpSlug?: string; + dumpOwnerUsername?: string; +} + +/** Where a notification's playlist is now, joined as {@link DumpRef} is. */ +export interface PlaylistRef { + playlistSlug?: string; + playlistOwnerUsername?: string; +} + /** * Carried by a `playlist_followed` notification, sent to a playlist's owner. */ -export interface PlaylistFollowedData { +export interface PlaylistFollowedData extends PlaylistRef { followerId: string; followerUsername: string; playlistId: string; @@ -42,7 +58,7 @@ export interface UserFollowedData { * Carried by a `user_dump_posted` notification, sent to the followers of a * user who posted a public dump. */ -export interface UserDumpPostedData { +export interface UserDumpPostedData extends DumpRef { dumperId: string; dumperUsername: string; dumpId: string; @@ -53,7 +69,7 @@ export interface UserDumpPostedData { * Carried by a `playlist_dump_added` notification, sent to the followers of a * public playlist when a public dump is added to it. */ -export interface PlaylistDumpAddedData { +export interface PlaylistDumpAddedData extends DumpRef, PlaylistRef { dumpId: string; dumpTitle: string; playlistId: string; @@ -61,7 +77,7 @@ export interface PlaylistDumpAddedData { } /** Carried by a `dump_upvoted` notification, sent to the dump's poster. */ -export interface DumpUpvotedData { +export interface DumpUpvotedData extends DumpRef { voterId: string; voterUsername: string; dumpId: string; @@ -70,9 +86,11 @@ export interface DumpUpvotedData { /** * Carried by a `user_mentioned` notification, sent to a user @mentioned in a - * comment, a dump, a playlist's description or a chat message. + * comment, a dump, a playlist's description or a chat message. Its + * {@link DumpRef} is the dump's, or the comment's dump's; its + * {@link PlaylistRef} the playlist's. */ -export interface UserMentionedData { +export interface UserMentionedData extends DumpRef, PlaylistRef { mentionerId: string; mentionerUsername: string; contextType: "comment" | "dump" | "playlist" | "chat"; @@ -91,7 +109,7 @@ export interface UserMentionedData { } /** Carried by a `dump_commented` notification, sent to the dump's poster. */ -export interface DumpCommentedData { +export interface DumpCommentedData extends DumpRef { commenterId: string; commenterUsername: string; commentId: string; @@ -100,7 +118,7 @@ export interface DumpCommentedData { } /** Carried by a `comment_liked` notification, sent to the comment's author. */ -export interface CommentLikedData { +export interface CommentLikedData extends DumpRef { likerId: string; likerUsername: string; commentId: string; @@ -109,9 +127,9 @@ export interface CommentLikedData { } /** - * A snapshot taken when the notification is made: a name or title changed - * later still shows the old one. The notification's `type` says which member - * it is. + * Stored when the notification is made, so a title changed later still shows + * the old one. The `…Username` fields and the refs are joined when it is sent, + * so they follow a rename. The notification's `type` says which member it is. */ export type NotificationData = | PlaylistFollowedData @@ -125,7 +143,7 @@ export type NotificationData = /** * Pushed to its user's sockets in a `notification_created` message when it is - * made, and returned by `GET /api/notifications`. + * made, and returned by `GET /api/me/notifications`. */ export interface Notification { id: string; diff --git a/shared/paths.ts b/shared/paths.ts new file mode 100644 index 00000000..04a1689b --- /dev/null +++ b/shared/paths.ts @@ -0,0 +1,163 @@ +/** + * Builds and parses the site's page paths, by the one rule they all follow: + * the path is a hierarchy, `@name` is a user, and any other word under the + * root is a category or an app page. 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. + * + * The client links every page through the builders (`src/utils/shared/urls.ts` + * wraps the dump and playlist ones), the emails prefix them with the + * frontend's URL, and the server's matchers (the Open Graph tags, the + * previews and backlinks of links to this site) read paths with + * {@link parsePagePath}. Migration 0021 rewrote the v3 links in stored text + * with the builders. + * + * @module + */ + +/** + * The feed tabs, in the tab bar's order. Each is a path segment: `/hot` for + * every dump, `/music/hot` for a category. They are reserved at the root, so + * no category can take one. + */ +export const FEED_TABS = ["hot", "new", "journal", "follow"] as const; +/** One of the {@link FEED_TABS}. */ +export type FeedTab = (typeof FEED_TABS)[number]; + +/** + * The tabs of a profile, each a segment after `/@name`. The bare `/@name` is + * the overview. + */ +export const PROFILE_TABS = [ + "dumps", + "upvoted", + "playlists", + "follow", + "invitees", + "manage", +] as const; +/** One of the {@link PROFILE_TABS}. */ +export type ProfileTab = (typeof PROFILE_TABS)[number]; + +/** + * The views of a profile's `follow` tab: the users followed at + * `/@name/follow`, the playlists at `/@name/follow/playlists`. + */ +export const FOLLOW_VIEWS = ["users", "playlists"] as const; +/** One of the {@link FOLLOW_VIEWS}. */ +export type FollowView = (typeof FOLLOW_VIEWS)[number]; + +/** + * The tabs of the search page: the dumps at `/search`, the others at + * `/search/`. + */ +export const SEARCH_TABS = ["dumps", "users", "playlists"] as const; +/** One of the {@link SEARCH_TABS}. */ +export type SearchTab = (typeof SEARCH_TABS)[number]; + +/** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */ +export interface OwnedItem { + ownerUsername: string; + slug: string; +} + +const seg = encodeURIComponent; + +/** + * Returns the path of `username`'s profile, of one of its tabs, or of a view + * of the `follow` tab. The default view, `users`, is the bare tab. + */ +export function userPath( + username: string, + tab?: ProfileTab, + view?: FollowView, +): string { + const base = `/@${seg(username)}`; + if (!tab) return base; + return view && view !== FOLLOW_VIEWS[0] + ? `${base}/${tab}/${view}` + : `${base}/${tab}`; +} + +/** Returns the path of a dump's page, `/@owner/dumps/`. */ +export function dumpPath(dump: OwnedItem): string { + return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`; +} + +/** Returns the path of a playlist's page, `/@owner/playlists/`. */ +export function playlistPath(playlist: OwnedItem): string { + return `${userPath(playlist.ownerUsername)}/playlists/${seg(playlist.slug)}`; +} + +/** + * Returns the path of a feed: every dump's at the root, or a category's at + * `/`, on `tab`, or on the viewer's default tab without one. + */ +export function feedPath(categorySlug?: string, tab?: FeedTab): string { + const base = categorySlug ? `/${seg(categorySlug)}` : ""; + return tab ? `${base}/${tab}` : base || "/"; +} + +/** + * Returns the path of the search page on `tab`, searching `query` when it is + * given. + */ +export function searchPath(tab: SearchTab = "dumps", query?: string): string { + const base = tab === SEARCH_TABS[0] ? "/search" : `/search/${tab}`; + return query ? `${base}?q=${seg(query)}` : base; +} + +/** + * Returned by {@link parsePagePath} for a path under a user. `rest` holds the + * segments after `/@name`, a profile tab and its view. + */ +export type PagePath = + | { kind: "user"; username: string; rest: string[] } + | { kind: "dump"; username: string; slug: string; edit: boolean } + | { kind: "playlist"; username: string; slug: string }; + +/** + * Returns what a page path under a user names: a profile (with its tab), a + * dump (or its editor) or a playlist. It returns null for any other path, a + * feed or an app page, and for one under a user that no page answers. The + * names are decoded but not looked up, so the user or the item may not exist. + */ +export function parsePagePath(pathname: string): PagePath | null { + let segments: string[]; + try { + segments = pathname.replace(/\/+$/, "").split("/").slice(1).map( + decodeURIComponent, + ); + } catch { + return null; + } + const [handle, section, slug, ...more] = segments; + if (!handle?.startsWith("@") || !/^\w+$/.test(handle.slice(1))) return null; + const username = handle.slice(1); + + if ((section === "dumps" || section === "playlists") && slug) { + if (section === "dumps" && more.length <= 1) { + if (more.length === 0) { + return { kind: "dump", username, slug, edit: false }; + } + if (more[0] === "edit") { + return { kind: "dump", username, slug, edit: true }; + } + } + if (section === "playlists" && more.length === 0) { + return { kind: "playlist", username, slug }; + } + return null; + } + + const rest = segments.slice(1); + if (rest.length === 0) return { kind: "user", username, rest }; + if (!(PROFILE_TABS as readonly string[]).includes(rest[0])) return null; + if (rest.length === 1) return { kind: "user", username, rest }; + if ( + rest.length === 2 && rest[0] === "follow" && + (FOLLOW_VIEWS as readonly string[]).includes(rest[1]) && + rest[1] !== FOLLOW_VIEWS[0] + ) return { kind: "user", username, rest }; + return null; +} diff --git a/shared/player.ts b/shared/player.ts index 1a6b6df5..057ac923 100644 --- a/shared/player.ts +++ b/shared/player.ts @@ -29,7 +29,7 @@ export interface TralbumTrack { } /** - * Returned by `GET /api/bandcamp/tracks` for a Bandcamp album or track page, + * Returned by `GET /api/remote/bandcamp` for a Bandcamp album or track page, * when the native player plays it. */ export interface Tralbum { @@ -55,7 +55,7 @@ export interface YouTubePlaylistTrack { } /** - * Returned by `GET /api/youtube/playlist` to the player, which shows the + * Returned by `GET /api/remote/youtube` to the player, which shows the * titles and lengths of a playlist embed's videos, since the embed exposes * neither. */ diff --git a/shared/playlist.ts b/shared/playlist.ts index 94996c67..a70b7435 100644 --- a/shared/playlist.ts +++ b/shared/playlist.ts @@ -1,7 +1,7 @@ /** * Playlists are ordered collections of dumps their owners curate. This module * types them, with the bodies that create, edit and reorder them. The client - * shows them on `/playlists/:playlistId`, on profiles and in search results. + * shows them on `/@owner/playlists/:slug`, on profiles and in search results. * * @module */ @@ -17,7 +17,13 @@ export interface Playlist { id: string; userId: string; title: string; - slug?: string; + /** + * Names the playlist in its page's path, `playlistPath` + * (`shared/paths.ts`) building it with {@link ownerUsername}. Made from the + * title on creation, it changes only when edited, and is unique among its + * owner's playlists. + */ + slug: string; /** It is written in Markdown. */ description?: string; /** @@ -35,24 +41,25 @@ export interface Playlist { imageMime?: string; /** * It counts the dumps the viewer can't see too. It is undefined when the - * query didn't count them, as for a new playlist or a membership. + * query didn't count them. */ dumpCount?: number; - /** It is undefined where `dumpCount` is. */ - ownerUsername?: string; + /** The owner's current name, which the playlist's path starts with. */ + ownerUsername: string; } /** - * Returned by `GET /api/playlists/:playlistId` to `PlaylistDetail`, with the - * dumps the caller may see, in the playlist's order. + * Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and + * by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in + * the playlist's order. */ export interface PlaylistWithDumps extends Playlist { dumps: Dump[]; } /** - * Returned by `GET /api/playlists/by-dump/:dumpId/memberships`, one per - * playlist of the caller's. `PlaylistMembershipPanel` ticks the playlists + * Returned by `GET /api/me/playlists?contains=:dumpId`, one per playlist of + * the caller's. `PlaylistMembershipPanel` ticks the playlists * with `hasDump`, in the add-to-playlist modal and the dump form. */ export interface PlaylistMembership { @@ -73,6 +80,11 @@ export interface CreatePlaylistRequest { */ export interface UpdatePlaylistRequest { title?: string; + /** + * A new slug, which the edit form's slug field sends. A title change never + * changes the slug by itself. + */ + slug?: string; /** `null` removes the description. */ description?: string | null; isPublic?: boolean; diff --git a/shared/settings.ts b/shared/settings.ts index efb6183b..cf03e835 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -1,7 +1,7 @@ /** * A user's settings follow their account from browser to browser, and the * server reads some of them itself (a digest is drawn in its reader's style). - * This module types them for `GET` and `PATCH /api/users/me/settings`; the + * This module types them for `GET` and `PATCH /api/me/settings`; the * client keeps them with its session. * * @module @@ -10,7 +10,7 @@ import type { ColorScheme, StyleName } from "./theme.ts"; /** - * Returned by `GET /api/users/me/settings`, and by a `PATCH` once applied. + * Returned by `GET /api/me/settings`, and by a `PATCH` once applied. * The client's `useSettings` reads it from the session. */ export interface UserSettings { @@ -29,7 +29,7 @@ export interface UserSettings { } /** - * Sent to `PATCH /api/users/me/settings` with the settings to change; the + * Sent to `PATCH /api/me/settings` with the settings to change; the * others keep their value. */ export type UpdateSettingsRequest = Partial; diff --git a/shared/slug.ts b/shared/slug.ts new file mode 100644 index 00000000..b13911dd --- /dev/null +++ b/shared/slug.ts @@ -0,0 +1,50 @@ +/** + * Defines what a slug is: the readable name of a category, a dump or a + * playlist in a page URL. The server makes and checks slugs with it, and the + * edit forms prefill their slug field with {@link slugify}. + * + * @module + */ + +/** The longest a dump's or a playlist's slug may be. */ +export const SLUG_MAX = 60; + +/** + * Matches a well-formed slug: lowercase letters and digits, in words joined by + * single hyphens. Categories, dumps and playlists share it. + */ +export const SLUG_RE: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Checks whether `slug` matches {@link SLUG_RE} and is at most `max` + * characters long. + */ +export function isValidSlug(slug: string, max: number = SLUG_MAX): boolean { + return slug.length <= max && SLUG_RE.test(slug); +} + +/** + * Turns `title` into lowercase ASCII words joined by hyphens, at most `max` + * characters long, cut at a hyphen when it must be cut. Accents are dropped, + * and a title with no ASCII letter or digit gives `untitled`. The result + * always passes {@link isValidSlug}. + */ +export function slugify(title: string, max: number = SLUG_MAX): string { + const slug = title + .toLowerCase() + .normalize("NFD") + // NFD splits an accented letter into the letter and a combining mark. + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .substring(0, max + 1) + .replace(/^-+/, ""); + // Cut at the last hyphen within `max` rather than in the middle of a word, + // unless the first word alone is longer. + const cut = slug.length > max + ? slug.substring( + 0, + slug.lastIndexOf("-", max) > 0 ? slug.lastIndexOf("-", max) : max, + ) + : slug; + return cut.replace(/-+$/, "") || "untitled"; +} diff --git a/shared/test/paths.test.ts b/shared/test/paths.test.ts new file mode 100644 index 00000000..bfd29c44 --- /dev/null +++ b/shared/test/paths.test.ts @@ -0,0 +1,77 @@ +import { assertEquals } from "@std/assert"; +import { + dumpPath, + feedPath, + parsePagePath, + playlistPath, + searchPath, + userPath, +} from "../paths.ts"; + +Deno.test("builders: one hierarchy, default views bare", () => { + assertEquals(userPath("bob"), "/@bob"); + assertEquals(userPath("bob", "playlists"), "/@bob/playlists"); + assertEquals(userPath("bob", "follow"), "/@bob/follow"); + assertEquals(userPath("bob", "follow", "users"), "/@bob/follow"); + assertEquals( + userPath("bob", "follow", "playlists"), + "/@bob/follow/playlists", + ); + const item = { ownerUsername: "bob", slug: "never-gonna" }; + assertEquals(dumpPath(item), "/@bob/dumps/never-gonna"); + assertEquals(playlistPath(item), "/@bob/playlists/never-gonna"); + assertEquals(feedPath(), "/"); + assertEquals(feedPath(undefined, "hot"), "/hot"); + assertEquals(feedPath("music"), "/music"); + assertEquals(feedPath("music", "follow"), "/music/follow"); + assertEquals(searchPath(), "/search"); + assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b"); + assertEquals(searchPath("dumps", "x"), "/search?q=x"); +}); + +Deno.test("parsePagePath: what a path under a user names", () => { + assertEquals(parsePagePath("/@bob"), { + kind: "user", + username: "bob", + rest: [], + }); + assertEquals(parsePagePath("/@bob/follow/playlists/"), { + kind: "user", + username: "bob", + rest: ["follow", "playlists"], + }); + assertEquals(parsePagePath("/@bob/dumps/never-gonna"), { + kind: "dump", + username: "bob", + slug: "never-gonna", + edit: false, + }); + assertEquals(parsePagePath("/%40bob/dumps/x/edit"), { + kind: "dump", + username: "bob", + slug: "x", + edit: true, + }); + assertEquals(parsePagePath("/@bob/playlists/mix"), { + kind: "playlist", + username: "bob", + slug: "mix", + }); + for ( + const other of [ + "/", + "/hot", + "/music/hot", + "/search/users", + "/@", + "/@b-ob", + "/@bob/nope", + "/@bob/follow/users", + "/@bob/dumps/x/y", + "/@bob/playlists/x/edit", + "/@bob/%E0%A4%A", + ] + ) { + assertEquals(parsePagePath(other), null, other); + } +}); diff --git a/shared/test/slug.test.ts b/shared/test/slug.test.ts new file mode 100644 index 00000000..f5280558 --- /dev/null +++ b/shared/test/slug.test.ts @@ -0,0 +1,25 @@ +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { isValidSlug, slugify } from "../slug.ts"; + +Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { + assertEquals(slugify("Hello, World!"), "hello-world"); + assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); + assertEquals(slugify(" --already--dashed-- "), "already-dashed"); + assertEquals(slugify("日本語"), "untitled"); + assertEquals(slugify(""), "untitled"); + assertEquals(slugify("x".repeat(100)).length, 60); + // A long title is cut at a word boundary, never left with a hyphen. + const long = slugify(`${"word ".repeat(12)}tail`); + assertEquals(long, "word-".repeat(12).slice(0, -1)); + assert(isValidSlug(long)); +}); + +Deno.test("isValidSlug: the format categories, dumps and playlists share", () => { + assert(isValidSlug("music")); + assert(isValidSlug("lo-fi-2")); + for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { + assertFalse(isValidSlug(bad), bad); + } + assertFalse(isValidSlug("a".repeat(61))); + assertFalse(isValidSlug("a".repeat(51), 50)); +}); diff --git a/shared/user.ts b/shared/user.ts index f671cae5..fb0ad416 100644 --- a/shared/user.ts +++ b/shared/user.ts @@ -10,7 +10,7 @@ /** * Sets what a user may do beyond the basics, through `ROLE_PERMISSIONS` * (`shared/permissions.ts`). An admin changes it with - * `PATCH /api/users/:userId/role`. + * `PATCH /api/users/:key/role`. */ export type Role = "user" | "moderator" | "admin"; @@ -33,7 +33,10 @@ export function isRole(value: unknown): value is Role { */ export interface PublicUser { id: string; - /** Fixed at sign-up. */ + /** + * Picked at sign-up, and changeable since, a former name staying an alias + * of the user for good. Pages write it as `@name` (`userPath`). + */ username: string; role: Role; createdAt: Date; @@ -51,7 +54,7 @@ export interface PublicUser { /** * Returned to a user about themselves: by sign-up and sign-in in - * {@link AuthResponse}, by `GET /api/users/me`, and by an avatar upload. + * {@link AuthResponse}, by `GET /api/me`, and by an avatar upload. */ export interface SelfUser extends PublicUser { /** Where password reset links go. It belongs to this account only. */ @@ -59,7 +62,7 @@ export interface SelfUser extends PublicUser { } /** - * Returned by `GET /api/users/search` to the editor's @mention autocomplete. + * Returned by `GET /api/users?q=` to the editor's @mention autocomplete. */ export interface UserSuggestion { id: string; @@ -70,7 +73,7 @@ export interface UserSuggestion { /** * Returned, as a flat list in sign-up order, by - * `GET /api/users/:username/invitees`. The profile page nests it into a tree + * `GET /api/users/:key/invitees`. The profile page nests it into a tree * by `invitedById`. */ export interface Invitee { @@ -82,13 +85,13 @@ export interface Invitee { createdAt: Date; } -/** Sent to `POST /api/users/login` by the login page. */ +/** Sent to `POST /api/sessions` by the login page. */ export interface LoginUserRequest { username: string; password: string; } -/** Sent to `POST /api/users/register` by the register page. */ +/** Sent to `POST /api/users` by the register page. */ export interface RegisterUserRequest { username: string; password: string; @@ -98,9 +101,10 @@ export interface RegisterUserRequest { } /** - * Passed to `updateUser` by the users routes and the password reset. Of a - * client's body, `PATCH /api/users/me` takes only `description` and `email`; - * the role and the password change through their own endpoints. + * Passed to `updateUser` by the users routes and the password reset, and sent + * by a client to `PATCH /api/me`, which takes only `description`, `email` and + * `username`; the role and the password change through their own endpoints, + * and the username through `renameUser`. */ export interface UpdateUserRequest { username?: string; @@ -131,7 +135,7 @@ export interface AuthResponse extends AuthTokens { } /** - * Returned by `GET /api/users/me/media-token` to a session from before media + * Returned by `GET /api/me/media-token` to a session from before media * tokens, which has none. */ export type MediaTokenResponse = Pick; diff --git a/shared/validation.ts b/shared/validation.ts index a89494a4..7cab8663 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -37,6 +37,17 @@ export const USERNAME_RE: RegExp = new RegExp( `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, ); +/** + * How often a slug or a username may change, counted from the aliases the + * changes left: a dump's or a playlist's slug 3 times a day, a username once + * a month. Moderators are exempt. The services refuse a change past it with a + * 429, and the forms say what the limit is. + */ +export const RENAME_LIMITS = { + SLUG: { count: 3, windowMs: 24 * 3_600_000 }, + USERNAME: { count: 1, windowMs: 30 * 24 * 3_600_000 }, +} as const; + /** * Matches an email address well enough to catch a typo; only the reset email * arriving proves it. diff --git a/src/App.css b/src/App.css index 5081f447..99572989 100644 --- a/src/App.css +++ b/src/App.css @@ -265,6 +265,7 @@ Each rule below adds only its own size / weight / width deltas. */ .dump-title-input, .profile-email-input, +.username-field-input, .playlist-edit-input, .playlist-edit-textarea { border: var(--border-width) solid var(--color-border); @@ -278,11 +279,33 @@ .dump-title-input:focus, .profile-email-input:focus, +.username-field-input:focus, .playlist-edit-input:focus, .playlist-edit-textarea:focus { border-color: var(--color-accent); } +/* A name and its "Rename" button, the hint and errors below. */ +.username-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.username-field-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.username-field-input { + flex: 1 1 10rem; + min-width: 0; + padding: 0.4rem 0.6rem; + font-size: var(--text-base); +} + .dump-title-input { flex: 1; min-width: 12rem; @@ -488,6 +511,27 @@ margin: 0; } +/* A slug after the path it ends, as the page's address reads. The prefix + wraps above the input on a narrow screen rather than squeezing it. */ +.slug-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; +} + +.slug-field-prefix { + font-size: var(--text-sm); + opacity: 0.6; + overflow-wrap: anywhere; +} + +.slug-field input { + flex: 1 1 12rem; + width: auto; + min-width: 0; +} + .dump-create-title-row { display: flex; align-items: center; diff --git a/src/App.tsx b/src/App.tsx index 3f736f8a..ee40898e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,30 +1,31 @@ /** * Renders the whole app, its providers around its routes. `main.tsx` mounts - * it. The routes, in the order they match, are these. + * it. Its paths follow one rule (`shared/paths.ts`): `@name` is a user, any + * other word under the root a category or an app page, a view of a page one + * more segment, and a default view the bare path, with no redirect. * - * - `/` redirects to the viewer's default feed tab, keeping the query string. - * - `/~/:feedTab` is the feed of every dump (`Index`). - * - `/dumps/:selectedDump` is a dump (`Dump`), and its `/edit` the dump's - * editor (`DumpEdit`), signed in only. + * - `/` is the feed of every dump on the viewer's default tab, and `/hot`, + * `/new`, `/journal` and `/follow` its tabs (`Index`). `/?share_url=` opens + * the create form on a shared link. + * - `/search` and `/search/:searchTab` are the search results (`Search`). * - `/register` and `/login` sign up and sign in (`UserRegister`, * `UserLogin`), signed out only. - * - `/users/:username` and `/users/:username/~/:profileTab` are a profile - * (`UserPublicProfile`). Its `/dumps`, `/upvoted` and `/playlists` are its - * full lists (`UserDumps`, `UserUpvoted`, `UserPlaylists`). - * - `/playlists/:playlistId` is a playlist (`PlaylistDetail`). - * - `/search` and `/search/~/:searchTab` are the search results (`Search`). * - `/reset-password` sets a new password from an emailed link - * (`ResetPassword`). - * - `/unsubscribe` turns off a digest from its emailed link (`Unsubscribe`). - * - `/notifications` lists the user's notifications (`Notifications`), signed - * in only. - * - `/:categorySlug/:feedTab` is a category's feed (`Index`), and - * `/:categorySlug` redirects to its default tab. + * (`ResetPassword`), and `/unsubscribe` turns off a digest from its emailed + * link (`Unsubscribe`). + * - `/notifications` (`Notifications`) and `/settings` (`Settings`), signed in + * only, and `/manage`, the site's tools, for `category:manage` (`Manage`). + * - `/@: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 + * (`Index`). * - Any other path is `NotFound`. * * The pages not marked are open to anyone. Every page is loaded with its - * route. Since category slugs share the root with the other routes, the server - * reserves those routes' names as slugs. + * route. Since category slugs share the root with the app's pages and the + * feed tabs, the server reserves those words as slugs; no slug has an `@`. * * @module */ @@ -32,7 +33,6 @@ import { lazy, Suspense } from "react"; import { BrowserRouter, - Navigate, Route, Routes, useLocation, @@ -41,9 +41,7 @@ import { import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; -import { useAuth } from "./hooks/auth/useAuth.ts"; -import { useDefaultFeedTab } from "./hooks/feed/useDefaultFeedTab.ts"; -import { DEFAULT_FEED_TAB } from "./config/feedTabs.ts"; +import { FEED_TABS, type FeedTab } from "../shared/paths.ts"; import { AuthProvider } from "./contexts/auth/AuthProvider.tsx"; import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx"; @@ -78,17 +76,6 @@ const UserPublicProfile = lazy(() => const UserRegister = lazy(() => import("./pages/UserRegister.tsx").then((m) => ({ default: m.UserRegister })) ); -const UserDumps = lazy(() => - import("./pages/UserDumps.tsx").then((m) => ({ default: m.UserDumps })) -); -const UserUpvoted = lazy(() => - import("./pages/UserUpvoted.tsx").then((m) => ({ default: m.UserUpvoted })) -); -const UserPlaylists = lazy(() => - import("./pages/UserPlaylists.tsx").then((m) => ({ - default: m.UserPlaylists, - })) -); const PlaylistDetail = lazy(() => import("./pages/PlaylistDetail.tsx").then((m) => ({ default: m.PlaylistDetail, @@ -99,6 +86,12 @@ const Notifications = lazy(() => default: m.Notifications, })) ); +const Settings = lazy(() => + import("./pages/Settings.tsx").then((m) => ({ default: m.Settings })) +); +const Manage = lazy(() => + import("./pages/Manage.tsx").then((m) => ({ default: m.Manage })) +); const Search = lazy(() => import("./pages/Search.tsx").then((m) => ({ default: m.Search })) ); @@ -115,56 +108,65 @@ const NotFound = lazy(() => ); /** - * Returns the viewer's default feed tab, from their settings. A signed-out - * viewer never gets "followed", which needs a session. + * Renders a feed, of every dump or of `categorySlug`'s, on the tab the path's + * segment after the scope names, or the viewer's default one on the bare + * path. Every feed path renders it at the same place in the tree, and the key + * keeps `Index` mounted on a change of tab, which sorts on the client, and + * remounts it on a change of category, so that it refetches. */ -function useResolvedDefaultTab() { - const { user } = useAuth(); - const [preferredTab] = useDefaultFeedTab(); - return preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; -} - -/** - * Redirects `/` to the default feed tab. The query string and the navigation - * state must survive: the web app manifest's share target opens - * `/?share_url=…` for an Android share, and the edit page hands the feed the - * id of a dump it just deleted. - */ -function IndexRedirect() { - const { search, state } = useLocation(); +function Feed({ categorySlug }: { categorySlug?: string }) { + const { pathname } = useLocation(); + const segments = pathname.split("/").filter(Boolean); + const rest = segments.slice(categorySlug ? 1 : 0); + const feedTab = rest[0]; + if ( + rest.length > 1 || + (feedTab && !(FEED_TABS as readonly string[]).includes(feedTab)) + ) { + return ; + } return ( - ); } /** - * Redirects a bare `/` to that category's default feed tab, keeping the - * query string and the navigation state as {@link IndexRedirect} does. + * Renders the pages under user `@:handle`, which the page fetches by name: a + * former name or another case reaches them too, and the page then puts the + * current one in its URL. A tab or view the profile doesn't have is + * `NotFound`, which the profile answers itself. */ -function CategoryRedirect() { - const { categorySlug } = useParams(); - const { search, state } = useLocation(); +function UserRoutes() { return ( - + + } /> + + + + } + /> + } /> + } /> + ); } /** - * Renders the feed of `/~/:feedTab` and `/:categorySlug/:feedTab` alike. The - * key remounts the feed on a change of category, so that it refetches, and - * keeps it mounted on a change of tab, which sorts on the client. + * Dispatches a path's first segment: `@name` is a user's, any other word a + * category's feed, the app's own pages having matched before. */ -function ScopedIndex() { - const { categorySlug } = useParams(); - return ; +function HandleRoutes() { + const { handle = "" } = useParams(); + return handle.startsWith("@") + ? + : ; } /** Renders the route table, and the floating buttons over every page. */ @@ -173,17 +175,12 @@ function AppRoutes() { <> - } /> - } /> - } /> - - - - } - /> + } /> + {FEED_TABS.map((tab) => ( + } /> + ))} + } /> + } /> } /> - } /> - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> } /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> + } /> } /> diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx index f54577e6..8eb23fd6 100644 --- a/src/components/category/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -13,20 +13,32 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { useNavigate, useParams } from "react-router"; +import { useNavigate } from "react-router"; import { t } from "@lingui/core/macro"; import { useCategories } from "../../hooks/category/useCategories.ts"; -import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; +import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; +import { feedPath, type FeedTab } from "../../../shared/paths.ts"; + +/** Passed to {@link CategorySwitcher} by `FeedTabBar`. */ +export interface CategorySwitcherProps { + /** The category shown, or none for every dump. */ + categorySlug?: string; + /** The tab shown, which picking a category keeps. */ + tab: FeedTab; +} /** * Renders the category menu at the head of `FeedTabBar`. Picking a category * keeps the feed tab, from `/music/new` to `/cinema/new`, and "All" leads to - * `/~/new`. It renders nothing while the site has no categories. + * `/new`; the viewer's default tab stays the bare path. It renders nothing + * while the site has no categories. */ -export function CategorySwitcher(): React.JSX.Element | null { +export function CategorySwitcher( + { categorySlug, tab }: CategorySwitcherProps, +): React.JSX.Element | null { const { categories } = useCategories(); const navigate = useNavigate(); - const { categorySlug, feedTab } = useParams(); + const defaultTab = useViewerDefaultFeedTab(); const [open, setOpen] = useState(false); const [menuStyle, setMenuStyle] = useState({}); @@ -77,20 +89,20 @@ export function CategorySwitcher(): React.JSX.Element | null { if (categories.length === 0) return null; - const value = categorySlug ?? "~"; - const tab = feedTab ?? DEFAULT_FEED_TAB; + // "" stands for every dump, which no category slug can be. + const value = categorySlug ?? ""; const currentLabel = categorySlug ? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug : t`All`; const options = [ - { value: "~", label: t`All` }, + { value: "", label: t`All` }, ...categories.map((c) => ({ value: c.slug, label: c.name })), ]; function select(slug: string) { setOpen(false); - navigate(`/${slug}/${tab}`); + navigate(feedPath(slug || undefined, tab === defaultTab ? undefined : tab)); } return ( diff --git a/src/components/chat/ChatMessageItem.tsx b/src/components/chat/ChatMessageItem.tsx index 7b2a8b3f..af2620e1 100644 --- a/src/components/chat/ChatMessageItem.tsx +++ b/src/components/chat/ChatMessageItem.tsx @@ -20,6 +20,7 @@ import { ChatEditBox } from "./ChatEditBox.tsx"; import { ReplyRef } from "./ReplyRef.tsx"; import { messageTextClass } from "../../utils/chat/message.ts"; import { clockTime } from "../../utils/chat/time.ts"; +import { userPath } from "../../../shared/paths.ts"; /** * Passed to {@link ChatMessageItem} by `ChatModal` for each message row. The @@ -168,7 +169,7 @@ export function ChatMessageItem( {!grouped && (
{message.authorUsername} diff --git a/src/components/comment/CommentNode.tsx b/src/components/comment/CommentNode.tsx index 16eb5bd5..cb4a1bcd 100644 --- a/src/components/comment/CommentNode.tsx +++ b/src/components/comment/CommentNode.tsx @@ -30,6 +30,7 @@ import { useWS } from "../../hooks/ws/useWS.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { expectOk } from "../../utils/shared/api.ts"; import { CommentForm } from "./CommentForm.tsx"; +import { userPath } from "../../../shared/paths.ts"; /** * Passed to {@link CommentNode} by `CommentThread` for each top-level comment, @@ -182,13 +183,14 @@ export function CommentNode({
{comment.authorUsername} + {/* The anchor on the dump's page, where every comment shows. */} diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx index 45c7509b..e3a0a492 100644 --- a/src/components/comment/CommentThread.tsx +++ b/src/components/comment/CommentThread.tsx @@ -1,5 +1,5 @@ /** - * Renders the comments under a dump on its page, `/dumps/:dumpId`. + * Renders the comments under a dump on its page, `/@:username/dumps/:slug`. * * @module */ diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx index 022aa7cd..3cd3fc43 100644 --- a/src/components/dump/create/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -101,11 +101,12 @@ export interface DumpCreateModalProps { /** * Renders the dump creation modal. The first panel takes a link, previewed - * through `GET /api/preview` and checked against `GET /api/dumps/by-url`, or a - * file. The second takes the "why", categories and visibility, and posts to - * `POST /api/dumps`. The third adds the new dump to playlists. The new dump is - * handed to `WSContext`'s `injectDump` so the feeds show it at once. Everything - * but the file is saved as a draft (`utils/dump/draft.ts`) until it is posted. + * through `GET /api/remote/preview` and checked against + * `GET /api/dumps?url=`, or a file. The second takes the "why", categories + * and visibility, and posts to `POST /api/dumps`. The third adds the new dump + * to playlists. The new dump is handed to `WSContext`'s `injectDump` so the + * feeds show it at once. Everything but the file is saved as a draft + * (`utils/dump/draft.ts`) until it is posted. */ export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, @@ -232,7 +233,7 @@ export function DumpCreateModal( debounceRef.current = setTimeout(() => { const encoded = encodeURIComponent(canonicalUrl); - authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal }) + authFetch(`${API_URL}/api/remote/preview?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { if (signal.aborted) return; @@ -253,7 +254,7 @@ export function DumpCreateModal( }); // Signed in, so that the poster's own private dumps count as duplicates. - authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal }) + authFetch(`${API_URL}/api/dumps?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { if (signal.aborted) return; diff --git a/src/components/dump/create/DuplicateNotice.tsx b/src/components/dump/create/DuplicateNotice.tsx index cdc1f06d..e8f35cb9 100644 --- a/src/components/dump/create/DuplicateNotice.tsx +++ b/src/components/dump/create/DuplicateNotice.tsx @@ -13,7 +13,7 @@ import { relativeTime } from "../../../utils/shared/relativeTime.ts"; /** * Passed to {@link DuplicateNotice} by `DumpCreateModal`, with the matches of - * `GET /api/dumps/by-url`. + * `GET /api/dumps?url=`. */ export interface DuplicateNoticeProps { /** Non-empty, latest first: the notice links the first one. */ @@ -37,7 +37,8 @@ export function DuplicateNotice(

- Already dumped by {first.username} {relativeTime(first.createdAt)} + Already dumped by {first.ownerUsername}{" "} + {relativeTime(first.createdAt)}

Hot }, { key: "new" as FeedTab, label: New }, { key: "journal" as FeedTab, label: Journal }, ...(user - ? [{ key: "followed" as FeedTab, label: Followed }] + ? [{ key: "follow" as FeedTab, label: Followed }] : []), ]; return ( navigate(`/${scope}/${t}`)} - leading={} + activeTab={tab} + onChange={(t) => + navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))} + leading={} /> ); } diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index 1ffd2726..cb5c640f 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -30,7 +30,7 @@ import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts"; type FollowedSection = "users" | "playlists"; /** - * Returns one section's feed, from `GET /api/follows/feed/
`, and the + * Returns one section's feed, from `GET /api/me/feed?from=
`, and the * call that loads its next page. A section cached like the index's feed starts * loaded and isn't fetched. */ @@ -39,8 +39,8 @@ function useFollowedSection( categorySlug: string | undefined, ): { state: DumpFeedState; loadMore: () => void } { const { user, authFetch } = useAuth(); - const url = `${API_URL}/api/follows/feed/${section}${ - categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" + const url = `${API_URL}/api/me/feed?from=${section}${ + categorySlug ? `&category=${encodeURIComponent(categorySlug)}` : "" }`; const { cached, saveState } = useFeedCache( @@ -114,8 +114,8 @@ export interface FollowedFeedProps { /** * Renders the "followed" tab in two sections, "from people" and "from - * playlists", from `GET /api/follows/feed/users` and - * `GET /api/follows/feed/playlists`. Both load at once, and each keeps its own + * playlists", from `GET /api/me/feed?from=users` and + * `GET /api/me/feed?from=playlists`. Both load at once, and each keeps its own * pages, cached like the index's feed. */ export function FollowedFeed( diff --git a/src/components/feed/PresenceRow.tsx b/src/components/feed/PresenceRow.tsx index 5ddece2d..a20bc8ac 100644 --- a/src/components/feed/PresenceRow.tsx +++ b/src/components/feed/PresenceRow.tsx @@ -10,6 +10,7 @@ import { t } from "@lingui/core/macro"; import { Avatar } from "../shared/Avatar.tsx"; import { useWSPresence } from "../../hooks/ws/useWS.ts"; import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts"; +import { userPath } from "../../../shared/paths.ts"; // A seat is an avatar or the "+N" counter. Past MAX_SEATS, the counter takes // the rest, so that a busy evening doesn't push the tabs out of the header. @@ -61,7 +62,7 @@ export function PresenceRow( {shown.map((u) => ( diff --git a/src/components/form/SlugField.tsx b/src/components/form/SlugField.tsx new file mode 100644 index 00000000..b98b2145 --- /dev/null +++ b/src/components/form/SlugField.tsx @@ -0,0 +1,105 @@ +/** + * Renders the slug of a dump or a playlist, the last segment of its page's + * path, in their edit forms. A slug changes only from here: a new title + * proposes one, which saving applies, and the old one keeps leading to the + * page. + * + * @module + */ + +import { useEffect, useId, useState } from "react"; +import { + type FieldValues, + type Path, + type PathValue, + useFormContext, + useWatch, +} from "react-hook-form"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { SLUG_MAX, SLUG_RE, slugify } from "../../../shared/slug.ts"; + +/** + * Passed to {@link SlugField} by `DumpEdit`'s and `PlaylistEditForm`'s forms, + * which send the slug only when it differs from `saved.slug`. + */ +export interface SlugFieldProps { + /** The form's slug field. */ + name: Path; + /** The form's title field, which the slug follows until it is typed in. */ + titleName: Path; + /** The page's path up to the slug, such as `/@bob/dumps/`. */ + prefix: string; + /** The item as saved, whose slug a title put back to its own restores. */ + saved: { title: string; slug: string }; + /** For a form outside `.form`, whose inputs take their own class. */ + inputClassName?: string; +} + +/** + * Renders the slug's input after its path, its error, and a hint that old + * links keep working. Until the user types in it, it follows the title, + * proposing `slugify(title)`, or the saved slug when the title slugifies as + * the saved one does. It must sit under a react-hook-form `FormProvider`. + */ +export function SlugField( + { name, titleName, prefix, saved, inputClassName }: SlugFieldProps, +): React.JSX.Element { + const id = useId(); + const { register, setValue, formState: { errors } } = useFormContext(); + const title = useWatch({ name: titleName }) as string | undefined; + const [typedIn, setTypedIn] = useState(false); + const error = errors[name]; + + useEffect(() => { + if (typedIn || title === undefined) return; + const proposed = slugify(title); + setValue( + name, + (proposed === slugify(saved.title) ? saved.slug : proposed) as PathValue< + T, + Path + >, + { shouldDirty: true }, + ); + }, [typedIn, title, name, saved.title, saved.slug, setValue]); + + return ( +
+ +
+ {prefix} + setTypedIn(true), + })} + /> +
+ {error?.message + ? ( + + {String(error.message)} + + ) + : ( + + Links to the old address keep working. + + )} +
+ ); +} diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index 29c988da..90bb809c 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -16,6 +16,7 @@ import { ChatButton } from "./ChatButton.tsx"; import { UserMenu } from "./UserMenu.tsx"; import { SearchBar } from "./SearchBar.tsx"; import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts"; +import { searchPath, userPath } from "../../../shared/paths.ts"; /** Loaded on first open, so its editor stays out of every page's bundle. */ const DumpCreateModal = lazy(() => @@ -72,7 +73,7 @@ export function AppHeader( const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); - // `/search` and its tabs, `/search/~/:searchTab`. + // `/search` and its tabs, `/search/:searchTab`. const isSearchPage = location.pathname === "/search" || location.pathname.startsWith("/search/"); const query = isSearchPage ? searchParams.get("q") ?? "" : ""; @@ -100,7 +101,7 @@ export function AppHeader( const searchExit = !isSearchPage ? historyIndex() : here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null); - const to = `/search?q=${encodeURIComponent(q)}`; + const to = searchPath("dumps", q); if (searchExit === null) navigate(to, { replace: true }); else navigate(to, { state: { searchExit } satisfies SearchEntryState }); } @@ -170,13 +171,13 @@ export function AppHeader( <>
{user.username} Playlists diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index 3d3e3574..7cb03b1c 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -1,6 +1,6 @@ /** * Renders the avatar menu that replaces the header's profile and playlists - * links on small screens. + * links on small screens, and leads to the settings and the site's tools. * * @module */ @@ -11,6 +11,8 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Avatar } from "../shared/Avatar.tsx"; import type { SelfUser } from "../../../shared/user.ts"; +import { can } from "../../utils/auth/permissions.ts"; +import { userPath } from "../../../shared/paths.ts"; /** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */ export interface UserMenuProps { @@ -19,7 +21,8 @@ export interface UserMenuProps { /** * Renders the signed-in user's avatar, which opens a menu with links to their - * profile and playlists. A click outside or Escape closes it. + * profile, their playlists and `/settings`, and to `/manage` for those who + * hold `category:manage`. A click outside or Escape closes it. */ export function UserMenu({ user }: UserMenuProps): React.JSX.Element { const [open, setOpen] = useState(false); @@ -62,7 +65,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element { {open && (
setOpen(false)} @@ -70,13 +73,31 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element { @{user.username} setOpen(false)} > Playlists + setOpen(false)} + > + Settings + + {can(user, "category:manage") && ( + setOpen(false)} + > + Manage + + )}
)}
diff --git a/src/components/playlist/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx index c5b9e999..c7053be7 100644 --- a/src/components/playlist/PlaylistCard.tsx +++ b/src/components/playlist/PlaylistCard.tsx @@ -18,6 +18,7 @@ import { markPlaylistVisited, } from "../../utils/shared/visited.ts"; import { Tooltip } from "../shared/Tooltip.tsx"; +import { userPath } from "../../../shared/paths.ts"; /** Passed to {@link PlaylistCard} by each list of playlists. */ export interface PlaylistCardProps { @@ -105,7 +106,7 @@ export function PlaylistCard( )} {playlist.ownerUsername && !isOwner && ( e.stopPropagation()} > diff --git a/src/components/playlist/PlaylistEditForm.tsx b/src/components/playlist/PlaylistEditForm.tsx index 47396661..f1673da9 100644 --- a/src/components/playlist/PlaylistEditForm.tsx +++ b/src/components/playlist/PlaylistEditForm.tsx @@ -28,6 +28,8 @@ import { TextEditor } from "../editor/TextEditor.tsx"; import { VisibilitySegmented } from "../form/SegmentedField.tsx"; import { FormError } from "../form/FormError.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx"; +import { SlugField } from "../form/SlugField.tsx"; +import { userPath } from "../../../shared/paths.ts"; /** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */ export interface PlaylistEditFormProps { @@ -41,6 +43,7 @@ export interface PlaylistEditFormProps { interface EditValues { title: string; + slug: string; description: string; isPublic: boolean; image: File | null; @@ -49,8 +52,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 and description that changed, then a new cover, if one was picked, - * with `POST /api/playlists/:playlistId/image`. + * title, slug and description that changed, then a new cover, if one was + * picked, with `PUT /api/playlists/:playlistId/cover`. */ export function PlaylistEditForm( { playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps, @@ -66,6 +69,7 @@ export function PlaylistEditForm( const form = useApiForm({ defaultValues: { title: playlist.title, + slug: playlist.slug, description: playlist.description ?? "", isPublic: playlist.isPublic, image: null, @@ -75,7 +79,7 @@ export function PlaylistEditForm( const title = form.watch("title"); const onSubmit = form.submit( - async ({ title, description, isPublic, image }) => { + async ({ title, slug, description, isPublic, image }) => { const updated = deserializePlaylist( await expectOk>( await authFetch(`${API_URL}/api/playlists/${playlist.id}`, { @@ -84,6 +88,7 @@ export function PlaylistEditForm( body: JSON.stringify( { ...(title !== playlist.title ? { title } : {}), + ...(slug !== playlist.slug ? { slug } : {}), ...(description !== (playlist.description ?? "") ? { description: description || null } : {}), @@ -101,8 +106,8 @@ export function PlaylistEditForm( // new updatedAt), which is what the page should show. onSaved(deserializePlaylist( await expectOk>( - await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, { - method: "POST", + await authFetch(`${API_URL}/api/playlists/${playlist.id}/cover`, { + method: "PUT", body: fd, }), ), @@ -160,6 +165,14 @@ export function PlaylistEditForm( )} /> + + name="slug" + titleName="title" + prefix={`${userPath(playlist.ownerUsername, "playlists")}/`} + saved={playlist} + inputClassName="playlist-edit-input" + /> + {playlist.ownerUsername && ( @{playlist.ownerUsername} diff --git a/src/components/profile/ChangePasswordModal.tsx b/src/components/profile/ChangePasswordModal.tsx index 7a4d821f..44d595b0 100644 --- a/src/components/profile/ChangePasswordModal.tsx +++ b/src/components/profile/ChangePasswordModal.tsx @@ -1,6 +1,5 @@ /** - * Renders the password change dialog, which the settings tab of one's own - * profile opens. + * Renders the password change dialog, which the settings page opens. * * @module */ @@ -53,8 +52,8 @@ export function ChangePasswordModal( // The server revokes every token issued before the change, this session's // included, and hands back fresh ones. Other tabs read them from storage. const tokens = await expectOk<{ token: string; mediaToken: string }>( - await authFetch(`${API_URL}/api/users/me/change-password`, { - method: "POST", + await authFetch(`${API_URL}/api/me/password`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword }), }), diff --git a/src/components/profile/DescriptionEditor.tsx b/src/components/profile/DescriptionEditor.tsx index 6db3d014..b845c366 100644 --- a/src/components/profile/DescriptionEditor.tsx +++ b/src/components/profile/DescriptionEditor.tsx @@ -30,7 +30,7 @@ export interface DescriptionEditorProps { } /** - * Renders the description's Markdown editor, saved to `PATCH /api/users/me`. + * Renders the description's Markdown editor, saved to `PATCH /api/me`. * Other browsers see the change through the socket's `user_updated` event. */ export function DescriptionEditor( @@ -48,7 +48,7 @@ export function DescriptionEditor( // Sent even when empty, since the server keeps the old one when it is left // out. await expectOk>( - await authFetch(`${API_URL}/api/users/me`, { + await authFetch(`${API_URL}/api/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify( diff --git a/src/components/profile/EmailEditor.tsx b/src/components/profile/EmailEditor.tsx index db4a4dca..06461040 100644 --- a/src/components/profile/EmailEditor.tsx +++ b/src/components/profile/EmailEditor.tsx @@ -28,7 +28,7 @@ export interface EmailEditorProps { } /** - * Renders the email field, saved to `PATCH /api/users/me`. Escape cancels. + * Renders the email field, saved to `PATCH /api/me`. Escape cancels. */ export function EmailEditor( { initialEmail, onCancel, onSaved }: EmailEditorProps, @@ -42,7 +42,7 @@ export function EmailEditor( const onSubmit = form.submit(async ({ email }) => { await expectOk>( - await authFetch(`${API_URL}/api/users/me`, { + await authFetch(`${API_URL}/api/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify( diff --git a/src/components/profile/ProfileDumpsTab.tsx b/src/components/profile/ProfileDumpsTab.tsx index 1d4a02e4..093447f0 100644 --- a/src/components/profile/ProfileDumpsTab.tsx +++ b/src/components/profile/ProfileDumpsTab.tsx @@ -1,72 +1,70 @@ /** - * Renders the "dumps" tab of a profile, which shows the user's dumps beside - * those they upvoted. + * Renders a profile's "dumps" or "upvoted" tab, the whole list of the user's + * dumps or of those they upvoted. * * @module */ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import type { Dump } from "../../../shared/dump.ts"; -import type { PagedList } from "../../utils/feed/pagedList.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import { canLoadMore } from "../../utils/feed/pagedList.ts"; +import { + type UserDumpList, + useUserDumpFeed, +} from "../../hooks/dump/useUserDumpFeed.ts"; +import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; import { DumpCardList } from "../dump/DumpCardList.tsx"; import { NewDumpButton } from "../dump/NewDumpButton.tsx"; +import { FeedFooter } from "../feed/FeedFooter.tsx"; +import { ListState } from "../shared/ListState.tsx"; import { ProfileSection } from "./ProfileSection.tsx"; /** Passed to {@link ProfileDumpsTab} by `UserPublicProfile`. */ export interface ProfileDumpsTabProps { - username: string; - dumps: PagedList; - votes: PagedList; + user: PublicUser; + list: UserDumpList; isOwnProfile: boolean; - /** It comes from `useProfile`, which fades out a withdrawn upvote. */ - upvotedCardClassName: (dump: Dump) => string | undefined; } /** - * Renders the first page of both lists, each linking to its full page. The - * owner also gets a "New dump" button. + * Renders the list from `useUserDumpFeed`, its next page loading as its end + * scrolls into view. The owner's own dumps get a "New dump" button. */ export function ProfileDumpsTab( - { username, dumps, votes, isOwnProfile, upvotedCardClassName }: - ProfileDumpsTabProps, + { user, list, isOwnProfile }: ProfileDumpsTabProps, ): React.JSX.Element { + const { state, loadMore, cardClassName } = useUserDumpFeed(user.id, list, { + isOwner: isOwnProfile, + }); + const loaded = state.status === "loaded" ? state.data : null; + const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded)); + return ( -
- } - viewAllHref={dumps.items.length > 0 - ? `/users/${username}/dumps` - : undefined} + } + > + data.items.length === 0} + empty={ +

+ Nothing here yet. +

+ } > - {dumps.items.length === 0 - ? ( -

- Nothing here yet. -

- ) - : } -
- 0 - ? `/users/${username}/upvoted` - : undefined} - > - {votes.items.length === 0 - ? ( -

- Nothing here yet. -

- ) - : ( - ( + <> + + - )} -
-
+ + )} + + ); } diff --git a/src/components/profile/ProfileFollowTab.tsx b/src/components/profile/ProfileFollowTab.tsx new file mode 100644 index 00000000..76b44627 --- /dev/null +++ b/src/components/profile/ProfileFollowTab.tsx @@ -0,0 +1,179 @@ +/** + * Renders a profile's "follow" tab, which shows the users the user follows at + * `/@name/follow` and the playlists at `/@name/follow/playlists`. + * + * @module + */ + +import { useCallback } from "react"; +import { Link, useNavigate } from "react-router"; +import { Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { Playlist } from "../../../shared/playlist.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import { type FollowView, userPath } from "../../../shared/paths.ts"; +import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts"; +import { + deserializePlaylist, + deserializePublicUser, +} from "../../utils/shared/deserialize.ts"; +import { usePagedList } from "../../hooks/feed/usePagedList.ts"; +import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; +import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts"; +import { Avatar } from "../shared/Avatar.tsx"; +import { ListState } from "../shared/ListState.tsx"; +import { TabBar } from "../shared/TabBar.tsx"; +import { FeedFooter } from "../feed/FeedFooter.tsx"; +import { FollowUserButton } from "../social/FollowButton.tsx"; +import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +/** Passed to {@link ProfileFollowTab} by `UserPublicProfile`. */ +export interface ProfileFollowTabProps { + user: PublicUser; + /** The view the path names, after `/follow`. */ + view: FollowView; +} + +/** Renders a followed user, with a button to follow or unfollow them. */ +function FollowedUserCard({ user }: { user: PublicUser }) { + return ( +
  • + + + @{user.username} + + +
  • + ); +} + +/** + * Returns page after page of what `userId` follows, from + * `GET /api/users/:userId/follow/`, and the sentinel that loads the + * next. + */ +function useFollowList( + userId: string, + view: FollowView, + deserialize: (wire: Wire) => T, +) { + const url = `${API_URL}/api/users/${userId}/follow/${view}`; + const paged = usePagedList( + url, + useCallback( + (page: number, signal?: AbortSignal) => + fetch(pageUrl(url, page), { signal }) + .then((res) => readPage(res, deserialize)), + [url, deserialize], + ), + ); + const loaded = paged.state.status === "loaded" ? paged.state.data : null; + const sentinelRef = useInfiniteScroll(paged.loadMore, canLoadMore(loaded)); + return { ...paged, sentinelRef }; +} + +/** Renders the users `userId` follows, the latest followed first. */ +function FollowedUsers({ userId }: { userId: string }) { + const { state, sentinelRef } = useFollowList( + userId, + "users", + deserializePublicUser, + ); + return ( + data.items.length === 0} + empty={ +

    + Not following anyone yet. +

    + } + > + {({ data }) => ( + <> +
      + {data.items.map((u) => )} +
    + + + )} +
    + ); +} + +/** + * Renders the public playlists `userId` follows, which the socket keeps + * current but never adds to. + */ +function FollowedPlaylists({ userId }: { userId: string }) { + const { state, setItems, sentinelRef } = useFollowList( + userId, + "playlists", + deserializePlaylist, + ); + usePlaylistListSync(setItems, { noNewEntries: true }); + return ( + data.items.length === 0} + empty={ +

    + No followed playlists yet. +

    + } + > + {({ data }) => ( + <> + + + + )} +
    + ); +} + +/** + * Renders the two views under a row of tabs that switch between their paths, + * each view loading its list when first shown. + */ +export function ProfileFollowTab( + { user, view }: ProfileFollowTabProps, +): React.JSX.Element { + const navigate = useNavigate(); + return ( + Followed}> + Users }, + { key: "playlists" as FollowView, label: Playlists }, + ]} + activeTab={view} + onChange={(v) => + navigate(userPath(user.username, "follow", v), { replace: true })} + innerClassName="followed-sub-nav" + /> + {view === "users" + ? + : } + + ); +} diff --git a/src/components/profile/ProfileFollowedTab.tsx b/src/components/profile/ProfileFollowedTab.tsx deleted file mode 100644 index 589b2719..00000000 --- a/src/components/profile/ProfileFollowedTab.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Renders the "followed" tab of a profile, which shows the users and the - * playlists the user follows. - * - * @module - */ - -import { Link } from "react-router"; -import { Trans } from "@lingui/react/macro"; -import type { Playlist } from "../../../shared/playlist.ts"; -import type { PublicUser } from "../../../shared/user.ts"; -import type { PagedList } from "../../utils/feed/pagedList.ts"; -import type { FetchState } from "../../hooks/shared/useFetchState.ts"; -import { Avatar } from "../shared/Avatar.tsx"; -import { ListState } from "../shared/ListState.tsx"; -import { LoadMoreButton } from "../shared/LoadMoreButton.tsx"; -import { FollowUserButton } from "../social/FollowButton.tsx"; -import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; -import { ProfileSection } from "./ProfileSection.tsx"; - -/** - * Loaded by `UserPublicProfile` when the tab is first opened. The followed - * users have no page of their own, so their list grows in the tab. - */ -export interface ProfileFollowed { - users: PagedList; - playlists: PagedList; -} - -/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */ -export interface ProfileFollowedTabProps { - username: string; - state: FetchState; - onLoadMoreUsers: () => void; -} - -/** Renders a followed user, with a button to follow or unfollow them. */ -function FollowedUserCard({ user }: { user: PublicUser }) { - return ( -
  • - - - @{user.username} - - -
  • - ); -} - -/** - * Renders the followed users, with a "Load more" button, beside the first - * page of the followed playlists, which link to the user's playlists page. - */ -export function ProfileFollowedTab( - { username, state, onLoadMoreUsers }: ProfileFollowedTabProps, -): React.JSX.Element { - return ( -
    - Following}> - data.users.items.length === 0} - empty={ -

    - Not following anyone yet. -

    - } - > - {({ data: { users } }) => ( - <> -
      - {users.items.map((u) => ( - - ))} -
    - {users.hasMore && ( - - )} - - )} -
    -
    - - Followed playlists} - viewAllHref={state.status === "loaded" && state.data.playlists.hasMore - ? `/users/${username}/playlists` - : undefined} - > - data.playlists.items.length === 0} - empty={ -

    - No followed playlists yet. -

    - } - > - {({ data }) => } -
    -
    -
    - ); -} diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index 7f67333f..5e140ddd 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -7,6 +7,7 @@ import { useRef, useState } from "react"; import { Link } from "react-router"; +import { can } from "../../utils/auth/permissions.ts"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../../config/api.ts"; @@ -22,6 +23,7 @@ import { FollowUserButton } from "../social/FollowButton.tsx"; import { EmailEditor } from "./EmailEditor.tsx"; import { ProfileOwnActions } from "./ProfileOwnActions.tsx"; import { RoleChip } from "./RoleSwitcher.tsx"; +import { userPath } from "../../../shared/paths.ts"; /** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */ export interface ProfileHeaderProps { @@ -34,7 +36,8 @@ export interface ProfileHeaderProps { /** * Renders the avatar, username, role and inviter. Visitors get a follow * button. On their own profile, users change their avatar - * (`POST /api/avatars/me`) and email, invite someone and log out. + * (`PUT /api/me/avatar`) and email, invite someone, reach their settings (and + * the site's tools, with `category:manage`) and log out. */ export function ProfileHeader( { user, isOwnProfile, onUserChange }: ProfileHeaderProps, @@ -54,8 +57,8 @@ export function ProfileHeader( try { const formData = new FormData(); formData.append("file", file); - const res = await authFetch(`${API_URL}/api/avatars/me`, { - method: "POST", + const res = await authFetch(`${API_URL}/api/me/avatar`, { + method: "PUT", body: formData, }); const body = parseAPIResponse>(await res.json()); @@ -114,7 +117,7 @@ export function ProfileHeader(

    invited by{" "} @{user.invitedByUsername} @@ -170,6 +173,14 @@ export function ProfileHeader( )} {isOwnProfile && ( + + Settings + + {can(me, "category:manage") && ( + + Manage + + )} diff --git a/src/components/profile/ProfileInviteesTab.tsx b/src/components/profile/ProfileInviteesTab.tsx index ee319290..b5a12900 100644 --- a/src/components/profile/ProfileInviteesTab.tsx +++ b/src/components/profile/ProfileInviteesTab.tsx @@ -13,6 +13,7 @@ import type { FetchState } from "../../hooks/shared/useFetchState.ts"; import { Avatar } from "../shared/Avatar.tsx"; import { ListState } from "../shared/ListState.tsx"; import { ProfileSection } from "./ProfileSection.tsx"; +import { userPath } from "../../../shared/paths.ts"; type InviteTreeNode = Invitee & { children: InviteTreeNode[] }; @@ -36,7 +37,7 @@ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { {nodes.map((node) => (

  • ; } diff --git a/src/components/profile/ProfileManageTab.tsx b/src/components/profile/ProfileManageTab.tsx index 6ac7366a..40313fa6 100644 --- a/src/components/profile/ProfileManageTab.tsx +++ b/src/components/profile/ProfileManageTab.tsx @@ -1,55 +1,77 @@ /** - * Renders the "manage" tab of a profile, which holds tools for another user on - * their profile and site-wide tools on one's own. + * Renders a profile's "manage" tab, `/@name/manage`, where those who hold + * `user:manage` change another user's role and name. The site's own tools are + * the `/manage` page's. * * @module */ import { Trans } from "@lingui/react/macro"; -import type { PublicUser, Role } from "../../../shared/user.ts"; -import { CategoryManager } from "../category/CategoryManager.tsx"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { deserializePublicUser } from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; import { ProfileSection } from "./ProfileSection.tsx"; import { RoleSwitcher } from "./RoleSwitcher.tsx"; +import { SettingRow } from "./SettingRow.tsx"; +import { UsernameField } from "./UsernameField.tsx"; /** * Passed to {@link ProfileManageTab} by `UserPublicProfile`, which shows the - * tab only when one of its sections applies. + * tab on another user's profile to those who hold `user:manage`. */ export interface ProfileManageTabProps { user: PublicUser; - isOwnProfile: boolean; - canManageUsers: boolean; - canManageCategories: boolean; - onRoleSaved: (role: Role) => void; + /** Called with the user as the server left them. */ + onUserSaved: (user: PublicUser) => void; } /** - * Renders the user's role to those who hold `user:manage`, on another user's - * profile, and the {@link CategoryManager} to those who hold - * `category:manage`, on their own. + * Renders the user's role and name. A rename here has no rate limit, and the + * profile's URL then takes the new name. */ export function ProfileManageTab( - { user, isOwnProfile, canManageUsers, canManageCategories, onRoleSaved }: - ProfileManageTabProps, + { user, onUserSaved }: ProfileManageTabProps, ): React.JSX.Element { + const { authFetch } = useAuth(); + + async function rename(username: string): Promise { + const renamed = deserializePublicUser( + await expectOk>( + await authFetch(`${API_URL}/api/users/${user.id}/username`, { + method: "PATCH", + body: JSON.stringify({ username }), + }), + ), + ); + onUserSaved(renamed); + return renamed.username; + } + return ( - <> - {!isOwnProfile && canManageUsers && ( - User management}> -
    - -
    -
    - )} - {isOwnProfile && canManageCategories && ( - Categories}> - - - )} - + User management}> +
    + onUserSaved({ ...user, role })} + /> + Username}> + + Their old name keeps leading to them, and nobody else can take + it. + + } + /> + +
    +
    ); } diff --git a/src/components/profile/ProfileOverviewTab.tsx b/src/components/profile/ProfileOverviewTab.tsx new file mode 100644 index 00000000..2c28fd02 --- /dev/null +++ b/src/components/profile/ProfileOverviewTab.tsx @@ -0,0 +1,91 @@ +/** + * Renders a profile's overview, its bare `/@name`, which shows the first page + * of the user's dumps beside those they upvoted. + * + * @module + */ + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import type { PublicUser } from "../../../shared/user.ts"; +import { userPath } from "../../../shared/paths.ts"; +import { useUserDumpFeed } from "../../hooks/dump/useUserDumpFeed.ts"; +import { DumpCardList } from "../dump/DumpCardList.tsx"; +import { NewDumpButton } from "../dump/NewDumpButton.tsx"; +import { ListState } from "../shared/ListState.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +/** Passed to {@link ProfileOverviewTab} by `UserPublicProfile`. */ +export interface ProfileOverviewTabProps { + user: PublicUser; + isOwnProfile: boolean; +} + +/** Counts a first page, with a `+` when there are more. */ +function countOf(state: ReturnType["state"]) { + return state.status === "loaded" + ? `${state.data.items.length}${state.data.hasMore ? "+" : ""}` + : "…"; +} + +/** + * Renders the first page of both lists, from `useUserDumpFeed`, each linking + * to its tab. The owner also gets a "New dump" button. + */ +export function ProfileOverviewTab( + { user, isOwnProfile }: ProfileOverviewTabProps, +): React.JSX.Element { + const dumps = useUserDumpFeed(user.id, "dumps", { isOwner: isOwnProfile }); + const votes = useUserDumpFeed(user.id, "upvoted", { + isOwner: isOwnProfile, + source: dumps.state.status === "loaded" + ? dumps.state.data.items + : undefined, + }); + const empty = ( +

    + Nothing here yet. +

    + ); + const hasItems = (s: typeof dumps.state) => + s.status === "loaded" && s.data.items.length > 0; + + return ( +
    + } + viewAllHref={hasItems(dumps.state) + ? userPath(user.username, "dumps") + : undefined} + > + data.items.length === 0} + empty={empty} + > + {({ data }) => } + + + + data.items.length === 0} + empty={empty} + > + {({ data }) => ( + + )} + + +
    + ); +} diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx index cd5e3955..808a0a62 100644 --- a/src/components/profile/ProfilePlaylistsTab.tsx +++ b/src/components/profile/ProfilePlaylistsTab.tsx @@ -1,58 +1,166 @@ /** - * Renders the "playlists" tab of a profile, which shows the playlists the user - * made. + * Renders a profile's "playlists" tab, the playlists the user made. Those they + * follow are the "follow" tab's. * * @module */ +import { useCallback, useState } from "react"; +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 type { PagedList } from "../../utils/feed/pagedList.ts"; -import { withFirst } from "../../utils/feed/pagedList.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import { + canLoadMore, + pageUrl, + readPage, + withFirst, +} from "../../utils/feed/pagedList.ts"; +import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; +import { + deserializePlaylist, + hydratePlaylist, +} from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useWS } from "../../hooks/ws/useWS.ts"; +import { useFeedCache } from "../../hooks/feed/useFeedCache.ts"; +import { usePagedList } from "../../hooks/feed/usePagedList.ts"; +import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; +import { usePositionAwareSync } from "../../hooks/feed/usePositionAwareSync.ts"; +import { + useScrollRestore, + useScrollSave, +} from "../../hooks/feed/useScrollSave.ts"; +import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts"; import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx"; import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; +import { FeedFooter } from "../feed/FeedFooter.tsx"; +import { ConfirmModal } from "../shared/ConfirmModal.tsx"; +import { ErrorCard } from "../shared/ErrorCard.tsx"; +import { ListState } from "../shared/ListState.tsx"; import { ProfileSection } from "./ProfileSection.tsx"; /** Passed to {@link ProfilePlaylistsTab} by `UserPublicProfile`. */ export interface ProfilePlaylistsTabProps { - username: string; - playlists: PagedList; + user: PublicUser; isOwnProfile: boolean; - /** It comes from `useProfile`, and puts a playlist made here first. */ - setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; } /** - * Renders the first page of the playlists, linking to the full page. The - * owner also gets a "New playlist" button. + * 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. */ export function ProfilePlaylistsTab( - { username, playlists, isOwnProfile, setPlaylists }: ProfilePlaylistsTabProps, + { user, isOwnProfile }: ProfilePlaylistsTabProps, ): React.JSX.Element { + const { user: me, authFetch } = useAuth(); + const { lastPlaylistEvent } = useWS(); + const { cached, saveState } = useFeedCache( + `feed:profile-playlists:${user.id}`, + hydratePlaylist, + ); + const url = `${API_URL}/api/users/${user.id}/playlists`; + const { state, setItems, loadMore } = usePagedList( + `${url}:${me?.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; + + const lastPlaylistItem = lastPlaylistEvent?.type === "updated" + ? (lastPlaylistEvent.playlist ?? null) + : null; + // Before usePlaylistListSync: it saves a playlist's place before the sync + // takes it out, and puts it back there when it turns public again. + usePositionAwareSync( + loaded?.items ?? [], + setItems, + lastPlaylistItem, + (p) => p.isPublic && p.userId === user.id, + ); + usePlaylistListSync(setItems, { isOwner: isOwnProfile, ownerId: user.id }); + + const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded)); + useScrollSave( + !!loaded, + useCallback((y) => { + if (loaded) saveState(loaded, y); + }, [loaded, saveState]), + ); + useScrollRestore(cached?.scrollY, !!loaded); + + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const handleDelete = async (playlistId: string) => { + setDeleteError(null); + try { + const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, { + method: "DELETE", + }); + if (!res.ok) await expectOk(res); // throws the server's message + } catch (err) { + setDeleteError(friendlyFetchError(err)); + return; + } + setItems((items) => items.filter((p) => p.id !== playlistId)); + }; + return ( - Playlists ({playlists.items.length} - {playlists.hasMore ? "+" : ""}) - - } + title={Playlists} actions={isOwnProfile && ( setPlaylists((items) => withFirst(items, p))} + onCreated={(p) => setItems((items) => withFirst(items, p))} /> )} - viewAllHref={playlists.items.length > 0 - ? `/users/${username}/playlists` - : undefined} > - {playlists.items.length === 0 - ? ( + {deleteError && ( + + )} + data.items.length === 0} + empty={

    No playlists yet.

    - ) - : } + } + > + {({ data }) => ( + <> + setConfirmDeleteId(p.id) + : undefined} + /> + + + )} +
    + {confirmDeleteId && ( + { + handleDelete(confirmDeleteId); + setConfirmDeleteId(null); + }} + onCancel={() => setConfirmDeleteId(null)} + /> + )}
    ); } diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx index 2be0c12d..93907545 100644 --- a/src/components/profile/ProfileSection.tsx +++ b/src/components/profile/ProfileSection.tsx @@ -9,8 +9,8 @@ import { Link } from "react-router"; import { Trans } from "@lingui/react/macro"; /** - * Passed to {@link ProfileSection} by the profile's tabs and by - * `UserPlaylists`. + * Passed to {@link ProfileSection} by the profile's tabs, and by the settings + * and manage pages. */ export interface ProfileSectionProps { title: ReactNode; diff --git a/src/components/profile/ProfileSubpageHeader.tsx b/src/components/profile/ProfileSubpageHeader.tsx deleted file mode 100644 index 003ac28a..00000000 --- a/src/components/profile/ProfileSubpageHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Renders the header of a profile's full lists, `/users/:username/dumps`, - * `/upvoted` and `/playlists`. - * - * @module - */ - -import type { ReactNode } from "react"; -import { Link } from "react-router"; -import type { PublicUser } from "../../../shared/user.ts"; -import { Avatar } from "../shared/Avatar.tsx"; - -/** - * Passed to {@link ProfileSubpageHeader} by each of those pages, once it has - * loaded the profile's user. - */ -export interface ProfileSubpageHeaderProps { - /** The `:username` of the route, which the back link leads to. */ - username: string; - profileUser: PublicUser; - title: string; - /** Buttons at the end of the title row. */ - actions?: ReactNode; -} - -/** - * Renders a link back to the profile, then the page's title beside the user's - * avatar. - */ -export function ProfileSubpageHeader( - { username, profileUser, title, actions }: ProfileSubpageHeaderProps, -): React.JSX.Element { - return ( -
    - - ← {profileUser.username} - -
    - -

    {title}

    - {actions} -
    -
    - ); -} diff --git a/src/components/profile/SettingRow.tsx b/src/components/profile/SettingRow.tsx index 0a65f53f..b4751d38 100644 --- a/src/components/profile/SettingRow.tsx +++ b/src/components/profile/SettingRow.tsx @@ -1,5 +1,6 @@ /** - * Renders one labelled setting of the profile's "settings" and "manage" tabs. + * Renders one labelled setting of the settings page and the profile's + * "manage" tab. * * @module */ @@ -7,7 +8,8 @@ import type { ReactNode } from "react"; /** - * Passed to {@link SettingRow} by `ProfileSettingsTab` and `RoleSwitcher`. + * Passed to {@link SettingRow} by `Settings`, `ProfileManageTab` and + * `RoleSwitcher`. */ export interface SettingRowProps { label: ReactNode; diff --git a/src/components/profile/UsernameField.tsx b/src/components/profile/UsernameField.tsx new file mode 100644 index 00000000..497a988c --- /dev/null +++ b/src/components/profile/UsernameField.tsx @@ -0,0 +1,85 @@ +/** + * Renders the field that renames a user, on the settings page for one's own + * name and on a profile's "manage" tab for a moderator. + * + * @module + */ + +import type { ReactNode } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { FormProvider } from "react-hook-form"; +import { USERNAME_RE, VALIDATION } from "../../../shared/validation.ts"; +import { useApiForm } from "../../hooks/form/useApiForm.ts"; +import { FormError } from "../form/FormError.tsx"; +import { SubmitButton } from "../form/SubmitButton.tsx"; + +/** Passed to {@link UsernameField} by `Settings` and `ProfileManageTab`. */ +export interface UsernameFieldProps { + username: string; + /** + * Sends the new name, to `PATCH /api/me` or + * `PATCH /api/users/:key/username`, and resolves to the name the server + * kept. A refusal rejects with its message, which the field shows. + */ + save: (username: string) => Promise; + /** Shown under the field, such as how often the name may change. */ + hint?: ReactNode; +} + +/** + * Renders the name in a text input with a "Rename" button, which is enabled + * once the name differs. The old name keeps leading to the user, as the hint + * should say. + */ +export function UsernameField( + { username, save, hint }: UsernameFieldProps, +): React.JSX.Element { + const form = useApiForm<{ username: string }>({ + defaultValues: { username }, + }); + const draft = form.watch("username").trim(); + const error = form.formState.errors.username; + + const onSubmit = form.submit(async ({ username: next }) => { + form.reset({ username: await save(next.trim()) }); + }); + + return ( + +
    +
    + + Renaming…} + disabled={!draft || draft === username} + > + Rename + +
    + {error?.message && ( + + {String(error.message)} + + )} + {hint &&

    {hint}

    } + + +
    + ); +} diff --git a/src/components/shared/Avatar.tsx b/src/components/shared/Avatar.tsx index f07179ae..fd495da2 100644 --- a/src/components/shared/Avatar.tsx +++ b/src/components/shared/Avatar.tsx @@ -25,7 +25,7 @@ export interface AvatarProps { } /** - * Renders the image from `GET /api/avatars/:userId`, or the user's initial + * Renders the image from `GET /api/users/:userId/avatar`, or the user's initial * when they have none or it fails to load. An avatar added, changed or removed * since the caller's data was fetched shows at once, through the * `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`. @@ -51,8 +51,8 @@ export function Avatar( if (effectiveHasAvatar && !imgFailed) { const src = effectiveVersion - ? `${API_URL}/api/avatars/${userId}?v=${effectiveVersion}` - : `${API_URL}/api/avatars/${userId}`; + ? `${API_URL}/api/users/${userId}/avatar?v=${effectiveVersion}` + : `${API_URL}/api/users/${userId}/avatar`; return ( ( ` show visitors, and users who haven't + * picked one in their settings. */ export const DEFAULT_FEED_TAB: FeedTab = "journal"; diff --git a/src/config/instance.ts b/src/config/instance.ts index 081b7c29..6edd9956 100644 --- a/src/config/instance.ts +++ b/src/config/instance.ts @@ -29,7 +29,7 @@ export const DEFAULT_STYLE: StyleName = (() => { })(); /** - * Whether the server sends email. Without it the settings tab shows the + * Whether the server sends email. Without it the settings page shows the * digest switches disabled, with a note why. */ export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on"; diff --git a/src/contexts/auth/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx index 975e555a..158d183e 100644 --- a/src/contexts/auth/AuthProvider.tsx +++ b/src/contexts/auth/AuthProvider.tsx @@ -82,7 +82,7 @@ export function AuthProvider( const validate = async () => { try { - const res = await read("/api/users/me"); + const res = await read("/api/me"); if (res.status === 401) { // Only this token is dead. The session may have moved on to fresh // tokens while we asked (a password change, here or in another tab). @@ -97,7 +97,7 @@ export function AuthProvider( // The settings follow the account, so another browser may have // changed them. A failure leaves the ones the session has. const settings: UserSettings | null = await dataOf( - await read("/api/users/me/settings"), + await read("/api/me/settings"), ); if (settings) merge({ settings }); } catch { @@ -123,7 +123,7 @@ export function AuthProvider( useEffect(() => { if (!token || !needsMediaToken) return; let cancelled = false; - fetch(`${API_URL}/api/users/me/media-token`, { + fetch(`${API_URL}/api/me/media-token`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }) diff --git a/src/contexts/social/FollowProvider.tsx b/src/contexts/social/FollowProvider.tsx index f34b6d58..97fab8c3 100644 --- a/src/contexts/social/FollowProvider.tsx +++ b/src/contexts/social/FollowProvider.tsx @@ -1,6 +1,6 @@ /** * Keeps what the signed-in user follows for the Follow buttons, loaded from - * `GET /api/follows/status` and again when another tab changes it. `App` mounts + * `GET /api/me/follow` and again when another tab changes it. `App` mounts * it around every page. * * @module @@ -65,7 +65,7 @@ export function FollowProvider( if (controller.signal.aborted) return; retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS); }; - fetch(`${API_URL}/api/follows/status`, { + fetch(`${API_URL}/api/me/follow`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }) @@ -103,8 +103,8 @@ export function FollowProvider( }); apply(follow); try { - const res = await authFetch(`${API_URL}/api/follows/${path}/${id}`, { - method: follow ? "POST" : "DELETE", + const res = await authFetch(`${API_URL}/api/me/follow/${path}/${id}`, { + method: follow ? "PUT" : "DELETE", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return true; diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts index 15bab4cd..5077c416 100644 --- a/src/contexts/ws/WSContext.ts +++ b/src/contexts/ws/WSContext.ts @@ -19,7 +19,7 @@ import type { Playlist } from "../../../shared/playlist.ts"; import type { PublicUser } from "../../../shared/user.ts"; /** - * Keeps a user's upvoted list live, on their profile and on `UserUpvoted`. + * Keeps a user's upvoted list live, on their profile's overview and tab. * `WSProvider` sets it as `lastVoteEvent` on each `votes_update`, and * `useUpvotedList` adds or drops the dump. */ diff --git a/src/hooks/auth/useSettings.ts b/src/hooks/auth/useSettings.ts index d79a6f45..df1f3d0f 100644 --- a/src/hooks/auth/useSettings.ts +++ b/src/hooks/auth/useSettings.ts @@ -1,7 +1,7 @@ /** * Gives components the signed-in user's settings, which are stored on the * server and kept with the session. `ThemeProvider` reads the style and - * scheme from it, and the profile's settings tab the digest switches. + * scheme from it, and the settings page the digest switches. * * @module */ @@ -25,7 +25,7 @@ export interface UseSettingsResult { settings: UserSettings | null; /** * Applies `patch` at once, then saves it with `PATCH - * /api/users/me/settings`. A refusal puts the previous settings back and + * /api/me/settings`. A refusal puts the previous settings back and * throws. */ updateSettings: (patch: UpdateSettingsRequest) => Promise; @@ -42,7 +42,7 @@ export function useSettings(): UseSettingsResult { if (previous) updateSession({ settings: { ...previous, ...patch } }); try { const saved = await expectOk( - await authFetch(`${API_URL}/api/users/me/settings`, { + await authFetch(`${API_URL}/api/me/settings`, { method: "PATCH", body: JSON.stringify(patch satisfies UpdateSettingsRequest), }), diff --git a/src/hooks/dump/useDumpCardLink.ts b/src/hooks/dump/useDumpCardLink.ts index cd20d119..d0d94891 100644 --- a/src/hooks/dump/useDumpCardLink.ts +++ b/src/hooks/dump/useDumpCardLink.ts @@ -47,7 +47,7 @@ export interface UseDumpCardLinkResult { * inside the card, such as the title or a category, keep their own target. */ export function useDumpCardLink( - dump: Pick, + dump: Pick, ): UseDumpCardLinkResult { const navigate = useNavigate(); diff --git a/src/hooks/dump/useUpvotedList.ts b/src/hooks/dump/useUpvotedList.ts index 0109778a..fa0ffab8 100644 --- a/src/hooks/dump/useUpvotedList.ts +++ b/src/hooks/dump/useUpvotedList.ts @@ -15,7 +15,7 @@ import { useAuth } from "../auth/useAuth.ts"; import { useWS } from "../ws/useWS.ts"; import { fadeClassName, useFading } from "../feed/useFading.ts"; -/** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */ +/** Passed to {@link useUpvotedList} by `useUserDumpFeed`, for an upvoted list. */ export interface UseUpvotedListOptions { /** * The list holds this user's upvotes. It is null while their profile loads. diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts index f5b58e08..c5f5829a 100644 --- a/src/hooks/dump/useUserDumpFeed.ts +++ b/src/hooks/dump/useUserDumpFeed.ts @@ -1,135 +1,100 @@ /** - * Feeds the profile subpages that list all of a user's dumps, or all they - * upvoted, loading them a page at a time. + * Feeds a profile's lists of dumps, the user's own and those they upvoted, to + * its overview and to its "dumps" and "upvoted" tabs, and keeps them live. * * @module */ -import { type RefCallback, useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { API_URL } from "../../config/api.ts"; -import { friendlyFetchError } from "../../utils/shared/api.ts"; -import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; -import { - canLoadMore, - mapItems, - type PagedList, - pageUrl, - readFirstPage, - readPage, - restoredList, -} from "../../utils/feed/pagedList.ts"; -import type { LoadState } from "../../utils/shared/loadState.ts"; +import { pageUrl, readPage } from "../../utils/feed/pagedList.ts"; import type { Dump } from "../../../shared/dump.ts"; -import type { PublicUser } from "../../../shared/user.ts"; import { deserializeDump, hydrateDump, } from "../../utils/shared/deserialize.ts"; import { useAuth } from "../auth/useAuth.ts"; +import { useWS } from "../ws/useWS.ts"; import { useFeedCache } from "../feed/useFeedCache.ts"; -import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; -import { useLoadMore } from "../feed/usePagedList.ts"; +import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts"; import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; +import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts"; +import { useDumpListSync } from "./useDumpListSync.ts"; +import { useUpvotedList } from "./useUpvotedList.ts"; -/** - * Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails - * when the user doesn't exist, and holds an empty list when only the dumps - * failed to load. - */ -export type UserDumpFeedState = LoadState<{ - profileUser: PublicUser; - dumps: PagedList; -}>; +/** One of a profile's lists of dumps, a path segment of its API and page. */ +export type UserDumpList = "dumps" | "upvoted"; -/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ -export interface UseUserDumpFeedResult { - state: UserDumpFeedState; +/** Returned by {@link useUserDumpFeed}. */ +export interface UseUserDumpFeedResult extends UsePagedListResult { /** - * It does nothing until loaded. It goes to `useDumpListSync` and - * `useUpvotedList`. + * It goes to the list's `DumpCardList`: on the upvoted list, it fades out a + * withdrawn upvote. */ - setItems: (fn: (prev: Dump[]) => Dump[]) => void; - /** It comes from `useInfiniteScroll`, for an element after the last card. */ - sentinelRef: RefCallback; + cardClassName?: (dump: Dump) => string | undefined; } /** - * Returns the profile of `username`, from `GET /api/users/:username`, with its - * dumps from `GET /api/users/:username/`, `endpoint` being "dumps" - * or "votes". The dumps are fetched signed in, so the user sees their own - * private ones. The next page loads as the sentinel scrolls into view. The - * list and its scroll position are saved under `cacheKey` as the page is - * left, and going back restores them, refetching only the profile. + * Returns `list` of user `userId`, from `GET /api/users/:userId/`, fetched + * signed in so users see their own private dumps, with the call that loads its + * next page. The socket keeps it live: a dump that goes private and public + * again comes back at its place, and the upvoted list follows the user's + * votes, `source` holding dumps at hand that a vote of theirs puts first at + * once. The list and the scroll position are saved as the page is left, under + * a key the overview and the tab share, and going back restores them. */ export function useUserDumpFeed( - username: string | undefined, - endpoint: string, - cacheKey: string, + userId: string, + list: UserDumpList, + { isOwner, source }: { isOwner: boolean; source?: Dump[] }, ): UseUserDumpFeedResult { - const { authFetch } = useAuth(); - const { cached, saveState } = useFeedCache(cacheKey, hydrateDump); - - const [state, setState] = useState({ status: "loading" }); - const [prevUsername, setPrevUsername] = useState(username); - if (prevUsername !== username) { - setPrevUsername(username); - setState({ status: "loading" }); - } - - const setDumps = useCallback( - (fn: (list: PagedList) => PagedList) => - setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }), - [], - ); - const setItems = useCallback( - (fn: (prev: Dump[]) => Dump[]) => setDumps(mapItems(fn)), - [setDumps], + const { user, authFetch } = useAuth(); + const { lastDumpEvent } = useWS(); + const { cached, saveState } = useFeedCache( + `feed:profile-${list}:${userId}`, + hydrateDump, ); - const url = `${API_URL}/api/users/${username}/${endpoint}`; - const fetchPage = useCallback( - (page: number, signal?: AbortSignal) => - authFetch(pageUrl(url, page), { signal }) - .then((res) => readPage(res, deserializeDump)), - [authFetch, url], + const url = `${API_URL}/api/users/${userId}/${list}`; + const paged = usePagedList( + `${url}:${user?.id ?? "guest"}`, + useCallback( + (page: number, signal?: AbortSignal) => + authFetch(pageUrl(url, page), { signal }) + .then((res) => readPage(res, deserializeDump)), + [authFetch, url], + ), + cached, ); + const { state, setItems } = paged; + const items = state.status === "loaded" ? state.data.items : []; - useEffect(() => { - if (!username) return; - const controller = new AbortController(); - const { signal } = controller; - // Back on the page, the list comes from the cache and only the user is - // fetched again. - const list = cached - ? restoredList(cached) - : authFetch(pageUrl(url, 1), { signal }) - .then((res) => readFirstPage(res, deserializeDump)); - Promise.all([fetchPublicUser(username, signal), list]) - .then(([profileUser, dumps]) => - setState({ status: "loaded", profileUser, dumps }) - ) - .catch((err) => { - if (signal.aborted) return; - setState({ status: "error", error: friendlyFetchError(err) }); - }); - return () => controller.abort(); - }, [username, cached, authFetch, url]); - - const list = state.status === "loaded" ? state.dumps : null; - const sentinelRef = useInfiniteScroll( - useLoadMore(list, setDumps, fetchPage), - canLoadMore(list), + const upvoted = useUpvotedList({ + profileUserId: list === "upvoted" ? userId : null, + setItems, + source, + }); + usePositionAwareSync( + items, + setItems, + lastDumpEvent, + list === "dumps" + ? (d) => !d.isPrivate && d.userId === userId + : (d) => !d.isPrivate && !upvoted.isWithdrawn(d.id), ); + useDumpListSync(setItems, { isOwner: list === "dumps" && isOwner }); useScrollSave( state.status === "loaded", useCallback((y) => { - if (state.status === "loaded") saveState(state.dumps, y); + if (state.status === "loaded") saveState(state.data, y); }, [state, saveState]), ); - useScrollRestore(cached?.scrollY, state.status === "loaded"); - return { state, setItems, sentinelRef }; + return { + ...paged, + cardClassName: list === "upvoted" ? upvoted.cardClassName : undefined, + }; } diff --git a/src/hooks/editor/useMentionAutocomplete.ts b/src/hooks/editor/useMentionAutocomplete.ts index f5c78b54..db17a131 100644 --- a/src/hooks/editor/useMentionAutocomplete.ts +++ b/src/hooks/editor/useMentionAutocomplete.ts @@ -1,6 +1,6 @@ /** * Suggests users to @mention as the user types in `TextEditor`. It keeps the - * state of the suggestions, fetched from `GET /api/users/search`, and + * state of the suggestions, fetched from `GET /api/users?q=`, and * `TextEditor` renders them in a `MentionDropdown`. * * @module @@ -158,9 +158,7 @@ export function useMentionAutocomplete( abortRef.current = controller; try { const res = await fetch( - `${API_URL}/api/users/search?q=${ - encodeURIComponent(mention.query) - }`, + `${API_URL}/api/users?q=${encodeURIComponent(mention.query)}`, { signal: controller.signal }, ); const body = await res.json(); diff --git a/src/hooks/feed/useDefaultFeedTab.ts b/src/hooks/feed/useDefaultFeedTab.ts index f46d4956..0996e104 100644 --- a/src/hooks/feed/useDefaultFeedTab.ts +++ b/src/hooks/feed/useDefaultFeedTab.ts @@ -6,13 +6,13 @@ */ import { useSyncExternalStore } from "react"; -import { - DEFAULT_FEED_TAB, - FEED_TABS, - type FeedTab, -} from "../../config/feedTabs.ts"; +import { FEED_TABS, type FeedTab } from "../../../shared/paths.ts"; +import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; +import { useAuth } from "../auth/useAuth.ts"; const STORAGE_KEY = "default-feed-tab"; +/** The v3 name of the "follow" tab, which a browser may still hold. */ +const V3_FOLLOW = "followed"; // The components reading the tab, told when this tab changes it. const listeners = new Set<() => void>(); @@ -20,11 +20,19 @@ const listeners = new Set<() => void>(); // Holds the choice made while storage is blocked, until a reload. let unstored: FeedTab | null = null; -/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */ +/** + * Returns the stored tab, or {@link DEFAULT_FEED_TAB} when the stored value + * isn't a tab. A v3 "followed" is stored again as "follow", once, so nobody + * loses their choice. + */ function readStored(): FeedTab { let stored: string | null; try { stored = localStorage.getItem(STORAGE_KEY); + if (stored === V3_FOLLOW) { + stored = "follow"; + localStorage.setItem(STORAGE_KEY, stored); + } } catch { stored = unstored; } @@ -55,12 +63,22 @@ function setDefaultFeedTab(tab: FeedTab): void { /** * Returns the feed tab the index opens on, and a setter that stores a new - * choice. The profile's settings set it. The index's redirect, its tab bar and - * `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is - * "followed" and nobody is signed in. A new choice reaches every reader at - * once, in this browser tab and the others. + * choice. The settings page sets it; the feed reads it through + * {@link useViewerDefaultFeedTab}. A new choice reaches every reader at once, + * in this browser tab and the others. */ export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] { const tab = useSyncExternalStore(subscribe, readStored); return [tab, setDefaultFeedTab]; } + +/** + * Returns the tab the bare `/` and `/` show this viewer: their + * choice, or {@link DEFAULT_FEED_TAB} for "follow" when nobody is signed in, + * since it needs a session. `Index` and its tab bar read it. + */ +export function useViewerDefaultFeedTab(): FeedTab { + const { user } = useAuth(); + const [preferred] = useDefaultFeedTab(); + return preferred === "follow" && !user ? DEFAULT_FEED_TAB : preferred; +} diff --git a/src/hooks/feed/usePagedList.ts b/src/hooks/feed/usePagedList.ts index 8d2692d5..ae80b7ca 100644 --- a/src/hooks/feed/usePagedList.ts +++ b/src/hooks/feed/usePagedList.ts @@ -32,10 +32,9 @@ export type FetchPage = ( /** * Returns the call that loads the page after `list`'s last one and appends it * through `setList`. It does nothing unless `canLoadMore(list)`; a page that - * fails leaves the list as it was, for the next call to try again. The pages - * that load their lists with their user (`UserPlaylists`, `useUserDumpFeed`, - * the profile's followed users) use it directly, and `usePagedList` for the - * others. + * fails leaves the list as it was, for the next call to try again. A page + * that loads its list along with more, as the search page does, uses it + * directly, and `usePagedList` for the others. */ export function useLoadMore( list: PagedList | null, @@ -78,8 +77,8 @@ export interface UsePagedListResult { * Returns the list `fetchPage` serves for `key`, from its first page, and the * call that loads the next. `key` changes as `useFetchState`'s does, and * `cached`, `useFeedCache`'s restored list for it, if any, is shown without - * loading. The index's feeds, the notifications and the popover of voters and - * likers use it. + * loading. The index's feeds, the profile's lists, the notifications and the + * popover of voters and likers use it. */ export function usePagedList( key: string | undefined, diff --git a/src/hooks/playlist/usePlaylist.ts b/src/hooks/playlist/usePlaylist.ts index 6c895084..0929fc5b 100644 --- a/src/hooks/playlist/usePlaylist.ts +++ b/src/hooks/playlist/usePlaylist.ts @@ -28,7 +28,8 @@ import { deserializePlaylistWithDumps, } from "../../utils/shared/deserialize.ts"; import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; -import { playlistUrl } from "../../utils/shared/urls.ts"; +import { isAt, playlistUrl } from "../../utils/shared/urls.ts"; +import { useCanonicalPath } from "../shared/useCanonicalPath.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useWS } from "../ws/useWS.ts"; import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; @@ -87,35 +88,45 @@ function withDetails( return { ...playlist, ...updated, - // Set only where the dumps were counted, which an edit's answer isn't. - ownerUsername: updated.ownerUsername ?? playlist.ownerUsername, + // Set only where the dumps were counted. + dumpCount: updated.dumpCount ?? playlist.dumpCount, dumps: playlist.dumps, }; } /** - * Returns the playlist `playlistId` (its id or its slug) from - * `GET /api/playlists/:playlistId`, with the dumps the viewer may see in it. - * The socket's events keep its details and dumps live: a dump another client - * removes fades out, one it adds is fetched, a new order is applied, one that - * goes private leaves and comes back at its place, and a deleted playlist - * sends the page home. A new slug replaces the URL. + * Returns the playlist at `/@username/playlists/slug` from + * `GET /api/users/@:username/playlists/:slug`, with the dumps the viewer may + * see in it. The socket's events keep its details and dumps live: a dump + * another client removes fades out, one it adds is fetched, a new order is + * applied, one that goes private leaves and comes back at its place, and a + * deleted playlist sends the page home. A new slug replaces the URL, and a + * path that names the playlist on screen, as that one does, fetches nothing. */ export function usePlaylist( - playlistId: string | undefined, + username: string | undefined, + slug: string | undefined, ): UsePlaylistResult { const navigate = useNavigate(); const { user, authFetch } = useAuth(); const { lastDumpEvent, lastPlaylistEvent } = useWS(); const [actionError, setActionError] = useState(null); + // The path last fetched. The URL may move on to the same playlist's current + // path, which mustn't fetch it again. + const [target, setTarget] = useState({ username, slug }); + // The server's last order of the dumps this viewer may see, which a refused // reorder goes back to. const serverOrderRef = useRef([]); const { state, setData } = useFetchState( - playlistId && `${playlistId}:${user?.id ?? "guest"}`, + target.username && target.slug && + `${target.username}/${target.slug}:${user?.id ?? "guest"}`, (signal) => - authFetch(`${API_URL}/api/playlists/${playlistId}`, { signal }) + authFetch( + `${API_URL}/api/users/@${target.username}/playlists/${target.slug}`, + { signal }, + ) .then(expectOk>) .then((wire) => { const playlist = deserializePlaylistWithDumps(wire); @@ -124,7 +135,14 @@ export function usePlaylist( }), ); const playlist = state.status === "loaded" ? state.data : null; - // The socket names playlists by id, while the URL may hold the slug. + if ( + (target.username !== username || target.slug !== slug) && + !isAt(playlist, username, slug) + ) { + setTarget({ username, slug }); + } + useCanonicalPath(playlist && playlistUrl(playlist)); + // The socket and the API's writes name playlists by id. const id = playlist?.id; const url = `${API_URL}/api/playlists/${id}`; @@ -198,13 +216,10 @@ export function usePlaylist( }; }, []); + // A new slug, or owner's name, reaches the URL through useCanonicalPath. const applyDetails = useCallback((updated: Playlist) => { - const slug = latestRef.current.playlist?.slug; setData((p) => withDetails(p, updated)); - if (updated.slug && updated.slug !== slug) { - navigate(playlistUrl(updated), { replace: true }); - } - }, [setData, navigate]); + }, [setData]); useEffect(() => { const ev = lastPlaylistEvent; diff --git a/src/hooks/playlist/usePlaylistMemberships.ts b/src/hooks/playlist/usePlaylistMemberships.ts index 5135b95e..35a25b57 100644 --- a/src/hooks/playlist/usePlaylistMemberships.ts +++ b/src/hooks/playlist/usePlaylistMemberships.ts @@ -35,7 +35,7 @@ export interface UsePlaylistMembershipsResult { /** * Returns the user's playlists with whether each holds `dumpId`, from - * `GET /api/playlists/by-dump/:dumpId/memberships`. A null `dumpId`, as before + * `GET /api/me/playlists?contains=:dumpId`. A null `dumpId`, as before * `DumpCreateModal` has posted its dump, loads nothing. */ export function usePlaylistMemberships( @@ -45,7 +45,7 @@ export function usePlaylistMemberships( const { state, setData } = useFetchState( dumpId ?? undefined, (signal) => - authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`, { + authFetch(`${API_URL}/api/me/playlists?contains=${dumpId}`, { signal, }) .then(expectOk[]>) diff --git a/src/hooks/profile/useProfile.ts b/src/hooks/profile/useProfile.ts index fafede14..1f738df5 100644 --- a/src/hooks/profile/useProfile.ts +++ b/src/hooks/profile/useProfile.ts @@ -1,237 +1,60 @@ /** - * Everything a profile page shows comes through this hook. It loads the user - * with the first page of their dumps, upvotes and playlists, and keeps them - * live. + * The user a profile page is about comes through this hook, which loads them + * by the name in the URL and keeps them live. Each tab of the profile loads + * its own lists, by the user's id. * * @module */ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; -import { API_URL } from "../../config/api.ts"; -import type { Dump } from "../../../shared/dump.ts"; -import type { Playlist } from "../../../shared/playlist.ts"; import type { PublicUser } from "../../../shared/user.ts"; -import type { Wire } from "../../../shared/api.ts"; -import { friendlyFetchError } from "../../utils/shared/api.ts"; -import { - deserializeDump, - deserializePlaylist, - hydrateDump, - hydratePlaylist, -} from "../../utils/shared/deserialize.ts"; import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; -import type { LoadState } from "../../utils/shared/loadState.ts"; -import { - type PagedList, - pageUrl, - readFirstPage, - restoredList, -} from "../../utils/feed/pagedList.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useWS } from "../ws/useWS.ts"; -import { useFeedCache } from "../feed/useFeedCache.ts"; -import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; -import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts"; -import { useDumpListSync } from "../dump/useDumpListSync.ts"; -import { useUpvotedList } from "../dump/useUpvotedList.ts"; -import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts"; - -/** - * Loaded by {@link useProfile} for `UserPublicProfile`. Each list holds its - * first page only, and has its own page for the rest. - */ -export interface ProfileData { - user: PublicUser; - dumps: PagedList; - votes: PagedList; - playlists: PagedList; -} +import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; /** Returned by {@link useProfile} to `UserPublicProfile`. */ export interface UseProfileResult { - state: LoadState; + state: FetchState; /** It is false while the profile loads. */ isOwnProfile: boolean; /** It shows the header's edits, which the server echoes to other browsers. */ setUser: (fn: (user: PublicUser) => PublicUser) => void; - /** - * It shows the playlist `NewPlaylistButton` made, which the socket echoes - * later. - */ - setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; - /** - * It comes from `useUpvotedList`, and goes to the upvoted list's - * `DumpCardList`. - */ - upvotedCardClassName: (dump: Dump) => string | undefined; -} - -type ListKey = "dumps" | "votes" | "playlists"; - -/** Returns `s` with `fn` applied to the items of its list `key`. */ -function updateList( - s: LoadState, - key: K, - fn: (items: ProfileData[K]["items"]) => ProfileData[K]["items"], -): LoadState { - if (s.status !== "loaded") return s; - return { ...s, [key]: { ...s[key], items: fn(s[key].items) } }; } /** - * Returns the profile of `username`, from `GET /api/users/:username`, with the - * first pages of `…/dumps`, `…/votes` and `…/playlists`, fetched signed in so - * that users see their own private items. A list the server refuses shows - * empty; the page fails only with the user. The socket's events keep the lists - * live, and `useUpvotedList` the upvoted one. The lists and the scroll - * position are saved as the page is left, and going back restores them, - * refetching only the user. + * Returns the user named `username`, from `GET /api/users/@:username`, which + * answers a former name or another case too. The profile then puts the + * current name in its URL, and that path, naming the user on screen, fetches + * nothing; another user's does. The socket's `user_updated` keeps them live. */ export function useProfile(username: string | undefined): UseProfileResult { - const { user: me, authFetch } = useAuth(); - const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS(); + const { user: me } = useAuth(); + const { lastUserEvent } = useWS(); - const dumpsCache = useFeedCache( - `feed:profile-dumps:${username ?? ""}`, - hydrateDump, + // The name last fetched, which the URL may leave for the current one. + const [target, setTarget] = useState(username); + const { state, setData } = useFetchState( + target, + (signal) => fetchPublicUser(target!, signal), ); - const votesCache = useFeedCache( - `feed:profile-votes:${username ?? ""}`, - hydrateDump, - ); - const playlistsCache = useFeedCache( - `feed:profile-playlists:${username ?? ""}`, - hydratePlaylist, - ); - - const [state, setState] = useState>({ - status: "loading", - }); - const [prevUsername, setPrevUsername] = useState(username); - if (prevUsername !== username) { - setPrevUsername(username); - setState({ status: "loading" }); + const user = state.status === "loaded" ? state.data : null; + if ( + target !== username && + user?.username.toLowerCase() !== username?.toLowerCase() + ) { + setTarget(username); } - const profileUserId = state.status === "loaded" ? state.user.id : null; - const isOwnProfile = !!me && me.id === profileUserId; - - const setUser = useCallback((fn: (user: PublicUser) => PublicUser) => { - setState((s) => s.status === "loaded" ? { ...s, user: fn(s.user) } : s); - }, []); - const setDumps = useCallback((fn: (items: Dump[]) => Dump[]) => { - setState((s) => updateList(s, "dumps", fn)); - }, []); - const setVotes = useCallback((fn: (items: Dump[]) => Dump[]) => { - setState((s) => updateList(s, "votes", fn)); - }, []); - const setPlaylists = useCallback((fn: (items: Playlist[]) => Playlist[]) => { - setState((s) => updateList(s, "playlists", fn)); - }, []); - - const dumpItems = state.status === "loaded" ? state.dumps.items : []; - usePositionAwareSync( - dumpItems, - setDumps, - lastDumpEvent, - (d) => !d.isPrivate && d.userId === profileUserId, - ); - useDumpListSync(setDumps, { isOwner: isOwnProfile }); - - const upvoted = useUpvotedList({ - profileUserId, - setItems: setVotes, - source: dumpItems, - }); - const voteItems = state.status === "loaded" ? state.votes.items : []; - usePositionAwareSync( - voteItems, - setVotes, - lastDumpEvent, - (d) => !d.isPrivate && !upvoted.isWithdrawn(d.id), - ); - useDumpListSync(setVotes); - - const playlistItems = state.status === "loaded" ? state.playlists.items : []; - const lastPlaylistItem = lastPlaylistEvent?.type === "updated" - ? (lastPlaylistEvent.playlist ?? null) - : null; - usePositionAwareSync( - playlistItems, - setPlaylists, - lastPlaylistItem, - (p) => p.isPublic && p.userId === profileUserId, - ); - usePlaylistListSync(setPlaylists, { - isOwner: isOwnProfile, - ownerId: profileUserId ?? undefined, - }); - useEffect(() => { if (!lastUserEvent) return; - // Mirrors a socket event into state, which can only happen in an effect. - // eslint-disable-next-line react-hooks/set-state-in-effect - setUser((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u); - }, [lastUserEvent, setUser]); - - const cachedDumps = dumpsCache.cached; - const cachedVotes = votesCache.cached; - const cachedPlaylists = playlistsCache.cached; - useEffect(() => { - if (!username) return; - const controller = new AbortController(); - const { signal } = controller; - const firstPageOf = (list: string, deserialize: (w: Wire) => T) => - authFetch(pageUrl(`${API_URL}/api/users/${username}/${list}`, 1), { - signal, - }).then((res) => readFirstPage(res, deserialize)); - - // Back on the page, the lists come from the cache and only the user is - // fetched again. - const cached = cachedDumps && cachedVotes && cachedPlaylists; - Promise.all([ - fetchPublicUser(username, signal), - cached - ? restoredList(cachedDumps) - : firstPageOf("dumps", deserializeDump), - cached - ? restoredList(cachedVotes) - : firstPageOf("votes", deserializeDump), - cached - ? restoredList(cachedPlaylists) - : firstPageOf("playlists", deserializePlaylist), - ]) - .then(([user, dumps, votes, playlists]) => - setState({ status: "loaded", user, dumps, votes, playlists }) - ) - .catch((err) => { - if (signal.aborted) return; - setState({ status: "error", error: friendlyFetchError(err) }); - }); - return () => controller.abort(); - }, [username, cachedDumps, cachedVotes, cachedPlaylists, authFetch]); - - const saveDumps = dumpsCache.saveState; - const saveVotes = votesCache.saveState; - const savePlaylists = playlistsCache.saveState; - useScrollSave( - state.status === "loaded", - useCallback((y) => { - if (state.status !== "loaded") return; - const { dumps, votes, playlists } = state; - saveDumps(dumps, y); - saveVotes(votes, y); - savePlaylists(playlists, y); - }, [state, saveDumps, saveVotes, savePlaylists]), - ); - useScrollRestore(cachedDumps?.scrollY, state.status === "loaded"); + setData((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u); + }, [lastUserEvent, setData]); return { state, - isOwnProfile, - setUser, - setPlaylists, - upvotedCardClassName: upvoted.cardClassName, + isOwnProfile: !!me && me.id === user?.id, + setUser: setData, }; } diff --git a/src/hooks/shared/useCanonicalPath.ts b/src/hooks/shared/useCanonicalPath.ts new file mode 100644 index 00000000..2c2edf41 --- /dev/null +++ b/src/hooks/shared/useCanonicalPath.ts @@ -0,0 +1,27 @@ +/** + * Puts a page's current path in the URL once the page knows it. A user, dump + * or playlist reached by a former name or slug, or by a name in another case, + * comes back from the API under its current one, from which the page builds + * its path; the server's OG middleware answers the same with a 301. + * + * @module + */ + +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router"; + +/** + * Replaces the current path with `canonical` when they differ, keeping the + * query string, the hash and the history entry's state. It replaces the + * entry, so going back skips the old path. Nothing happens while `canonical` + * is null, as while the page loads. The profile, dump, dump editor and + * playlist pages call it. + */ +export function useCanonicalPath(canonical: string | null | undefined): void { + const { pathname, search, hash, state } = useLocation(); + const navigate = useNavigate(); + useEffect(() => { + if (!canonical || canonical === pathname) return; + navigate(`${canonical}${search}${hash}`, { replace: true, state }); + }, [canonical, pathname, search, hash, state, navigate]); +} diff --git a/src/hooks/shared/useTabParam.ts b/src/hooks/shared/useTabParam.ts index f0e189ab..f8a298d1 100644 --- a/src/hooks/shared/useTabParam.ts +++ b/src/hooks/shared/useTabParam.ts @@ -1,5 +1,6 @@ /** - * Keeps a page's active tab in its URL, for the profile and search pages. + * Keeps a page's active tab in its path, as one more segment, for the profile + * and search pages. * * @module */ @@ -7,44 +8,36 @@ import { useCallback } from "react"; import { useLocation, useNavigate } from "react-router"; -/** Separates the page's path from the tab, as App.tsx's routes expect. */ -const SEP = "/~/"; - /** - * Returns the tab named after `/~/` in the current path, or `defaultTab` when - * there is none or it isn't in `validTabs`, and a setter that navigates to a - * tab in place of the current history entry. The page's route must take both - * `` and `/~/:tab`, as `/users/:username/~/:profileTab` does. The - * default tab's URL is the bare path, except at the root, where it stays - * explicit. + * Returns the tab the path segment after `base` names, `defaultTab` on the + * bare `base`, or null when the segment names none of `validTabs`, for the + * page to answer as not found; and a setter that navigates to a tab in place + * of the current history entry, the default tab to the bare `base`. A segment + * after the tab's, as the profile's `/follow/playlists`, is the tab's own + * business. */ export function useTabParam( + base: string, validTabs: Iterable, defaultTab: T, -): [T, (tab: T) => void] { +): [T | null, (tab: T) => void] { const location = useLocation(); const navigate = useNavigate(); const valid = new Set(validTabs as Iterable); - const path = location.pathname; - const sepAt = path.indexOf(SEP); - - let base = sepAt === -1 ? path : path.slice(0, sepAt); - // The root "/" becomes "". - if (base.endsWith("/")) base = base.slice(0, -1); - - const raw = sepAt === -1 - ? null - : path.slice(sepAt + SEP.length).replace(/\/$/, ""); - const tab = (raw && valid.has(raw) ? raw : defaultTab) as T; + const segment = location.pathname.slice(base.length).split("/")[1] ?? ""; + const tab = segment === "" + ? defaultTab + : valid.has(segment) && segment !== defaultTab + ? segment as T + : null; const setTab = useCallback( (t: T) => { - const target = t === defaultTab && base ? base : `${base}${SEP}${t}`; // Keeps the query string (the search page's `?q=`) and the entry's // state, since this is the same entry and `AppHeader`'s search reads // where to go back to from it. - navigate(`${target}${location.search}`, { + navigate(`${base}${t === defaultTab ? "" : `/${t}`}${location.search}`, { replace: true, state: location.state, }); diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index e87f8292..a8f03e28 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -5,11 +5,11 @@ * @module */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { dumpThumbnailUrl, dumpUrl } from "../utils/shared/urls.ts"; +import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts"; import { can } from "../utils/auth/permissions.ts"; import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx"; @@ -46,6 +46,8 @@ import { PageError } from "../components/layout/PageError.tsx"; import { Markdown } from "../components/editor/Markdown.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; +import { userPath } from "../../shared/paths.ts"; +import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; type DumpState = | { status: "loading" } @@ -53,16 +55,18 @@ type DumpState = | { status: "loaded"; dump: Dump }; /** - * Renders `/dumps/:selectedDump`, for anyone who may see the dump: a private - * dump answers 404 to anyone but its poster. The dump comes from - * `GET /api/dumps/:dumpId`, or from the navigation's state when `DumpEdit` or - * a title edit here just saved it. Its comments, related dumps and playlists - * come from their own endpoints, and `WSContext`'s events keep them current. - * The poster and moderators can edit the title in place, and get a link to - * `DumpEdit`. + * Renders `/@:username/dumps/:slug`, for anyone who may see the dump: a + * private dump answers 404 to anyone but its poster. The dump comes from + * `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when + * `DumpEdit` or a title edit here just saved it. Reached by a former name or + * slug, the URL takes the current ones. Its comments, related dumps and + * playlists come from their own endpoints once it is loaded, and + * `WSContext`'s events keep them current. The poster and moderators can edit + * the title in place, and get a link to `DumpEdit`. */ export function Dump(): React.JSX.Element { - const { selectedDump } = useParams(); + const { handle, slug } = useParams(); + const username = handle?.slice(1); const location = useLocation(); const navigate = useNavigate(); // Taken once, since it stays in the history entry, where a reload or Back @@ -70,10 +74,7 @@ export function Dump(): React.JSX.Element { const [handedOver] = useState( () => (location.state as { dump?: Dump } | null)?.dump ?? null, ); - const preloaded = handedOver && - (handedOver.id === selectedDump || handedOver.slug === selectedDump) - ? handedOver - : null; + const preloaded = isAt(handedOver, username, slug) ? handedOver : null; useEffect(() => { if (!(location.state as { dump?: Dump } | null)?.dump) return; navigate(`${location.pathname}${location.search}${location.hash}`, { @@ -85,6 +86,14 @@ export function Dump(): React.JSX.Element { const [dumpState, setDumpState] = useState( preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" }, ); + const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null; + useCanonicalPath(loadedDump && dumpUrl(loadedDump)); + // The dump on screen, which a path it was just given, by useCanonicalPath or + // a slug edited elsewhere, needn't fetch again. + const shownRef = useRef(loadedDump); + useEffect(() => { + shownRef.current = loadedDump; + }, [loadedDump]); const [op, setOp] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); @@ -120,11 +129,11 @@ export function Dump(): React.JSX.Element { } = useWS(); useEffect(() => { - if (!selectedDump) return; + if (!username || !slug) return; const controller = new AbortController(); if (preloaded) { - fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`, { + fetch(`${API_URL}/api/users/${preloaded.userId}`, { signal: controller.signal, }) .then((r) => r.json()) @@ -133,14 +142,16 @@ export function Dump(): React.JSX.Element { return () => controller.abort(); } + if (isAt(shownRef.current, username, slug)) return; + (async () => { setDumpState({ status: "loading" }); setOp(null); try { - const res = await authFetch(`${API_URL}/api/dumps/${selectedDump}`, { - cache: "no-store", - signal: controller.signal, - }); + const res = await authFetch( + `${API_URL}/api/users/@${username}/dumps/${slug}`, + { cache: "no-store", signal: controller.signal }, + ); const apiResponse = parseAPIResponse>(await res.json()); if (!apiResponse.success) { throw new Error(apiResponse.error.message); @@ -148,7 +159,7 @@ export function Dump(): React.JSX.Element { const dump: Dump = deserializeDump(apiResponse.data); setDumpState({ status: "loaded", dump }); - fetch(`${API_URL}/api/users/by-id/${dump.userId}`, { + fetch(`${API_URL}/api/users/${dump.userId}`, { signal: controller.signal, }) .then((r) => r.json()) @@ -160,7 +171,7 @@ export function Dump(): React.JSX.Element { } })(); return () => controller.abort(); - }, [selectedDump, preloaded, authFetch]); + }, [username, slug, preloaded, authFetch]); useEffect(() => { if (!lastDumpEvent) return; @@ -174,10 +185,12 @@ export function Dump(): React.JSX.Element { }); }, [lastDumpEvent]); + const loadedDumpId = loadedDump?.id ?? null; + useEffect(() => { - if (!selectedDump) return; + if (!loadedDumpId) return; const controller = new AbortController(); - authFetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { + authFetch(`${API_URL}/api/dumps/${loadedDumpId}/comments`, { signal: controller.signal, }) .then((r) => r.json()) @@ -190,13 +203,13 @@ export function Dump(): React.JSX.Element { return () => controller.abort(); // connectionEpoch refetches on reconnect, for the comments missed while the // socket was down. - }, [selectedDump, authFetch, connectionEpoch]); + }, [loadedDumpId, authFetch, connectionEpoch]); // The dumps that link to this one, or that it links to. useEffect(() => { - if (!selectedDump) return; + if (!loadedDumpId) return; const controller = new AbortController(); - authFetch(`${API_URL}/api/dumps/${selectedDump}/related`, { + authFetch(`${API_URL}/api/dumps/${loadedDumpId}/related`, { signal: controller.signal, }) .then((r) => r.json()) @@ -207,13 +220,13 @@ export function Dump(): React.JSX.Element { }) .catch(() => {}); return () => controller.abort(); - }, [selectedDump, authFetch]); + }, [loadedDumpId, authFetch]); // The server leaves out the private playlists of others. useEffect(() => { - if (!selectedDump) return; + if (!loadedDumpId) return; const controller = new AbortController(); - authFetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, { + authFetch(`${API_URL}/api/dumps/${loadedDumpId}/playlists`, { signal: controller.signal, }) .then((r) => r.json()) @@ -228,7 +241,7 @@ export function Dump(): React.JSX.Element { return () => controller.abort(); // Any playlist event may change the list, and they are rare enough to // refetch on each. - }, [selectedDump, authFetch, lastPlaylistEvent]); + }, [loadedDumpId, authFetch, lastPlaylistEvent]); // A notification links to `#comment-`. Rerun when the comments arrive, // since the element doesn't exist before. @@ -246,7 +259,6 @@ export function Dump(): React.JSX.Element { return () => clearTimeout(timer); }, [comments, location.hash]); - const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null; useEffect(() => { if ( !lastCommentEvent || !loadedDumpId || @@ -472,7 +484,7 @@ export function Dump(): React.JSX.Element { /> {op ? ( - + {op.username} ) diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 2d071ea1..7f3c33de 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -47,6 +47,9 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; import { VisibilityToggle } from "../components/form/SegmentedField.tsx"; +import { SlugField } from "../components/form/SlugField.tsx"; +import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; +import { userPath } from "../../shared/paths.ts"; type DumpEditState = | { status: "loading" } @@ -54,14 +57,18 @@ type DumpEditState = | { status: "loaded"; dump: Dump }; /** - * Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators; - * anyone else signed in gets an error rather than a form the API would refuse - * to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the - * preview refresh and the deletion apply at once, and the form's other fields - * on "Save", which goes back to the dump's page. A deletion goes to the index. + * Renders `/@:username/dumps/:slug/edit`, for the dump's poster and + * moderators; anyone else signed in gets an error rather than a form the API + * would refuse to save. The dump comes from + * `GET /api/users/@:username/dumps/:slug`, which answers a former slug too, + * and the URL then takes the current one. The thumbnail, the preview refresh + * and the deletion apply at once, and the form's other fields on "Save", + * which goes back to the dump's page, at its new path if the slug changed. A + * deletion goes to the index. */ export function DumpEdit(): React.JSX.Element { - const { selectedDump } = useParams(); + const { handle, slug } = useParams(); + const username = handle?.slice(1); const navigate = useNavigate(); const { authFetch, token, mediaToken, user } = useRequiredAuth(); @@ -82,16 +89,23 @@ export function DumpEdit(): React.JSX.Element { const [thumbError, setThumbError] = useState(null); const [deleteError, setDeleteError] = useState(null); + useCanonicalPath( + state.status === "loaded" ? `${dumpUrl(state.dump)}/edit` : null, + ); + useEffect(() => { - if (!selectedDump) return; + if (!username || !slug) return; (async () => { setState({ status: "loading" }); try { - const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { - cache: "no-store", - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const res = await fetch( + `${API_URL}/api/users/@${username}/dumps/${slug}`, + { + cache: "no-store", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ); const apiResponse = parseAPIResponse>(await res.json()); if (apiResponse.success) { @@ -112,7 +126,7 @@ export function DumpEdit(): React.JSX.Element { setState({ status: "error", error: friendlyFetchError(err) }); } })(); - }, [selectedDump, token, user]); + }, [username, slug, token, user]); const handleRefreshMetadata = async () => { if (state.status !== "loaded" || state.dump.kind !== "url") return; @@ -121,7 +135,7 @@ export function DumpEdit(): React.JSX.Element { setRefreshError(null); try { const res = await authFetch( - `${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`, + `${API_URL}/api/dumps/${state.dump.id}/metadata`, { method: "POST" }, ); const apiResponse = parseAPIResponse>(await res.json()); @@ -322,6 +336,7 @@ interface DumpEditFormProps { interface EditValues { title: string; + slug: string; url: string; comment: string; isPublic: boolean; @@ -359,6 +374,7 @@ function DumpEditForm({ const form = useApiForm({ defaultValues: { title: dump.title, + slug: dump.slug, url: dump.url ?? "", comment: dump.comment ?? "", isPublic: !dump.isPrivate, @@ -371,9 +387,11 @@ function DumpEditForm({ const currentTitle = form.watch("title"); const onSubmit = form.submit( - async ({ title, url, comment, isPublic, newFile }) => { + async ({ title, slug, url, comment, isPublic, newFile }) => { const trimmedTitle = title.trim(); const isPrivate = !isPublic; + // Sent only when changed, since each change counts against its limit. + const newSlug = slug !== dump.slug ? slug : undefined; let res: Response; const categoryIds = [...selectedCategoryIds]; @@ -382,6 +400,7 @@ function DumpEditForm({ const formData = new FormData(); formData.append("file", newFile); formData.append("title", trimmedTitle); + if (newSlug) formData.append("slug", newSlug); if (comment.trim()) formData.append("comment", comment.trim()); formData.append("categoryIds", JSON.stringify(categoryIds)); // In the same request, so the new file never goes out under the old @@ -398,12 +417,14 @@ function DumpEditForm({ ? { url: url.trim() || undefined, title: trimmedTitle, + slug: newSlug, comment: comment.trim() || null, isPrivate, categoryIds, } : { title: trimmedTitle, + slug: newSlug, comment: comment.trim() || null, isPrivate, categoryIds, @@ -509,6 +530,13 @@ function DumpEditForm({ rules={{ required: true }} /> + + name="slug" + titleName="title" + prefix={`${userPath(dump.ownerUsername, "dumps")}/`} + saved={dump} + /> + name="comment" label={t`Why?`} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index d01fad53..7a96e274 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,22 +1,18 @@ /** * Renders the home page, which shows every dump, or one category's, in the - * "hot", "new", "journal" and "followed" tabs. + * "hot", "new", "journal" and "follow" tabs. * * @module */ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { useLocation, useParams } from "react-router"; +import { useLocation } from "react-router"; import { AppHeader } from "../components/layout/AppHeader.tsx"; import { PresenceRow } from "../components/feed/PresenceRow.tsx"; import { FeedTabBar } from "../components/feed/FeedTabBar.tsx"; -import { - DEFAULT_FEED_TAB, - FEED_TABS, - type FeedTab, -} from "../config/feedTabs.ts"; -import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; +import { type FeedTab } from "../../shared/paths.ts"; +import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; import { API_URL } from "../config/api.ts"; @@ -41,18 +37,18 @@ import { JournalFeed } from "../components/feed/JournalFeed.tsx"; import { FollowedFeed } from "../components/feed/FollowedFeed.tsx"; import { NotFound } from "./NotFound.tsx"; -/** - * Passed to {@link Index} by the router, from the `:categorySlug` of the URL. - */ +/** Passed to {@link Index} by the router, from the path. */ export interface IndexProps { /** Without it, the feed holds every dump. An unknown slug renders 404. */ categorySlug?: string; + /** The tab the path names, or none for the viewer's default one. */ + feedTab?: FeedTab; } /** - * Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the - * "followed" tab for a signed-in user only. The feed comes from - * `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time, + * Renders `/`, `/:feedTab`, `/:categorySlug` and `/:categorySlug/:feedTab`, + * for anyone, the "follow" tab for a signed-in user only. The feed comes from + * `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time, * and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps * posted since, the live vote counts, and who's online. The router keys the * page by category, so switching tabs keeps the feed and switching categories @@ -60,7 +56,7 @@ export interface IndexProps { * Android (`?share_url=`). */ export function Index( - { categorySlug }: IndexProps, + { categorySlug, feedTab }: IndexProps, ): React.JSX.Element { const location = useLocation(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) @@ -79,22 +75,16 @@ export function Index( categorySlug ? (category ? category.name : undefined) : null, ); - const { feedTab: rawTab } = useParams(); - const [preferredTab] = useDefaultFeedTab(); - const defaultTab: FeedTab = preferredTab === "followed" && !user - ? DEFAULT_FEED_TAB - : preferredTab; - // A guest on "followed", from a shared link or after signing out, gets the + const defaultTab = useViewerDefaultFeedTab(); + // A guest on "follow", from a shared link or after signing out, gets the // default tab rather than an empty page. - const tab: FeedTab = - (FEED_TABS as readonly string[]).includes(rawTab ?? "") && - (rawTab !== "followed" || !!user) - ? (rawTab as FeedTab) - : defaultTab; + const tab: FeedTab = feedTab && (feedTab !== "follow" || !!user) + ? feedTab + : defaultTab; const feedUrl = categorySlug - ? `${API_URL}/api/categories/${encodeURIComponent(categorySlug)}/dumps` - : `${API_URL}/api/dumps/`; + ? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}` + : `${API_URL}/api/dumps`; const fetchPage = useCallback( (page: number, signal?: AbortSignal) => authFetch(pageUrl(feedUrl, page), { signal }) @@ -113,9 +103,9 @@ export function Index( ); useDumpListSync(setItems); - // The Web Share Target: Android's share sheet opens `/?share_url=…`, which - // the redirect from `/` keeps. The query is dropped from the URL at once, so - // a reload doesn't share the link a second time. + // The Web Share Target: Android's share sheet opens `/?share_url=…`. The + // query is dropped from the URL at once, so a reload doesn't share the link + // a second time. const searchParams = new URLSearchParams(location.search); const shareUrl = searchParams.get("share_url") ?? searchParams.get("share_text") ?? ""; @@ -201,7 +191,7 @@ export function Index( fitWithin=".app-header-center" fitBeside=".feed-sort" /> - +
  • } disableNew={dumpsState.status === "error"} @@ -209,7 +199,7 @@ export function Index( />
    - +
    @@ -217,9 +207,7 @@ export function Index( )} {tab === "journal" && } - {tab === "followed" && user && ( - - )} + {tab === "follow" && user && }
    ); } diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx new file mode 100644 index 00000000..06bcb542 --- /dev/null +++ b/src/pages/Manage.tsx @@ -0,0 +1,38 @@ +/** + * Renders the site's tools, `/manage`, for those who hold `category:manage`. + * Another user's role and name are managed on their profile's "manage" tab. + * + * @module + */ + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { can } from "../utils/auth/permissions.ts"; +import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { ProfileSection } from "../components/profile/ProfileSection.tsx"; +import { CategoryManager } from "../components/category/CategoryManager.tsx"; +import { NotFound } from "./NotFound.tsx"; + +/** + * Renders the category manager, signed in only; without `category:manage`, + * which the API checks again, the page isn't found. + */ +export function Manage(): React.JSX.Element { + useDocumentTitle(t`Manage`); + const { user } = useRequiredAuth(); + if (!can(user, "category:manage")) return ; + return ( + +
    +

    + Manage +

    +
    + Categories}> + + +
    + ); +} diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 59c12310..2f9730f9 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,6 +1,6 @@ /** * Renders the notifications page, `/notifications`, for a signed-in user. It - * reads them from `GET /api/notifications` and adds the ones `WSContext` + * reads them from `GET /api/me/notifications` and adds the ones `WSContext` * receives while it is open. * * @module @@ -22,11 +22,13 @@ import { useWS } from "../hooks/ws/useWS.ts"; import type { CommentLikedData, DumpCommentedData, + DumpRef, DumpUpvotedData, Notification, NotificationData, PlaylistDumpAddedData, PlaylistFollowedData, + PlaylistRef, UserDumpPostedData, UserFollowedData, UserMentionedData, @@ -35,6 +37,7 @@ import { deserializeNotification } from "../utils/shared/deserialize.ts"; import { pageUrl, readPage } from "../utils/feed/pagedList.ts"; import { usePagedList } from "../hooks/feed/usePagedList.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; +import { dumpPath, playlistPath, userPath } from "../../shared/paths.ts"; type NotifIconKind = | "upvote" @@ -103,40 +106,63 @@ function NotifIcon({ type }: { type: Notification["type"] }) { } /** - * Returns where notification `n` leads. A notification about a comment links - * to its `#comment-` anchor on the dump's page. + * Returns the path of a notification's dump, from the ref the server joined, + * with `hash` after it; null once the dump is deleted. */ -function notificationLink(n: Notification): string { +function dumpLink(ref: DumpRef, hash = ""): string | null { + return ref.dumpSlug && ref.dumpOwnerUsername + ? `${ + dumpPath({ ownerUsername: ref.dumpOwnerUsername, slug: ref.dumpSlug }) + }${hash}` + : null; +} + +/** Returns the path of a notification's playlist, as {@link dumpLink} does. */ +function playlistLink(ref: PlaylistRef): string | null { + return ref.playlistSlug && ref.playlistOwnerUsername + ? playlistPath({ + ownerUsername: ref.playlistOwnerUsername, + slug: ref.playlistSlug, + }) + : null; +} + +/** + * Returns where notification `n` leads, or null when what it is about is + * gone. A notification about a comment links to its `#comment-` anchor on + * the dump's page. + */ +function notificationLink(n: Notification): string | null { const data = n.data as NotificationData; switch (n.type) { case "user_followed": - return `/users/${(data as UserFollowedData).followerUsername}`; + return userPath((data as UserFollowedData).followerUsername); case "playlist_followed": - return `/playlists/${(data as PlaylistFollowedData).playlistId}`; + return playlistLink(data as PlaylistFollowedData); case "user_dump_posted": - return `/dumps/${(data as UserDumpPostedData).dumpId}`; case "playlist_dump_added": - return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`; case "dump_upvoted": - return `/dumps/${(data as DumpUpvotedData).dumpId}`; + return dumpLink(data as DumpRef); case "dump_commented": - return `/dumps/${(data as DumpCommentedData).dumpId}#comment-${ - (data as DumpCommentedData).commentId - }`; + return dumpLink( + data as DumpCommentedData, + `#comment-${(data as DumpCommentedData).commentId}`, + ); case "comment_liked": - return `/dumps/${(data as CommentLikedData).dumpId}#comment-${ - (data as CommentLikedData).commentId - }`; + return dumpLink( + data as CommentLikedData, + `#comment-${(data as CommentLikedData).commentId}`, + ); case "user_mentioned": { const d = data as UserMentionedData; if (d.contextType === "comment") { - return `/dumps/${d.dumpId}#comment-${d.contextId}`; + return dumpLink(d, `#comment-${d.contextId}`); } - if (d.contextType === "dump") return `/dumps/${d.contextId}`; + if (d.contextType === "dump") return dumpLink(d); // A query string alone keeps this page, and `ChatProvider` opens the // chat over it. if (d.contextType === "chat") return `?chat=open`; - return `/playlists/${d.contextId}`; + return playlistLink(d); } } } @@ -286,7 +312,11 @@ export function Notifications(): React.JSX.Element { user?.id, (page, signal) => authFetch( - pageUrl(`${API_URL}/api/notifications`, page, NOTIFICATIONS_PAGE_SIZE), + pageUrl( + `${API_URL}/api/me/notifications`, + page, + NOTIFICATIONS_PAGE_SIZE, + ), { signal }, ).then((res) => readPage(res, deserializeNotification)), ); @@ -316,7 +346,7 @@ export function Notifications(): React.JSX.Element { useEffect(() => { if (!loaded || markedReadRef.current === lastNotification) return; markedReadRef.current = lastNotification; - authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" }) + authFetch(`${API_URL}/api/me/notifications/read`, { method: "POST" }) .then((res) => { if (res.ok) clearUnreadNotifications(); }) @@ -375,17 +405,10 @@ export function Notifications(): React.JSX.Element { : t`Earlier`}
      - {items.map((n) => ( -
    • - + {items.map((n) => { + const link = notificationLink(n); + const inner = ( + <>
      @@ -403,9 +426,25 @@ export function Notifications(): React.JSX.Element { {!n.read && (
    • - ))} + + ); + return ( +
    • + {link + ? ( + + {inner} + + ) + :
      {inner}
      } +
    • + ); + })}
    ))} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index cd9926e5..33c08c66 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -23,14 +23,14 @@ import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx"; import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx"; /** - * Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see - * the playlist: a private one only to its owner. `usePlaylist` loads it and - * keeps it live. Its owner, or a user with `playlist:moderate`, can edit or + * Renders `/@:username/playlists/:slug`, for anyone who may see the playlist: + * a private one only to its owner. `usePlaylist` loads it, by a former name or + * slug too, and keeps it live. Its owner, or a user with `playlist:moderate`, can edit or * delete it, drag its dumps into a new order, and remove one, with a short * window to undo. */ export function PlaylistDetail(): React.JSX.Element { - const { playlistId } = useParams<{ playlistId: string }>(); + const { handle, slug } = useParams(); const navigate = useNavigate(); const { user } = useAuth(); const { @@ -43,7 +43,7 @@ export function PlaylistDetail(): React.JSX.Element { move, saveOrder, deletePlaylist, - } = usePlaylist(playlistId); + } = usePlaylist(handle?.slice(1), slug); const [editOpen, setEditOpen] = useState(false); const headerRef = useHeightTransition(editOpen); diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index d9993432..d3ac74d7 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -44,11 +44,14 @@ export function ResetPassword(): React.JSX.Element { const onSubmit = form.submit(async ({ newPassword }) => { await expectOk( - await fetch(`${API_URL}/api/users/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, newPassword }), - }), + await fetch( + `${API_URL}/api/password-resets/${encodeURIComponent(token)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword }), + }, + ), ); setDone(true); }); diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 1ee6684b..29d67875 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -39,9 +39,13 @@ import { } from "../utils/feed/pagedList.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useLoadMore } from "../hooks/feed/usePagedList.ts"; - -const SEARCH_TABS = ["dumps", "users", "playlists"] as const; -type Tab = (typeof SEARCH_TABS)[number]; +import { + SEARCH_TABS, + searchPath, + type SearchTab as Tab, + userPath, +} from "../../shared/paths.ts"; +import { NotFound } from "./NotFound.tsx"; /** * Held by `Search` for a query, from the first page of `SearchResults`. The @@ -56,7 +60,7 @@ interface Results { } /** - * Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come + * 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. @@ -65,7 +69,7 @@ export function Search(): React.JSX.Element { useDocumentTitle(t`Search`); const [searchParams] = useSearchParams(); const q = searchParams.get("q") ?? ""; - const [tab, setTab] = useTabParam(SEARCH_TABS, "dumps"); + const [tab, setTab] = useTabParam(searchPath(), SEARCH_TABS, "dumps"); const { user, authFetch } = useAuth(); const search = useCallback(async (page: number, signal?: AbortSignal) => { @@ -120,6 +124,8 @@ export function Search(): React.JSX.Element { return count !== null ? t`${label} (${count})` : label; } + if (!tab) return ; + return (
    @@ -183,7 +189,7 @@ export function Search(): React.JSX.Element { {results.users.map((u) => (
  • @{u.username} diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/pages/Settings.tsx similarity index 59% rename from src/components/profile/ProfileSettingsTab.tsx rename to src/pages/Settings.tsx index 712a2b0b..460ebb90 100644 --- a/src/components/profile/ProfileSettingsTab.tsx +++ b/src/pages/Settings.tsx @@ -1,27 +1,41 @@ /** - * Renders the "settings" tab of the signed-in user's own profile, where they - * change their password and this browser's preferences. + * Renders the signed-in user's settings page, `/settings`, where they change + * their name, their password, their account's settings and this browser's. * * @module */ import { useState } from "react"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; // A label shared with the emails is named by its id, its words being in // locales/ only; the macro's Trans takes the English in place. import { Trans as Message } from "@lingui/react"; -import type { ColorScheme, StyleName } from "../../../shared/theme.ts"; -import type { FeedTab } from "../../config/feedTabs.ts"; -import { useTheme } from "../../hooks/theme/useTheme.ts"; -import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; -import { useSoundsEnabled } from "../../hooks/shared/useSoundsEnabled.ts"; -import { useSettings } from "../../hooks/auth/useSettings.ts"; -import { EMAIL_ENABLED } from "../../config/instance.ts"; -import { previewChime } from "../../utils/shared/sound.ts"; -import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx"; -import { ChangePasswordModal } from "./ChangePasswordModal.tsx"; -import { ProfileSection } from "./ProfileSection.tsx"; -import { SettingRow } from "./SettingRow.tsx"; +import type { ColorScheme, StyleName } from "../../shared/theme.ts"; +import type { FeedTab } from "../../shared/paths.ts"; +import type { Wire } from "../../shared/api.ts"; +import type { SelfUser } from "../../shared/user.ts"; +import { API_URL } from "../config/api.ts"; +import { useTheme } from "../hooks/theme/useTheme.ts"; +import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; +import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts"; +import { useSettings } from "../hooks/auth/useSettings.ts"; +import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { EMAIL_ENABLED } from "../config/instance.ts"; +import { previewChime } from "../utils/shared/sound.ts"; +import { expectOk } from "../utils/shared/api.ts"; +import { deserializeSelfUser } from "../utils/shared/deserialize.ts"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { + Segmented, + type SegmentedOption, +} from "../components/shared/Segmented.tsx"; +import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx"; +import { ProfileSection } from "../components/profile/ProfileSection.tsx"; +import { SettingRow } from "../components/profile/SettingRow.tsx"; +import { UsernameField } from "../components/profile/UsernameField.tsx"; +import { RENAME_LIMITS } from "../../shared/validation.ts"; // The styles' names are their own, in every language. const STYLE_OPTIONS: SegmentedOption[] = [ @@ -43,7 +57,7 @@ const FEED_TAB_OPTIONS: SegmentedOption[] = [ { value: "hot", label: Hot }, { value: "new", label: New }, { value: "journal", label: Journal }, - { value: "followed", label: Followed }, + { value: "follow", label: Followed }, ]; const ON_OFF_OPTIONS: SegmentedOption[] = [ @@ -52,13 +66,16 @@ const ON_OFF_OPTIONS: SegmentedOption[] = [ ]; /** - * Renders the password change, which opens {@link ChangePasswordModal}, and - * the settings, each saved as soon as it is clicked. The style, color scheme - * and digests are saved to the account; the default feed tab and sounds, for - * now, to this browser. `UserPublicProfile` renders it on one's own profile - * only. + * Renders `/settings`, signed in only: the username, the password change, + * which opens {@link ChangePasswordModal}, and the settings, each saved as + * soon as it is clicked. A new name is saved to `PATCH /api/me`, at most once + * in 30 days but for those with `user:manage`, and the old one keeps leading + * to the user. The style, color scheme and digests are saved to the account; + * the default feed tab and sounds, for now, to this browser. */ -export function ProfileSettingsTab(): React.JSX.Element { +export function Settings(): React.JSX.Element { + useDocumentTitle(t`Settings`); + const { user, authFetch, updateSession } = useRequiredAuth(); const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); @@ -66,11 +83,44 @@ export function ProfileSettingsTab(): React.JSX.Element { // Until the session has the settings, a click would save over unknown ones. const digestsDisabled = !EMAIL_ENABLED || !settings; const [changePasswordOpen, setChangePasswordOpen] = useState(false); + const renameDays = RENAME_LIMITS.USERNAME.windowMs / 86_400_000; + + async function rename(username: string): Promise { + const self = deserializeSelfUser( + await expectOk>( + await authFetch(`${API_URL}/api/me`, { + method: "PATCH", + body: JSON.stringify({ username }), + }), + ), + ); + updateSession({ user: self }); + return self.username; + } return ( - <> + +
    +

    + Settings +

    +
    Account}>
    + Username}> + + You can change it once every {renameDays}{" "} + days. Links to your old name keep working, and nobody else can + take it. + + } + /> + Password}>