From 708d1130134f826ebdb0a92b199903555dde0e00 Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 9 Oct 2026 11:42:01 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20a=20/community=20page=20shows=20the=20sit?= =?UTF-8?q?e's=20week=20and=20month,=20from=20the=20same=20figures=20as=20?= =?UTF-8?q?the=20digests,=20and=20every=20recurring=20server=20task=20is?= =?UTF-8?q?=20a=20job=20of=20one=20scheduler=20=E2=80=94=20the=20digests'?= =?UTF-8?q?=20top=20dumps=20and=20community=20section=20(who=20joined,=20t?= =?UTF-8?q?he=20new=20public=20playlists,=20the=20chat=20and=20the=20total?= =?UTF-8?q?s)=20existed=20only=20in=20emails.=20community-service.ts=20now?= =?UTF-8?q?=20gathers=20them=20for=20a=20period=20from=20listTopDumps,=20l?= =?UTF-8?q?istNewUsers=20and=20listNewPublicPlaylists,=20each=20in=20its?= =?UTF-8?q?=20own=20service=20and=20returning=20the=20API's=20Dump,=20Publ?= =?UTF-8?q?icUser=20and=20Playlist;=20the=20digest=20builds=20its=20sectio?= =?UTF-8?q?ns=20from=20the=20same=20call,=20its=20email-only=20DumpRow=20a?= =?UTF-8?q?nd=20DUMP=5FCOLS=20replaced=20by=20one=20Dump=20mapper=20that?= =?UTF-8?q?=20networkOf=20uses=20too,=20and=20links=20its=20community=20se?= =?UTF-8?q?ction=20to=20the=20page.=20The=20page,=20/community=20for=20the?= =?UTF-8?q?=20last=20week=20and=20/community/monthly=20for=20the=20month,?= =?UTF-8?q?=20signed=20in=20only=20and=20linked=20from=20the=20avatar=20me?= =?UTF-8?q?nu,=20reads=20GET=20/api/community=3Fperiod=3D,=20which=20serve?= =?UTF-8?q?s=20figures=20kept=20in=20memory=20and=20recomputed=20every=20G?= =?UTF-8?q?ERBEUR=5FCOMMUNITY=5FREFRESH=5FMINUTES=20(15=20by=20default).?= =?UTF-8?q?=20It=20shows=20the=20four=20figures,=20the=20top=20dumps=20and?= =?UTF-8?q?=20the=20new=20playlists=20as=20live=20cards,=20and=20the=20new?= =?UTF-8?q?=20members=20as=20UserCards,=20the=20follow=20tab's=20user=20ro?= =?UTF-8?q?w=20made=20shared.=20Its=20words=20are=20the=20emails'=20own:?= =?UTF-8?q?=20the=20messages=20both=20use=20are=20renamed=20from=20email.d?= =?UTF-8?q?igest.*=20to=20digest.*,=20as=20digest.weekly.off=20was.=20api/?= =?UTF-8?q?lib/scheduler.ts's=20startJobs=20runs=20the=20digests,=20the=20?= =?UTF-8?q?community=20refresh,=20the=20WebSocket=20ping,=20the=20typing?= =?UTF-8?q?=20expiry=20and=20the=20rate-limit=20sweep,=20which=20main.ts?= =?UTF-8?q?=20starts,=20so=20none=20of=20them=20runs=20on=20import=20any?= =?UTF-8?q?=20more.=20PERIOD=5FMS=20moves=20to=20shared/settings.ts.=20com?= =?UTF-8?q?munity=20is=20reserved=20as=20a=20category=20slug,=20and=20migr?= =?UTF-8?q?ation=200022=20renames=20a=20category=20that=20had=20it,=20with?= =?UTF-8?q?=20renameReservedCategories=20moved=20out=20of=200021=20into=20?= =?UTF-8?q?reserved-slugs.ts.=20A=20group's=20dump=20or=20playlist=20cards?= =?UTF-8?q?=20now=20fill=20its=20column.=20262=20backend=20tests=20and=206?= =?UTF-8?q?78=20frontend=20tests=20pass,=20and=20deno=20check,=20tsc,=20es?= =?UTF-8?q?lint,=20doc:lint=20and=20i18n:compile=20--strict=20are=20clean;?= =?UTF-8?q?=20checked=20in=20screenshots=20in=20Smooth=20light=20at=201280?= =?UTF-8?q?=20and=20390px,=20Smooth=20dark=20with=20mocked=20playlists,=20?= =?UTF-8?q?Brutalist=20dark=20at=20390px=20and=20Kanagawa=20dark=20at=2012?= =?UTF-8?q?80px.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + API.md | 9 +- README.md | 35 +-- api/config.ts | 7 + api/db/migrate.ts | 2 + api/db/migrations/0021_page_urls.ts | 25 +- api/db/migrations/0022_reserve_community.ts | 14 + api/lib/rate-limit.ts | 25 +- api/lib/reserved-slugs.ts | 29 +- api/lib/scheduler.ts | 46 ++++ api/mail/digest.ts | 30 +- api/main.ts | 20 +- api/routes/community.ts | 37 +++ api/services/community-service.ts | 93 +++++++ api/services/digest-service.ts | 258 ++++++------------ api/services/dump-service.ts | 20 ++ api/services/playlist-service.ts | 14 + api/services/user-service.ts | 12 + api/services/ws-service.ts | 73 +++-- api/test/db/migrate.test.ts | 4 +- .../migrations/0022_reserve_community.test.ts | 21 ++ api/test/lib/reserved-slugs.test.ts | 12 +- api/test/lib/scheduler.test.ts | 53 ++++ api/test/mail/digest.test.ts | 5 + api/test/routes/community.test.ts | 35 +++ api/test/services/community-service.test.ts | 88 ++++++ api/test/services/digest-service.test.ts | 2 +- locales/en.mjs | 2 +- locales/en.po | 242 +++++++++------- locales/fr.mjs | 2 +- locales/fr.po | 242 +++++++++------- shared/community.ts | 32 +++ shared/paths.ts | 13 + shared/settings.ts | 10 + src/App.css | 74 ++++- src/App.tsx | 16 ++ src/components/layout/UserMenu.tsx | 19 +- src/components/profile/ProfileFollowTab.tsx | 31 +-- src/components/social/UserCard.tsx | 51 ++++ src/config/api.ts | 2 + src/pages/Community.tsx | 163 +++++++++++ src/test/components/layout/UserMenu.test.tsx | 6 +- src/test/pages/Community.test.tsx | 98 +++++++ src/utils/shared/deserialize.ts | 15 + 44 files changed, 1466 insertions(+), 524 deletions(-) create mode 100644 api/db/migrations/0022_reserve_community.ts create mode 100644 api/lib/scheduler.ts create mode 100644 api/routes/community.ts create mode 100644 api/services/community-service.ts create mode 100644 api/test/db/migrations/0022_reserve_community.test.ts create mode 100644 api/test/lib/scheduler.test.ts create mode 100644 api/test/routes/community.test.ts create mode 100644 api/test/services/community-service.test.ts create mode 100644 shared/community.ts create mode 100644 src/components/social/UserCard.tsx create mode 100644 src/pages/Community.tsx create mode 100644 src/test/pages/Community.test.tsx diff --git a/.env.example b/.env.example index 115dd820..8fb25b98 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,9 @@ GERBEUR_EMAIL_LOCALE=en # style. Applied at runtime; changing it needs a restart of both the API and Vite. GERBEUR_DEFAULT_STYLE=smooth +# How often, in minutes, the /community page's figures are recomputed. +GERBEUR_COMMUNITY_REFRESH_MINUTES=15 + # Markdown body of the welcome email sent on account creation, replacing the # built-in one (written in GERBEUR_EMAIL_LOCALE). Supports {{username}} and # {{site_name}}. Use \n for line breaks in single-line .env values, or a diff --git a/API.md b/API.md index 609b813c..493c3375 100644 --- a/API.md +++ b/API.md @@ -34,6 +34,7 @@ concept has one word, the same as in pages. | `/@/playlists/` | | anyone; a private playlist, its owner | `PlaylistDetail` | | `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` | | `/notifications` | | signed in | `Notifications` | +| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` | | `/settings` | | signed in | `Settings` | | `/manage` | | `category:manage` | `Manage` | | `/login`, `/register?token=` | | signed out | `UserLogin`, `UserRegister` | @@ -67,7 +68,7 @@ page to a comment. 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` + unsubscribe settings manage community api ws assets emojibase` (`api/lib/reserved-slugs.ts`). Dump and playlist slugs reserve nothing, and no slug has an `@`. @@ -160,6 +161,12 @@ thumbnail, cover or attachment takes the owner's media token as `?token=`. - `POST /api/categories`, `PUT`, `DELETE /api/categories/:id` — `category:manage`. Changes them. +### community + +- `GET /api/community?period=weekly|monthly` — signed in. The site's top + dumps, new members, new public playlists and totals over the last week (by + default) or month, as of the server's last refresh. + ### chat - `GET /api/chat?before=&after=` — signed in. A page of messages. The chat diff --git a/README.md b/README.md index 4c51d63a..655497d5 100644 --- a/README.md +++ b/README.md @@ -46,23 +46,24 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default See [`.env.example`](.env.example) for the full list with descriptions. Key variables: -| Variable | Description | Default | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — | -| `GERBEUR_PUBLIC_URL` | Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | `http://localhost:GERBEUR_PORT` | -| `GERBEUR_PORT` | Internal port Oak listens on | `8000` | -| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` | -| `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` | -| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) | -| `GERBEUR_SITE_NAME` | Site name used in the browser tab, app header, OG meta tags, and emails | `gerbeur` | -| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`); unset sends no email | unset | -| `GERBEUR_FROM_EMAIL` | Sender of every email — required when `GERBEUR_SMTPS_URL` is set, or the server won't start | unset | -| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` | -| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` | -| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template | -| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` | -| `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` | -| `VITE_API_PORT` | API port baked into the frontend bundle | `8000` | +| Variable | Description | Default | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — | +| `GERBEUR_PUBLIC_URL` | Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | `http://localhost:GERBEUR_PORT` | +| `GERBEUR_PORT` | Internal port Oak listens on | `8000` | +| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` | +| `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` | +| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) | +| `GERBEUR_SITE_NAME` | Site name used in the browser tab, app header, OG meta tags, and emails | `gerbeur` | +| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`); unset sends no email | unset | +| `GERBEUR_FROM_EMAIL` | Sender of every email — required when `GERBEUR_SMTPS_URL` is set, or the server won't start | unset | +| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` | +| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` | +| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template | +| `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` | +| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` | +| `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` | +| `VITE_API_PORT` | API port baked into the frontend bundle | `8000` | ## Production diff --git a/api/config.ts b/api/config.ts index 89d9dc60..6eab9ed8 100644 --- a/api/config.ts +++ b/api/config.ts @@ -164,6 +164,13 @@ export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; */ export const UNUSED_INVITES_RETENTION_DAYS = 7; +/** + * How often, in minutes, the `/community` page's figures are recomputed + * (`GERBEUR_COMMUNITY_REFRESH_MINUTES`), 15 by default. + */ +export const COMMUNITY_REFRESH_MINUTES: number = + Number(Deno.env.get("GERBEUR_COMMUNITY_REFRESH_MINUTES")) || 15; + /** * The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG * tags, the icons' manifest and the emails. diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 72e87da7..855daae1 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -28,6 +28,7 @@ import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment 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"; +import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts"; interface Migration { name: string; @@ -68,6 +69,7 @@ const MIGRATIONS: Migration[] = [ { name: "0019_user_settings", up: up0019UserSettings }, { name: "0020_digest_schedule", up: up0020DigestSchedule }, { name: "0021_page_urls", up: up0021PageUrls }, + { name: "0022_reserve_community", up: up0022ReserveCommunity }, ]; /** diff --git a/api/db/migrations/0021_page_urls.ts b/api/db/migrations/0021_page_urls.ts index d895a581..9c13c2e5 100644 --- a/api/db/migrations/0021_page_urls.ts +++ b/api/db/migrations/0021_page_urls.ts @@ -28,7 +28,7 @@ import { userPath, } from "../../../shared/paths.ts"; import { type SlugKind, uniqueSlug } from "../../lib/slugify.ts"; -import { isReservedSlug } from "../../lib/reserved-slugs.ts"; +import { renameReservedCategories } 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. */ @@ -67,29 +67,6 @@ function reslug(db: DatabaseSync, kind: SlugKind): PathsByKey { 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: "", diff --git a/api/db/migrations/0022_reserve_community.ts b/api/db/migrations/0022_reserve_community.ts new file mode 100644 index 00000000..9735e848 --- /dev/null +++ b/api/db/migrations/0022_reserve_community.ts @@ -0,0 +1,14 @@ +/** + * Reserves `community` at the URL root for the community page, renaming a + * category that had it as its slug. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; +import { renameReservedCategories } from "../../lib/reserved-slugs.ts"; + +/** Run once by `runMigrations`. */ +export function up(db: DatabaseSync): void { + renameReservedCategories(db); +} diff --git a/api/lib/rate-limit.ts b/api/lib/rate-limit.ts index 42ff49ec..6503518c 100644 --- a/api/lib/rate-limit.ts +++ b/api/lib/rate-limit.ts @@ -6,6 +6,8 @@ * @module */ +import type { Job } from "./scheduler.ts"; + interface Bucket { tokens: number; at: number; @@ -42,11 +44,18 @@ export function takeToken(key: string, limit: RateLimit): boolean { return true; } -// Every limit in use refills within the hour, so an older bucket is full and -// dropping it changes nothing. -setInterval(() => { - const cutoff = Date.now() - 60 * 60_000; - for (const [key, bucket] of buckets) { - if (bucket.at < cutoff) buckets.delete(key); - } -}, 60_000); +/** + * Drops the buckets untouched for an hour, every minute. Every limit in use + * refills within the hour, so such a bucket is full and dropping it changes + * nothing. `main.ts` starts it. + */ +export const rateLimitSweepJob: Job = { + name: "rate-limit", + everyMs: 60_000, + run() { + const cutoff = Date.now() - 60 * 60_000; + for (const [key, bucket] of buckets) { + if (bucket.at < cutoff) buckets.delete(key); + } + }, +}; diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index d139fa13..14281273 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -2,7 +2,8 @@ * Checks which slugs a category may take. A category's feed lives at the URL * root (`/music`), so its slug must not shadow another top-level page. The * category service runs this check when a category is created or renamed, and - * migration 0021 renamed the categories it newly reserved. + * the migrations that reserve more words rename the categories that had one + * with {@link renameReservedCategories}. * * 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 @@ -11,6 +12,7 @@ * @module */ +import type { DatabaseSync } from "node:sqlite"; import { FEED_TABS } from "../../shared/paths.ts"; /** @@ -29,6 +31,7 @@ export const RESERVED_SLUGS: readonly string[] = [ "unsubscribe", "settings", "manage", + "community", "api", "ws", "assets", @@ -39,3 +42,27 @@ export const RESERVED_SLUGS: readonly string[] = [ export function isReservedSlug(slug: string): boolean { return RESERVED_SLUGS.includes(slug); } + +/** + * Renames each category whose slug is now reserved, such as `hot`, to the + * first of `-2`, `-3`… that is free, and logs it. Migration 0021 + * runs it for v4's words, and 0022 for `community`. + */ +export 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}": its slug is reserved`, + ); + } +} diff --git a/api/lib/scheduler.ts b/api/lib/scheduler.ts new file mode 100644 index 00000000..907a861d --- /dev/null +++ b/api/lib/scheduler.ts @@ -0,0 +1,46 @@ +/** + * Runs the server's recurring tasks. Each module that needs one exports a + * {@link Job}, and `main.ts` passes them all to {@link startJobs} when it runs + * directly, so nothing recurs in the tests, which call a job's `run` when + * they need it. + * + * @module + */ + +/** A recurring task, exported by its module for `main.ts` to start. */ +export interface Job { + /** Names it in the log, as `[digest]`. */ + name: string; + /** How long from one run's start to the next. */ + everyMs: number; + /** How long after the start the first run waits, `everyMs` by default. */ + firstAfterMs?: number; + /** Logs what it did itself, if anything; its errors are logged here. */ + run(): void | Promise; +} + +/** + * Starts running each of `jobs` on its own timer. A run still going when the + * next is due makes that one skip, and a run that throws or rejects is logged + * and the next one runs anyway. + */ +export function startJobs(jobs: readonly Job[]): void { + for (const job of jobs) { + let running = false; + const tick = async () => { + if (running) return; + running = true; + try { + await job.run(); + } catch (err) { + console.error(`[${job.name}] run failed:`, err); + } finally { + running = false; + } + }; + setTimeout(() => { + tick(); + setInterval(tick, job.everyMs); + }, job.firstAfterMs ?? job.everyMs); + } +} diff --git a/api/mail/digest.ts b/api/mail/digest.ts index 4a1a152b..31da5de6 100644 --- a/api/mail/digest.ts +++ b/api/mail/digest.ts @@ -105,6 +105,8 @@ export interface DigestContent { notifications: string; followed: string; chat: string; + /** The community page's view of the cadence's period. */ + community: string; settings: string; /** The one-click page that turns this cadence off. */ unsubscribe: string; @@ -124,11 +126,11 @@ const ACTIVITY_ORDER: ActivityKind[] = [ function countLabel(kind: ActivityKind, n: number): string { switch (kind) { case "upvotes": - return i18n._("email.digest.count.upvotes", { n }); + return i18n._("digest.count.upvotes", { n }); case "followers": return i18n._("email.digest.count.followers", { n }); case "comments": - return i18n._("email.digest.count.comments", { n }); + return i18n._("digest.count.comments", { n }); case "mentions": return i18n._("email.digest.count.mentions", { n }); case "likes": @@ -173,8 +175,8 @@ const addedToLine = (playlist: string) => const uploadLabel = () => i18n._("email.digest.upload"); const invitedLine = (inviter: string, reader: string) => inviter === reader - ? i18n._("email.digest.invitedByYou") - : i18n._("email.digest.invitedBy", { username: inviter }); + ? i18n._("digest.invitedByYou") + : i18n._("digest.invitedBy", { username: inviter }); const andMoreLine = (n: number) => i18n._("email.digest.andMore", { n }); const playlistMeta = (owner: string, n: number) => i18n._("email.digest.playlistMeta", { username: owner, n }); @@ -286,9 +288,7 @@ export function renderDigestEmail( md.push( `## ${ escMd( - monthly - ? i18n._("email.digest.topMonth") - : i18n._("email.digest.topWeek"), + monthly ? i18n._("digest.topMonth") : i18n._("digest.topWeek"), ) }`, ); @@ -304,20 +304,20 @@ export function renderDigestEmail( if (community) { const { dumps, upvotes, comments } = community.totals; md.push( - `## ${escMd(i18n._("email.digest.community"))}`, + `## ${escMd(i18n._("digest.community"))}`, ); if (monthly) { md.push( [ figure( dumps, - i18n._("email.digest.stat.dumps", { n: dumps }), + i18n._("digest.stat.dumps", { n: dumps }), ), figure(upvotes, countLabel("upvotes", upvotes)), figure(comments, countLabel("comments", comments)), figure( community.chatMessages, - i18n._("email.digest.stat.chat", { n: community.chatMessages }), + i18n._("digest.stat.chat", { n: community.chatMessages }), ), ].join(" · "), ); @@ -326,7 +326,7 @@ export function renderDigestEmail( md.push( `### ${ escMd( - i18n._("email.digest.newMembers"), + i18n._("digest.newMembers"), ) }`, [ @@ -345,7 +345,7 @@ export function renderDigestEmail( } if (community.playlists.length > 0) { md.push( - `### ${escMd(i18n._("email.digest.newPlaylists"))}`, + `### ${escMd(i18n._("digest.newPlaylists"))}`, [ ...community.playlists.map((p) => `- ${mdLink(p.title, p.url)} · ${ @@ -378,6 +378,12 @@ export function renderDigestEmail( }*`, ); } + md.push(`${ + mdLink( + i18n._("email.digest.communityPage"), + content.links.community, + ) + } →`); } md.push( diff --git a/api/main.ts b/api/main.ts index 853cbe5e..7b39b6e7 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,6 @@ /** - * Builds the Oak application and, when run directly, starts the digests' - * scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes + * Builds the Oak application and, when run directly, starts the recurring + * jobs 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. @@ -15,6 +15,7 @@ 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 communityRouter from "./routes/community.ts"; import commentsRouter from "./routes/comments.ts"; import dumpsRouter from "./routes/dumps.ts"; import filesRouter from "./routes/files.ts"; @@ -36,7 +37,11 @@ import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; -import { startDigestScheduler } from "./services/digest-service.ts"; +import { startJobs } from "./lib/scheduler.ts"; +import { rateLimitSweepJob } from "./lib/rate-limit.ts"; +import { digestJob } from "./services/digest-service.ts"; +import { communityJob } from "./services/community-service.ts"; +import { pingJob, typingExpiryJob } from "./services/ws-service.ts"; /** * The whole server, exported so the tests' helpers can send it requests @@ -76,6 +81,7 @@ for ( categoriesRouter, searchRouter, chatRouter, + communityRouter, invitesRouter, attachmentsRouter, remoteRouter, @@ -100,7 +106,13 @@ app.addEventListener( ); if (import.meta.main) { - startDigestScheduler(); + startJobs([ + digestJob, + communityJob, + pingJob, + typingExpiryJob, + rateLimitSweepJob, + ]); await app.listen({ hostname: LISTEN_HOST, port: PORT }); } diff --git a/api/routes/community.ts b/api/routes/community.ts new file mode 100644 index 00000000..6f1058c2 --- /dev/null +++ b/api/routes/community.ts @@ -0,0 +1,37 @@ +/** + * Serves the figures of the client's `/community` page, which + * `community-service.ts` recomputes on a timer. + * + * - `GET /api/community?period=weekly|monthly` — signed in. Answers the + * site's figures over the last week (by default) or month. + * + * @module + */ + +import { Router } from "@oak/oak"; +import type { APIResponse } from "../../shared/api.ts"; +import type { CommunityStats } from "../../shared/community.ts"; +import { isDigestCadence } from "../../shared/settings.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; +import { getCommunityStats } from "../services/community-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/community" }); + +router.get("/", authMiddleware, (ctx: AuthContext) => { + const period = ctx.request.url.searchParams.get("period") ?? "weekly"; + if (!isDigestCadence(period)) { + throw new APIException(400, "period must be weekly or monthly"); + } + const responseBody: APIResponse = { + success: true, + data: getCommunityStats(period), + }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/services/community-service.ts b/api/services/community-service.ts new file mode 100644 index 00000000..1e927723 --- /dev/null +++ b/api/services/community-service.ts @@ -0,0 +1,93 @@ +/** + * Gathers what the whole site did over a period, for the `/community` page + * and the digests' shared sections. The digests ask {@link communityStatsOf} + * for each reader's own period. The page reads the figures of the week and + * the month ending at the last refresh, which {@link communityJob} redoes + * every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query. + * + * @module + */ + +import { db } from "../db/connection.ts"; +import { COMMUNITY_REFRESH_MINUTES } from "../config.ts"; +import type { CommunityStats } from "../../shared/community.ts"; +import { + DIGEST_CADENCES, + type DigestCadence, + PERIOD_MS, +} from "../../shared/settings.ts"; +import type { Job } from "../lib/scheduler.ts"; +import { listTopDumps } from "./dump-service.ts"; +import { listNewPublicPlaylists } from "./playlist-service.ts"; +import { listNewUsers } from "./user-service.ts"; + +/** How many top dumps a period keeps, as many as the monthly recap shows. */ +const TOP_MAX = 10; + +/** Returns how many rows of `table` were created from `from` to `to`. */ +function countOf( + table: string, + from: string, + to: string, + where = "1", +): number { + return (db.prepare( + `SELECT COUNT(*) AS n FROM ${table} + WHERE ${where} AND created_at >= ? AND created_at < ?;`, + ).get(from, to) as { n: number }).n; +} + +/** + * Returns what the site did from `from` to `to`: its most upvoted public + * dumps, who joined, the new public playlists, the chat's messages and the + * totals. + */ +export function communityStatsOf(from: Date, to: Date): CommunityStats { + const [f, t] = [from.toISOString(), to.toISOString()]; + return { + from, + to, + top: listTopDumps(from, to, TOP_MAX), + members: listNewUsers(from, to), + playlists: listNewPublicPlaylists(from, to), + chatMessages: countOf("chat_messages", f, t), + totals: { + dumps: countOf("dumps", f, t, "is_private = 0"), + upvotes: countOf("votes", f, t), + comments: countOf("comments", f, t, "deleted = 0"), + }, + }; +} + +/** The figures {@link getCommunityStats} serves, by period. */ +const latest = new Map(); + +/** Recomputes the figures of each period, ending at `now`. */ +export function refreshCommunityStats(now: Date = new Date()): void { + for (const period of DIGEST_CADENCES) { + latest.set( + period, + communityStatsOf(new Date(now.getTime() - PERIOD_MS[period]), now), + ); + } +} + +/** + * Returns the figures of `period` as of the last refresh, for + * `GET /api/community`, refreshing them first if there are none yet. + */ +export function getCommunityStats(period: DigestCadence): CommunityStats { + if (!latest.has(period)) refreshCommunityStats(); + return latest.get(period)!; +} + +/** + * Refreshes the figures at startup, then every + * {@link COMMUNITY_REFRESH_MINUTES}. `main.ts` starts it. + */ +export const communityJob: Job = { + name: "community", + everyMs: COMMUNITY_REFRESH_MINUTES * 60_000, + firstAfterMs: 0, + run: () => refreshCommunityStats(), +}; diff --git a/api/services/digest-service.ts b/api/services/digest-service.ts index d35b76cc..61e7c941 100644 --- a/api/services/digest-service.ts +++ b/api/services/digest-service.ts @@ -1,14 +1,15 @@ /** * Sends each user their weekly digest and monthly recap, unless they turned - * it off. `main.ts` starts {@link startDigestScheduler}, which every quarter - * of an hour sends the digests that are due; `digest_schedule` remembers, for + * it off. `main.ts` starts {@link digestJob}, which every quarter of an hour + * sends the digests that are due; `digest_schedule` remembers, for * each user and cadence, the start of the period their next one covers. * * A period is 7 days or 30, rolling from when the user's schedule began, so * the digests spread over the week instead of leaving together. A digest * covers the period just ended, or the last full period when the server was * down longer than one; a digest with nothing to show isn't sent, and either - * way the next period begins. + * way the next period begins. The top dumps and the community section come + * from `community-service.ts`, which the `/community` page reads too. * * @module */ @@ -20,7 +21,17 @@ import { DIGEST_CADENCES, DIGEST_SETTING, type DigestCadence, + PERIOD_MS, } from "../../shared/settings.ts"; +import type { CommunityStats } from "../../shared/community.ts"; +import type { Dump } from "../../shared/dump.ts"; +import { + DUMP_SELECT_COLUMNS_ALIASED, + dumpRowToApi, + isDumpRow, +} from "../model/dump.ts"; +import { parseRows } from "../model/rows.ts"; +import type { Job } from "../lib/scheduler.ts"; import { SETTINGS_COLUMNS } from "../model/settings.ts"; import type { CommentLikedData, @@ -33,7 +44,12 @@ import type { UserFollowedData, UserMentionedData, } from "../../shared/notification.ts"; -import { dumpPath, feedPath, playlistPath } from "../../shared/paths.ts"; +import { + communityPath, + dumpPath, + feedPath, + playlistPath, +} from "../../shared/paths.ts"; import { type ActivityKind, type DigestContent, @@ -47,13 +63,8 @@ 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"; +import { communityStatsOf } from "./community-service.ts"; -const DAY_MS = 86_400_000; -/** How long each cadence's period lasts. */ -export const PERIOD_MS: Record = { - weekly: 7 * DAY_MS, - monthly: 30 * DAY_MS, -}; const TICK_MS = 15 * 60_000; // What one digest shows at most, as the drafts settled it. @@ -94,55 +105,21 @@ function quoteOf(markdown: string | undefined | null): string | undefined { : flat; } -/** The columns {@link toDigestDump} reads, selected from `dumps d`. */ -const DUMP_COLS = `d.id, d.slug, d.title, d.kind, d.url, d.user_id, - d.created_at, d.updated_at, d.file_mime, d.file_size, d.rich_content, - d.custom_thumbnail_mime, d.vote_count, u.username AS poster, - (SELECT COUNT(*) FROM comments c WHERE c.dump_id = d.id AND c.deleted = 0) AS comment_count`; - -interface DumpRow { - id: string; - slug: string; - title: string; - kind: string; - url: string | null; - user_id: string; - created_at: string; - updated_at: string | null; - file_mime: string | null; - file_size: number | null; - rich_content: string | null; - custom_thumbnail_mime: string | null; - vote_count: number; - poster: string; - comment_count: number; -} - /** - * Returns the image a mail client can load for `row`, which must be public: + * Returns the image a mail client can load for `dump`, which must be public: * the thumbnails route for a custom thumbnail or an image or video upload, * else the link preview's image, through the image proxy when it is plain * http, which a mail client may refuse. */ -function thumbnailOf(row: DumpRow): string | null { - const isMedia = row.kind === "file" && - /^(image|video)\//.test(row.file_mime ?? ""); - if (row.custom_thumbnail_mime || isMedia) { - const version = dumpFileVersion({ - fileSize: row.file_size ?? undefined, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - }); - return `${PUBLIC_URL}/api/dumps/${row.id}/thumbnail?v=${version}`; - } - let thumbnailUrl: unknown; - try { - thumbnailUrl = row.rich_content - ? JSON.parse(row.rich_content)?.thumbnailUrl - : undefined; - } catch { - return null; +function thumbnailOf(dump: Dump): string | null { + const isMedia = dump.kind === "file" && + /^(image|video)\//.test(dump.fileMime ?? ""); + if (dump.thumbnailMime || isMedia) { + return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${ + dumpFileVersion(dump) + }`; } + const thumbnailUrl = dump.richContent?.thumbnailUrl; if (typeof thumbnailUrl !== "string") return null; if (thumbnailUrl.startsWith("https://")) return thumbnailUrl; if (thumbnailUrl.startsWith("http://")) { @@ -153,26 +130,24 @@ function thumbnailOf(row: DumpRow): string | null { return null; } -function toDigestDump(row: DumpRow, readerId: string): DigestDump { +function toDigestDump(dump: Dump, readerId: string): DigestDump { let domain: string | null = null; - if (row.kind === "url" && row.url) { + if (dump.kind === "url" && dump.url) { try { - domain = new URL(row.url).hostname.replace(/^www\./, ""); + domain = new URL(dump.url).hostname.replace(/^www\./, ""); } catch { domain = null; } } return { - title: row.title, - url: `${FRONTEND_URL}${ - dumpPath({ ownerUsername: row.poster, slug: row.slug }) - }`, - poster: row.poster, + title: dump.title, + url: `${FRONTEND_URL}${dumpPath(dump)}`, + poster: dump.ownerUsername, domain, - thumbnail: thumbnailOf(row), - votes: row.vote_count, - comments: row.comment_count, - mine: row.user_id === readerId, + thumbnail: thumbnailOf(dump), + votes: dump.voteCount, + comments: dump.commentCount, + mine: dump.userId === readerId, }; } @@ -339,119 +314,59 @@ function highlightOf( */ function networkOf(readerId: string, w: Window): DigestContent["network"] { const rows = db.prepare( - `SELECT ${DUMP_COLS}, NULL AS playlist_title, d.created_at AS at - FROM dumps d JOIN users u ON u.id = d.user_id + `SELECT ${DUMP_SELECT_COLUMNS_ALIASED}, NULL AS playlist_title, d.created_at AS at + FROM dumps d WHERE d.is_private = 0 AND d.user_id != :reader AND d.created_at >= :from AND d.created_at < :to AND d.user_id IN (SELECT followed_user_id FROM follows WHERE follower_id = :reader AND followed_user_id IS NOT NULL) UNION ALL - SELECT ${DUMP_COLS}, p.title AS playlist_title, pd.added_at AS at + SELECT ${DUMP_SELECT_COLUMNS_ALIASED}, p.title AS playlist_title, pd.added_at AS at FROM playlist_dumps pd JOIN playlists p ON p.id = pd.playlist_id JOIN dumps d ON d.id = pd.dump_id - JOIN users u ON u.id = d.user_id WHERE p.is_public = 1 AND d.is_private = 0 AND d.user_id != :reader AND pd.added_at >= :from AND pd.added_at < :to AND pd.playlist_id IN (SELECT followed_playlist_id FROM follows WHERE follower_id = :reader AND followed_playlist_id IS NOT NULL);`, - ).all({ reader: readerId, from: w.from, to: w.to }) as unknown as (DumpRow & { - playlist_title: string | null; - at: string; - })[]; + ).all({ reader: readerId, from: w.from, to: w.to }); + const found = parseRows(rows, isDumpRow, (row) => ({ + dump: dumpRowToApi(row), + playlistTitle: row.playlist_title as string | null, + at: row.at as string, + })); // A dump both posted by someone followed and added to a followed playlist // is listed once, as the post. - const byId = new Map(); - for (const row of rows) { - const seen = byId.get(row.id); - if (!seen || (seen.playlist_title && !row.playlist_title)) { - byId.set(row.id, row); + const byId = new Map(); + for (const item of found) { + const seen = byId.get(item.dump.id); + if (!seen || (seen.playlistTitle && !item.playlistTitle)) { + byId.set(item.dump.id, item); } } const sorted = [...byId.values()].sort((a, b) => - b.vote_count - a.vote_count || b.at.localeCompare(a.at) + b.dump.voteCount - a.dump.voteCount || b.at.localeCompare(a.at) ); if (sorted.length === 0) return null; return { - dumps: sorted.slice(0, NETWORK_MAX).map((row) => ({ - ...toDigestDump(row, readerId), - ...(row.playlist_title ? { addedTo: row.playlist_title } : {}), + dumps: sorted.slice(0, NETWORK_MAX).map(({ dump, playlistTitle }) => ({ + ...toDigestDump(dump, readerId), + ...(playlistTitle ? { addedTo: playlistTitle } : {}), })), more: Math.max(0, sorted.length - NETWORK_MAX), }; } /** - * Returns the period's most upvoted public dumps among those posted in it, - * the newer first on a tie, as the "hot" tab would. + * Returns the community section from `stats`, the site's figures for the + * period, leaving the reader out of who joined. */ -function topOf( +function communityOf( readerId: string, - cadence: DigestCadence, - w: Window, -): DigestDump[] { - const rows = db.prepare( - `SELECT ${DUMP_COLS} FROM dumps d JOIN users u ON u.id = d.user_id - WHERE d.is_private = 0 AND d.vote_count > 0 - AND d.created_at >= ? AND d.created_at < ? - ORDER BY d.vote_count DESC, d.created_at DESC - LIMIT ?;`, - ).all(w.from, w.to, TOP_MAX[cadence]) as unknown as DumpRow[]; - return rows.map((row) => toDigestDump(row, readerId)); -} - -const countOf = (sql: string, ...params: string[]) => - (db.prepare(sql).get(...params) as { n: number }).n; - -/** - * Returns what the whole site did in the period: who joined, the new public - * playlists, the chat's activity and the totals. - */ -function communityOf(readerId: string, w: Window): DigestContent["community"] { - const members = db.prepare( - `SELECT u.username, i.username AS invited_by - FROM users u LEFT JOIN users i ON i.id = u.invited_by - WHERE u.created_at >= ? AND u.created_at < ? AND u.id != ? - ORDER BY u.created_at;`, - ).all(w.from, w.to, readerId) as { - username: string; - invited_by: string | null; - }[]; - const playlists = db.prepare( - `SELECT p.id, p.slug, p.title, u.username AS owner, - (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) AS dumps - FROM playlists p JOIN users u ON u.id = p.user_id - WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? - ORDER BY dumps DESC, p.created_at DESC;`, - ).all(w.from, w.to) as { - id: string; - slug: string; - title: string; - owner: string; - dumps: number; - }[]; - const chatMessages = countOf( - `SELECT COUNT(*) AS n FROM chat_messages WHERE created_at >= ? AND created_at < ?;`, - w.from, - w.to, - ); - const totals = { - dumps: countOf( - `SELECT COUNT(*) AS n FROM dumps WHERE is_private = 0 AND created_at >= ? AND created_at < ?;`, - w.from, - w.to, - ), - upvotes: countOf( - `SELECT COUNT(*) AS n FROM votes WHERE created_at >= ? AND created_at < ?;`, - w.from, - w.to, - ), - comments: countOf( - `SELECT COUNT(*) AS n FROM comments WHERE deleted = 0 AND created_at >= ? AND created_at < ?;`, - w.from, - w.to, - ), - }; + stats: CommunityStats, +): DigestContent["community"] { + const members = stats.members.filter((m) => m.id !== readerId); + const { playlists, chatMessages, totals } = stats; if ( members.length + playlists.length + chatMessages + totals.dumps + totals.upvotes + totals.comments === 0 @@ -459,16 +374,14 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] { return { members: members.slice(0, MEMBERS_MAX).map((m) => ({ username: m.username, - invitedBy: m.invited_by, + invitedBy: m.invitedByUsername ?? null, })), membersMore: Math.max(0, members.length - MEMBERS_MAX), playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({ title: p.title, - url: `${FRONTEND_URL}${ - playlistPath({ ownerUsername: p.owner, slug: p.slug }) - }`, - owner: p.owner, - dumps: p.dumps, + url: `${FRONTEND_URL}${playlistPath(p)}`, + owner: p.ownerUsername, + dumps: p.dumpCount ?? 0, })), playlistsMore: Math.max(0, playlists.length - PLAYLISTS_MAX), chatMessages, @@ -493,8 +406,11 @@ export function buildDigest( const weekly = cadence === "weekly"; const activity = weekly ? activityOf(readerId, w) : null; const network = weekly ? networkOf(readerId, w) : null; - const top = topOf(readerId, cadence, w); - const community = communityOf(readerId, w); + const stats = communityStatsOf(from, to); + const top = stats.top.slice(0, TOP_MAX[cadence]).map((d) => + toDigestDump(d, readerId) + ); + const community = communityOf(readerId, stats); if (!activity && !network && top.length === 0 && !community) return null; return { cadence, @@ -509,6 +425,7 @@ export function buildDigest( notifications: `${FRONTEND_URL}/notifications`, followed: `${FRONTEND_URL}${feedPath(undefined, "follow")}`, chat: `${FRONTEND_URL}/?chat=open`, + community: `${FRONTEND_URL}${communityPath(cadence)}`, settings: `${FRONTEND_URL}/settings`, unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${ encodeURIComponent(token) @@ -616,19 +533,14 @@ export async function sendDueDigests(now: Date = new Date()): Promise { /** * Sends the due digests a minute after the server starts, then every quarter - * of an hour. A run still sending when the next is due makes that one skip. - * `main.ts` calls it when run directly, never in the tests. + * of an hour. `main.ts` starts it. */ -export function startDigestScheduler(): void { - let running = false; - const tick = () => { - if (running) return; - running = true; - sendDueDigests() - .then((n) => n > 0 && console.log(`[digest] sent ${n}`)) - .catch((err) => console.error("[digest] run failed:", err)) - .finally(() => running = false); - }; - setTimeout(tick, 60_000); - setInterval(tick, TICK_MS); -} +export const digestJob: Job = { + name: "digest", + everyMs: TICK_MS, + firstAfterMs: 60_000, + async run() { + const n = await sendDueDigests(); + if (n > 0) console.log(`[digest] sent ${n}`); + }, +}; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 3b578757..1bb5f798 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -390,6 +390,26 @@ export function listDumps( return pageOf(items, limit); } +/** + * Returns the `limit` most upvoted public dumps posted from `from` to `to`, + * the newer first on a tie, leaving out those no one upvoted. The community + * service gathers them for the `/community` page and the digests. + * + * @throws {APIException} 500 when a row doesn't parse as a dump. + */ +export function listTopDumps(from: Date, to: Date, limit: number): Dump[] { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps + WHERE is_private = 0 AND vote_count > 0 + AND created_at >= ? AND created_at < ? + ORDER BY vote_count DESC, created_at DESC + LIMIT ?;`, + ).all(from.toISOString(), to.toISOString(), limit); + const items = parseRows(rows, isDumpRow, dumpRowToApi); + attachCategoryIds(items); + return items; +} + /** * Returns the dumps `requestingUserId` may see that were posted with the same * link as `url`, newest first, for the create form's duplicate notice diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index d7fbec1c..dbd3181b 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -180,6 +180,20 @@ export function searchPlaylists( return parseRows(rows, isPlaylistRow, playlistRowToApi); } +/** + * Returns the public playlists created from `from` to `to`, the fullest first + * and the newer on a tie. The community service gathers them for the + * `/community` page and the digests. + */ +export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] { + const rows = db.prepare( + `SELECT ${PLAYLIST_SELECT} + WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? + ORDER BY dump_count DESC, p.created_at DESC;`, + ).all(from.toISOString(), to.toISOString()); + return parseRows(rows, isPlaylistRow, playlistRowToApi); +} + /** * Returns a page of `userId`'s playlists, newest first, for * `GET /api/users/:key/playlists`. The private ones are listed only when diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 6a8bf552..bd8f0b50 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -314,6 +314,18 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { return parseRows(rows, isUserRow, userRowToApi).map(toPublicUser); } +/** + * Returns the users who joined from `from` to `to`, in sign-up order, each + * with the name of whoever invited them. The community service gathers them + * for the `/community` page and the digests. + */ +export function listNewUsers(from: Date, to: Date): PublicUser[] { + const rows = db.prepare( + `${USER_SELECT} WHERE u.created_at >= ? AND u.created_at < ? ORDER BY u.created_at;`, + ).all(from.toISOString(), to.toISOString()); + return parseRows(rows, isUserRow, userRowToApi).map(toPublicUser); +} + /** * Applies `request` to user `userId` and returns the updated user. It trusts * `request`: the routes pick the fields a caller may change. The username diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index f6508032..512734ab 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -5,8 +5,8 @@ * it sends. The services and routes that change dumps, comments, playlists and * users call the `broadcast*` functions here after the change is stored. A * `broadcast*` function sends to everyone unless its doc names an audience, so - * its caller must not pass it anything private. The module also pings every - * socket and closes the ones that stop answering. + * its caller must not pass it anything private. Its jobs ping every socket, + * closing the ones that stop answering, and expire the typing indicators. * * @module */ @@ -21,6 +21,7 @@ import { isMemberVisibleTo, type PlaylistMember, } from "../lib/playlist-members.ts"; +import type { Job } from "../lib/scheduler.ts"; /** * Created by the WebSocket route for each socket it opens, and kept here until @@ -421,19 +422,25 @@ export function setClientTyping(client: WsClient, typing: boolean): void { if (typing !== wasTyping) broadcastTyping(); } -// Expires the typing users who went quiet without a false, such as by closing -// the tab. -setInterval(() => { - const now = Date.now(); - let changed = false; - for (const client of clients) { - if (client.typingAt && now - client.typingAt >= TYPING_TTL) { - client.typingAt = undefined; - changed = true; +/** + * Expires the typing users who went quiet without a false, such as by closing + * the tab, every 2 seconds. `main.ts` starts it. + */ +export const typingExpiryJob: Job = { + name: "typing", + everyMs: 2_000, + run() { + const now = Date.now(); + let changed = false; + for (const client of clients) { + if (client.typingAt && now - client.typingAt >= TYPING_TTL) { + client.typingAt = undefined; + changed = true; + } } - } - if (changed) broadcastTyping(); -}, 2_000); + if (changed) broadcastTyping(); + }, +}; /** * Checks whether `userId` is reading the chat on any of their sockets. The @@ -461,19 +468,27 @@ export function handleClientPong(client: WsClient): void { const PING_INTERVAL = 30_000; const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage); -setInterval(() => { - for (const client of clients) { - if (client.socket.readyState !== WebSocket.OPEN) { - clients.delete(client); - continue; +/** + * Pings every client each {@link PING_INTERVAL}, closing the ones that didn't + * answer the previous ping. `main.ts` starts it. + */ +export const pingJob: Job = { + name: "ping", + everyMs: PING_INTERVAL, + run() { + for (const client of clients) { + if (client.socket.readyState !== WebSocket.OPEN) { + clients.delete(client); + continue; + } + // Undefined, not false, before the first ping. + if (client.pongReceived === false) { + client.socket.close(1001, "Ping timeout"); + clients.delete(client); + continue; + } + client.pongReceived = false; + sendRaw(client.socket, PING); } - // Undefined, not false, before the first ping. - if (client.pongReceived === false) { - client.socket.close(1001, "Ping timeout"); - clients.delete(client); - continue; - } - client.pongReceived = false; - sendRaw(client.socket, PING); - } -}, PING_INTERVAL); + }, +}; diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index ed98d583..988d26dd 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, 21); + assertEquals(applied.n, 22); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 21, + 22, ); db.close(); }); diff --git a/api/test/db/migrations/0022_reserve_community.test.ts b/api/test/db/migrations/0022_reserve_community.test.ts new file mode 100644 index 00000000..c7225284 --- /dev/null +++ b/api/test/db/migrations/0022_reserve_community.test.ts @@ -0,0 +1,21 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0022 } from "../../../db/migrations/0022_reserve_community.ts"; + +Deno.test("migration 0022 renames a category named community", () => { + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE categories (id TEXT PRIMARY KEY, slug TEXT);`); + for (const slug of ["community", "community-2", "music"]) { + db.prepare(`INSERT INTO categories (id, slug) VALUES (?, ?);`).run( + crypto.randomUUID(), + slug, + ); + } + up0022(db); + up0022(db); // idempotent + const slugs = (db.prepare(`SELECT slug FROM categories ORDER BY slug;`) + .all() as { slug: string }[]).map((r) => r.slug); + assertEquals(slugs, ["community-2", "community-3", "music"]); + db.close(); +}); diff --git a/api/test/lib/reserved-slugs.test.ts b/api/test/lib/reserved-slugs.test.ts index a282f76c..99de3402 100644 --- a/api/test/lib/reserved-slugs.test.ts +++ b/api/test/lib/reserved-slugs.test.ts @@ -3,7 +3,17 @@ import { assert, assertFalse } from "@std/assert"; import { isReservedSlug } from "../../lib/reserved-slugs.ts"; Deno.test("category slugs: the feed tabs, app pages and server paths are reserved", () => { - for (const word of ["hot", "follow", "settings", "manage", "api", "ws"]) { + for ( + const word of [ + "hot", + "follow", + "settings", + "manage", + "community", + "api", + "ws", + ] + ) { assert(isReservedSlug(word), word); } // v3's words are free again: nothing lives at /dumps or /users now. diff --git a/api/test/lib/scheduler.test.ts b/api/test/lib/scheduler.test.ts new file mode 100644 index 00000000..77b8fb55 --- /dev/null +++ b/api/test/lib/scheduler.test.ts @@ -0,0 +1,53 @@ +import "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { FakeTime } from "@std/testing/time"; +import { startJobs } from "../../lib/scheduler.ts"; + +Deno.test("startJobs: waits for the first run, then repeats, skipping a run still going", async () => { + using time = new FakeTime(); + let runs = 0; + let release = () => {}; + startJobs([{ + name: "slow", + everyMs: 1_000, + firstAfterMs: 500, + run: () => { + runs++; + return new Promise((resolve) => release = resolve); + }, + }]); + await time.tickAsync(499); + assertEquals(runs, 0); + await time.tickAsync(1); + assertEquals(runs, 1); + // Still running at the next tick: skipped. + await time.tickAsync(1_000); + assertEquals(runs, 1); + release(); + await time.tickAsync(1_000); + assertEquals(runs, 2); +}); + +Deno.test("startJobs: a failed run is logged, and the next one runs anyway", async () => { + using time = new FakeTime(); + const errors: unknown[][] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => errors.push(args); + try { + let runs = 0; + startJobs([{ + name: "flaky", + everyMs: 1_000, + run: () => { + runs++; + throw new Error("boom"); + }, + }]); + await time.tickAsync(2_000); + assertEquals(runs, 2); + assertEquals(errors.length, 2); + assertEquals(errors[0][0], "[flaky] run failed:"); + } finally { + console.error = consoleError; + } +}); diff --git a/api/test/mail/digest.test.ts b/api/test/mail/digest.test.ts index a0dce975..74e780e2 100644 --- a/api/test/mail/digest.test.ts +++ b/api/test/mail/digest.test.ts @@ -6,6 +6,7 @@ const links = { notifications: "https://g.test/notifications", followed: "https://g.test/~/followed", chat: "https://g.test/?chat=open", + community: "https://g.test/community/monthly", settings: "https://g.test/users/ann/~/settings", unsubscribe: "https://g.test/unsubscribe?token=t", }; @@ -99,6 +100,10 @@ Deno.test("renderDigestEmail: the monthly recap leads with a wide image and the assertStringIncludes(email.html, "1,284"); assertStringIncludes(email.html, "invited by you"); assertStringIncludes(email.html, "Turn off monthly recaps"); + assertStringIncludes( + email.text, + "[See it all on the community page](https://g.test/community/monthly)", + ); // Only #2 and #3 get a thumbnail beside the hero. assert(email.html.match(/]*width="64"/g)?.length === 2); }); diff --git a/api/test/routes/community.test.ts b/api/test/routes/community.test.ts new file mode 100644 index 00000000..4de47d7d --- /dev/null +++ b/api/test/routes/community.test.ts @@ -0,0 +1,35 @@ +import { createUser, db, get, insertDump, test } from "../helpers.ts"; +import { assertEquals } from "@std/assert"; +import type { CommunityStats } from "../../../shared/community.ts"; +import { refreshCommunityStats } from "../../services/community-service.ts"; + +function vote(dumpId: string, n: number) { + db.prepare(`UPDATE dumps SET vote_count = ? WHERE id = ?;`).run(n, dumpId); +} + +test("GET /api/community: signed in only, for a week or a month", async () => { + const ann = await createUser(); + vote( + insertDump({ + userId: ann.id, + title: "Hit", + createdAt: new Date(Date.now() - 1000).toISOString(), + }).id, + 1, + ); + refreshCommunityStats(); + + assertEquals((await get("/api/community")).status, 401); + assertEquals( + (await get("/api/community?period=daily", { auth: ann.auth })).status, + 400, + ); + const week = await get("/api/community", { auth: ann.auth }); + assertEquals(week.status, 200); + assertEquals(week.data.top.map((d) => d.title), ["Hit"]); + assertEquals(week.data.members.map((m) => m.username), [ann.username]); + const month = await get("/api/community?period=monthly", { + auth: ann.auth, + }); + assertEquals(month.data.totals.dumps, 1); +}); diff --git a/api/test/services/community-service.test.ts b/api/test/services/community-service.test.ts new file mode 100644 index 00000000..f5a3dd64 --- /dev/null +++ b/api/test/services/community-service.test.ts @@ -0,0 +1,88 @@ +import { + createUser, + db, + insertComment, + insertDump, + insertPlaylist, + test, +} from "../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { + communityStatsOf, + getCommunityStats, + refreshCommunityStats, +} from "../../services/community-service.ts"; + +const DAY = 86_400_000; +const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); +const week = () => + [new Date(Date.now() - 7 * DAY), new Date(Date.now() + 60_000)] as const; + +function vote(dumpId: string, n: number) { + db.prepare(`UPDATE dumps SET vote_count = ? WHERE id = ?;`).run(n, dumpId); +} + +test("communityStatsOf: the period's top public dumps, members, public playlists and totals", async () => { + const ann = await createUser({ createdAt: ago(20 * DAY) }); + const bob = await createUser({ invitedBy: ann.id, createdAt: ago(2 * DAY) }); + const cy = await createUser({ createdAt: ago(DAY) }); + const hit = insertDump({ userId: bob.id, title: "Hit" }); + vote(hit.id, 5); + const tie = insertDump({ userId: ann.id, title: "Tie", createdAt: ago(DAY) }); + vote(tie.id, 2); + const newer = insertDump({ userId: ann.id, title: "Newer" }); + vote(newer.id, 2); + insertDump({ userId: ann.id, title: "No votes" }); + vote(insertDump({ userId: ann.id, title: "Private", isPrivate: true }).id, 9); + vote( + insertDump({ userId: ann.id, title: "Too old", createdAt: ago(10 * DAY) }) + .id, + 9, + ); + insertComment({ dumpId: hit.id, userId: cy.id, body: "nice" }); + insertPlaylist({ userId: ann.id, title: "Small" }); + const full = insertPlaylist({ userId: bob.id, title: "Full" }); + insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false }); + db.prepare( + `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?);`, + ).run(full.id, hit.id, ago(0), full.id, tie.id, ago(0)); + + const stats = communityStatsOf(...week()); + assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]); + assertEquals(stats.top[0].ownerUsername, bob.username); + assertEquals( + stats.members.map((m) => [m.username, m.invitedByUsername]), + [[bob.username, ann.username], [cy.username, undefined]], + ); + assertEquals( + stats.playlists.map((p) => [p.title, p.dumpCount]), + [["Full", 2], ["Small", 0]], + ); + assertEquals(stats.totals, { dumps: 4, upvotes: 0, comments: 1 }); + assertEquals(stats.chatMessages, 0); +}); + +test("getCommunityStats: serves the last refresh, of the week or the month", async () => { + const ann = await createUser({ createdAt: ago(20 * DAY) }); + vote( + insertDump({ userId: ann.id, title: "Month", createdAt: ago(20 * DAY) }) + .id, + 1, + ); + refreshCommunityStats(); + assertEquals(getCommunityStats("weekly").top, []); + assertEquals(getCommunityStats("monthly").top.map((d) => d.title), [ + "Month", + ]); + assertEquals(getCommunityStats("monthly").members.length, 1); + + vote( + insertDump({ userId: ann.id, title: "Later", createdAt: ago(1000) }).id, + 1, + ); + assertEquals(getCommunityStats("weekly").top, []); + refreshCommunityStats(); + assertEquals(getCommunityStats("weekly").top.map((d) => d.title), [ + "Later", + ]); +}); diff --git a/api/test/services/digest-service.test.ts b/api/test/services/digest-service.test.ts index d3ecd410..6cffda7d 100644 --- a/api/test/services/digest-service.test.ts +++ b/api/test/services/digest-service.test.ts @@ -11,10 +11,10 @@ import { import { assert, assertEquals, assertFalse } from "@std/assert"; import { buildDigest, - PERIOD_MS, sendDueDigests, syncDigestSchedules, } from "../../services/digest-service.ts"; +import { PERIOD_MS } from "../../../shared/settings.ts"; import { getSettings, updateSettings, diff --git a/locales/en.mjs b/locales/en.mjs index 40f63a54..a96443c1 100644 --- a/locales/en.mjs +++ b/locales/en.mjs @@ -1 +1 @@ -/*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\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"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\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" new\"]}]],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"QctOgc\":[\"Could not connect to the server. Check your connection and try again.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"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…\"],\"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\"],\"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\"],\"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\"],\"7noMWp\":[\"Nothing happened this week.\"],\"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\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"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\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" new\"]}]],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"QctOgc\":[\"Could not connect to the server. Check your connection and try again.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"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…\"],\"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\"],\"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.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"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\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"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\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"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\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file diff --git a/locales/en.po b/locales/en.po index e05a6d8a..9dc78a52 100644 --- a/locales/en.po +++ b/locales/en.po @@ -14,190 +14,203 @@ msgstr "" "Plural-Forms: \n" #. js-lingui-explicit-id -#: api/mail/digest.ts:127 -msgid "email.digest.count.upvotes" +#: api/mail/digest.ts:129 +#: src/pages/Community.tsx:49 +msgid "digest.count.upvotes" msgstr "{n, plural, one {upvote} other {upvotes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:129 +#: api/mail/digest.ts:131 msgid "email.digest.count.followers" msgstr "{n, plural, one {new follower} other {new followers}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:131 -msgid "email.digest.count.comments" +#: api/mail/digest.ts:133 +#: src/pages/Community.tsx:50 +msgid "digest.count.comments" msgstr "{n, plural, one {comment} other {comments}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:133 +#: api/mail/digest.ts:135 msgid "email.digest.count.mentions" msgstr "{n, plural, one {mention} other {mentions}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:135 +#: api/mail/digest.ts:137 msgid "email.digest.count.likes" msgstr "{n, plural, one {comment like} other {comment likes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:137 +#: api/mail/digest.ts:139 msgid "email.digest.count.playlistFollows" msgstr "{n, plural, one {playlist follow} other {playlist follows}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:153 +#: api/mail/digest.ts:155 msgid "email.digest.hl.mentionComment" msgstr "{who} mentioned you in a comment on {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:156 +#: api/mail/digest.ts:158 msgid "email.digest.hl.mentionIn" msgstr "{who} mentioned you in {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:158 +#: api/mail/digest.ts:160 msgid "email.digest.hl.mentionPlaylist" msgstr "{who} mentioned you in the playlist {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:160 +#: api/mail/digest.ts:162 msgid "email.digest.hl.comment" msgstr "{who} commented on your dump {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:162 +#: api/mail/digest.ts:164 msgid "email.digest.hl.playlistFollow" msgstr "{who} followed your playlist {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:164 +#: api/mail/digest.ts:166 msgid "email.digest.hl.userFollow" msgstr "{who} started following you" #. js-lingui-explicit-id -#: api/mail/digest.ts:166 +#: api/mail/digest.ts:168 msgid "email.digest.hl.commentLike" msgstr "{who} liked your comment on {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:170 +#: api/mail/digest.ts:172 msgid "email.digest.by" msgstr "by @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:172 +#: api/mail/digest.ts:174 msgid "email.digest.addedTo" msgstr "added to {playlist}" #. js-lingui-explicit-id -#: api/mail/digest.ts:173 +#: api/mail/digest.ts:175 msgid "email.digest.upload" msgstr "upload" #. js-lingui-explicit-id -#: api/mail/digest.ts:176 -msgid "email.digest.invitedByYou" +#: api/mail/digest.ts:178 +#: src/pages/Community.tsx:141 +msgid "digest.invitedByYou" msgstr "invited by you" #. js-lingui-explicit-id -#: api/mail/digest.ts:177 -msgid "email.digest.invitedBy" +#: api/mail/digest.ts:179 +#: src/pages/Community.tsx:143 +msgid "digest.invitedBy" msgstr "invited by @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:178 +#: api/mail/digest.ts:180 msgid "email.digest.andMore" msgstr "and {n} more" #. js-lingui-explicit-id -#: api/mail/digest.ts:180 +#: api/mail/digest.ts:182 msgid "email.digest.playlistMeta" msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:203 +#: api/mail/digest.ts:205 msgid "email.digest.yours" msgstr "yours" #. js-lingui-explicit-id -#: api/mail/digest.ts:221 +#: api/mail/digest.ts:223 #: src/pages/Settings.tsx:154 msgid "digest.monthly" msgstr "Monthly recap" #. js-lingui-explicit-id -#: api/mail/digest.ts:221 +#: api/mail/digest.ts:223 #: src/pages/Settings.tsx:145 msgid "digest.weekly" msgstr "Weekly digest" #. js-lingui-explicit-id -#: api/mail/digest.ts:229 +#: api/mail/digest.ts:231 msgid "email.digest.monthly.title" msgstr "The month on {site}" #. js-lingui-explicit-id -#: api/mail/digest.ts:230 +#: api/mail/digest.ts:232 msgid "email.digest.weekly.title" msgstr "Your week, {username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:238 +#: api/mail/digest.ts:240 msgid "email.digest.forYou" msgstr "For you" #. js-lingui-explicit-id -#: api/mail/digest.ts:247 +#: api/mail/digest.ts:249 msgid "email.digest.theChat" msgstr "the chat" #. js-lingui-explicit-id -#: api/mail/digest.ts:262 +#: api/mail/digest.ts:264 msgid "email.digest.seeAll" msgstr "{n, plural, one {See the notification} other {See all # notifications}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:271 +#: api/mail/digest.ts:273 msgid "email.digest.network" msgstr "From people you follow" #. js-lingui-explicit-id -#: api/mail/digest.ts:277 +#: api/mail/digest.ts:279 msgid "email.digest.more" msgstr "{n} more on your Followed feed" #. js-lingui-explicit-id -#: api/mail/digest.ts:290 -msgid "email.digest.topMonth" +#: api/mail/digest.ts:291 +#: src/pages/Community.tsx:127 +msgid "digest.topMonth" msgstr "Top this month" #. js-lingui-explicit-id #: api/mail/digest.ts:291 -msgid "email.digest.topWeek" +#: src/pages/Community.tsx:126 +msgid "digest.topWeek" msgstr "Top this week" #. js-lingui-explicit-id #: api/mail/digest.ts:307 -msgid "email.digest.community" +#: src/components/layout/UserMenu.tsx:94 +#: src/pages/Community.tsx:73 +#: src/pages/Community.tsx:100 +msgid "digest.community" msgstr "Community" #. js-lingui-explicit-id #: api/mail/digest.ts:314 -msgid "email.digest.stat.dumps" +#: src/pages/Community.tsx:48 +msgid "digest.stat.dumps" msgstr "{n, plural, one {dump} other {dumps}}" #. js-lingui-explicit-id #: api/mail/digest.ts:320 -msgid "email.digest.stat.chat" +#: src/pages/Community.tsx:53 +msgid "digest.stat.chat" msgstr "{n, plural, one {chat message} other {chat messages}}" #. js-lingui-explicit-id #: api/mail/digest.ts:329 -msgid "email.digest.newMembers" +#: src/pages/Community.tsx:133 +msgid "digest.newMembers" msgstr "New members" #. js-lingui-explicit-id #: api/mail/digest.ts:348 -msgid "email.digest.newPlaylists" +#: src/pages/Community.tsx:154 +msgid "digest.newPlaylists" msgstr "New public playlists" #. js-lingui-explicit-id @@ -221,64 +234,69 @@ 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:387 +#: api/mail/digest.ts:383 +msgid "email.digest.communityPage" +msgstr "See it all on the community page" + +#. js-lingui-explicit-id +#: api/mail/digest.ts:393 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:388 +#: api/mail/digest.ts:394 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:392 +#: api/mail/digest.ts:398 #: src/pages/Unsubscribe.tsx:118 msgid "digest.monthly.off" msgstr "Turn off monthly recaps" #. js-lingui-explicit-id -#: api/mail/digest.ts:392 +#: api/mail/digest.ts:398 #: src/pages/Unsubscribe.tsx:117 msgid "digest.weekly.off" msgstr "Turn off weekly digests" #. js-lingui-explicit-id -#: api/mail/digest.ts:397 +#: api/mail/digest.ts:403 msgid "email.settings" msgstr "Settings" #. js-lingui-explicit-id -#: api/mail/digest.ts:405 +#: api/mail/digest.ts:411 msgid "email.digest.monthly.subject" msgstr "The month on {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:409 +#: api/mail/digest.ts:415 msgid "email.digest.weekly.subject" msgstr "Your week on {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:432 +#: api/mail/digest.ts:438 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:441 +#: api/mail/digest.ts:447 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:445 +#: api/mail/digest.ts:451 msgid "email.digest.pre.members" msgstr "{n, plural, one {# new member} other {# new members}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:448 +#: api/mail/digest.ts:454 msgid "email.digest.pre.playlists" msgstr "{n, plural, one {# new playlist} other {# new playlists}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:452 +#: api/mail/digest.ts:458 msgid "email.digest.pre.topWeek" msgstr "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}" @@ -383,12 +401,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/chat/ChatModal.tsx:627 +#: src/components/chat/ChatModal.tsx:649 msgid "{0} and {1} are typing…" msgstr "{0} and {1} are typing…" #. placeholder {0}: typingNames[0] -#: src/components/chat/ChatModal.tsx:625 +#: src/components/chat/ChatModal.tsx:647 msgid "{0} is typing…" msgstr "{0} is typing…" @@ -407,11 +425,11 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/pages/Notifications.tsx:497 +#: src/pages/Notifications.tsx:499 msgid "{totalUnread, plural, one {# new} other {# new}}" msgstr "{totalUnread, plural, one {# new} other {# new}}" -#: src/components/chat/ChatModal.tsx:756 +#: src/components/chat/ChatModal.tsx:778 msgid "{unseenCount} new messages" msgstr "{unseenCount} new messages" @@ -445,55 +463,55 @@ msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:237 +#: src/pages/Notifications.tsx:239 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:197 +#: src/pages/Notifications.tsx:199 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:247 +#: src/pages/Notifications.tsx:249 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:263 +#: src/pages/Notifications.tsx:265 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:207 +#: src/pages/Notifications.tsx:209 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:188 +#: src/pages/Notifications.tsx:190 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:227 +#: src/pages/Notifications.tsx:229 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:217 +#: src/pages/Notifications.tsx:219 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" -#: src/components/chat/ChatModal.tsx:755 +#: src/components/chat/ChatModal.tsx:777 msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:258 +#: src/pages/Notifications.tsx:260 msgid "a comment" msgstr "a comment" @@ -501,7 +519,7 @@ msgstr "a comment" msgid "A link is required" msgstr "A link is required" -#: src/pages/Notifications.tsx:261 +#: src/pages/Notifications.tsx:263 msgid "a post" msgstr "a post" @@ -602,8 +620,8 @@ msgstr "Cancel" msgid "Cancel removal" msgstr "Cancel removal" -#: src/components/chat/ChatModal.tsx:816 -#: src/components/chat/ChatModal.tsx:817 +#: src/components/chat/ChatModal.tsx:838 +#: src/components/chat/ChatModal.tsx:839 msgid "Cancel reply" msgstr "Cancel reply" @@ -641,7 +659,7 @@ msgstr "Change password…" msgid "Change skin tone" msgstr "Change skin tone" -#: src/components/chat/ChatModal.tsx:716 +#: src/components/chat/ChatModal.tsx:738 #: src/components/layout/ChatButton.tsx:30 #: src/components/layout/ChatFab.tsx:54 #: src/components/layout/ChatFab.tsx:55 @@ -728,7 +746,7 @@ msgstr "Could not save" 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." -#: src/components/chat/ChatModal.tsx:642 +#: src/components/chat/ChatModal.tsx:664 msgid "Couldn't load the chat." msgstr "Couldn't load the chat." @@ -839,12 +857,12 @@ msgstr "Description (optional)" msgid "Discard" msgstr "Discard" -#: src/components/chat/ChatModal.tsx:720 +#: src/components/chat/ChatModal.tsx:742 msgid "Disconnected — reconnecting…" msgstr "Disconnected — reconnecting…" -#: src/components/chat/ChatModal.tsx:792 -#: src/components/chat/ChatModal.tsx:793 +#: src/components/chat/ChatModal.tsx:814 +#: src/components/chat/ChatModal.tsx:815 msgid "Dismiss" msgstr "Dismiss" @@ -892,7 +910,7 @@ msgstr "Dumps" msgid "Dumps ({0})" msgstr "Dumps ({0})" -#: src/pages/Notifications.tsx:303 +#: src/pages/Notifications.tsx:305 msgid "Earlier" msgstr "Earlier" @@ -1062,7 +1080,7 @@ msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." #: src/components/feed/FeedTabBar.tsx:43 -#: src/components/profile/ProfileFollowTab.tsx:163 +#: src/components/profile/ProfileFollowTab.tsx:140 #: src/pages/Settings.tsx:54 #: src/pages/UserPublicProfile.tsx:161 msgid "Followed" @@ -1184,7 +1202,7 @@ msgstr "Live updates unavailable." msgid "Load more" msgstr "Load more" -#: src/components/chat/ChatModal.tsx:741 +#: src/components/chat/ChatModal.tsx:763 msgid "Load older messages" msgstr "Load older messages" @@ -1193,7 +1211,7 @@ msgstr "Load older messages" msgid "Loading dump…" msgstr "Loading dump…" -#: src/components/chat/ChatModal.tsx:634 +#: src/components/chat/ChatModal.tsx:656 msgid "Loading messages…" msgstr "Loading messages…" @@ -1210,7 +1228,7 @@ msgid "Loading profile…" msgstr "Loading profile…" #: src/components/category/CategoryManager.tsx:60 -#: src/components/chat/ChatModal.tsx:740 +#: src/components/chat/ChatModal.tsx:762 #: src/components/editor/EmojiPickerPopup.tsx:125 #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 @@ -1234,7 +1252,7 @@ msgstr "Log in to like" msgid "Log in to vote" msgstr "Log in to vote" -#: src/components/layout/UserMenu.tsx:115 +#: src/components/layout/UserMenu.tsx:124 #: src/pages/UserPublicProfile.tsx:131 msgid "Log out" msgstr "Log out" @@ -1251,7 +1269,7 @@ msgstr "Login failed" msgid "Lowercase letters, numbers and single hyphens only" msgstr "Lowercase letters, numbers and single hyphens only" -#: src/components/layout/UserMenu.tsx:102 +#: src/components/layout/UserMenu.tsx:111 #: src/pages/Manage.tsx:24 #: src/pages/Manage.tsx:29 #: src/pages/UserPublicProfile.tsx:164 @@ -1275,7 +1293,7 @@ msgstr "Mute" msgid "Name" msgstr "Name" -#: src/components/chat/ChatModal.tsx:669 +#: src/components/chat/ChatModal.tsx:691 #: src/components/feed/FeedTabBar.tsx:40 #: src/pages/Settings.tsx:52 msgid "New" @@ -1323,7 +1341,7 @@ msgstr "No dumps yet. Be the first!" msgid "No emoji found." msgstr "No emoji found." -#: src/components/profile/ProfileFollowTab.tsx:136 +#: src/components/profile/ProfileFollowTab.tsx:113 msgid "No followed playlists yet." msgstr "No followed playlists yet." @@ -1331,7 +1349,7 @@ msgstr "No followed playlists yet." msgid "No invitees yet." msgstr "No invitees yet." -#: src/components/chat/ChatModal.tsx:653 +#: src/components/chat/ChatModal.tsx:675 msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" @@ -1356,19 +1374,27 @@ msgstr "No reply from the server — this message may not have been sent" msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." -#: src/components/profile/ProfileFollowTab.tsx:99 +#: src/components/profile/ProfileFollowTab.tsx:76 msgid "Not following anyone yet." msgstr "Not following anyone yet." +#: src/pages/Community.tsx:116 +msgid "Nothing happened this month." +msgstr "Nothing happened this month." + +#: src/pages/Community.tsx:115 +msgid "Nothing happened this week." +msgstr "Nothing happened this week." + #: src/components/profile/ProfileDumpsTab.tsx:53 #: src/components/profile/ProfileOverviewTab.tsx:47 -#: src/pages/Notifications.tsx:511 +#: src/pages/Notifications.tsx:513 msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:57 -#: src/pages/Notifications.tsx:376 -#: src/pages/Notifications.tsx:494 +#: src/pages/Notifications.tsx:378 +#: src/pages/Notifications.tsx:496 #: src/pages/Settings.tsx:128 msgid "Notifications" msgstr "Notifications" @@ -1462,8 +1488,8 @@ msgstr "Play" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 -#: src/components/layout/UserMenu.tsx:85 -#: src/components/profile/ProfileFollowTab.tsx:167 +#: src/components/layout/UserMenu.tsx:86 +#: src/components/profile/ProfileFollowTab.tsx:144 #: src/components/profile/ProfilePlaylistsTab.tsx:118 #: src/pages/Search.tsx:123 #: src/pages/UserPublicProfile.tsx:160 @@ -1574,7 +1600,7 @@ msgstr "Replace file" msgid "Reply" msgstr "Reply" -#: src/components/chat/ChatModal.tsx:804 +#: src/components/chat/ChatModal.tsx:826 msgid "Replying to" msgstr "Replying to" @@ -1661,7 +1687,7 @@ msgstr "Searching…" msgid "Seek" msgstr "Seek" -#: src/components/chat/ChatModal.tsx:844 +#: src/components/chat/ChatModal.tsx:866 msgid "Send" msgstr "Send" @@ -1683,13 +1709,13 @@ msgstr "Server unreachable" msgid "Set new password" msgstr "Set new password" -#: src/components/layout/UserMenu.tsx:93 +#: src/components/layout/UserMenu.tsx:102 #: src/pages/Settings.tsx:70 #: src/pages/Settings.tsx:82 msgid "Settings" msgstr "Settings" -#: src/components/chat/ChatModal.tsx:628 +#: src/components/chat/ChatModal.tsx:650 msgid "Several people are typing…" msgstr "Several people are typing…" @@ -1732,7 +1758,7 @@ msgstr "Submit search" msgid "That file is over {limit}." msgstr "That file is over {limit}." -#: src/pages/Notifications.tsx:260 +#: src/pages/Notifications.tsx:262 msgid "the chat" msgstr "the chat" @@ -1760,6 +1786,10 @@ msgstr "This invite link is missing, expired, or already used." msgid "This is a mirage." msgstr "This is a mirage." +#: src/pages/Community.tsx:104 +msgid "This month" +msgstr "This month" + #: src/pages/NotFound.tsx:21 msgid "This page does not exist." msgstr "This page does not exist." @@ -1776,6 +1806,10 @@ 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/Community.tsx:103 +msgid "This week" +msgstr "This week" + #: src/pages/DumpEdit.tsx:455 msgid "Thumbnail" msgstr "Thumbnail" @@ -1791,7 +1825,7 @@ msgstr "Title" msgid "Title is required." msgstr "Title is required." -#: src/pages/Notifications.tsx:301 +#: src/pages/Notifications.tsx:303 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Today" @@ -1801,7 +1835,7 @@ msgstr "Today" msgid "Track {0}" msgstr "Track {0}" -#: src/components/chat/ChatModal.tsx:645 +#: src/components/chat/ChatModal.tsx:667 msgid "Try again" msgstr "Try again" @@ -1817,7 +1851,7 @@ msgstr "Turn off the weekly digest?" msgid "Turning off…" msgstr "Turning off…" -#: src/components/chat/ChatModal.tsx:829 +#: src/components/chat/ChatModal.tsx:851 msgid "Type a message…" msgstr "Type a message…" @@ -1900,7 +1934,7 @@ msgstr "User" msgid "User management" msgstr "User management" -#: src/components/layout/UserMenu.tsx:60 +#: src/components/layout/UserMenu.tsx:61 msgid "User menu" msgstr "User menu" @@ -1912,7 +1946,7 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/components/profile/ProfileFollowTab.tsx:166 +#: src/components/profile/ProfileFollowTab.tsx:143 #: src/pages/Search.tsx:122 msgid "Users" msgstr "Users" @@ -1956,7 +1990,7 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" -#: src/pages/Notifications.tsx:302 +#: src/pages/Notifications.tsx:304 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Yesterday" @@ -1973,7 +2007,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:514 +#: src/pages/Notifications.tsx:516 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." diff --git a/locales/fr.mjs b/locales/fr.mjs index 4f1aa84f..be36acd4 100644 --- a/locales/fr.mjs +++ b/locales/fr.mjs @@ -1 +1 @@ -/*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\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"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\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"QctOgc\":[\"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"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…\"],\"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\"],\"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é\"],\"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\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"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\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"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\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"QctOgc\":[\"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"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…\"],\"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\"],\"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.community\":[\"La communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus votées du mois\"],\"digest.topWeek\":[\"Les plus votées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus 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.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus 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\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"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\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"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é\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file diff --git a/locales/fr.po b/locales/fr.po index 1c275f14..e630d590 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -14,190 +14,203 @@ msgstr "" "Language-Team: \n" #. js-lingui-explicit-id -#: api/mail/digest.ts:127 -msgid "email.digest.count.upvotes" +#: api/mail/digest.ts:129 +#: src/pages/Community.tsx:49 +msgid "digest.count.upvotes" msgstr "{n, plural, one {vote} other {votes}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:129 +#: api/mail/digest.ts:131 msgid "email.digest.count.followers" msgstr "{n, plural, one {nouvel abonné} other {nouveaux abonnés}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:131 -msgid "email.digest.count.comments" +#: api/mail/digest.ts:133 +#: src/pages/Community.tsx:50 +msgid "digest.count.comments" msgstr "{n, plural, one {commentaire} other {commentaires}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:133 +#: api/mail/digest.ts:135 msgid "email.digest.count.mentions" msgstr "{n, plural, one {mention} other {mentions}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:135 +#: api/mail/digest.ts:137 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:137 +#: api/mail/digest.ts:139 msgid "email.digest.count.playlistFollows" msgstr "{n, plural, one {abonnement à vos collections} other {abonnements à vos collections}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:153 +#: api/mail/digest.ts:155 msgid "email.digest.hl.mentionComment" msgstr "{who} vous a mentionné dans un commentaire sur {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:156 +#: api/mail/digest.ts:158 msgid "email.digest.hl.mentionIn" msgstr "{who} vous a mentionné dans {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:158 +#: api/mail/digest.ts:160 msgid "email.digest.hl.mentionPlaylist" msgstr "{who} vous a mentionné dans la collection {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:160 +#: api/mail/digest.ts:162 msgid "email.digest.hl.comment" msgstr "{who} a commenté votre reco {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:162 +#: api/mail/digest.ts:164 msgid "email.digest.hl.playlistFollow" msgstr "{who} a suivi votre collection {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:164 +#: api/mail/digest.ts:166 msgid "email.digest.hl.userFollow" msgstr "{who} vous suit désormais" #. js-lingui-explicit-id -#: api/mail/digest.ts:166 +#: api/mail/digest.ts:168 msgid "email.digest.hl.commentLike" msgstr "{who} a aimé votre commentaire sur {target}" #. js-lingui-explicit-id -#: api/mail/digest.ts:170 +#: api/mail/digest.ts:172 msgid "email.digest.by" msgstr "par @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:172 +#: api/mail/digest.ts:174 msgid "email.digest.addedTo" msgstr "ajoutée à {playlist}" #. js-lingui-explicit-id -#: api/mail/digest.ts:173 +#: api/mail/digest.ts:175 msgid "email.digest.upload" msgstr "fichier" #. js-lingui-explicit-id -#: api/mail/digest.ts:176 -msgid "email.digest.invitedByYou" +#: api/mail/digest.ts:178 +#: src/pages/Community.tsx:141 +msgid "digest.invitedByYou" msgstr "sur votre invitation" #. js-lingui-explicit-id -#: api/mail/digest.ts:177 -msgid "email.digest.invitedBy" +#: api/mail/digest.ts:179 +#: src/pages/Community.tsx:143 +msgid "digest.invitedBy" msgstr "sur invitation de @{username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:178 +#: api/mail/digest.ts:180 msgid "email.digest.andMore" msgstr "et {n} de plus" #. js-lingui-explicit-id -#: api/mail/digest.ts:180 +#: api/mail/digest.ts:182 msgid "email.digest.playlistMeta" msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:203 +#: api/mail/digest.ts:205 msgid "email.digest.yours" msgstr "à vous" #. js-lingui-explicit-id -#: api/mail/digest.ts:221 +#: api/mail/digest.ts:223 #: src/pages/Settings.tsx:154 msgid "digest.monthly" msgstr "Récap mensuel" #. js-lingui-explicit-id -#: api/mail/digest.ts:221 +#: api/mail/digest.ts:223 #: src/pages/Settings.tsx:145 msgid "digest.weekly" msgstr "Résumé hebdomadaire" #. js-lingui-explicit-id -#: api/mail/digest.ts:229 +#: api/mail/digest.ts:231 msgid "email.digest.monthly.title" msgstr "Le mois sur {site}" #. js-lingui-explicit-id -#: api/mail/digest.ts:230 +#: api/mail/digest.ts:232 msgid "email.digest.weekly.title" msgstr "Votre semaine, {username}" #. js-lingui-explicit-id -#: api/mail/digest.ts:238 +#: api/mail/digest.ts:240 msgid "email.digest.forYou" msgstr "Pour vous" #. js-lingui-explicit-id -#: api/mail/digest.ts:247 +#: api/mail/digest.ts:249 msgid "email.digest.theChat" msgstr "la tribune" #. js-lingui-explicit-id -#: api/mail/digest.ts:262 +#: api/mail/digest.ts:264 msgid "email.digest.seeAll" msgstr "{n, plural, one {Voir la notification} other {Voir les # notifications}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:271 +#: api/mail/digest.ts:273 msgid "email.digest.network" msgstr "De ceux que vous suivez" #. js-lingui-explicit-id -#: api/mail/digest.ts:277 +#: api/mail/digest.ts:279 msgid "email.digest.more" msgstr "{n} de plus dans votre fil Suivi" #. js-lingui-explicit-id -#: api/mail/digest.ts:290 -msgid "email.digest.topMonth" +#: api/mail/digest.ts:291 +#: src/pages/Community.tsx:127 +msgid "digest.topMonth" msgstr "Les plus votées du mois" #. js-lingui-explicit-id #: api/mail/digest.ts:291 -msgid "email.digest.topWeek" +#: src/pages/Community.tsx:126 +msgid "digest.topWeek" msgstr "Les plus votées de la semaine" #. js-lingui-explicit-id #: api/mail/digest.ts:307 -msgid "email.digest.community" +#: src/components/layout/UserMenu.tsx:94 +#: src/pages/Community.tsx:73 +#: src/pages/Community.tsx:100 +msgid "digest.community" msgstr "La communauté" #. js-lingui-explicit-id #: api/mail/digest.ts:314 -msgid "email.digest.stat.dumps" +#: src/pages/Community.tsx:48 +msgid "digest.stat.dumps" msgstr "{n, plural, one {reco} other {recos}}" #. js-lingui-explicit-id #: api/mail/digest.ts:320 -msgid "email.digest.stat.chat" +#: src/pages/Community.tsx:53 +msgid "digest.stat.chat" msgstr "{n, plural, one {message dans la tribune} other {messages dans la tribune}}" #. js-lingui-explicit-id #: api/mail/digest.ts:329 -msgid "email.digest.newMembers" +#: src/pages/Community.tsx:133 +msgid "digest.newMembers" msgstr "Nouveaux membres" #. js-lingui-explicit-id #: api/mail/digest.ts:348 -msgid "email.digest.newPlaylists" +#: src/pages/Community.tsx:154 +msgid "digest.newPlaylists" msgstr "Nouvelles collections publiques" #. js-lingui-explicit-id @@ -221,64 +234,69 @@ 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:387 +#: api/mail/digest.ts:383 +msgid "email.digest.communityPage" +msgstr "Tout voir sur la page de la communauté" + +#. js-lingui-explicit-id +#: api/mail/digest.ts:393 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:388 +#: api/mail/digest.ts:394 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:392 +#: api/mail/digest.ts:398 #: 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:392 +#: api/mail/digest.ts:398 #: 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:397 +#: api/mail/digest.ts:403 msgid "email.settings" msgstr "Paramètres" #. js-lingui-explicit-id -#: api/mail/digest.ts:405 +#: api/mail/digest.ts:411 msgid "email.digest.monthly.subject" msgstr "Le mois sur {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:409 +#: api/mail/digest.ts:415 msgid "email.digest.weekly.subject" msgstr "Votre semaine sur {site}, {range}" #. js-lingui-explicit-id -#: api/mail/digest.ts:432 +#: api/mail/digest.ts:438 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:441 +#: api/mail/digest.ts:447 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:445 +#: api/mail/digest.ts:451 msgid "email.digest.pre.members" msgstr "{n, plural, one {# nouveau membre} other {# nouveaux membres}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:448 +#: api/mail/digest.ts:454 msgid "email.digest.pre.playlists" msgstr "{n, plural, one {# nouvelle collection} other {# nouvelles collections}}" #. js-lingui-explicit-id -#: api/mail/digest.ts:452 +#: api/mail/digest.ts:458 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}}" @@ -383,12 +401,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/chat/ChatModal.tsx:627 +#: src/components/chat/ChatModal.tsx:649 msgid "{0} and {1} are typing…" msgstr "{0} et {1} sont en train d'écrire…" #. placeholder {0}: typingNames[0] -#: src/components/chat/ChatModal.tsx:625 +#: src/components/chat/ChatModal.tsx:647 msgid "{0} is typing…" msgstr "{0} est en train d'écrire…" @@ -407,11 +425,11 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/pages/Notifications.tsx:497 +#: src/pages/Notifications.tsx:499 msgid "{totalUnread, plural, one {# new} other {# new}}" msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}" -#: src/components/chat/ChatModal.tsx:756 +#: src/components/chat/ChatModal.tsx:778 msgid "{unseenCount} new messages" msgstr "{unseenCount} nouveaux messages" @@ -445,55 +463,55 @@ msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:237 +#: src/pages/Notifications.tsx:239 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:197 +#: src/pages/Notifications.tsx:199 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:247 +#: src/pages/Notifications.tsx:249 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:263 +#: src/pages/Notifications.tsx:265 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:207 +#: src/pages/Notifications.tsx:209 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:188 +#: src/pages/Notifications.tsx:190 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:227 +#: src/pages/Notifications.tsx:229 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:217 +#: src/pages/Notifications.tsx:219 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" -#: src/components/chat/ChatModal.tsx:755 +#: src/components/chat/ChatModal.tsx:777 msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:258 +#: src/pages/Notifications.tsx:260 msgid "a comment" msgstr "un commentaire" @@ -501,7 +519,7 @@ msgstr "un commentaire" msgid "A link is required" msgstr "Il faut un lien" -#: src/pages/Notifications.tsx:261 +#: src/pages/Notifications.tsx:263 msgid "a post" msgstr "une publication" @@ -602,8 +620,8 @@ msgstr "Annuler" msgid "Cancel removal" msgstr "Annuler la suppression" -#: src/components/chat/ChatModal.tsx:816 -#: src/components/chat/ChatModal.tsx:817 +#: src/components/chat/ChatModal.tsx:838 +#: src/components/chat/ChatModal.tsx:839 msgid "Cancel reply" msgstr "Annuler la réponse" @@ -641,7 +659,7 @@ msgstr "Changer le mot de passe…" msgid "Change skin tone" msgstr "Changer la couleur de peau" -#: src/components/chat/ChatModal.tsx:716 +#: src/components/chat/ChatModal.tsx:738 #: src/components/layout/ChatButton.tsx:30 #: src/components/layout/ChatFab.tsx:54 #: src/components/layout/ChatFab.tsx:55 @@ -728,7 +746,7 @@ msgstr "Sauvegarde impossible" 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." -#: src/components/chat/ChatModal.tsx:642 +#: src/components/chat/ChatModal.tsx:664 msgid "Couldn't load the chat." msgstr "Impossible de charger le chat." @@ -839,12 +857,12 @@ msgstr "Description (facultatif)" msgid "Discard" msgstr "Abandonner" -#: src/components/chat/ChatModal.tsx:720 +#: src/components/chat/ChatModal.tsx:742 msgid "Disconnected — reconnecting…" msgstr "Déconnecté — reconnexion…" -#: src/components/chat/ChatModal.tsx:792 -#: src/components/chat/ChatModal.tsx:793 +#: src/components/chat/ChatModal.tsx:814 +#: src/components/chat/ChatModal.tsx:815 msgid "Dismiss" msgstr "Fermer" @@ -892,7 +910,7 @@ msgstr "Recos" msgid "Dumps ({0})" msgstr "Recos ({0})" -#: src/pages/Notifications.tsx:303 +#: src/pages/Notifications.tsx:305 msgid "Earlier" msgstr "Plus tôt" @@ -1062,7 +1080,7 @@ msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." #: src/components/feed/FeedTabBar.tsx:43 -#: src/components/profile/ProfileFollowTab.tsx:163 +#: src/components/profile/ProfileFollowTab.tsx:140 #: src/pages/Settings.tsx:54 #: src/pages/UserPublicProfile.tsx:161 msgid "Followed" @@ -1184,7 +1202,7 @@ msgstr "Mises à jour en direct indisponibles." msgid "Load more" msgstr "Charger plus" -#: src/components/chat/ChatModal.tsx:741 +#: src/components/chat/ChatModal.tsx:763 msgid "Load older messages" msgstr "Charger les messages plus anciens" @@ -1193,7 +1211,7 @@ msgstr "Charger les messages plus anciens" msgid "Loading dump…" msgstr "Chargement de la reco…" -#: src/components/chat/ChatModal.tsx:634 +#: src/components/chat/ChatModal.tsx:656 msgid "Loading messages…" msgstr "Chargement des messages…" @@ -1210,7 +1228,7 @@ msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/category/CategoryManager.tsx:60 -#: src/components/chat/ChatModal.tsx:740 +#: src/components/chat/ChatModal.tsx:762 #: src/components/editor/EmojiPickerPopup.tsx:125 #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 @@ -1234,7 +1252,7 @@ msgstr "Se connecter pour aimer" msgid "Log in to vote" msgstr "Se connecter pour voter" -#: src/components/layout/UserMenu.tsx:115 +#: src/components/layout/UserMenu.tsx:124 #: src/pages/UserPublicProfile.tsx:131 msgid "Log out" msgstr "Se déconnecter" @@ -1251,7 +1269,7 @@ msgstr "Connexion échouée" msgid "Lowercase letters, numbers and single hyphens only" msgstr "Minuscules, chiffres et tirets simples seulement" -#: src/components/layout/UserMenu.tsx:102 +#: src/components/layout/UserMenu.tsx:111 #: src/pages/Manage.tsx:24 #: src/pages/Manage.tsx:29 #: src/pages/UserPublicProfile.tsx:164 @@ -1275,7 +1293,7 @@ msgstr "Couper le son" msgid "Name" msgstr "Nom" -#: src/components/chat/ChatModal.tsx:669 +#: src/components/chat/ChatModal.tsx:691 #: src/components/feed/FeedTabBar.tsx:40 #: src/pages/Settings.tsx:52 msgid "New" @@ -1323,7 +1341,7 @@ msgstr "Pas encore de recos. Soyez le premier !" msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/components/profile/ProfileFollowTab.tsx:136 +#: src/components/profile/ProfileFollowTab.tsx:113 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." @@ -1331,7 +1349,7 @@ msgstr "Pas encore de collections suivies." msgid "No invitees yet." msgstr "Aucun invité pour le moment." -#: src/components/chat/ChatModal.tsx:653 +#: src/components/chat/ChatModal.tsx:675 msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" @@ -1356,19 +1374,27 @@ msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoy msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." -#: src/components/profile/ProfileFollowTab.tsx:99 +#: src/components/profile/ProfileFollowTab.tsx:76 msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." +#: src/pages/Community.tsx:116 +msgid "Nothing happened this month." +msgstr "Rien de neuf ce mois-ci." + +#: src/pages/Community.tsx:115 +msgid "Nothing happened this week." +msgstr "Rien de neuf cette semaine." + #: src/components/profile/ProfileDumpsTab.tsx:53 #: src/components/profile/ProfileOverviewTab.tsx:47 -#: src/pages/Notifications.tsx:511 +#: src/pages/Notifications.tsx:513 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:57 -#: src/pages/Notifications.tsx:376 -#: src/pages/Notifications.tsx:494 +#: src/pages/Notifications.tsx:378 +#: src/pages/Notifications.tsx:496 #: src/pages/Settings.tsx:128 msgid "Notifications" msgstr "Notifications" @@ -1462,8 +1488,8 @@ msgstr "Lecture" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 -#: src/components/layout/UserMenu.tsx:85 -#: src/components/profile/ProfileFollowTab.tsx:167 +#: src/components/layout/UserMenu.tsx:86 +#: src/components/profile/ProfileFollowTab.tsx:144 #: src/components/profile/ProfilePlaylistsTab.tsx:118 #: src/pages/Search.tsx:123 #: src/pages/UserPublicProfile.tsx:160 @@ -1574,7 +1600,7 @@ msgstr "Remplacer le fichier" msgid "Reply" msgstr "Répondre" -#: src/components/chat/ChatModal.tsx:804 +#: src/components/chat/ChatModal.tsx:826 msgid "Replying to" msgstr "En réponse à" @@ -1661,7 +1687,7 @@ msgstr "Recherche…" msgid "Seek" msgstr "Position" -#: src/components/chat/ChatModal.tsx:844 +#: src/components/chat/ChatModal.tsx:866 msgid "Send" msgstr "Envoyer" @@ -1683,13 +1709,13 @@ msgstr "Serveur inaccessible" msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/components/layout/UserMenu.tsx:93 +#: src/components/layout/UserMenu.tsx:102 #: src/pages/Settings.tsx:70 #: src/pages/Settings.tsx:82 msgid "Settings" msgstr "Paramètres" -#: src/components/chat/ChatModal.tsx:628 +#: src/components/chat/ChatModal.tsx:650 msgid "Several people are typing…" msgstr "Plusieurs personnes écrivent…" @@ -1732,7 +1758,7 @@ msgstr "Lancer la recherche" msgid "That file is over {limit}." msgstr "Ce fichier dépasse {limit}." -#: src/pages/Notifications.tsx:260 +#: src/pages/Notifications.tsx:262 msgid "the chat" msgstr "la tribune" @@ -1760,6 +1786,10 @@ msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." msgid "This is a mirage." msgstr "C'est un mirage." +#: src/pages/Community.tsx:104 +msgid "This month" +msgstr "Ce mois-ci" + #: src/pages/NotFound.tsx:21 msgid "This page does not exist." msgstr "Rien à voir, circulez." @@ -1776,6 +1806,10 @@ 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/Community.tsx:103 +msgid "This week" +msgstr "Cette semaine" + #: src/pages/DumpEdit.tsx:455 msgid "Thumbnail" msgstr "Miniature" @@ -1791,7 +1825,7 @@ msgstr "Titre" msgid "Title is required." msgstr "Un titre est requis." -#: src/pages/Notifications.tsx:301 +#: src/pages/Notifications.tsx:303 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Aujourd'hui" @@ -1801,7 +1835,7 @@ msgstr "Aujourd'hui" msgid "Track {0}" msgstr "Piste {0}" -#: src/components/chat/ChatModal.tsx:645 +#: src/components/chat/ChatModal.tsx:667 msgid "Try again" msgstr "Réessayer" @@ -1817,7 +1851,7 @@ msgstr "Ne plus recevoir le résumé hebdomadaire ?" msgid "Turning off…" msgstr "Désactivation…" -#: src/components/chat/ChatModal.tsx:829 +#: src/components/chat/ChatModal.tsx:851 msgid "Type a message…" msgstr "Écrivez un message…" @@ -1900,7 +1934,7 @@ msgstr "Utilisateur" msgid "User management" msgstr "Gestion utilisateur" -#: src/components/layout/UserMenu.tsx:60 +#: src/components/layout/UserMenu.tsx:61 msgid "User menu" msgstr "Menu utilisateur" @@ -1912,7 +1946,7 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/components/profile/ProfileFollowTab.tsx:166 +#: src/components/profile/ProfileFollowTab.tsx:143 #: src/pages/Search.tsx:122 msgid "Users" msgstr "Utilisateurs" @@ -1956,7 +1990,7 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/pages/Notifications.tsx:302 +#: src/pages/Notifications.tsx:304 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Hier" @@ -1973,7 +2007,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:514 +#: src/pages/Notifications.tsx:516 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." diff --git a/shared/community.ts b/shared/community.ts new file mode 100644 index 00000000..e1a69980 --- /dev/null +++ b/shared/community.ts @@ -0,0 +1,32 @@ +/** + * What the whole site did over a period, one of the digests' cadences: its + * most upvoted dumps, who joined, the new public playlists and the totals. + * This module types it for the `/community` page, which shows it as it was at + * the server's last refresh; the digests gather the same figures for each + * reader's own period. + * + * @module + */ + +import type { Dump } from "./dump.ts"; +import type { Playlist } from "./playlist.ts"; +import type { PublicUser } from "./user.ts"; + +/** + * Returned by `GET /api/community?period=` to the `Community` page, and + * gathered by `community-service.ts`, which the digests read too. + */ +export interface CommunityStats { + /** The period covered, from `from` to `to`. */ + from: Date; + to: Date; + /** The most upvoted public dumps posted in it, 10 at most. */ + top: Dump[]; + /** In sign-up order, each with `invitedByUsername` when someone invited them. */ + members: PublicUser[]; + /** The public playlists created in it, the fullest first, with `dumpCount`. */ + playlists: Playlist[]; + chatMessages: number; + /** Public dumps posted, upvotes cast and comments written in it. */ + totals: { dumps: number; upvotes: number; comments: number }; +} diff --git a/shared/paths.ts b/shared/paths.ts index 04a1689b..6040c127 100644 --- a/shared/paths.ts +++ b/shared/paths.ts @@ -15,6 +15,8 @@ * @module */ +import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts"; + /** * 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 @@ -55,6 +57,12 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const; /** One of the {@link SEARCH_TABS}. */ export type SearchTab = (typeof SEARCH_TABS)[number]; +/** + * The views of the community page, one per digest cadence: the week at + * `/community`, the month at `/community/monthly`. + */ +export const COMMUNITY_VIEWS: readonly DigestCadence[] = DIGEST_CADENCES; + /** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */ export interface OwnedItem { ownerUsername: string; @@ -107,6 +115,11 @@ export function searchPath(tab: SearchTab = "dumps", query?: string): string { return query ? `${base}?q=${seg(query)}` : base; } +/** Returns the path of the community page on `view`, the week by default. */ +export function communityPath(view: DigestCadence = "weekly"): string { + return view === COMMUNITY_VIEWS[0] ? "/community" : `/community/${view}`; +} + /** * Returned by {@link parsePagePath} for a path under a user. `rest` holds the * segments after `/@name`, a profile tab and its view. diff --git a/shared/settings.ts b/shared/settings.ts index cf03e835..f82ee0fe 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -50,6 +50,16 @@ export const DIGEST_CADENCES = ["weekly", "monthly"] as const; */ export type DigestCadence = typeof DIGEST_CADENCES[number]; +const DAY_MS = 86_400_000; +/** + * How long each cadence's period lasts, rolling: a digest covers the one just + * ended, and the `/community` page the one ending now. + */ +export const PERIOD_MS: Record = { + weekly: 7 * DAY_MS, + monthly: 30 * DAY_MS, +}; + /** Checks whether `value` is a {@link DigestCadence}. */ export function isDigestCadence(value: unknown): value is DigestCadence { return typeof value === "string" && diff --git a/src/App.css b/src/App.css index 071594bd..8cb226db 100644 --- a/src/App.css +++ b/src/App.css @@ -2735,6 +2735,13 @@ body.has-player .chat-fab { padding: 1rem; } +/* A list of dump or playlist cards in a group fills the group's column, its + edges in line with the label's rule. */ +.page-group .dump-feed { + padding: 0; + max-width: none; +} + /* A card of settings rules its rows apart; they keep the grid's columns, so the labels still line up. */ .page-card.profile-appearance-grid { @@ -5913,8 +5920,8 @@ body.has-player .chat-fab { padding: 0.2rem 0; } -/* ── Followed user list ── */ -.followed-user-list { +/* ── User cards: the follow tab's users, the community's new members ── */ +.user-card-list { list-style: none; margin: 0; padding: 0; @@ -5923,7 +5930,7 @@ body.has-player .chat-fab { gap: 0.5rem; } -.followed-user-card { +.user-card { display: flex; align-items: center; justify-content: space-between; @@ -5932,7 +5939,7 @@ body.has-player .chat-fab { border-radius: var(--radius-md); } -.followed-user-card-link { +.user-card-link { display: flex; align-items: center; gap: 0.6rem; @@ -5942,18 +5949,33 @@ body.has-player .chat-fab { flex: 1; } -.followed-user-card-link:hover .followed-user-card-name { +.user-card-link:hover .user-card-name { color: var(--color-accent); } -.followed-user-card-name { - font-size: var(--text-md); - font-weight: 600; +.user-card-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.user-card-name, +.user-card-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.user-card-name { + font-size: var(--text-md); + font-weight: 600; +} + +.user-card-note { + font-size: var(--text-sm); + color: var(--color-text-muted); +} + /* ── Invite tree ── */ /* Every row but the very first has the same gap above it, a child's as a sibling's: the gap is the node's top padding, so the connectors can run @@ -7562,3 +7584,39 @@ body.has-player .chat-fab { background: color-mix(in srgb, var(--color-danger, #c0392b) 18%, transparent); color: var(--color-danger, #c0392b); } + +/* ── Community page ── */ +.community-figures { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin: 0; +} + +.community-figure { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 0.2rem; + min-width: 0; +} + +.community-figure-value { + margin: 0; + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.community-figure-label { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-align: center; +} + +@media (max-width: 480px) { + .community-figures { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/App.tsx b/src/App.tsx index ee40898e..1fa2ec15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,8 @@ * link (`Unsubscribe`). * - `/notifications` (`Notifications`) and `/settings` (`Settings`), signed in * only, and `/manage`, the site's tools, for `category:manage` (`Manage`). + * - `/community` and `/community/monthly`, the site's week and month + * (`Community`), signed in only. * - `/@: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` @@ -81,6 +83,9 @@ const PlaylistDetail = lazy(() => default: m.PlaylistDetail, })) ); +const Community = lazy(() => + import("./pages/Community.tsx").then((m) => ({ default: m.Community })) +); const Notifications = lazy(() => import("./pages/Notifications.tsx").then((m) => ({ default: m.Notifications, @@ -207,6 +212,17 @@ function AppRoutes() { } /> + {["/community", "/community/:view"].map((path) => ( + + + + } + /> + ))} Playlists + setOpen(false)} + > + + - - - @{user.username} - - - - ); -} - /** * Returns page after page of what `userId` follows, from * `GET /api/users/:userId/follow/`, and the sentinel that loads the @@ -102,8 +79,8 @@ function FollowedUsers({ userId }: { userId: string }) { > {({ data }) => ( <> -
    - {data.items.map((u) => )} +
      + {data.items.map((u) => )}
    ` of a `.user-card-list`, with the follow button, + * which `FollowUserButton` hides on the viewer's own card. + */ +export function UserCard({ user, note }: UserCardProps): React.JSX.Element { + return ( +
  • + + + + @{user.username} + {note && {note}} + + + +
  • + ); +} diff --git a/src/config/api.ts b/src/config/api.ts index 2954841d..65ee4233 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -34,6 +34,8 @@ export const WS_URL: string = API_URL ? API_URL.replace(/^http/, "ws") : `${location.protocol.replace("http", "ws")}//${location.host}`; +/** The community endpoint, for the community page. */ +export const COMMUNITY_URL = `${API_URL}/api/community`; /** The search endpoint, for the search page. */ export const SEARCH_URL = `${API_URL}/api/search`; /** How many items a page of a feed or list asks the API for. */ diff --git a/src/pages/Community.tsx b/src/pages/Community.tsx new file mode 100644 index 00000000..e2e5c3f6 --- /dev/null +++ b/src/pages/Community.tsx @@ -0,0 +1,163 @@ +/** + * Renders the community page, `/community` for the last week and + * `/community/monthly` for the last month, for a signed-in user: the site's + * figures, its most upvoted dumps, who joined and the new public playlists. + * The server recomputes them on a timer (`community-service.ts`), and the + * digests send each reader the same sections, in the same words. + * + * @module + */ + +import { useCallback } from "react"; +import { i18n } from "@lingui/core"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Trans as Message } from "@lingui/react"; +import type { Wire } from "../../shared/api.ts"; +import type { CommunityStats } from "../../shared/community.ts"; +import type { DigestCadence } from "../../shared/settings.ts"; +import { COMMUNITY_VIEWS, communityPath } from "../../shared/paths.ts"; +import { COMMUNITY_URL } from "../config/api.ts"; +import { useAuth } from "../hooks/auth/useAuth.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { useFetchState } from "../hooks/shared/useFetchState.ts"; +import { useTabParam } from "../hooks/shared/useTabParam.ts"; +import { expectOk } from "../utils/shared/api.ts"; +import { deserializeCommunityStats } from "../utils/shared/deserialize.ts"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; +import { TabBar } from "../components/shared/TabBar.tsx"; +import { ListState } from "../components/shared/ListState.tsx"; +import { DumpCardList } from "../components/dump/DumpCardList.tsx"; +import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; +import { UserCard } from "../components/social/UserCard.tsx"; +import { NotFound } from "./NotFound.tsx"; + +/** Checks whether `stats` has nothing to show at all. */ +function isQuiet(stats: CommunityStats): boolean { + const { dumps, upvotes, comments } = stats.totals; + return stats.top.length + stats.members.length + stats.playlists.length + + stats.chatMessages + dumps + upvotes + comments === 0; +} + +/** Renders the period's four figures, each over its label. */ +function Figures({ stats }: { stats: CommunityStats }) { + const { dumps, upvotes, comments } = stats.totals; + const figures: [number, string][] = [ + [dumps, i18n._("digest.stat.dumps", { n: dumps })], + [upvotes, i18n._("digest.count.upvotes", { n: upvotes })], + [comments, i18n._("digest.count.comments", { n: comments })], + [ + stats.chatMessages, + i18n._("digest.stat.chat", { n: stats.chatMessages }), + ], + ]; + return ( +
    + {figures.map(([n, label]) => ( +
    +
    {label}
    +
    {i18n.number(n)}
    +
    + ))} +
    + ); +} + +/** + * Renders `/community` and `/community/:view`, from `GET /api/community`. + * A view no period names is `NotFound`. + */ +export function Community(): React.JSX.Element { + useDocumentTitle(i18n._("digest.community")); + const { user, authFetch } = useAuth(); + const [period, setPeriod] = useTabParam( + communityPath(), + COMMUNITY_VIEWS, + "weekly", + ); + + const load = useCallback( + async (signal: AbortSignal) => { + const url = new URL(COMMUNITY_URL, location.origin); + url.searchParams.set("period", period ?? "weekly"); + return deserializeCommunityStats( + await expectOk>( + await authFetch(url.toString(), { signal }), + ), + ); + }, + [authFetch, period], + ); + const { state } = useFetchState(period ?? undefined, load); + + if (!period) return ; + const weekly = period === "weekly"; + + return ( + + } /> + This week }, + { key: "monthly", label: This month }, + ]} + activeTab={period} + onChange={setPeriod} + /> + isQuiet(data)} + empty={ +

    + {weekly + ? t`Nothing happened this week.` + : t`Nothing happened this month.`} +

    + } + > + {({ data }) => ( + <> + + {data.top.length > 0 && ( + + : } + > + + + )} + {data.members.length > 0 && ( + }> +
      + {data.members.map((m) => ( + + : ( + + ))} + /> + ))} +
    +
    + )} + {data.playlists.length > 0 && ( + }> + + + )} + + )} +
    +
    + ); +} diff --git a/src/test/components/layout/UserMenu.test.tsx b/src/test/components/layout/UserMenu.test.tsx index 6af07d05..225c8b51 100644 --- a/src/test/components/layout/UserMenu.test.tsx +++ b/src/test/components/layout/UserMenu.test.tsx @@ -7,7 +7,7 @@ import { currentPath, renderApp, renderWithContexts } from "../../render.tsx"; import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts"; describe("UserMenu", () => { - test("opens, links to profile and playlists, closes on Escape and outside", async () => { + test("opens, links to profile, playlists, community and settings, closes on Escape and outside", async () => { const user = userEvent.setup(); renderWithContexts( <> @@ -26,6 +26,10 @@ describe("UserMenu", () => { "href", "/@ann/playlists", ); + expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute( + "href", + "/community", + ); expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveAttribute( "href", "/settings", diff --git a/src/test/pages/Community.test.tsx b/src/test/pages/Community.test.tsx new file mode 100644 index 00000000..a8002a67 --- /dev/null +++ b/src/test/pages/Community.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Community } from "../../pages/Community.tsx"; +import { calls, route } from "../fetch.ts"; +import { + makeDump, + makePlaylist, + makeSession, + makeUser, + wire, +} from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; + +function stats( + { + top = [] as ReturnType[], + members = [] as ReturnType[], + playlists = [] as ReturnType[], + chatMessages = 0, + totals = { dumps: 0, upvotes: 0, comments: 0 }, + } = {}, +) { + return wire({ + from: new Date("2026-10-02T00:00:00Z"), + to: new Date("2026-10-09T00:00:00Z"), + top, + members, + playlists, + chatMessages, + totals, + }); +} + +function open(url: string) { + const user = userEvent.setup(); + const session = makeSession({ username: "ann" }); + renderApp(, { route: url, path: "/community/*", session }); + return user; +} + +describe("Community", () => { + test("the week: figures, top dumps, new members and playlists", async () => { + route("GET /api/community", () => + stats({ + top: [makeDump({ title: "Big hit", voteCount: 7 })], + members: [ + makeUser({ username: "bob", invitedByUsername: "ann" }), + makeUser({ username: "cy", invitedByUsername: "bob" }), + makeUser({ username: "dee" }), + ], + playlists: [makePlaylist({ title: "Fresh mix", dumpCount: 3 })], + chatMessages: 1284, + totals: { dumps: 12, upvotes: 1, comments: 0 }, + })); + open("/community"); + expect(await screen.findByText("Big hit")).toBeInTheDocument(); + const [req] = calls("GET /api/community"); + expect(req.url.searchParams.get("period")).toBe("weekly"); + expect(req.headers.get("Authorization")).toMatch(/^Bearer /); + + expect(screen.getByRole("heading", { name: /Community/ })) + .toBeInTheDocument(); + expect(screen.getByText("Top this week")).toBeInTheDocument(); + expect(screen.getByText("1,284")).toBeInTheDocument(); + expect(screen.getByText("chat messages")).toBeInTheDocument(); + expect(screen.getByText("upvote")).toBeInTheDocument(); + expect(screen.getByText("invited by you")).toBeInTheDocument(); + expect(screen.getByText("invited by @bob")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /@dee/ })).toHaveAttribute( + "href", + "/@dee", + ); + expect(screen.getByText("Fresh mix")).toBeInTheDocument(); + }); + + test("the month is its own view, and a quiet one says so", async () => { + route( + "GET /api/community", + (req) => + req.url.searchParams.get("period") === "monthly" + ? stats() + : stats({ totals: { dumps: 1, upvotes: 0, comments: 0 } }), + ); + const user = open("/community"); + expect(await screen.findByText("dump")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "This month" })); + expect(currentPath()).toBe("/community/monthly"); + expect(await screen.findByText("Nothing happened this month.")) + .toBeInTheDocument(); + }); + + test("a view no period names is not found", () => { + open("/community/daily"); + expect(screen.getByText("This page does not exist.")).toBeInTheDocument(); + expect(calls("GET /api/community")).toHaveLength(0); + }); +}); diff --git a/src/utils/shared/deserialize.ts b/src/utils/shared/deserialize.ts index 60cfbe4d..0183c865 100644 --- a/src/utils/shared/deserialize.ts +++ b/src/utils/shared/deserialize.ts @@ -11,6 +11,7 @@ import type { Wire } from "../../../shared/api.ts"; import type { Category } from "../../../shared/category.ts"; import type { ChatMessage } from "../../../shared/chat.ts"; import type { Comment } from "../../../shared/comment.ts"; +import type { CommunityStats } from "../../../shared/community.ts"; import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts"; import type { Notification } from "../../../shared/notification.ts"; import type { @@ -127,3 +128,17 @@ export function deserializeNotification(raw: Wire): Notification { export function hydrateNotification(raw: unknown): Notification { return deserializeNotification(raw as Wire); } + +/** Returns `raw` as {@link CommunityStats}. */ +export function deserializeCommunityStats( + raw: Wire, +): CommunityStats { + return { + ...raw, + from: new Date(raw.from), + to: new Date(raw.to), + top: raw.top.map(deserializeDump), + members: raw.members.map(deserializePublicUser), + playlists: raw.playlists.map(deserializePlaylist), + }; +}