v4: settings are stored on the server, starting with the style, the color scheme and the digest switches — every setting lived in the browser's localStorage, so it didn't follow an account to another browser and the server couldn't read it, which the digests need (whether to send each, and in which style). user_settings (migration 0019) holds one row per user who changed anything, the defaults otherwise, and GET/PATCH /api/users/me/settings read and change it (settings-service, model/settings, validators/settings, shared/settings). AuthProvider reads the settings into the session alongside the user, on mount and on focus, so a change made elsewhere arrives; useSettings changes them at once and puts them back if the server refuses. ThemeProvider now sits inside AuthProvider: a signed-in user's choice comes from their settings and is mirrored to localStorage for index.html's first-paint script, a visitor's stays in the browser, and an account with no choice yet takes the one this browser had, once. Until anyone picks a style, the instance's GERBEUR_DEFAULT_STYLE applies, written into index.html's default-style tag along with email-enabled (src/config/instance.ts), and a browser no longer stores the default as if it had been chosen, so changing the instance's style reaches everyone who never picked one. The settings tab gets an Email section with the weekly digest and monthly recap switches, disabled with a note when the server has no SMTP. The style and scheme names moved to shared/theme.ts in the previous commit; the default feed tab and sounds stay in the browser for now. 223 backend tests (new: settings defaults, patches, isolation, refusals and cascade; index.html's new tags) and 658 frontend tests (new: useSettings, the account's theme winning and a browser's carried over, settings read into the session, the digest switches) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
36
README.md
36
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
28
api/db/migrations/0019_user_settings.ts
Normal file
28
api/db/migrations/0019_user_settings.ts
Normal file
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
.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;
|
||||
}
|
||||
|
||||
55
api/model/settings.ts
Normal file
55
api/model/settings.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<UserSettings> = {
|
||||
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<UserSettings> = {
|
||||
success: true,
|
||||
data: updateSettings(ctx.state.user.userId, body),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/:userId/role",
|
||||
authMiddleware,
|
||||
|
||||
56
api/services/settings-service.ts
Normal file
56
api/services/settings-service.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ const TABLES = [
|
||||
"invites",
|
||||
"attachments",
|
||||
"chat_messages",
|
||||
"user_settings",
|
||||
"categories",
|
||||
"playlists",
|
||||
"dumps",
|
||||
|
||||
@@ -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,
|
||||
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__</body>",
|
||||
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__</body>",
|
||||
);
|
||||
assertEquals(
|
||||
await loadIndexHtml(root),
|
||||
"<title>gerbeur</title><body>embed</body>",
|
||||
"<title>gerbeur</title><body>embed smooth off</body>",
|
||||
);
|
||||
|
||||
await Deno.writeTextFile(path, "<title>rebuilt __SITE_NAME__</title>");
|
||||
|
||||
@@ -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<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
});
|
||||
assertEquals(initial.data, DEFAULT_SETTINGS);
|
||||
|
||||
const changed = await patch<UserSettings>("/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<UserSettings>("/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<UserSettings>("/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<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
json: { style: null },
|
||||
});
|
||||
assertEquals(reset.data.style, null);
|
||||
});
|
||||
|
||||
32
api/validators/settings.ts
Normal file
32
api/validators/settings.ts
Normal file
@@ -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<keyof UpdateSettingsRequest, (v: unknown) => 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)
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,15 @@
|
||||
<meta name="site-name" content="__SITE_NAME__" />
|
||||
<meta name="site-emoji" content="__SITE_EMOJI__" />
|
||||
<meta name="bandcamp-player" content="__BANDCAMP_PLAYER__" />
|
||||
<meta name="default-style" content="__DEFAULT_STYLE__" />
|
||||
<meta name="email-enabled" content="__EMAIL_ENABLED__" />
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
|
||||
<title>__SITE_NAME__</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>(function(){var s=localStorage.getItem("style"),c=localStorage.getItem("color-scheme");if(s&&s!=="smooth"&&s!=="default")document.documentElement.setAttribute("data-style",s);if(c&&c!=="auto")document.documentElement.setAttribute("data-color-scheme",c);})();</script>
|
||||
<script>(function(){var d=document.querySelector('meta[name="default-style"]'),s=localStorage.getItem("style");if(!s||s==="default")s=d&&d.content;var c=localStorage.getItem("color-scheme");if(s&&s!=="smooth"&&s.indexOf("__")!==0)document.documentElement.setAttribute("data-style",s);if(c&&c!=="auto")document.documentElement.setAttribute("data-color-scheme",c);})();</script>
|
||||
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
43
shared/settings.ts
Normal file
43
shared/settings.ts
Normal file
@@ -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<UserSettings>;
|
||||
|
||||
/** 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,
|
||||
};
|
||||
@@ -7034,6 +7034,10 @@ body.has-player .chat-fab {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-appearance-note {
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
/* ── Categories ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.visually-hidden {
|
||||
|
||||
12
src/App.tsx
12
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 (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
@@ -253,7 +253,7 @@ export function App(): React.JSX.Element {
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<boolean>[] = [
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
<ProfileSection title={<Trans>Email</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Weekly digest</Trans>}>
|
||||
<Segmented
|
||||
options={ON_OFF_OPTIONS}
|
||||
value={settings?.digestWeekly ?? true}
|
||||
disabled={digestsDisabled}
|
||||
onChange={(on) =>
|
||||
updateSettings({ digestWeekly: on }).catch(() => {})}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={<Trans>Monthly recap</Trans>}>
|
||||
<Segmented
|
||||
options={ON_OFF_OPTIONS}
|
||||
value={settings?.digestMonthly ?? true}
|
||||
disabled={digestsDisabled}
|
||||
onChange={(on) =>
|
||||
updateSettings({ digestMonthly: on }).catch(() => {})}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!EMAIL_ENABLED && (
|
||||
<p className="form-hint profile-appearance-note">
|
||||
<Trans>
|
||||
This site doesn't send email, so no digest goes out.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</ProfileSection>
|
||||
{changePasswordOpen && (
|
||||
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
|
||||
)}
|
||||
|
||||
35
src/config/instance.ts
Normal file
35
src/config/instance.ts
Normal file
@@ -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`
|
||||
* `<meta>` 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";
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T extends string>(
|
||||
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<StyleName>(() =>
|
||||
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<StyleName | null>(() =>
|
||||
readChoice("style", STYLE_NAMES)
|
||||
);
|
||||
const [colorScheme, setColorSchemeState] = useState<ColorScheme>(() =>
|
||||
readChoice("color-scheme", COLOR_SCHEMES, "auto")
|
||||
const [localScheme, setLocalScheme] = useState<ColorScheme | null>(() =>
|
||||
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<string | null>(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 (
|
||||
<ThemeContext.Provider value={value}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
60
src/hooks/auth/useSettings.ts
Normal file
60
src/hooks/auth/useSettings.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
/** 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<UserSettings>(
|
||||
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 };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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</0>"
|
||||
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</0>"
|
||||
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?"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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</0>"
|
||||
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</0>"
|
||||
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?"
|
||||
|
||||
50
src/test/components/profile/ProfileSettingsTab.test.tsx
Normal file
50
src/test/components/profile/ProfileSettingsTab.test.tsx
Normal file
@@ -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(<ProfileSettingsTab />, { 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<AuthProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
70
src/test/hooks/auth/useSettings.test.tsx
Normal file
70
src/test/hooks/auth/useSettings.test.tsx
Normal file
@@ -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 (
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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<void>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
@@ -257,8 +263,8 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
return render(ui, { wrapper: Wrapper });
|
||||
|
||||
@@ -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<AuthResponse, "mediaToken"> & {
|
||||
mediaToken?: string;
|
||||
settings?: UserSettings;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user