diff --git a/.env.example b/.env.example index 2bf403ce..115dd820 100644 --- a/.env.example +++ b/.env.example @@ -63,8 +63,9 @@ GERBEUR_FROM_EMAIL= GERBEUR_EMAIL_LOCALE=en # The instance's style: smooth (default), brutalist, geocities, nyt, kanagawa -# or melange. The welcome and password reset emails are drawn in it; a digest -# uses its reader's own style. +# or melange. Visitors and accounts see it until they pick one, and the welcome +# and password reset emails are drawn in it; a digest uses its reader's own +# style. Applied at runtime; changing it needs a restart of both the API and Vite. GERBEUR_DEFAULT_STYLE=smooth # Markdown body of the welcome email sent on account creation, replacing the diff --git a/README.md b/README.md index ad66db39..1ea42382 100644 --- a/README.md +++ b/README.md @@ -45,23 +45,23 @@ 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`), used by 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 | +| `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 @@ -148,7 +148,7 @@ api/ gerbeur.db # SQLite database (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) src/ # React frontend (Vite) - config/ # API base URL, feed tabs, player mode + config/ # API base URL, feed tabs, player mode, instance settings pages/ # One component per route, nothing else components/ # React components only (.tsx) contexts/ # FooContext.ts + FooProvider.tsx pairs diff --git a/api/config.ts b/api/config.ts index 8fa87cd7..a007adf9 100644 --- a/api/config.ts +++ b/api/config.ts @@ -43,9 +43,10 @@ export type EmailLocale = "en" | "fr"; export const EMAIL_LOCALE: EmailLocale = Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim() === "fr" ? "fr" : "en"; /** - * The instance's style (`GERBEUR_DEFAULT_STYLE`). The emails that aren't a - * user's own (the welcome and the password reset) are drawn in it. A value - * that isn't a style means "smooth". + * The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a + * visitor or their account picks one, through index.html, and the emails that + * aren't a user's own (the welcome and the password reset) are drawn in it. A + * value that isn't a style means "smooth". */ export const DEFAULT_STYLE: StyleName = (() => { const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim(); diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 59aa8418..c8edc0e9 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -25,6 +25,7 @@ import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation. import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts"; import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts"; import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts"; +import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts"; interface Migration { name: string; @@ -62,6 +63,7 @@ const MIGRATIONS: Migration[] = [ name: "0018_attachment_resource_type", up: up0018AttachmentResourceType, }, + { name: "0019_user_settings", up: up0019UserSettings }, ]; /** diff --git a/api/db/migrations/0019_user_settings.ts b/api/db/migrations/0019_user_settings.ts new file mode 100644 index 00000000..c8ec4e8e --- /dev/null +++ b/api/db/migrations/0019_user_settings.ts @@ -0,0 +1,28 @@ +/** + * Gives each user settings stored on the server, so that they follow the + * account across browsers and the server can read them: the digests need the + * reader's choice to receive them and their style. `user_settings` holds one + * row per user who changed anything; a user without one has the defaults. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; + +/** + * Run once by `runMigrations`. Existing users get no row, so they start with + * the defaults, digests on, like a new account. + */ +export function up(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY, + style TEXT, + color_scheme TEXT, + digest_weekly INTEGER NOT NULL DEFAULT 1, + digest_monthly INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index d56454d0..9b744672 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -213,6 +213,16 @@ CREATE UNIQUE INDEX idx_notifications_dedup ON notifications(user_id, source_key) WHERE source_key IS NOT NULL; +CREATE TABLE user_settings ( + user_id TEXT PRIMARY KEY, + style TEXT, + color_scheme TEXT, + digest_weekly INTEGER NOT NULL DEFAULT 1, + digest_monthly INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE chat_messages ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts index 2c1dd9b8..65fda03d 100644 --- a/api/lib/index-html.ts +++ b/api/lib/index-html.ts @@ -6,7 +6,13 @@ * @module */ -import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { + BANDCAMP_PLAYER, + DEFAULT_STYLE, + OG_SITE_NAME, + SITE_EMOJI, + SMTPS_URL, +} from "../config.ts"; import { emojiToCodepoint } from "./site-icons.ts"; /** @@ -21,8 +27,8 @@ const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); const indexHtml = new Map(); /** - * Returns `${root}/index.html` with the site name, emoji, icon version and - * Bandcamp player mode filled in. + * Returns `${root}/index.html` with the site name, emoji, icon version, + * Bandcamp player mode, default style and whether email is on filled in. * * @throws {Error} When the file can't be read. */ @@ -35,7 +41,9 @@ export async function loadIndexHtml(root: string): Promise { .replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__ICON_VERSION__", ICON_VERSION) - .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); + .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) + .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) + .replaceAll("__EMAIL_ENABLED__", SMTPS_URL ? "on" : "off"); indexHtml.set(root, { mtime, html }); return html; } diff --git a/api/model/settings.ts b/api/model/settings.ts new file mode 100644 index 00000000..dc92a34e --- /dev/null +++ b/api/model/settings.ts @@ -0,0 +1,55 @@ +/** + * Checks the rows `settings-service.ts` reads from `user_settings`, and turns + * them into the {@link UserSettings} the settings endpoints answer. + * + * @module + */ + +import type { SQLOutputValue } from "node:sqlite"; +import type { UserSettings } from "../../shared/settings.ts"; +import { isColorScheme, isStyleName } from "../../shared/theme.ts"; + +/** + * Stored by `updateSettings` and read back by `getSettings`. A user without a + * row has the defaults. + */ +export interface UserSettingsRow { + user_id: string; + /** A style name, or null for the instance's. */ + style: string | null; + color_scheme: string | null; + /** 0 or 1. */ + digest_weekly: number; + /** 0 or 1. */ + digest_monthly: number; + updated_at: string; + [key: string]: SQLOutputValue; +} + +/** + * Checks whether `obj`, a row `getSettings` selected, is a + * {@link UserSettingsRow}. + */ +export function isUserSettingsRow(obj: unknown): obj is UserSettingsRow { + return !!obj && typeof obj === "object" && + "user_id" in obj && typeof obj.user_id === "string" && + "style" in obj && (obj.style === null || typeof obj.style === "string") && + "color_scheme" in obj && + (obj.color_scheme === null || typeof obj.color_scheme === "string") && + "digest_weekly" in obj && typeof obj.digest_weekly === "number" && + "digest_monthly" in obj && typeof obj.digest_monthly === "number" && + "updated_at" in obj && typeof obj.updated_at === "string"; +} + +/** + * Converts a row to the {@link UserSettings} the client gets. A style or + * scheme the code no longer knows, as after one is removed, reads as unset. + */ +export function settingsRowToApi(row: UserSettingsRow): UserSettings { + return { + style: isStyleName(row.style) ? row.style : null, + colorScheme: isColorScheme(row.color_scheme) ? row.color_scheme : null, + digestWeekly: row.digest_weekly === 1, + digestMonthly: row.digest_monthly === 1, + }; +} diff --git a/api/routes/users.ts b/api/routes/users.ts index 3cfd72e0..332c80f0 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -19,6 +19,9 @@ * tokens. * - `PATCH /api/users/me` — signed in. Updates the caller's profile fields: * description and email. + * - `GET /api/users/me/settings` — signed in. Answers the caller's settings. + * - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's + * settings, and answers them all. * - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another * user's role. * - `GET /api/users/search?q=` — public. Answers up to 8 users whose name @@ -91,6 +94,9 @@ import { resetPassword, } from "../services/password-reset-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; +import type { UserSettings } from "../../shared/settings.ts"; +import { isUpdateSettingsRequest } from "../validators/settings.ts"; +import { getSettings, updateSettings } from "../services/settings-service.ts"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { getDumpsByUser, @@ -301,6 +307,26 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); +router.get("/me/settings", authMiddleware, (ctx: AuthContext) => { + const responseBody: APIResponse = { + success: true, + data: getSettings(ctx.state.user.userId), + }; + ctx.response.body = responseBody; +}); + +router.patch("/me/settings", authMiddleware, async (ctx: AuthContext) => { + const body: unknown = await ctx.request.body.json(); + if (!isUpdateSettingsRequest(body)) { + throw new APIException(400, "Invalid settings"); + } + const responseBody: APIResponse = { + success: true, + data: updateSettings(ctx.state.user.userId, body), + }; + ctx.response.body = responseBody; +}); + router.patch( "/:userId/role", authMiddleware, diff --git a/api/services/settings-service.ts b/api/services/settings-service.ts new file mode 100644 index 00000000..730c622c --- /dev/null +++ b/api/services/settings-service.ts @@ -0,0 +1,56 @@ +/** + * Reads and writes each user's settings in `user_settings`. The users routes + * answer `GET` and `PATCH /api/users/me/settings` with it, and the digests read + * their readers' choices from the same table. + * + * @module + */ + +import { db } from "../db/connection.ts"; +import { + DEFAULT_SETTINGS, + type UpdateSettingsRequest, + type UserSettings, +} from "../../shared/settings.ts"; +import { isUserSettingsRow, settingsRowToApi } from "../model/settings.ts"; + +/** Returns the settings of user `userId`, the defaults if they changed none. */ +export function getSettings(userId: string): UserSettings { + const row = db.prepare( + `SELECT user_id, style, color_scheme, digest_weekly, digest_monthly, updated_at + FROM user_settings WHERE user_id = ?;`, + ).get(userId); + return isUserSettingsRow(row) + ? settingsRowToApi(row) + : { ...DEFAULT_SETTINGS }; +} + +/** + * Applies `patch` to the settings of user `userId` and returns them all. The + * settings it leaves out keep their value. + */ +export function updateSettings( + userId: string, + patch: UpdateSettingsRequest, +): UserSettings { + const next = { ...getSettings(userId), ...patch }; + db.prepare( + `INSERT INTO user_settings + (user_id, style, color_scheme, digest_weekly, digest_monthly, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id) DO UPDATE SET + style = excluded.style, + color_scheme = excluded.color_scheme, + digest_weekly = excluded.digest_weekly, + digest_monthly = excluded.digest_monthly, + updated_at = excluded.updated_at;`, + ).run( + userId, + next.style, + next.colorScheme, + next.digestWeekly ? 1 : 0, + next.digestMonthly ? 1 : 0, + new Date().toISOString(), + ); + return next; +} diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index b4159715..838e860e 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, 18); + assertEquals(applied.n, 19); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 18, + 19, ); db.close(); }); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 729fdae2..23d3453f 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -34,6 +34,7 @@ const TABLES = [ "invites", "attachments", "chat_messages", + "user_settings", "categories", "playlists", "dumps", diff --git a/api/test/lib/index-html.test.ts b/api/test/lib/index-html.test.ts index e7a9d8af..b5917446 100644 --- a/api/test/lib/index-html.test.ts +++ b/api/test/lib/index-html.test.ts @@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a const path = `${root}/index.html`; await Deno.writeTextFile( path, - "__SITE_NAME____BANDCAMP_PLAYER__", + "__SITE_NAME____BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__", ); assertEquals( await loadIndexHtml(root), - "gerbeurembed", + "gerbeurembed smooth off", ); await Deno.writeTextFile(path, "rebuilt __SITE_NAME__"); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index b096a358..06d0d22e 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -29,6 +29,10 @@ import { import { authenticateMedia } from "../../lib/auth.ts"; import type { Dump } from "../../../shared/dump.ts"; import type { PaginatedData } from "../../../shared/api.ts"; +import { + DEFAULT_SETTINGS, + type UserSettings, +} from "../../../shared/settings.ts"; function invite(inviterId: string, createdAt = new Date()): string { const token = crypto.randomUUID(); @@ -541,3 +545,62 @@ test("GET /api/users/me/media-token issues a media token for the caller", async assertEquals(res.status, 200); assertEquals(await authenticateMedia(res.data.mediaToken), user.id); }); + +// ── Settings ───────────────────────────────────────────────────────────────── + +test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => { + const ann = await createUser(); + const bob = await createUser(); + const initial = await get("/api/users/me/settings", { + auth: ann.auth, + }); + assertEquals(initial.data, DEFAULT_SETTINGS); + + const changed = await patch("/api/users/me/settings", { + auth: ann.auth, + json: { style: "kanagawa", digestWeekly: false }, + }); + assertEquals(changed.data, { + ...DEFAULT_SETTINGS, + style: "kanagawa", + digestWeekly: false, + }); + const scheme = await patch("/api/users/me/settings", { + auth: ann.auth, + json: { colorScheme: "dark" }, + }); + assertEquals(scheme.data.style, "kanagawa"); + assertEquals(scheme.data.colorScheme, "dark"); + assertEquals(scheme.data.digestWeekly, false); + + const bobs = await get("/api/users/me/settings", { + auth: bob.auth, + }); + assertEquals(bobs.data, DEFAULT_SETTINGS); + // Deleting the account takes its settings along. + db.prepare(`DELETE FROM users WHERE id = ?;`).run(ann.id); + assertEquals(count(`SELECT COUNT(*) as n FROM user_settings;`), 0); +}); + +test("settings: signed in only, and a bad value or unknown key is refused", async () => { + const ann = await createUser(); + assertEquals((await get("/api/users/me/settings")).status, 401); + for ( + const json of [ + { style: "comic" }, + { colorScheme: "sepia" }, + { digestMonthly: "no" }, + { theme: "smooth" }, + [], + ] + ) { + const res = await patch("/api/users/me/settings", { auth: ann.auth, json }); + assertEquals(res.status, 400, JSON.stringify(json)); + } + // Null hands the style back to the instance's. + const reset = await patch("/api/users/me/settings", { + auth: ann.auth, + json: { style: null }, + }); + assertEquals(reset.data.style, null); +}); diff --git a/api/validators/settings.ts b/api/validators/settings.ts new file mode 100644 index 00000000..27ed279f --- /dev/null +++ b/api/validators/settings.ts @@ -0,0 +1,32 @@ +/** + * Checks the body of `PATCH /api/users/me/settings` before the users route + * trusts it. + * + * @module + */ + +import type { UpdateSettingsRequest } from "../../shared/settings.ts"; +import { isColorScheme, isStyleName } from "../../shared/theme.ts"; + +const VALIDATORS: Record boolean> = + { + style: (v) => v === null || isStyleName(v), + colorScheme: (v) => v === null || isColorScheme(v), + digestWeekly: (v) => typeof v === "boolean", + digestMonthly: (v) => typeof v === "boolean", + }; + +/** + * Checks whether `obj` is an {@link UpdateSettingsRequest}: an object whose + * every key is a setting, each with a value that setting takes. An unknown + * key fails, so a typo doesn't pass for a change. + */ +export function isUpdateSettingsRequest( + obj: unknown, +): obj is UpdateSettingsRequest { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false; + return Object.entries(obj).every(([key, value]) => + key in VALIDATORS && + VALIDATORS[key as keyof UpdateSettingsRequest](value) + ); +} diff --git a/index.html b/index.html index 8f346c6f..11a2dc3b 100644 --- a/index.html +++ b/index.html @@ -14,13 +14,15 @@ + + __SITE_NAME__
- + diff --git a/shared/settings.ts b/shared/settings.ts new file mode 100644 index 00000000..64cc4c71 --- /dev/null +++ b/shared/settings.ts @@ -0,0 +1,43 @@ +/** + * A user's settings follow their account from browser to browser, and the + * server reads some of them itself (a digest is drawn in its reader's style). + * This module types them for `GET` and `PATCH /api/users/me/settings`; the + * client keeps them with its session. + * + * @module + */ + +import type { ColorScheme, StyleName } from "./theme.ts"; + +/** + * Returned by `GET /api/users/me/settings`, and by a `PATCH` once applied. + * The client's `useSettings` reads it from the session. + */ +export interface UserSettings { + /** + * Null until the user picks one, so the instance's default style + * (`GERBEUR_DEFAULT_STYLE`) applies, and so a browser's earlier choice can be + * carried over on first sign-in. + */ + style: StyleName | null; + /** Null until the user picks one, which reads as "auto". */ + colorScheme: ColorScheme | null; + /** Whether the weekly digest is sent. */ + digestWeekly: boolean; + /** Whether the monthly recap is sent. */ + digestMonthly: boolean; +} + +/** + * Sent to `PATCH /api/users/me/settings` with the settings to change; the + * others keep their value. + */ +export type UpdateSettingsRequest = Partial; + +/** The settings of a user who never changed any. Digests are on by default. */ +export const DEFAULT_SETTINGS: UserSettings = { + style: null, + colorScheme: null, + digestWeekly: true, + digestMonthly: true, +}; diff --git a/src/App.css b/src/App.css index 1cb885f7..44a15df3 100644 --- a/src/App.css +++ b/src/App.css @@ -7034,6 +7034,10 @@ body.has-player .chat-fab { opacity: 0.7; } +.profile-appearance-note { + margin: 0.75rem 0 0; +} + /* ── Categories ─────────────────────────────────────────────────────────── */ .visually-hidden { diff --git a/src/App.tsx b/src/App.tsx index 5f9d6615..c0cbbedf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -231,14 +231,14 @@ function AppRoutes() { } /** - * Renders the app. The player, socket, follow and chat providers read the - * session, and `ChatProvider` reads the URL, so it sits inside the router. + * Renders the app. The theme, player, socket, follow and chat providers read + * the session, and `ChatProvider` reads the URL, so it sits inside the router. * `GlobalPlayer` sits outside the routes, so playback survives navigation. */ export function App(): React.JSX.Element { return ( - - + + @@ -253,7 +253,7 @@ export function App(): React.JSX.Element { - - + + ); } diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/components/profile/ProfileSettingsTab.tsx index b22b93d1..104240a9 100644 --- a/src/components/profile/ProfileSettingsTab.tsx +++ b/src/components/profile/ProfileSettingsTab.tsx @@ -12,6 +12,8 @@ import type { FeedTab } from "../../config/feedTabs.ts"; import { useTheme } from "../../hooks/theme/useTheme.ts"; import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; import { useSoundsEnabled } from "../../hooks/shared/useSoundsEnabled.ts"; +import { useSettings } from "../../hooks/auth/useSettings.ts"; +import { EMAIL_ENABLED } from "../../config/instance.ts"; import { previewChime } from "../../utils/shared/sound.ts"; import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx"; import { ChangePasswordModal } from "./ChangePasswordModal.tsx"; @@ -48,14 +50,18 @@ const ON_OFF_OPTIONS: SegmentedOption[] = [ /** * Renders the password change, which opens {@link ChangePasswordModal}, and - * the style, color scheme, default feed tab and sounds, each stored for this - * browser as soon as it is clicked. `UserPublicProfile` renders it on one's - * own profile only. + * the settings, each saved as soon as it is clicked. The style, color scheme + * and digests are saved to the account; the default feed tab and sounds, for + * now, to this browser. `UserPublicProfile` renders it on one's own profile + * only. */ export function ProfileSettingsTab(): React.JSX.Element { const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); + const { settings, updateSettings } = useSettings(); + // Until the session has the settings, a click would save over unknown ones. + const digestsDisabled = !EMAIL_ENABLED || !settings; const [changePasswordOpen, setChangePasswordOpen] = useState(false); return ( @@ -117,6 +123,35 @@ export function ProfileSettingsTab(): React.JSX.Element { + Email}> +
+ Weekly digest}> + + updateSettings({ digestWeekly: on }).catch(() => {})} + /> + + Monthly recap}> + + updateSettings({ digestMonthly: on }).catch(() => {})} + /> + +
+ {!EMAIL_ENABLED && ( +

+ + This site doesn't send email, so no digest goes out. + +

+ )} +
{changePasswordOpen && ( setChangePasswordOpen(false)} /> )} diff --git a/src/config/instance.ts b/src/config/instance.ts new file mode 100644 index 00000000..603d5f32 --- /dev/null +++ b/src/config/instance.ts @@ -0,0 +1,35 @@ +/** + * Lets a deployment set what the app can't know from the browser: the style + * a visitor sees before choosing one, and whether the server sends email, + * without which the digest settings do nothing. + * + * The server writes `GERBEUR_DEFAULT_STYLE` into index.html's `default-style` + * `` tag, and whether `GERBEUR_SMTPS_URL` is set into `email-enabled` + * (`api/lib/index-html.ts`, and the Vite config in development). index.html's + * inline theme script reads the first one too. + * + * @module + */ + +import { isStyleName, type StyleName } from "../../shared/theme.ts"; + +function readMeta(name: string): string | undefined { + return document.querySelector(`meta[name="${name}"]`)?.getAttribute( + "content", + )?.trim(); +} + +/** + * The instance's style, which applies until a visitor or their account picks + * one. A missing or unknown value means "smooth". `ThemeProvider` reads it. + */ +export const DEFAULT_STYLE: StyleName = (() => { + const meta = readMeta("default-style"); + return isStyleName(meta) ? meta : "smooth"; +})(); + +/** + * Whether the server sends email. Without it the settings tab shows the + * digest switches disabled, with a note why. + */ +export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on"; diff --git a/src/contexts/auth/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx index 0e28d9c0..f0915435 100644 --- a/src/contexts/auth/AuthProvider.tsx +++ b/src/contexts/auth/AuthProvider.tsx @@ -10,6 +10,7 @@ import { type ReactNode, useEffect, useState } from "react"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { deserializeSelfUser } from "../../utils/shared/deserialize.ts"; +import type { UserSettings } from "../../../shared/settings.ts"; import { API_URL } from "../../config/api.ts"; import { AUTH_STORAGE_KEY, @@ -27,8 +28,9 @@ export interface AuthProviderProps { /** * Provides {@link AuthContext}. The session starts as the stored one and * follows it when another tab changes it. The server checks it on mount and - * whenever the tab regains focus: a 401 ends it, and a user changed on the - * server replaces the stored one. A session from before media tokens gets one. + * whenever the tab regains focus: a 401 ends it, and a user or settings + * changed on the server replace the stored ones. A session from before media + * tokens gets one. */ export function AuthProvider( { children }: AuthProviderProps, @@ -89,6 +91,27 @@ export function AuthProvider( }); } } + // The settings follow the account, so another browser may have + // changed them. A failure leaves the ones the session has. + const settingsRes = await fetch(`${API_URL}/api/users/me/settings`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (settingsRes.ok) { + const json = await settingsRes.json(); + if (json?.success && json.data) { + const settings = json.data as UserSettings; + setAuthResponse((prev) => { + if ( + !prev || prev.token !== token || + JSON.stringify(prev.settings) === JSON.stringify(settings) + ) return prev; + const updated = { ...prev, settings }; + storeSession(updated); + return updated; + }); + } + } } catch { // Offline is not signed out: only a 401 ends the session. } diff --git a/src/contexts/theme/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts index 97b45388..9d9a08c8 100644 --- a/src/contexts/theme/ThemeContext.ts +++ b/src/contexts/theme/ThemeContext.ts @@ -9,8 +9,8 @@ import { createContext } from "react"; import type { ColorScheme, StyleName } from "../../../shared/theme.ts"; /** - * Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the - * choice for this browser. + * Provided by `ThemeProvider` to `useTheme`. A setter applies the choice and + * stores it in this browser, and in the account's settings when signed in. */ export interface ThemeContextValue { style: StyleName; diff --git a/src/contexts/theme/ThemeProvider.tsx b/src/contexts/theme/ThemeProvider.tsx index 86c796af..2d24ceae 100644 --- a/src/contexts/theme/ThemeProvider.tsx +++ b/src/contexts/theme/ThemeProvider.tsx @@ -1,12 +1,14 @@ /** - * Lets each browser pick the site's style and color scheme. It holds the - * choice, stores it in localStorage and applies it to the document. `App` - * mounts it outermost. + * Lets each user pick the site's style and color scheme, and applies the + * choice to the document. A signed-in user's choice is one of their settings, + * so it follows them across browsers; a visitor's stays in this browser. Until + * either picks a style, the instance's default applies. `App` mounts it inside + * `AuthProvider`, whose session holds the settings. * * @module */ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ThemeContext } from "./ThemeContext.ts"; import { COLOR_SCHEMES, @@ -14,49 +16,90 @@ import { STYLE_NAMES, type StyleName, } from "../../../shared/theme.ts"; +import type { UpdateSettingsRequest } from "../../../shared/settings.ts"; +import { DEFAULT_STYLE } from "../../config/instance.ts"; +import { useSettings } from "../../hooks/auth/useSettings.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; /** Passed to {@link ThemeProvider} by `App`. */ export interface ThemeProviderProps { children: React.ReactNode; } -/** Returns the stored `key` when it is one of `allowed`, else `fallback`. */ +/** Returns the stored `key` when it is one of `allowed`, else null. */ function readChoice( key: string, allowed: readonly T[], - fallback: T, -): T { +): T | null { try { const stored = localStorage.getItem(key); return (allowed as readonly string[]).includes(stored ?? "") ? stored as T - : fallback; + : null; } catch { - return fallback; + return null; + } +} + +function writeChoice(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Blocked storage only costs the choice at the next visit. } } /** - * Provides {@link ThemeContext}. It sets the choices on the root element as + * Provides {@link ThemeContext}. It sets the theme on the root element as * `data-style` and `data-color-scheme`, which the stylesheets key on, and - * leaves them out for the defaults, "smooth" and "auto". + * leaves them out for "smooth" and "auto". * - * An inline script in index.html sets the same attributes from the same - * localStorage keys before the first paint, so the page doesn't flash the - * default theme. The two must agree. + * This browser's localStorage keeps the last choice made or read from the + * account, which an inline script in index.html applies before the first + * paint, so the page doesn't flash another theme. The two must agree. When an + * account has no choice yet and this browser has one, it is saved to the + * account, once per session. */ export function ThemeProvider( { children }: ThemeProviderProps, ): React.JSX.Element { - // A value from an older version, or edited by hand, falls back to the - // default ("smooth" was once stored as "default"). - const [style, setStyleState] = useState(() => - readChoice("style", STYLE_NAMES, "smooth") + const { settings, updateSettings } = useSettings(); + const { token } = useAuth(); + // An unknown stored value, such as "default", which "smooth" was once + // stored as, reads as no choice. + const [localStyle, setLocalStyle] = useState(() => + readChoice("style", STYLE_NAMES) ); - const [colorScheme, setColorSchemeState] = useState(() => - readChoice("color-scheme", COLOR_SCHEMES, "auto") + const [localScheme, setLocalScheme] = useState(() => + readChoice("color-scheme", COLOR_SCHEMES) ); + const accountStyle = settings?.style ?? null; + const accountScheme = settings?.colorScheme ?? null; + useEffect(() => { + if (accountStyle) writeChoice("style", accountStyle); + }, [accountStyle]); + useEffect(() => { + if (accountScheme) writeChoice("color-scheme", accountScheme); + }, [accountScheme]); + + const carriedOver = useRef(null); + useEffect(() => { + if (!settings || !token || carriedOver.current === token) return; + carriedOver.current = token; + const patch: UpdateSettingsRequest = {}; + if (settings.style === null && localStyle) patch.style = localStyle; + if (settings.colorScheme === null && localScheme) { + patch.colorScheme = localScheme; + } + if (Object.keys(patch).length > 0) { + updateSettings(patch).catch(() => {/* tried again next session */}); + } + }, [settings, token, localStyle, localScheme, updateSettings]); + + const style = accountStyle ?? localStyle ?? DEFAULT_STYLE; + const colorScheme = accountScheme ?? localScheme ?? "auto"; + useEffect(() => { const el = document.documentElement; if (style === "smooth") { @@ -64,7 +107,6 @@ export function ThemeProvider( } else { el.setAttribute("data-style", style); } - localStorage.setItem("style", style); }, [style]); useEffect(() => { @@ -74,15 +116,24 @@ export function ThemeProvider( } else { el.setAttribute("data-color-scheme", colorScheme); } - localStorage.setItem("color-scheme", colorScheme); }, [colorScheme]); - const value = useMemo(() => ({ - style, - colorScheme, - setStyle: setStyleState, - setColorScheme: setColorSchemeState, - }), [style, colorScheme]); + const signedIn = settings !== null; + const setStyle = useCallback((next: StyleName) => { + setLocalStyle(next); + writeChoice("style", next); + if (signedIn) updateSettings({ style: next }).catch(() => {}); + }, [signedIn, updateSettings]); + const setColorScheme = useCallback((next: ColorScheme) => { + setLocalScheme(next); + writeChoice("color-scheme", next); + if (signedIn) updateSettings({ colorScheme: next }).catch(() => {}); + }, [signedIn, updateSettings]); + + const value = useMemo( + () => ({ style, colorScheme, setStyle, setColorScheme }), + [style, colorScheme, setStyle, setColorScheme], + ); return ( diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index c44d5228..d4ebf0f0 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -10,6 +10,7 @@ import { useCallback, useContext } from "react"; import { AuthContext } from "../../contexts/auth/AuthContext.ts"; import type { AuthResponse, SelfUser } from "../../../shared/user.ts"; +import type { UserSettings } from "../../../shared/settings.ts"; import type { Session } from "../../utils/auth/storage.ts"; import { forgetStoredSession, @@ -23,6 +24,11 @@ import { */ export interface UseAuthResult { user: SelfUser | null; + /** + * The user's settings, once `AuthProvider` has read them. `useSettings` is + * the way to read and change them. + */ + settings: UserSettings | null; token: string | null; /** * The token for file and thumbnail URLs, where it shows in the query string. @@ -174,6 +180,7 @@ export function useAuth(): UseAuthResult { return { user: authResponse?.user ?? null, + settings: authResponse?.settings ?? null, token: authResponse?.token ?? null, mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null, login, @@ -201,6 +208,7 @@ export interface UseRequiredAuthResult extends UseAuthResult { export function useRequiredAuth(): UseRequiredAuthResult { const { user, + settings, token, mediaToken, login, @@ -217,6 +225,7 @@ export function useRequiredAuth(): UseRequiredAuthResult { } return { user, + settings, token, mediaToken, login, diff --git a/src/hooks/auth/useSettings.ts b/src/hooks/auth/useSettings.ts new file mode 100644 index 00000000..d79a6f45 --- /dev/null +++ b/src/hooks/auth/useSettings.ts @@ -0,0 +1,60 @@ +/** + * Gives components the signed-in user's settings, which are stored on the + * server and kept with the session. `ThemeProvider` reads the style and + * scheme from it, and the profile's settings tab the digest switches. + * + * @module + */ + +import { useCallback } from "react"; + +import { API_URL } from "../../config/api.ts"; +import type { + UpdateSettingsRequest, + UserSettings, +} from "../../../shared/settings.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { useAuth } from "./useAuth.ts"; + +/** Returned by {@link useSettings} to whatever reads or changes settings. */ +export interface UseSettingsResult { + /** + * The settings, or null when signed out or before `AuthProvider` has read + * them from the server. + */ + settings: UserSettings | null; + /** + * Applies `patch` at once, then saves it with `PATCH + * /api/users/me/settings`. A refusal puts the previous settings back and + * throws. + */ + updateSettings: (patch: UpdateSettingsRequest) => Promise; +} + +/** Returns the settings and their setter. It must be called under `AuthProvider`. */ +export function useSettings(): UseSettingsResult { + const { authFetch, updateSession, token, settings } = useAuth(); + + const updateSettings = useCallback( + async (patch: UpdateSettingsRequest) => { + if (!token) return; + const previous = settings; + if (previous) updateSession({ settings: { ...previous, ...patch } }); + try { + const saved = await expectOk( + await authFetch(`${API_URL}/api/users/me/settings`, { + method: "PATCH", + body: JSON.stringify(patch satisfies UpdateSettingsRequest), + }), + ); + updateSession({ settings: saved }); + } catch (err) { + updateSession({ settings: previous ?? undefined }); + throw err; + } + }, + [authFetch, updateSession, token, settings], + ); + + return { settings, updateSettings }; +} diff --git a/src/locales/en.js b/src/locales/en.js index cf6ba234..411e8fa6 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"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"]],"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"],""],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"P5MuiB":["Add image"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"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."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qnUJBI":["The new order couldn\'t be saved. Try again."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', + '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"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"]],"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"],""],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"O3oNi5":["Email"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"P5MuiB":["Add image"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"SXLuHT":["Weekly digest"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WF0Vw0":["Monthly recap"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"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."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jTGs8A":["This site doesn\'t send email, so no digest goes out."],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qnUJBI":["The new order couldn\'t be saved. Try again."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', ), }; diff --git a/src/locales/en.po b/src/locales/en.po index 1afa1027..189cde9d 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:108 +#: src/components/playlist/PlaylistCard.tsx:117 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" @@ -155,7 +155,7 @@ msgstr "a comment" msgid "a post" msgstr "a post" -#: src/components/profile/ProfileSettingsTab.tsx:66 +#: src/components/profile/ProfileSettingsTab.tsx:69 msgid "Account" msgstr "Account" @@ -204,7 +204,7 @@ msgstr "Already have an account? <0>Log in" msgid "An unexpected error occurred." msgstr "An unexpected error occurred." -#: src/components/profile/ProfileSettingsTab.tsx:79 +#: src/components/profile/ProfileSettingsTab.tsx:82 msgid "Appearance" msgstr "Appearance" @@ -219,7 +219,7 @@ msgstr "At least {0} characters" msgid "At most {maxLength} characters." msgstr "At most {maxLength} characters." -#: src/components/profile/ProfileSettingsTab.tsx:35 +#: src/components/profile/ProfileSettingsTab.tsx:34 msgid "Auto" msgstr "Auto" @@ -283,7 +283,7 @@ msgstr "Change image" msgid "Change password" msgstr "Change password" -#: src/components/profile/ProfileSettingsTab.tsx:74 +#: src/components/profile/ProfileSettingsTab.tsx:77 msgid "Change password…" msgstr "Change password…" @@ -332,7 +332,7 @@ msgstr "Close without posting? The attached file won't be kept — everything yo msgid "Collapse player" msgstr "Collapse player" -#: src/components/profile/ProfileSettingsTab.tsx:88 +#: src/components/profile/ProfileSettingsTab.tsx:91 msgid "Color scheme" msgstr "Color scheme" @@ -404,11 +404,11 @@ msgstr "Creating…" msgid "Current password" msgstr "Current password" -#: src/components/profile/ProfileSettingsTab.tsx:37 +#: src/components/profile/ProfileSettingsTab.tsx:36 msgid "Dark" msgstr "Dark" -#: src/components/profile/ProfileSettingsTab.tsx:99 +#: src/components/profile/ProfileSettingsTab.tsx:102 msgid "Default tab" msgstr "Default tab" @@ -443,7 +443,7 @@ msgstr "Delete dump" msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/playlist/PlaylistCard.tsx:132 +#: src/components/playlist/PlaylistCard.tsx:141 #: src/pages/PlaylistDetail.tsx:136 #: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" @@ -597,6 +597,10 @@ msgstr "Editing" msgid "Editing {0}" msgstr "Editing {0}" +#: src/components/profile/ProfileSettingsTab.tsx:126 +msgid "Email" +msgstr "Email" + #: src/pages/UserRegister.tsx:152 msgid "Email address" msgstr "Email address" @@ -666,7 +670,7 @@ msgstr "Failed to update avatar" msgid "Failed to update role" msgstr "Failed to update role" -#: src/components/profile/ProfileSettingsTab.tsx:97 +#: src/components/profile/ProfileSettingsTab.tsx:100 msgid "Feeds" msgstr "Feeds" @@ -700,7 +704,7 @@ msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." #: src/components/feed/FeedTabBar.tsx:49 -#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/components/profile/ProfileSettingsTab.tsx:43 #: src/pages/UserPublicProfile.tsx:191 msgid "Followed" msgstr "Followed" @@ -746,7 +750,7 @@ msgid "Go to login" msgstr "Go to login" #: src/components/feed/FeedTabBar.tsx:45 -#: src/components/profile/ProfileSettingsTab.tsx:41 +#: src/components/profile/ProfileSettingsTab.tsx:40 msgid "Hot" msgstr "Hot" @@ -790,7 +794,7 @@ msgid "Invitees" msgstr "Invitees" #: src/components/feed/FeedTabBar.tsx:47 -#: src/components/profile/ProfileSettingsTab.tsx:43 +#: src/components/profile/ProfileSettingsTab.tsx:42 msgid "Journal" msgstr "Journal" @@ -798,7 +802,7 @@ msgstr "Journal" msgid "Keep editing" msgstr "Keep editing" -#: src/components/profile/ProfileSettingsTab.tsx:36 +#: src/components/profile/ProfileSettingsTab.tsx:35 msgid "Light" msgstr "Light" @@ -905,6 +909,10 @@ msgstr "Max {limit}" msgid "Moderator" msgstr "Moderator" +#: src/components/profile/ProfileSettingsTab.tsx:137 +msgid "Monthly recap" +msgstr "Monthly recap" + #: src/components/player/MediaPlayer.tsx:346 msgid "Mute" msgstr "Mute" @@ -920,7 +928,7 @@ msgstr "new" #: src/components/chat/ChatModal.tsx:669 #: src/components/feed/FeedTabBar.tsx:46 -#: src/components/profile/ProfileSettingsTab.tsx:42 +#: src/components/profile/ProfileSettingsTab.tsx:41 msgid "New" msgstr "New" @@ -1014,7 +1022,7 @@ msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:54 -#: src/components/profile/ProfileSettingsTab.tsx:108 +#: src/components/profile/ProfileSettingsTab.tsx:111 #: src/pages/Notifications.tsx:282 #: src/pages/Notifications.tsx:334 msgid "Notifications" @@ -1024,11 +1032,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" -#: src/components/profile/ProfileSettingsTab.tsx:49 +#: src/components/profile/ProfileSettingsTab.tsx:48 msgid "Off" msgstr "Off" -#: src/components/profile/ProfileSettingsTab.tsx:48 +#: src/components/profile/ProfileSettingsTab.tsx:47 msgid "On" msgstr "On" @@ -1057,7 +1065,7 @@ msgstr "or <0>browse files" msgid "Page not found" msgstr "Page not found" -#: src/components/profile/ProfileSettingsTab.tsx:68 +#: src/components/profile/ProfileSettingsTab.tsx:71 #: src/pages/UserLogin.tsx:82 msgid "Password" msgstr "Password" @@ -1144,7 +1152,7 @@ msgstr "Previous track" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 -#: src/components/playlist/PlaylistCard.tsx:95 +#: src/components/playlist/PlaylistCard.tsx:103 #: src/components/playlist/PlaylistHeader.tsx:75 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 @@ -1155,7 +1163,7 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/playlist/PlaylistCard.tsx:94 +#: src/components/playlist/PlaylistCard.tsx:102 #: src/components/playlist/PlaylistHeader.tsx:75 msgid "public" msgstr "public" @@ -1356,7 +1364,7 @@ msgstr "Some images couldn't be uploaded." msgid "Something went wrong" msgstr "Something went wrong" -#: src/components/profile/ProfileSettingsTab.tsx:110 +#: src/components/profile/ProfileSettingsTab.tsx:113 msgid "Sounds" msgstr "Sounds" @@ -1364,7 +1372,7 @@ msgstr "Sounds" msgid "Start fresh" msgstr "Start fresh" -#: src/components/profile/ProfileSettingsTab.tsx:81 +#: src/components/profile/ProfileSettingsTab.tsx:84 msgid "Style" msgstr "Style" @@ -1412,6 +1420,10 @@ msgstr "This page does not exist." msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." +#: src/components/profile/ProfileSettingsTab.tsx:149 +msgid "This site doesn't send email, so no digest goes out." +msgstr "This site doesn't send email, so no digest goes out." + #: src/pages/DumpEdit.tsx:433 msgid "Thumbnail" msgstr "Thumbnail" @@ -1540,6 +1552,10 @@ msgstr "View dump →" msgid "Volume" msgstr "Volume" +#: src/components/profile/ProfileSettingsTab.tsx:128 +msgid "Weekly digest" +msgstr "Weekly digest" + #: src/components/dump/create/DumpCreateModal.tsx:711 #: src/pages/DumpEdit.tsx:515 msgid "What makes it worth it?" diff --git a/src/locales/fr.js b/src/locales/fr.js index cddc8013..8f61a8d3 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"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"]],"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"],""],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"P5MuiB":["Ajouter une image"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"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."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qnUJBI":["Le nouvel ordre n\'a pas pu être enregistré. Réessayez."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', + '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"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"]],"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"],""],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"O3oNi5":["E-mail"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"P5MuiB":["Ajouter une image"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"SXLuHT":["Résumé hebdomadaire"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WF0Vw0":["Récap mensuel"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"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."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jTGs8A":["Ce site n\'envoie pas d\'e-mails, aucun résumé ne part donc."],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qnUJBI":["Le nouvel ordre n\'a pas pu être enregistré. Réessayez."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', ), }; diff --git a/src/locales/fr.po b/src/locales/fr.po index 75436f4f..0bcb8a04 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/playlist/PlaylistCard.tsx:108 +#: src/components/playlist/PlaylistCard.tsx:117 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" @@ -155,7 +155,7 @@ msgstr "un commentaire" msgid "a post" msgstr "une publication" -#: src/components/profile/ProfileSettingsTab.tsx:66 +#: src/components/profile/ProfileSettingsTab.tsx:69 msgid "Account" msgstr "Compte" @@ -204,7 +204,7 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter" msgid "An unexpected error occurred." msgstr "Une erreur inattendue s'est produite." -#: src/components/profile/ProfileSettingsTab.tsx:79 +#: src/components/profile/ProfileSettingsTab.tsx:82 msgid "Appearance" msgstr "Apparence" @@ -219,7 +219,7 @@ msgstr "Au moins {0} caractères" msgid "At most {maxLength} characters." msgstr "{maxLength} caractères au maximum." -#: src/components/profile/ProfileSettingsTab.tsx:35 +#: src/components/profile/ProfileSettingsTab.tsx:34 msgid "Auto" msgstr "Auto" @@ -283,7 +283,7 @@ msgstr "Changer l'image" msgid "Change password" msgstr "Changer le mot de passe" -#: src/components/profile/ProfileSettingsTab.tsx:74 +#: src/components/profile/ProfileSettingsTab.tsx:77 msgid "Change password…" msgstr "Changer le mot de passe…" @@ -332,7 +332,7 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez msgid "Collapse player" msgstr "Réduire le lecteur" -#: src/components/profile/ProfileSettingsTab.tsx:88 +#: src/components/profile/ProfileSettingsTab.tsx:91 msgid "Color scheme" msgstr "Thème de couleur" @@ -404,11 +404,11 @@ msgstr "Création…" msgid "Current password" msgstr "Mot de passe actuel" -#: src/components/profile/ProfileSettingsTab.tsx:37 +#: src/components/profile/ProfileSettingsTab.tsx:36 msgid "Dark" msgstr "Sombre" -#: src/components/profile/ProfileSettingsTab.tsx:99 +#: src/components/profile/ProfileSettingsTab.tsx:102 msgid "Default tab" msgstr "Onglet par défaut" @@ -443,7 +443,7 @@ msgstr "Supprimer la reco" msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/playlist/PlaylistCard.tsx:132 +#: src/components/playlist/PlaylistCard.tsx:141 #: src/pages/PlaylistDetail.tsx:136 #: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" @@ -597,6 +597,10 @@ msgstr "Modification" msgid "Editing {0}" msgstr "Édition de {0}" +#: src/components/profile/ProfileSettingsTab.tsx:126 +msgid "Email" +msgstr "E-mail" + #: src/pages/UserRegister.tsx:152 msgid "Email address" msgstr "Adresse e-mail" @@ -666,7 +670,7 @@ msgstr "Impossible de mettre à jour l'avatar" msgid "Failed to update role" msgstr "Erreur lors de la mise à jour du rôle" -#: src/components/profile/ProfileSettingsTab.tsx:97 +#: src/components/profile/ProfileSettingsTab.tsx:100 msgid "Feeds" msgstr "Flux" @@ -700,7 +704,7 @@ msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." #: src/components/feed/FeedTabBar.tsx:49 -#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/components/profile/ProfileSettingsTab.tsx:43 #: src/pages/UserPublicProfile.tsx:191 msgid "Followed" msgstr "Suivi" @@ -746,7 +750,7 @@ msgid "Go to login" msgstr "Aller à la connexion" #: src/components/feed/FeedTabBar.tsx:45 -#: src/components/profile/ProfileSettingsTab.tsx:41 +#: src/components/profile/ProfileSettingsTab.tsx:40 msgid "Hot" msgstr "Tendances" @@ -790,7 +794,7 @@ msgid "Invitees" msgstr "Invités" #: src/components/feed/FeedTabBar.tsx:47 -#: src/components/profile/ProfileSettingsTab.tsx:43 +#: src/components/profile/ProfileSettingsTab.tsx:42 msgid "Journal" msgstr "Journal" @@ -798,7 +802,7 @@ msgstr "Journal" msgid "Keep editing" msgstr "Continuer l'édition" -#: src/components/profile/ProfileSettingsTab.tsx:36 +#: src/components/profile/ProfileSettingsTab.tsx:35 msgid "Light" msgstr "Clair" @@ -905,6 +909,10 @@ msgstr "Max {limit}" msgid "Moderator" msgstr "Modérateur" +#: src/components/profile/ProfileSettingsTab.tsx:137 +msgid "Monthly recap" +msgstr "Récap mensuel" + #: src/components/player/MediaPlayer.tsx:346 msgid "Mute" msgstr "Couper le son" @@ -920,7 +928,7 @@ msgstr "nouveau" #: src/components/chat/ChatModal.tsx:669 #: src/components/feed/FeedTabBar.tsx:46 -#: src/components/profile/ProfileSettingsTab.tsx:42 +#: src/components/profile/ProfileSettingsTab.tsx:41 msgid "New" msgstr "Nouveau" @@ -1014,7 +1022,7 @@ msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:54 -#: src/components/profile/ProfileSettingsTab.tsx:108 +#: src/components/profile/ProfileSettingsTab.tsx:111 #: src/pages/Notifications.tsx:282 #: src/pages/Notifications.tsx:334 msgid "Notifications" @@ -1024,11 +1032,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" -#: src/components/profile/ProfileSettingsTab.tsx:49 +#: src/components/profile/ProfileSettingsTab.tsx:48 msgid "Off" msgstr "Désactivés" -#: src/components/profile/ProfileSettingsTab.tsx:48 +#: src/components/profile/ProfileSettingsTab.tsx:47 msgid "On" msgstr "Activés" @@ -1057,7 +1065,7 @@ msgstr "ou <0>parcourir les fichiers" msgid "Page not found" msgstr "Page introuvable" -#: src/components/profile/ProfileSettingsTab.tsx:68 +#: src/components/profile/ProfileSettingsTab.tsx:71 #: src/pages/UserLogin.tsx:82 msgid "Password" msgstr "Mot de passe" @@ -1144,7 +1152,7 @@ msgstr "Piste précédente" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 -#: src/components/playlist/PlaylistCard.tsx:95 +#: src/components/playlist/PlaylistCard.tsx:103 #: src/components/playlist/PlaylistHeader.tsx:75 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 @@ -1155,7 +1163,7 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/playlist/PlaylistCard.tsx:94 +#: src/components/playlist/PlaylistCard.tsx:102 #: src/components/playlist/PlaylistHeader.tsx:75 msgid "public" msgstr "public" @@ -1356,7 +1364,7 @@ msgstr "Certaines images n'ont pas pu être envoyées." msgid "Something went wrong" msgstr "Une erreur est survenue" -#: src/components/profile/ProfileSettingsTab.tsx:110 +#: src/components/profile/ProfileSettingsTab.tsx:113 msgid "Sounds" msgstr "Sons" @@ -1364,7 +1372,7 @@ msgstr "Sons" msgid "Start fresh" msgstr "Repartir de zéro" -#: src/components/profile/ProfileSettingsTab.tsx:81 +#: src/components/profile/ProfileSettingsTab.tsx:84 msgid "Style" msgstr "Style" @@ -1412,6 +1420,10 @@ msgstr "Rien à voir, circulez." msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." +#: src/components/profile/ProfileSettingsTab.tsx:149 +msgid "This site doesn't send email, so no digest goes out." +msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc." + #: src/pages/DumpEdit.tsx:433 msgid "Thumbnail" msgstr "Miniature" @@ -1540,6 +1552,10 @@ msgstr "Voir la reco →" msgid "Volume" msgstr "Volume" +#: src/components/profile/ProfileSettingsTab.tsx:128 +msgid "Weekly digest" +msgstr "Résumé hebdomadaire" + #: src/components/dump/create/DumpCreateModal.tsx:711 #: src/pages/DumpEdit.tsx:515 msgid "What makes it worth it?" diff --git a/src/test/components/profile/ProfileSettingsTab.test.tsx b/src/test/components/profile/ProfileSettingsTab.test.tsx new file mode 100644 index 00000000..c060809d --- /dev/null +++ b/src/test/components/profile/ProfileSettingsTab.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { ProfileSettingsTab } from "../../../components/profile/ProfileSettingsTab.tsx"; +import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts"; +import { calls, route } from "../../fetch.ts"; +import { makeSession } from "../../fixtures.ts"; +import { renderApp } from "../../render.tsx"; + +vi.mock("../../../config/instance.ts", () => ({ + DEFAULT_STYLE: "smooth", + EMAIL_ENABLED: true, +})); + +describe("ProfileSettingsTab", () => { + test("the digest switches save to the account", async () => { + route("GET /api/users/me/settings", () => ({ + ...DEFAULT_SETTINGS, + digestMonthly: false, + })); + route( + "PATCH /api/users/me/settings", + (req) => ({ ...DEFAULT_SETTINGS, ...(req.body as object) }), + ); + const user = userEvent.setup(); + renderApp(, { session: makeSession() }); + + const row = (label: string) => + screen.getByText(label).closest(".profile-appearance-row") as HTMLElement; + const weeklyOff = await waitFor(() => { + const button = within(row("Weekly digest")).getByRole("button", { + name: "Off", + }); + expect(button).toBeEnabled(); + return button; + }); + expect( + within(row("Monthly recap")).getByRole("button", { name: "Off" }), + ).toHaveClass("active"); + expect(screen.queryByText(/doesn't send email/)).toBeNull(); + + await user.click(weeklyOff); + await waitFor(() => + expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({ + digestWeekly: false, + }) + ); + expect(weeklyOff).toHaveClass("active"); + }); +}); diff --git a/src/test/contexts/auth/AuthProvider.test.tsx b/src/test/contexts/auth/AuthProvider.test.tsx index deac19a2..7c0c7e1f 100644 --- a/src/test/contexts/auth/AuthProvider.test.tsx +++ b/src/test/contexts/auth/AuthProvider.test.tsx @@ -120,4 +120,21 @@ describe("AuthProvider", () => { second.token, ); }); + + test("reads the settings from the server into the session", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/users/me/settings", () => ({ + style: "nyt", + colorScheme: null, + digestWeekly: false, + digestMonthly: true, + })); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.settings?.style).toBe("nyt")); + expect( + JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).settings.digestWeekly, + ).toBe(false); + }); }); diff --git a/src/test/contexts/theme/ThemeProvider.test.tsx b/src/test/contexts/theme/ThemeProvider.test.tsx index db06dae2..ed6529a9 100644 --- a/src/test/contexts/theme/ThemeProvider.test.tsx +++ b/src/test/contexts/theme/ThemeProvider.test.tsx @@ -1,8 +1,12 @@ import { describe, expect, test } from "vitest"; -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { ThemeProvider } from "../../../contexts/theme/ThemeProvider.tsx"; +import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; import { useTheme } from "../../../hooks/theme/useTheme.ts"; +import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts"; +import { calls, route } from "../../fetch.ts"; +import { makeSession, storeSession, wire } from "../../fixtures.ts"; describe("ThemeProvider", () => { const wrapper = ({ children }: { children: ReactNode }) => ( @@ -43,4 +47,57 @@ describe("ThemeProvider", () => { "smooth", ); }); + + describe("signed in", () => { + const authWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const signIn = (settings: typeof DEFAULT_SETTINGS) => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/users/me/settings", () => settings); + route( + "PATCH /api/users/me/settings", + (req) => ({ ...settings, ...(req.body as object) }), + ); + }; + + test("the account's choice wins over this browser's, and is kept here", async () => { + localStorage.setItem("style", "nyt"); + signIn({ ...DEFAULT_SETTINGS, style: "kanagawa", colorScheme: "dark" }); + const { result } = renderHook(useTheme, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.style).toBe("kanagawa")); + expect(result.current.colorScheme).toBe("dark"); + expect(root.getAttribute("data-style")).toBe("kanagawa"); + expect(localStorage.getItem("style")).toBe("kanagawa"); + expect(calls("PATCH /api/users/me/settings")).toHaveLength(0); + + act(() => result.current.setStyle("melange")); + await waitFor(() => + expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({ + style: "melange", + }) + ); + expect(result.current.style).toBe("melange"); + act(() => result.current.setStyle("smooth")); + act(() => result.current.setColorScheme("auto")); + }); + + test("an account without a choice takes this browser's, once", async () => { + localStorage.setItem("style", "brutalist"); + signIn({ ...DEFAULT_SETTINGS }); + const { result } = renderHook(useTheme, { wrapper: authWrapper }); + await waitFor(() => + expect(calls("PATCH /api/users/me/settings")).toHaveLength(1) + ); + expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({ + style: "brutalist", + }); + expect(result.current.style).toBe("brutalist"); + act(() => result.current.setStyle("smooth")); + }); + }); }); diff --git a/src/test/hooks/auth/useSettings.test.tsx b/src/test/hooks/auth/useSettings.test.tsx new file mode 100644 index 00000000..316ead55 --- /dev/null +++ b/src/test/hooks/auth/useSettings.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode, useState } from "react"; +import { AuthContext } from "../../../contexts/auth/AuthContext.ts"; +import type { Session } from "../../../utils/auth/storage.ts"; +import { useSettings } from "../../../hooks/auth/useSettings.ts"; +import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts"; +import { calls, deferred, fail, ok, route } from "../../fetch.ts"; +import { makeSession } from "../../fixtures.ts"; + +function wrapperFor(initial: Session | null) { + return function Wrapper({ children }: { children: ReactNode }) { + const [authResponse, setAuthResponse] = useState(initial); + return ( + + {children} + + ); + }; +} + +describe("useSettings", () => { + test("null when signed out, or before the server has answered", () => { + expect( + renderHook(useSettings, { wrapper: wrapperFor(null) }).result.current + .settings, + ).toBeNull(); + expect( + renderHook(useSettings, { wrapper: wrapperFor(makeSession()) }).result + .current.settings, + ).toBeNull(); + }); + + test("a change shows at once, and the server's answer is kept", async () => { + const reply = deferred(); + route("PATCH /api/users/me/settings", () => reply.promise); + const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } }; + const { result } = renderHook(useSettings, { + wrapper: wrapperFor(session), + }); + let saving!: Promise; + act(() => { + saving = result.current.updateSettings({ digestWeekly: false }); + }); + expect(result.current.settings?.digestWeekly).toBe(false); + expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({ + digestWeekly: false, + }); + await act(async () => { + reply.resolve( + ok({ ...DEFAULT_SETTINGS, digestWeekly: false, style: "nyt" }), + ); + await saving; + }); + expect(result.current.settings?.style).toBe("nyt"); + }); + + test("a refusal puts the previous settings back and throws", async () => { + route("PATCH /api/users/me/settings", () => fail("nope", 500)); + const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } }; + const { result } = renderHook(useSettings, { + wrapper: wrapperFor(session), + }); + await act(async () => { + await expect(result.current.updateSettings({ digestMonthly: false })) + .rejects.toThrow("nope"); + }); + expect(result.current.settings?.digestMonthly).toBe(true); + }); +}); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index ecd3f5ec..cbd25949 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -310,8 +310,17 @@ describe("UserPublicProfile", () => { ); await user.click(screen.getByRole("button", { name: "New" })); expect(localStorage.getItem("default-feed-tab")).toBe("new"); - await user.click(screen.getByRole("button", { name: "Off" })); + const sounds = screen.getByText("Sounds").closest( + ".profile-appearance-row", + ) as HTMLElement; + await user.click(within(sounds).getByRole("button", { name: "Off" })); expect(localStorage.getItem("sounds")).toBe("off"); + // Without SMTP on the server, the digests can't be switched. + const weekly = screen.getByText("Weekly digest").closest( + ".profile-appearance-row", + ) as HTMLElement; + expect(within(weekly).getByRole("button", { name: "On" })).toBeDisabled(); + expect(screen.getByText(/doesn't send email/)).toBeInTheDocument(); document.documentElement.removeAttribute("data-style"); document.documentElement.removeAttribute("data-color-scheme"); }); diff --git a/src/test/render.tsx b/src/test/render.tsx index 3492bca6..6388b8dd 100644 --- a/src/test/render.tsx +++ b/src/test/render.tsx @@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import type { Session } from "../utils/auth/storage.ts"; import type { Category } from "../../shared/category.ts"; +import { DEFAULT_SETTINGS } from "../../shared/settings.ts"; import { AuthContext } from "../contexts/auth/AuthContext.ts"; import { AuthProvider } from "../contexts/auth/AuthProvider.tsx"; import { @@ -219,6 +220,11 @@ export function defaultRoutes( () => JSON.parse(JSON.stringify(session?.user ?? null)), ); route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" })); + route( + "GET /api/users/me/settings", + () => ({ ...DEFAULT_SETTINGS }), + fallback, + ); } /** @@ -235,8 +241,8 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) { const entries = Array.isArray(initial) ? initial : [initial]; const Wrapper = ({ children }: { children: ReactNode }) => ( - - + + @@ -257,8 +263,8 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) { - - + + ); return render(ui, { wrapper: Wrapper }); diff --git a/src/utils/auth/storage.ts b/src/utils/auth/storage.ts index 215ca38e..46293721 100644 --- a/src/utils/auth/storage.ts +++ b/src/utils/auth/storage.ts @@ -6,15 +6,19 @@ */ import type { AuthResponse } from "../../../shared/user.ts"; +import type { UserSettings } from "../../../shared/settings.ts"; import { deserializeAuthResponse } from "../shared/deserialize.ts"; /** * Held by `AuthProvider` and stored here. It is the {@link AuthResponse} of a * sign-in or sign-up, except that one stored before media tokens existed has - * none until `AuthProvider` fetches it. + * none until `AuthProvider` fetches it. `AuthProvider` adds the user's + * settings once it has read them from the server, and `useSettings` keeps + * them current. */ export type Session = Omit & { mediaToken?: string; + settings?: UserSettings; }; /** diff --git a/vite.config.ts b/vite.config.ts index 02e55e9f..3d710a41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,10 +5,13 @@ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { brotliCompressSync, constants as zlib, gzipSync } from "node:zlib"; import linguiConfig from "./lingui.config.ts"; +import { isStyleName } from "./shared/theme.ts"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed"; +const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : "smooth"; +const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off"; // Cache-busting token for the favicon URLs (see index.html). Changes when the // emoji changes, so browsers don't keep serving a stale cached icon. Mirrors @@ -126,6 +129,8 @@ export default defineConfig({ .replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) + .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) + .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED) : html, }, },